backup point before blockchain

This commit is contained in:
2026-04-19 01:42:00 +01:00
parent 185c680b37
commit e0dc9ba2ba
13 changed files with 1261 additions and 343 deletions

View File

@@ -64,14 +64,18 @@ The AI subsystem is centered on:
### 2.2 Models ### 2.2 Models
- Primary model: gemini-2.5-flash - Primary model: gemini-2.5-flash
- Fallback model: gemini-2.0-flash - Optional secondary Gemini model: AI_MODEL_SECONDARY_GEMINI
- Model list is de-duplicated and iterated in order - 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
### 2.3 Environment Variables ### 2.3 Environment Variables
- AI_API_KEY (or AI_API_KEY2 / AI_API_KEY3 fallback) - AI_API_KEY (or AI_API_KEY2 / AI_API_KEY3 fallback)
- AI_MODEL_PRIMARY (optional override) - AI_MODEL_PRIMARY (optional override)
- AI_MODEL_SECONDARY_GEMINI (optional override)
- AI_MODEL_FALLBACK (optional override) - AI_MODEL_FALLBACK (optional override)
- GROQ_API_KEY (or AI_GROQ_API_KEY)
## 3. AI Capability Matrix ## 3. AI Capability Matrix
@@ -162,13 +166,13 @@ sequenceDiagram
autonumber autonumber
participant AIS as AIService participant AIS as AIService
participant G1 as Gemini Primary participant G1 as Gemini Primary
participant G2 as Gemini Fallback participant G2 as Gemini Secondary (optional)
AIS->>G1: buildPrevalidationPrompt + inline file AIS->>G1: buildPrevalidationPrompt + inline file
alt Primary succeeds alt Primary succeeds
G1-->>AIS: JSON precheck G1-->>AIS: JSON precheck
else Primary fails else Primary fails
AIS->>G2: same precheck request AIS->>G2: same precheck request (if configured)
G2-->>AIS: JSON precheck G2-->>AIS: JSON precheck
end end
AIS->>AIS: parse precheck JSON AIS->>AIS: parse precheck JSON
@@ -201,26 +205,32 @@ sequenceDiagram
autonumber autonumber
participant AIS as AIService participant AIS as AIService
participant GP as Gemini Primary participant GP as Gemini Primary
participant GF as Gemini Fallback participant GS as Gemini Secondary (optional)
participant GR as Groq Fallback
AIS->>GP: generate analysis (strict JSON) AIS->>GP: generate analysis (strict JSON)
alt GP success with usable output alt GP success with usable output
GP-->>AIS: text GP-->>AIS: text
else GP fails else GP fails
AIS->>GF: generate analysis (strict JSON) AIS->>GS: generate analysis (strict JSON)
alt GF success alt GS success
GF-->>AIS: text GS-->>AIS: text
else GF fails else GS fails
AIS->>GP: lenient generation attempt AIS->>GP: lenient generation attempt
GP-->>AIS: raw text alt lenient success
GP-->>AIS: raw text
else lenient fails
AIS->>GR: generate analysis (strict JSON)
GR-->>AIS: text
end
end end
end end
AIS->>AIS: parseJsonResponse AIS->>AIS: parseJsonResponse
alt parse failed alt parse failed
AIS->>GF: repairMalformedJson(originalText, parseError) AIS->>GR: repairMalformedJson(originalText, parseError)
alt repair success alt repair success
GF-->>AIS: repaired JSON text GR-->>AIS: repaired JSON text
AIS->>AIS: parse repaired JSON AIS->>AIS: parse repaired JSON
else repair failed else repair failed
AIS->>AIS: emergencyExtractFields(rawText) AIS->>AIS: emergencyExtractFields(rawText)

View File

@@ -293,7 +293,7 @@ export default function DashboardPage() {
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
className="grid gap-8 xl:grid-cols-[1.45fr,0.95fr] xl:items-end" className="grid gap-8 lg:grid-cols-[1.45fr,0.95fr] lg:items-end"
> >
<div className="space-y-4"> <div className="space-y-4">
<p className="inline-flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-xs font-medium text-primary"> <p className="inline-flex items-center gap-2 rounded-full border border-primary/25 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
@@ -429,7 +429,7 @@ export default function DashboardPage() {
</div> </div>
<div className="mx-auto max-w-7xl px-6 py-10"> <div className="mx-auto max-w-7xl px-6 py-10">
<div className="grid gap-5 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-5 md:grid-cols-2 lg:grid-cols-4">
<Card className="rounded-2xl border-border/60 bg-card/70 p-6"> <Card className="rounded-2xl border-border/60 bg-card/70 p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Total Files</p> <p className="text-sm text-muted-foreground">Total Files</p>
@@ -583,7 +583,7 @@ export default function DashboardPage() {
</span> </span>
</div> </div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
<div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3"> <div className="rounded-xl border border-border/50 bg-muted/25 px-4 py-3">
<p className="text-xs text-muted-foreground">Completed Samples</p> <p className="text-xs text-muted-foreground">Completed Samples</p>
<p className="mt-1 text-2xl font-semibold text-foreground"> <p className="mt-1 text-2xl font-semibold text-foreground">
@@ -648,9 +648,9 @@ export default function DashboardPage() {
</Card> </Card>
{hasChartData ? ( {hasChartData ? (
<div className="mt-6 grid grid-cols-1 gap-5 xl:grid-cols-12"> <div className="mt-6 grid grid-cols-1 gap-5 lg:grid-cols-12">
{chartData && chartData.trends.length > 0 && ( {chartData && chartData.trends.length > 0 && (
<Card className="rounded-2xl border-border/60 p-5 xl:col-span-8"> <Card className="rounded-2xl border-border/60 p-5 lg:col-span-8">
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<BarChart3 className="h-4 w-4 text-primary" /> <BarChart3 className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium"> <h2 className="text-sm font-medium">
@@ -664,7 +664,7 @@ export default function DashboardPage() {
)} )}
{chartData && chartData.byStatus.length > 0 && ( {chartData && chartData.byStatus.length > 0 && (
<Card className="rounded-2xl border-border/60 p-5 xl:col-span-4"> <Card className="rounded-2xl border-border/60 p-5 lg:col-span-4">
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-primary" /> <CheckCircle2 className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium">Processing Status</h2> <h2 className="text-sm font-medium">Processing Status</h2>
@@ -681,7 +681,7 @@ export default function DashboardPage() {
)} )}
{chartData && chartData.byType.length > 0 && ( {chartData && chartData.byType.length > 0 && (
<Card className="rounded-2xl border-border/60 p-5 xl:col-span-7"> <Card className="rounded-2xl border-border/60 p-5 lg:col-span-7">
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<FileText className="h-4 w-4 text-primary" /> <FileText className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium"> <h2 className="text-sm font-medium">
@@ -694,7 +694,7 @@ export default function DashboardPage() {
</Card> </Card>
)} )}
<Card className="rounded-2xl border-border/60 p-5 xl:col-span-5"> <Card className="rounded-2xl border-border/60 p-5 lg:col-span-5">
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" /> <Sparkles className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium">Recent Analyses</h2> <h2 className="text-sm font-medium">Recent Analyses</h2>

45
docs/ROUTING_ENDPOINTS.md Normal file
View File

@@ -0,0 +1,45 @@
# API & Routing Endpoints
## API Endpoints
### 1. Clerk Webhook
- **Path:** `/api/webhooks/clerk`
- **Method:** `POST`
- **Purpose:** Handles Clerk webhook events (authentication, user sync, etc.)
### 2. File Upload (UploadThing)
- **Path:** `/api/uploadthing`
- **Methods:** `GET`, `POST`
- **Purpose:** Handles file uploads (contracts, images) with authentication
---
## Protected App Routes (Require Login)
Defined in middleware ([proxy.ts](../proxy.ts)):
- `/dashboard` and all sub-pages
- `/contracts` and all sub-pages
- `/chat` and all sub-pages
- `/claims` and all sub-pages
- `/blockchain` and all sub-pages
- `/settings` and all sub-pages
- `/api/contracts` and all sub-pages
- `/api/chat`
- `/api/claims`
---
## Feature API Actions (Server Actions)
These are called from the frontend, not as REST endpoints:
- `/features/contracts/api/contract.action.ts` (contract CRUD, AI analysis)
- `/features/analytics/api/stats.action.ts` (dashboard stats)
- `/features/notifications/api/notification.action.ts` (notifications)
- `/features/auth/api/user.action.ts` (user sync)
---
**Note:**
- All `/dashboard`, `/contracts`, `/chat`, `/claims`, `/blockchain`, `/settings` routes are protected and require authentication.
- API endpoints under `/api/` may also be protected by middleware.
- For more details, see the middleware configuration in [proxy.ts](../proxy.ts).

309
docs/implementation_plan.md Normal file
View File

@@ -0,0 +1,309 @@
# Blockchain Integration — LexiChain BFSI Platform
## Goal
Add a **fully functional, 100% free** blockchain module to the existing LexiChain platform. This module provides:
1. **Proof of Deposit** — SHA-256 hash of each contract document is stored on-chain with a timestamp, making submission dates provable and tamper-proof.
2. **On-chain Verification** — Anyone can verify that a document existed at a specific time.
3. **Blockchain Explorer UI** — A new `/blockchain` page showing all on-chain transactions + per-contract verification status.
---
## User Review Required
> [!IMPORTANT]
> **Zero cost guaranteed.** The entire implementation uses:
> - **Hardhat local node** for development (free, instant, unlimited)
> - **Ethereum Sepolia testnet** for demo/presentation (free test ETH from faucets)
> - No MetaMask required for end-users — all blockchain operations run **server-side** via a backend wallet
> [!WARNING]
> **Server-side wallet approach**: Instead of requiring users to install MetaMask and sign transactions, the server holds a private key and signs transactions on behalf of the platform. This is the right UX for a BFSI enterprise platform (users shouldn't need crypto knowledge). The private key is stored in `.env` and never exposed to the client.
> [!IMPORTANT]
> **Dual-mode architecture**:
> - `NODE_ENV=development` → Hardhat local node (`http://127.0.0.1:8545`)
> - `NODE_ENV=production` or env flag → Sepolia testnet (via free Alchemy/Infura RPC)
>
> You can switch between modes with a single env variable change.
---
## Proposed Changes
### Component 1: Smart Contract (Hardhat + Solidity)
Creates a standalone `blockchain/` directory at the project root with a Hardhat project for developing, testing, and deploying the smart contract.
#### [NEW] [blockchain/contracts/DocumentRegistry.sol](file:///c:/Stage/Project-PFE/bfsi-project/blockchain/contracts/DocumentRegistry.sol)
Solidity smart contract with:
- `registerDocument(bytes32 docHash, string calldata fileName)` — stores hash + metadata on-chain
- `verifyDocument(bytes32 docHash)` — checks if a hash exists and returns timestamp + depositor
- `getDocumentsByDepositor(address depositor)` — lists all docs registered by an address
- Events: `DocumentRegistered(bytes32 indexed docHash, uint256 timestamp, address indexed depositor, string fileName)`
- Modifier to prevent duplicate registrations
#### [NEW] [blockchain/hardhat.config.ts](file:///c:/Stage/Project-PFE/bfsi-project/blockchain/hardhat.config.ts)
Hardhat config with:
- Local Hardhat network (default, free)
- Sepolia network config (reads RPC URL + private key from env)
- Solidity 0.8.24 compiler
#### [NEW] [blockchain/test/DocumentRegistry.test.ts](file:///c:/Stage/Project-PFE/bfsi-project/blockchain/test/DocumentRegistry.test.ts)
Comprehensive tests:
- Register a document and verify timestamp
- Prevent duplicate registration
- Verify non-existent document returns zero
- Multiple documents by same depositor
#### [NEW] [blockchain/scripts/deploy.ts](file:///c:/Stage/Project-PFE/bfsi-project/blockchain/scripts/deploy.ts)
Deployment script that outputs the contract address for use in `.env`.
#### [NEW] [blockchain/package.json](file:///c:/Stage/Project-PFE/bfsi-project/blockchain/package.json)
Separate package.json for the Hardhat project (keeps blockchain dependencies isolated from the Next.js app).
---
### Component 2: Next.js Blockchain Service Layer
Server-side service that connects to the blockchain from Next.js server actions. No browser wallet needed.
#### [NEW] [lib/services/blockchain.service.ts](file:///c:/Stage/Project-PFE/bfsi-project/lib/services/blockchain.service.ts)
Core blockchain service:
- `hashDocument(fileUrl: string): Promise<string>` — downloads contract PDF and computes SHA-256
- `registerOnChain(documentHash: string, fileName: string): Promise<{ txHash, blockNumber, blockTimestamp }>` — sends transaction to smart contract
- `verifyOnChain(documentHash: string): Promise<{ exists, timestamp, depositor }>` — reads on-chain data
- Uses `ethers.js v6` with `JsonRpcProvider` + `Wallet` (server-side, no MetaMask)
- Auto-detects network mode from env vars
#### [NEW] [lib/services/blockchain.types.ts](file:///c:/Stage/Project-PFE/bfsi-project/lib/services/blockchain.types.ts)
TypeScript types for blockchain data:
```typescript
interface BlockchainProof {
documentHash: string;
txHash: string;
blockNumber: number;
blockTimestamp: Date;
network: 'hardhat' | 'sepolia';
contractAddress: string;
explorerUrl: string | null; // Sepolia etherscan link
}
```
---
### Component 3: Server Actions & Integration
#### [NEW] [features/blockchain/api/blockchain.action.ts](file:///c:/Stage/Project-PFE/bfsi-project/features/blockchain/api/blockchain.action.ts)
New server actions:
- `registerContractOnBlockchain(contractId: string)` — hashes + registers + saves proof to DB
- `verifyContractOnBlockchain(contractId: string)` — verifies on-chain status
- `getBlockchainTransactions()` — fetches all blockchain proofs for the authenticated user
#### [MODIFY] [features/contracts/api/contract.action.ts](file:///c:/Stage/Project-PFE/bfsi-project/features/contracts/api/contract.action.ts)
After successful AI analysis, **automatically trigger** blockchain registration:
- In `analyzeContractAction()`, after `ContractService.updateWithAIResults()`, call `BlockchainService.hashAndRegister()`
- Save `documentHash`, `txHash` to the contract record
- This means every analyzed contract gets an automatic on-chain proof
---
### Component 4: Database Schema Update
#### [MODIFY] [prisma/schema.prisma](file:///c:/Stage/Project-PFE/bfsi-project/prisma/schema.prisma)
Expand the existing blockchain placeholder fields on `Contract`:
```diff
// Blockchain (later)
- documentHash String?
- txHash String?
- ipfsUrl String?
+ documentHash String?
+ txHash String?
+ blockNumber Int?
+ blockTimestamp DateTime?
+ blockchainNetwork String? // 'hardhat' | 'sepolia'
+ contractAddress String? // smart contract address used
```
Add a new `BlockchainTransaction` model for the explorer view:
```prisma
model BlockchainTransaction {
id String @id @default(cuid())
userId String
user User @relation(...)
contractId String
contract Contract @relation(...)
documentHash String
txHash String @unique
blockNumber Int
blockTimestamp DateTime
network String // 'hardhat' | 'sepolia'
contractAddress String
status String @default("CONFIRMED") // PENDING, CONFIRMED, FAILED
createdAt DateTime @default(now())
}
```
---
### Component 5: Frontend — Blockchain Explorer Page
#### [NEW] [app/(dashboard)/blockchain/page.tsx](file:///c:/Stage/Project-PFE/bfsi-project/app/(dashboard)/blockchain/page.tsx)
New dashboard page at `/blockchain` with:
- **Header stats**: Total verified contracts, latest block, network status
- **Transaction table**: All blockchain proofs with txHash, contract name, timestamp, verification status
- **Verification panel**: Paste a document hash to check its on-chain status
- **Network indicator**: Shows whether connected to Hardhat (local) or Sepolia
#### [NEW] [features/blockchain/components/blockchain-explorer.tsx](file:///c:/Stage/Project-PFE/bfsi-project/features/blockchain/components/blockchain-explorer.tsx)
Main explorer component with:
- Animated blockchain-themed cards
- Transaction list with expandable details
- Real-time verification status badges
- Network health indicator
#### [NEW] [features/blockchain/components/verify-document.tsx](file:///c:/Stage/Project-PFE/bfsi-project/features/blockchain/components/verify-document.tsx)
Standalone verification widget:
- File upload → compute hash → check on-chain
- Shows proof details if found (timestamp, block, depositor)
- Visual "Verified ✓" / "Not Found ✗" result
#### [NEW] [features/blockchain/components/blockchain-proof-badge.tsx](file:///c:/Stage/Project-PFE/bfsi-project/features/blockchain/components/blockchain-proof-badge.tsx)
Small badge component to show on contract cards:
- 🟢 "On-Chain Verified" (with txHash link)
- 🟡 "Pending" (registration in progress)
- ⚫ "Not Registered" (no blockchain proof yet)
---
### Component 6: Navigation & Integration Updates
#### [MODIFY] [components/layout/navigation.tsx](file:///c:/Stage/Project-PFE/bfsi-project/components/layout/navigation.tsx)
Add new nav item:
```typescript
{
href: "/blockchain",
label: "Blockchain",
icon: <Link2 className="w-5 h-5" />,
description: "On-chain proofs",
}
```
#### [MODIFY] [types/contract.types.ts](file:///c:/Stage/Project-PFE/bfsi-project/types/contract.types.ts)
Add blockchain fields to the Contract interface:
```typescript
blockNumber: number | null;
blockTimestamp: Date | null;
blockchainNetwork: string | null;
contractAddress: string | null;
```
#### [MODIFY] [.env.example](file:///c:/Stage/Project-PFE/bfsi-project/.env.example)
Add blockchain env vars:
```env
# Blockchain
BLOCKCHAIN_PRIVATE_KEY= # Server wallet private key (Hardhat default for dev)
BLOCKCHAIN_RPC_URL= # RPC endpoint (empty = Hardhat local)
BLOCKCHAIN_CONTRACT_ADDRESS= # Deployed DocumentRegistry address
BLOCKCHAIN_NETWORK=hardhat # 'hardhat' or 'sepolia'
SEPOLIA_RPC_URL= # Free Alchemy/Infura Sepolia RPC
```
---
## Architecture Diagram
```mermaid
flowchart TD
U[User Browser] --> UI[Next.js App Router]
UI -->|Server Action| BA[blockchain.action.ts]
BA --> BS[BlockchainService]
BS -->|ethers.js v6| BC[Smart Contract on Blockchain]
BS -->|Hash document| PDF[Contract PDF from UploadThing]
BA --> DB[(PostgreSQL - BlockchainTransaction)]
UI -->|Server Action| CA[contract.action.ts]
CA -->|After AI analysis| BA
subgraph "Local Dev"
HN[Hardhat Node :8545]
end
subgraph "Demo/Production"
SN[Sepolia Testnet]
end
BC -.-> HN
BC -.-> SN
```
---
## Implementation Order
| Step | Component | Estimated Effort |
|------|-----------|-----------------|
| 1 | Hardhat project + Smart Contract + Tests | ~30 min |
| 2 | Deploy to local Hardhat node | ~5 min |
| 3 | `BlockchainService` (server-side ethers.js) | ~30 min |
| 4 | Prisma schema update + migration | ~10 min |
| 5 | Server actions (`blockchain.action.ts`) | ~20 min |
| 6 | Integration into `analyzeContractAction` | ~10 min |
| 7 | Blockchain Explorer page + components | ~45 min |
| 8 | Navigation update + proof badges | ~15 min |
| 9 | Deploy to Sepolia testnet (optional, for demo) | ~15 min |
**Total: ~3 hours of implementation**
---
## Open Questions
> [!IMPORTANT]
> **1. Auto-register vs. Manual button?**
> The plan currently auto-registers contracts on the blockchain after AI analysis completes. Alternatively, we could add a manual "Register on Blockchain" button per contract. Which do you prefer? (I recommend **both**: auto-register + manual re-register option)
> [!IMPORTANT]
> **2. Sepolia for presentation?**
> Do you want me to also set up the Sepolia testnet deployment for your PFE presentation/jury? This gives you real Etherscan links to show. You'll just need to grab free Sepolia ETH from a faucet (takes 2 minutes). We can start with Hardhat local and add Sepolia later.
> [!IMPORTANT]
> **3. IPFS integration?**
> Your schema has an `ipfsUrl` field. Do you want to also store contract files on IPFS (using a free service like Pinata with 1GB free tier)? This would give you decentralized file storage + on-chain hash. Or should we skip this to keep things simpler?
---
## Verification Plan
### Automated Tests
1. **Smart contract tests**`cd blockchain && npx hardhat test` (tests register, verify, duplicate prevention)
2. **Integration test** — Upload a contract → AI analysis → verify blockchain fields are populated in DB
3. **Service test** — Call `BlockchainService.hashDocument()` + `registerOnChain()` directly
### Manual Verification
1. Start Hardhat node → deploy contract → upload a contract in the app → check `/blockchain` page shows the transaction
2. Use the verification panel to re-verify a document hash
3. Check the contract detail view shows the "On-Chain Verified" badge
4. If Sepolia is configured: verify the txHash on [Sepolia Etherscan](https://sepolia.etherscan.io/)

View File

@@ -428,10 +428,15 @@ export async function analyzeContractAction(id: string) {
}); });
// Analyze with AI // Analyze with AI
const forceFallbackModelTest =
process.env.AI_FORCE_FALLBACK_TEST === "1" ||
String(process.env.AI_FORCE_FALLBACK_TEST).toLowerCase() === "true";
const aiResults = await AIService.analyzeContract(contract.fileUrl, { const aiResults = await AIService.analyzeContract(contract.fileUrl, {
userId: contract.userId, userId: contract.userId,
fileName: contract.fileName, fileName: contract.fileName,
maxRetries: 3, maxRetries: 3,
forceFallbackModelTest,
}); });
// Validate results // Validate results

View File

@@ -52,7 +52,11 @@ import {
import { toast } from "sonner"; import { toast } from "sonner";
import { ContractChatModal } from "@/features/contracts/components/modals/contract-chat-modal"; import { ContractChatModal } from "@/features/contracts/components/modals/contract-chat-modal";
import { ContractProofModal } from "@/features/contracts/components/modals/contract-proof-modal"; import { ContractProofModal } from "@/features/contracts/components/modals/contract-proof-modal";
import { stripMarkdown, exportToCSV, exportToPDF } from "@/features/contracts/utils/export.utils"; import {
stripMarkdown,
exportToCSV,
exportToPDF,
} from "@/features/contracts/utils/export.utils";
interface Contract { interface Contract {
id: string; id: string;
@@ -1080,12 +1084,12 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
title="Download contract" title="Download contract"
onClick={() => { onClick={() => {
if (contract.fileUrl) { if (contract.fileUrl) {
const downloadUrl = contract.fileUrl + "?download=1";
const link = document.createElement("a"); const link = document.createElement("a");
link.href = downloadUrl; link.href = contract.fileUrl;
link.download = link.download =
contract.fileUrl.split("/").pop() || "contract"; contract.fileUrl.split("/").pop() || "contract";
link.target = "_blank";
link.rel = "noopener noreferrer";
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
@@ -1307,7 +1311,8 @@ export function ContractsList({ refreshTrigger }: { refreshTrigger?: number }) {
</button> </button>
</div> </div>
<p className="mt-2 min-h-[62px] rounded-xl border border-white/10 dark:border-white/5 bg-background/50 px-3 py-2 font-medium text-foreground whitespace-pre-wrap break-words shadow-inner"> <p className="mt-2 min-h-[62px] rounded-xl border border-white/10 dark:border-white/5 bg-background/50 px-3 py-2 font-medium text-foreground whitespace-pre-wrap break-words shadow-inner">
{stripMarkdown(selectedContract.policyNumber) || "N/A"} {stripMarkdown(selectedContract.policyNumber) ||
"N/A"}
</p> </p>
</div> </div>
<div className="flex min-h-[120px] flex-col rounded-2xl border border-border/30 bg-muted/20 px-3 py-3 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-lg hover:-translate-y-1 hover:border-primary/30"> <div className="flex min-h-[120px] flex-col rounded-2xl border border-border/30 bg-muted/20 px-3 py-3 backdrop-blur-md transition-all duration-300 hover:bg-muted/40 hover:shadow-lg hover:-translate-y-1 hover:border-primary/30">

View File

@@ -1,10 +1,12 @@
"use client"; "use client";
import { useScrollAnimation } from "@/hooks/useScrollAnimation"; import { useScrollAnimation } from "@/hooks/useScrollAnimation";
import { Sparkles, Github, Twitter, Linkedin, Mail } from "lucide-react"; import { Github, Twitter, Linkedin, Mail } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
// Social Icon Component // ==========================================
// Composant : Icône Sociale Premium
// ==========================================
function SocialIcon({ function SocialIcon({
icon: Icon, icon: Icon,
href, href,
@@ -20,13 +22,20 @@ function SocialIcon({
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label={label} aria-label={label}
className="text-slate-500 hover:text-slate-900 dark:hover:text-white transition-colors duration-200" className="group relative flex items-center justify-center w-10 h-10 rounded-full bg-slate-50 dark:bg-white/[0.03] border border-slate-200 dark:border-white/10 transition-all duration-300 hover:-translate-y-1 hover:border-primary/50 hover:shadow-[0_0_20px_rgba(var(--primary),0.2)]"
> >
<Icon className="w-5 h-5" /> {/* Lueur d'arrière-plan au survol */}
<div className="absolute inset-0 rounded-full bg-primary/10 opacity-0 group-hover:opacity-100 blur-md transition-opacity duration-300" />
{/* Icône */}
<Icon className="w-4 h-4 text-slate-500 dark:text-slate-400 group-hover:text-primary relative z-10 transition-colors duration-300" />
</a> </a>
); );
} }
// ==========================================
// Composant Principal : Footer
// ==========================================
export function Footer() { export function Footer() {
const { ref, isVisible } = useScrollAnimation<HTMLElement>({ const { ref, isVisible } = useScrollAnimation<HTMLElement>({
threshold: 0.1, threshold: 0.1,
@@ -42,60 +51,66 @@ export function Footer() {
<footer <footer
id="footer" id="footer"
ref={ref} ref={ref}
className="relative bg-white dark:bg-slate-950 border-t border-slate-200 dark:border-slate-800 py-12 px-4 sm:px-6 lg:px-8" className="relative overflow-hidden bg-background pt-20 pb-12 px-4 sm:px-6 lg:px-8"
> >
{/* Effet Wow 1 : Ligne de démarcation en dégradé */}
<div className="absolute top-0 left-0 w-full h-[1px] bg-gradient-to-r from-transparent via-foreground/20 dark:via-foreground/15 to-transparent" />
{/* Effet Wow 2 : Lueur d'ambiance à la base de la page */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[800px] h-[300px] bg-[radial-gradient(ellipse_at_bottom,hsla(var(--primary),0.15)_0%,transparent_60%)] pointer-events-none" />
<div <div
className="max-w-7xl mx-auto" className="relative z-10 max-w-7xl mx-auto"
style={{ style={{
opacity: isVisible ? 1 : 0, opacity: isVisible ? 1 : 0,
transform: isVisible ? "translateY(0)" : "translateY(20px)", transform: isVisible ? "translateY(0)" : "translateY(30px)",
transition: "all 0.6s ease-out", transition: "all 0.8s cubic-bezier(0.2, 0.8, 0.2, 1)",
}} }}
> >
{/* Main Content */} <div className="flex flex-col items-center space-y-10">
<div className="flex flex-col items-center space-y-8"> {/* Logo & Nom de la marque avec Lueur */}
{/* Logo */} <a href="#" className="inline-flex flex-col items-center gap-3 group">
<a href="#" className="inline-flex items-center gap-2 group">
<div className="relative"> <div className="relative">
{/* Lueur sous le logo */}
<div className="absolute inset-0 bg-primary/20 blur-xl rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<Image <Image
src="/LexiChain.png" src="/LexiChain.png"
alt="LexiChain Logo" alt="LexiChain Logo"
width={48} width={56}
height={48} height={56}
className=" className="relative z-10 w-14 h-14 object-contain transition-transform duration-500 ease-out group-hover:scale-110"
relative z-10
w-12 h-12 object-contain
transition-all duration-300 ease-out
group-hover:scale-110
"
/> />
</div> </div>
<span className="text-xl font-bold tracking-tight text-foreground/90 group-hover:text-foreground transition-colors duration-300">
LexiChain
</span>
</a> </a>
{/* Navigation Links */} {/* Liens de Navigation avec animation de soulignement */}
<nav className="flex items-center gap-8"> <nav className="flex items-center gap-8 md:gap-12">
{navLinks.map((link) => ( {navLinks.map((link) => (
<a <a
key={link.label} key={link.label}
href={link.href} href={link.href}
className="text-sm text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition-colors duration-200" className="relative text-sm font-medium text-muted-foreground hover:text-foreground transition-colors duration-300 group py-1"
> >
{link.label} {link.label}
{/* Ligne d'animation au survol */}
<span className="absolute bottom-0 left-0 w-0 h-[2px] bg-primary transition-all duration-300 ease-out group-hover:w-full rounded-full" />
</a> </a>
))} ))}
</nav> </nav>
{/* Divider */} {/* Séparateur minimaliste */}
<div className="w-full max-w-4xl h-px bg-slate-200 dark:bg-slate-800" /> <div className="w-full max-w-md h-px bg-gradient-to-r from-transparent via-border to-transparent" />
{/* Bottom Row */} {/* Section Inférieure (Copyright & Réseaux) */}
<div className="flex items-center gap-8"> <div className="flex flex-col md:flex-row items-center justify-between w-full max-w-4xl gap-6">
{/* Copyright */} <p className="text-sm font-medium text-muted-foreground/60 flex items-center gap-2">
<p className="text-sm text-slate-500 dark:text-slate-500"> <span className="w-2 h-2 rounded-full bg-primary/80 animate-pulse" />
© LexiChain © {new Date().getFullYear()} LexiChain. All rights reserved.
</p> </p>
{/* Social Icons */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<SocialIcon <SocialIcon
icon={Twitter} icon={Twitter}

View File

@@ -1,21 +1,15 @@
"use client"; "use client";
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from "react";
import { import { Target, Zap, Shield, Lock, TrendingUp } from "lucide-react";
Target,
Zap,
Shield,
Lock,
TrendingUp,
Award,
Check,
} from "lucide-react";
import { BentoGrid } from "@/components/ui/bento-grid"; import { BentoGrid } from "@/components/ui/bento-grid";
import { Spotlight } from "@/components/ui/spotlight-new"; import { Spotlight } from "@/components/ui/spotlight-new";
import { GlowingEffect } from "@/components/ui/glowing-effect"; import { GlowingEffect } from "@/components/ui/glowing-effect";
import { useScrollAnimation } from "@/hooks/useScrollAnimation"; import { useScrollAnimation } from "@/hooks/useScrollAnimation";
// Count Up Hook // ==========================================
// Hooks (Kept your optimized hook)
// ==========================================
function useCountUp( function useCountUp(
end: number, end: number,
duration: number = 2000, duration: number = 2000,
@@ -26,63 +20,44 @@ function useCountUp(
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!startOnView) { if (!startOnView) return;
return;
}
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
if (entry.isIntersecting && !hasStarted) { if (entry.isIntersecting && !hasStarted) setHasStarted(true);
setHasStarted(true);
}
}, },
{ threshold: 0.5 }, { threshold: 0.5 },
); );
if (ref.current) observer.observe(ref.current);
if (ref.current) {
observer.observe(ref.current);
}
return () => observer.disconnect(); return () => observer.disconnect();
}, [hasStarted, startOnView]); }, [hasStarted, startOnView]);
useEffect(() => { useEffect(() => {
if (!hasStarted) return; if (!hasStarted || end === 0) return;
let startTime: number | null = null; let startTime: number | null = null;
let animationFrame: number; let animationFrame: number;
const animate = (timestamp: number) => { const animate = (timestamp: number) => {
if (!startTime) startTime = timestamp; if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1); const progress = Math.min((timestamp - startTime) / duration, 1);
const easeOut = 1 - Math.pow(1 - progress, 4); // Smoother cubic out
// Ease out cubic
const easeOut = 1 - Math.pow(1 - progress, 3);
const nextValue = end * easeOut; const nextValue = end * easeOut;
const formattedValue = Number.isInteger(end) setCount(
? Math.floor(nextValue) Number.isInteger(end)
: Number(nextValue.toFixed(1)); ? Math.floor(nextValue)
setCount(formattedValue); : Number(nextValue.toFixed(1)),
);
if (progress < 1) { if (progress < 1) animationFrame = requestAnimationFrame(animate);
animationFrame = requestAnimationFrame(animate);
}
}; };
animationFrame = requestAnimationFrame(animate); animationFrame = requestAnimationFrame(animate);
return () => cancelAnimationFrame(animationFrame);
return () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
};
}, [hasStarted, end, duration]); }, [hasStarted, end, duration]);
return { count, ref }; return { count, ref };
} }
// Stat Card Component // ==========================================
interface StatCardProps { // Types
// ==========================================
interface StatItem {
value: string; value: string;
numericValue?: number; numericValue?: number;
suffix?: string; suffix?: string;
@@ -91,7 +66,6 @@ interface StatCardProps {
icon: React.ElementType; icon: React.ElementType;
gradient: string; gradient: string;
className?: string; className?: string;
cardColor?: string;
glowColor?: string; glowColor?: string;
spotlight?: { spotlight?: {
gradientFirst?: string; gradientFirst?: string;
@@ -100,11 +74,13 @@ interface StatCardProps {
duration?: number; duration?: number;
xOffset?: number; xOffset?: number;
}; };
delay: number;
additional?: string; additional?: string;
isText?: boolean; isText?: boolean;
} }
// ==========================================
// High-Impact Stat Card
// ==========================================
function StatCard({ function StatCard({
value, value,
numericValue, numericValue,
@@ -114,143 +90,130 @@ function StatCard({
icon: Icon, icon: Icon,
gradient, gradient,
className, className,
cardColor,
glowColor, glowColor,
spotlight, spotlight,
delay, delay,
additional, additional,
isText = false, isText = false,
}: StatCardProps) { }: StatItem & { delay: number }) {
const { ref: scrollRef, isVisible } = useScrollAnimation<HTMLDivElement>({ const { ref: scrollRef, isVisible } = useScrollAnimation<HTMLDivElement>({
threshold: 0.3, threshold: 0.3,
}); });
const { count, ref: countRef } = useCountUp(numericValue || 0, 2000); const { count, ref: countRef } = useCountUp(numericValue || 0, 2500);
return ( return (
<div <div
ref={scrollRef} ref={scrollRef}
className={`relative ${className || ""}`} className={`relative group h-full ${className || ""}`}
style={{ style={{
opacity: isVisible ? 1 : 0, opacity: isVisible ? 1 : 0,
transform: isVisible ? "translateY(0)" : "translateY(30px)", transform: isVisible
transition: `all 0.6s ease-out ${delay}s`, ? "translateY(0) scale(1)"
: "translateY(40px) scale(0.95)",
transition: `all 0.8s cubic-bezier(0.22, 1, 0.36, 1) ${delay}s`,
}} }}
> >
{/* Outer Glow behind the card */}
<div <div
className={`relative h-full overflow-hidden rounded-2xl border border-border/60 p-7 shadow-[0_20px_60px_-30px_rgba(15,23,42,0.25)] backdrop-blur group ${cardColor || "bg-card/80"}`} className={`absolute -inset-0.5 rounded-3xl blur-2xl opacity-0 group-hover:opacity-40 transition-opacity duration-700 ${gradient}`}
> />
{/* Glowing Effect */}
<GlowingEffect
disabled={false}
blur={40}
spread={60}
proximity={80}
variant="default"
borderWidth={2}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-500"
/>
<div className="absolute inset-0"> {/* The Card Body:
<Spotlight Gradient border wrapper + inner glassmorphism
gradientFirst={spotlight?.gradientFirst} */}
gradientSecond={spotlight?.gradientSecond} <div className="relative h-full rounded-[22px] p-[1px] bg-gradient-to-b from-white/20 via-white/5 to-transparent overflow-hidden">
gradientThird={spotlight?.gradientThird} <div className="relative h-full rounded-[21px] bg-card/60 dark:bg-black/40 backdrop-blur-2xl p-8 overflow-hidden shadow-2xl flex flex-col justify-between">
duration={spotlight?.duration ?? 8} <GlowingEffect
xOffset={spotlight?.xOffset ?? 120} blur={40}
spread={60}
proximity={80}
variant="default"
borderWidth={2}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-700"
/> />
</div>
{/* Hover Glow */} <div className="absolute inset-0 pointer-events-none mix-blend-screen">
{glowColor && ( <Spotlight
<div {...spotlight}
className={`absolute -inset-1 ${glowColor} opacity-0 group-hover:opacity-20 blur-2xl transition-opacity duration-500 rounded-2xl`} duration={spotlight?.duration ?? 8}
/> xOffset={spotlight?.xOffset ?? 120}
)} />
{/* Background Icon */}
<div className="absolute -top-6 -right-6 opacity-10">
<Icon className="w-28 h-28 text-foreground" />
</div>
{/* Content */}
<div className="relative z-10 flex h-full flex-col justify-between">
<div>
{/* Icon */}
<div
className={`inline-flex items-center gap-2 rounded-full ${gradient} px-4 py-2 text-foreground shadow-lg`}
>
<Icon className="w-4 h-4" />
<span className="text-xs font-semibold tracking-widest uppercase text-foreground/80">
Performance
</span>
</div>
{/* Value */}
<div ref={countRef} className="mt-6">
{isText ? (
<span className="text-4xl md:text-5xl lg:text-6xl font-semibold text-foreground tracking-tight">
{value}
</span>
) : (
<span className="text-4xl md:text-5xl lg:text-6xl font-semibold text-foreground tracking-tight">
{prefix}
{numericValue !== undefined && !Number.isInteger(numericValue)
? count.toFixed(1)
: numericValue !== undefined
? count
: value}
{suffix}
</span>
)}
</div>
{/* Label */}
<p className="mt-4 text-base md:text-lg font-medium text-foreground/90">
{label}
</p>
</div> </div>
{/* Additional Info */} {/* Majestic Background Icon */}
{additional && ( <div className="absolute -top-10 -right-10 opacity-5 dark:opacity-10 transition-transform duration-1000 ease-out group-hover:scale-125 group-hover:-rotate-12 pointer-events-none">
<div className="flex items-center gap-2 pt-6"> <Icon
<TrendingUp className="w-4 h-4 text-emerald-500" /> className={`w-56 h-56 ${glowColor?.replace("bg-", "text-")}`}
<span className="text-sm text-muted-foreground"> aria-hidden="true"
{additional} />
</span> </div>
{/* Content */}
<div className="relative z-10 flex flex-col h-full">
<div>
{/* Vibrant Badge */}
<div
className={`inline-flex items-center gap-2 rounded-full ${gradient} px-4 py-1.5 shadow-[0_0_20px_rgba(0,0,0,0.3)] ring-1 ring-white/20`}
>
<Icon className="w-4 h-4 text-white drop-shadow-md" />
<span className="text-[11px] font-black tracking-widest uppercase text-white drop-shadow-md">
Performance
</span>
</div>
{/* Massive Gradient Text */}
<div ref={countRef} className="mt-8 mb-2">
{isText ? (
<span className="text-5xl md:text-6xl lg:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-b from-foreground to-foreground/50">
{value}
</span>
) : (
<span className="text-5xl md:text-6xl lg:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-b from-foreground to-foreground/50 flex items-baseline">
{prefix}
{numericValue !== undefined &&
!Number.isInteger(numericValue)
? count.toFixed(1)
: numericValue !== undefined
? count
: value}
<span className="text-4xl md:text-5xl text-foreground/40 ml-1 font-semibold">
{suffix}
</span>
</span>
)}
</div>
<p className="text-lg font-medium text-foreground/80 tracking-wide">
{label}
</p>
</div> </div>
)}
{/* Glowing Additional Info */}
{additional && (
<div className="flex items-center gap-3 pt-8 mt-auto">
<div className="relative flex items-center justify-center w-8 h-8 rounded-full bg-emerald-500/20 ring-1 ring-emerald-500/40">
<div className="absolute inset-0 rounded-full bg-emerald-500/20 blur-md animate-pulse" />
<TrendingUp className="w-4 h-4 text-emerald-400 relative z-10" />
</div>
<span className="text-sm font-semibold text-foreground/70 tracking-wide uppercase">
{additional}
</span>
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
// ==========================================
// Main Component
// ==========================================
export function Stats() { export function Stats() {
const { ref: headerRef, isVisible: headerVisible } = const { ref: headerRef, isVisible: headerVisible } =
useScrollAnimation<HTMLDivElement>(); useScrollAnimation<HTMLDivElement>();
interface StatItem {
value: string;
numericValue?: number;
suffix?: string;
prefix?: string;
label: string;
icon: React.ElementType;
gradient: string;
className?: string;
cardColor?: string;
glowColor?: string;
spotlight?: {
gradientFirst?: string;
gradientSecond?: string;
gradientThird?: string;
duration?: number;
xOffset?: number;
};
additional?: string;
isText?: boolean;
}
const stats: StatItem[] = [ const stats: StatItem[] = [
{ {
value: "99.9", value: "99.9",
@@ -258,35 +221,27 @@ export function Stats() {
suffix: "%", suffix: "%",
label: "OCR + AI Accuracy", label: "OCR + AI Accuracy",
icon: Target, icon: Target,
gradient: "bg-gradient-to-r from-blue-500 to-indigo-600", gradient: "bg-gradient-to-r from-cyan-500 to-blue-600",
cardColor: glowColor: "bg-cyan-500",
"bg-gradient-to-br from-blue-50/80 to-indigo-50/80 dark:from-blue-950/30 dark:to-indigo-950/30",
glowColor: "bg-blue-500",
className: "md:col-span-2", className: "md:col-span-2",
spotlight: { spotlight: {
gradientFirst: gradientFirst:
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(217, 91%, 60%, .28) 0, hsla(217, 91%, 60%, .12) 55%, hsla(217, 91%, 60%, 0) 80%)", "radial-gradient(68% 68% at 55% 31%, hsla(217, 100%, 60%, 0.4) 0, transparent 80%)",
gradientSecond:
"radial-gradient(50% 50% at 50% 50%, hsla(258, 90%, 66%, .24) 0, hsla(258, 90%, 66%, .08) 80%, transparent 100%)",
gradientThird:
"radial-gradient(50% 50% at 50% 50%, hsla(217, 91%, 60%, .18) 0, hsla(217, 91%, 60%, .06) 80%, transparent 100%)",
}, },
additional: "+0.3% this month", additional: "+0.3% this month",
}, },
{ {
value: "< 3", value: "< 10",
label: "Average AI Response Time", label: "Avg. AI Response Time",
icon: Zap, icon: Zap,
gradient: "bg-gradient-to-r from-amber-500 to-orange-600", gradient: "bg-gradient-to-r from-orange-500 to-amber-600",
cardColor: glowColor: "bg-orange-500",
"bg-gradient-to-br from-amber-50/80 to-orange-50/80 dark:from-amber-950/30 dark:to-orange-950/30", isText: true,
glowColor: "bg-amber-500",
spotlight: { spotlight: {
gradientFirst: gradientFirst:
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(48, 96%, 53%, .28) 0, hsla(48, 96%, 53%, .12) 55%, hsla(48, 96%, 53%, 0) 80%)", "radial-gradient(68% 68% at 55% 31%, hsla(30, 100%, 60%, 0.4) 0, transparent 80%)",
}, },
additional: "Average under 3 seconds", additional: "Under 10 seconds",
isText: true,
}, },
{ {
value: "100", value: "100",
@@ -294,93 +249,80 @@ export function Stats() {
suffix: "%", suffix: "%",
label: "Blockchain Verified", label: "Blockchain Verified",
icon: Shield, icon: Shield,
gradient: "bg-gradient-to-r from-emerald-500 to-teal-600", gradient: "bg-gradient-to-r from-emerald-400 to-teal-600",
cardColor:
"bg-gradient-to-br from-emerald-50/80 to-teal-50/80 dark:from-emerald-950/30 dark:to-teal-950/30",
glowColor: "bg-emerald-500", glowColor: "bg-emerald-500",
spotlight: { spotlight: {
gradientFirst: gradientFirst:
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(158, 64%, 52%, .28) 0, hsla(158, 64%, 52%, .12) 55%, hsla(158, 64%, 52%, 0) 80%)", "radial-gradient(68% 68% at 55% 31%, hsla(150, 100%, 50%, 0.3) 0, transparent 80%)",
}, },
additional: "All documents certified", additional: "All docs certified",
}, },
{ {
value: "GDPR", value: "GDPR",
label: "Full European Compliance", label: "European Compliance",
icon: Lock, icon: Lock,
gradient: "bg-gradient-to-r from-violet-500 to-purple-600", gradient: "bg-gradient-to-r from-fuchsia-500 to-purple-600",
cardColor: glowColor: "bg-fuchsia-500",
"bg-gradient-to-br from-violet-50/80 to-purple-50/80 dark:from-violet-950/30 dark:to-purple-950/30",
glowColor: "bg-violet-500",
className: "md:col-span-2", className: "md:col-span-2",
isText: true,
spotlight: { spotlight: {
gradientFirst: gradientFirst:
"radial-gradient(68.54% 68.72% at 55.02% 31.46%, hsla(258, 90%, 66%, .28) 0, hsla(258, 90%, 66%, .12) 55%, hsla(258, 90%, 66%, 0) 80%)", "radial-gradient(68% 68% at 55% 31%, hsla(280, 100%, 60%, 0.4) 0, transparent 80%)",
}, },
additional: "ISO 27001 certified", additional: "ISO 27001 Certified",
isText: true,
}, },
]; ];
return ( return (
<section <section
id="stats" id="stats"
className="relative py-16 px-4 sm:px-6 lg:px-8 overflow-hidden" className="relative py-32 px-4 sm:px-6 lg:px-8 overflow-hidden bg-background"
> >
{/* Gradient Background */} {/* Massive Aurora Background Effect
<div className="absolute inset-0 bg-gradient-to-br from-background via-muted/40 to-background"> This replaces the simple gradient with deep, colorful glowing orbs
{/* Grid Pattern Overlay */} */}
<div className="absolute inset-0 grid-pattern opacity-15" /> <div className="absolute top-0 left-1/2 -translate-x-1/2 w-[1000px] h-[500px] opacity-30 dark:opacity-40 pointer-events-none blur-[120px] rounded-full mix-blend-screen bg-gradient-to-b from-primary/60 via-indigo-500/20 to-transparent" />
<div className="absolute bottom-0 right-0 w-[600px] h-[600px] opacity-20 pointer-events-none blur-[150px] rounded-full mix-blend-screen bg-purple-600/40" />
{/* Radial Glow */} {/* Grid Pattern */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,hsla(var(--primary),0.2)_0%,transparent_55%)]" /> <div className="absolute inset-0 bg-[url('https://res.cloudinary.com/aceternity/image/upload/v1705626490/grid-pattern_q5ymq0.png')] opacity-[0.05] dark:opacity-[0.1]" />
</div>
<div className="relative max-w-7xl mx-auto"> <div className="relative max-w-7xl mx-auto z-10">
{/* Section Header */} {/* Section Header */}
<div <div
ref={headerRef} ref={headerRef}
className="text-center mb-14" className="flex flex-col items-center text-center mb-24"
style={{ style={{
opacity: headerVisible ? 1 : 0, opacity: headerVisible ? 1 : 0,
transform: headerVisible ? "translateY(0)" : "translateY(30px)", transform: headerVisible ? "translateY(0)" : "translateY(30px)",
transition: "all 0.6s ease-out", transition: "all 1s cubic-bezier(0.22, 1, 0.36, 1)",
}} }}
> >
<span className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-card/60 px-4 py-1 text-xs uppercase tracking-[0.3em] text-muted-foreground"> {/* Glowing Pill */}
Platform Metrics <div className="relative inline-flex mb-8">
</span> <div className="absolute inset-0 rounded-full blur-md bg-primary/30 animate-pulse" />
<span className="relative inline-flex items-center gap-2 rounded-full border border-primary/50 bg-background/80 backdrop-blur-xl px-5 py-2 text-xs font-black uppercase tracking-[0.3em] text-primary shadow-[0_0_20px_rgba(var(--primary),0.2)]">
Platform Metrics
</span>
</div>
<h2 className="mt-6 text-4xl md:text-5xl lg:text-6xl font-semibold text-foreground"> <h2 className="text-5xl md:text-6xl lg:text-7xl font-bold text-foreground tracking-tighter leading-[1.1]">
Corporate-Grade Results, Measured. Corporate-Grade Results, <br className="hidden md:block" />
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary via-purple-500 to-blue-500">
Quantified.
</span>
</h2> </h2>
<p className="mt-4 text-base md:text-lg text-muted-foreground max-w-2xl mx-auto"> <p className="mt-8 text-xl text-foreground/60 max-w-2xl mx-auto font-medium">
Transparent performance benchmarks that prove reliability, accuracy, Transparent performance benchmarks proving reliability, accuracy,
and compliance at enterprise scale. and compliance at enterprise scale.
</p> </p>
</div> </div>
{/* Stats Grid */} {/* Stats Grid */}
<BentoGrid className="max-w-6xl gap-6 md:auto-rows-[16rem]"> <BentoGrid className="max-w-6xl mx-auto gap-8 md:auto-rows-[22rem]">
{stats.map((stat, index) => ( {stats.map((stat, index) => (
<StatCard <StatCard key={stat.label} {...stat} delay={index * 0.15} />
key={stat.label}
value={stat.value}
numericValue={stat.numericValue}
suffix={stat.suffix || ""}
prefix={stat.prefix || ""}
label={stat.label}
icon={stat.icon}
gradient={stat.gradient}
className={stat.className}
cardColor={stat.cardColor}
glowColor={stat.glowColor}
spotlight={stat.spotlight}
delay={index * 0.1}
additional={stat.additional}
isText={stat.isText}
/>
))} ))}
</BentoGrid> </BentoGrid>
</div> </div>

View File

@@ -19,13 +19,30 @@ import { keyManager } from "@/lib/services/ai/key-manager";
const PRIMARY_ANALYSIS_MODEL = const PRIMARY_ANALYSIS_MODEL =
process.env.AI_MODEL_PRIMARY || "gemini-3.1-flash-lite-preview"; process.env.AI_MODEL_PRIMARY || "gemini-3.1-flash-lite-preview";
const GEMINI_SECONDARY_ANALYSIS_MODEL =
process.env.AI_MODEL_SECONDARY_GEMINI || "";
const FALLBACK_ANALYSIS_MODEL = const FALLBACK_ANALYSIS_MODEL =
process.env.AI_MODEL_FALLBACK || "gemini-2.0-flash"; process.env.AI_MODEL_FALLBACK || "llama-3.3-70b-versatile";
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";
const GEMINI_ANALYSIS_MODELS = Array.from(
new Set(
[PRIMARY_ANALYSIS_MODEL, GEMINI_SECONDARY_ANALYSIS_MODEL].filter(Boolean),
),
);
const ANALYSIS_MODELS = Array.from( const ANALYSIS_MODELS = Array.from(
new Set([PRIMARY_ANALYSIS_MODEL, FALLBACK_ANALYSIS_MODEL]), new Set([...GEMINI_ANALYSIS_MODELS, `groq:${FALLBACK_ANALYSIS_MODEL}`]),
); );
const FORCE_FALLBACK_TEST =
process.env.AI_FORCE_FALLBACK_TEST === "1" ||
String(process.env.AI_FORCE_FALLBACK_TEST).toLowerCase() === "true";
type ValidationEnvelope = { type ValidationEnvelope = {
contractValidation?: { contractValidation?: {
isValidContract?: boolean; isValidContract?: boolean;
@@ -72,6 +89,21 @@ const isAdaptiveKeyPoints = (
}; };
export class AIService { export class AIService {
private static isTransientGeminiError(message: string): boolean {
const normalized = message.toLowerCase();
return (
normalized.includes("503") ||
normalized.includes("service unavailable") ||
normalized.includes("high demand") ||
normalized.includes("temporarily unavailable") ||
normalized.includes("backend error") ||
normalized.includes("internal server error") ||
normalized.includes("bad gateway") ||
normalized.includes("gateway timeout") ||
normalized.includes("deadline exceeded")
);
}
/** /**
* Domain-specific guidance for contract Q&A. * Domain-specific guidance for contract Q&A.
* This keeps responses focused on what matters most for each contract family. * This keeps responses focused on what matters most for each contract family.
@@ -116,6 +148,8 @@ export class AIService {
keyManager.resetKeys(); keyManager.resetKeys();
try { try {
const maxRetries = Math.min(3, Math.max(1, options?.maxRetries ?? 2)); const maxRetries = Math.min(3, Math.max(1, options?.maxRetries ?? 2));
const forceFallbackModelTest =
options?.forceFallbackModelTest ?? FORCE_FALLBACK_TEST;
// Step 1: Download raw file bytes from storage URL. // Step 1: Download raw file bytes from storage URL.
const response = await fetch(fileUrl); const response = await fetch(fileUrl);
@@ -168,6 +202,7 @@ export class AIService {
prompt: `${basePrompt}${correctionHint}`, prompt: `${basePrompt}${correctionHint}`,
base64, base64,
mimeType, mimeType,
forceFallbackModelTest,
}); });
if (!text) { if (!text) {
@@ -247,7 +282,7 @@ export class AIService {
// Better error messages // Better error messages
if (errorMessage.includes("API key")) { if (errorMessage.includes("API key")) {
throw new Error( throw new Error(
"Invalid or missing Gemini API key. Check AI_API_KEY in your .env file", "Invalid or missing AI API key. Check AI_API_KEY1/2/3 for Gemini and GROQ_API_KEY for Groq fallback.",
); );
} else if (errorMessage.includes("INVALID_CONTRACT:")) { } else if (errorMessage.includes("INVALID_CONTRACT:")) {
const reason = String(errorMessage) const reason = String(errorMessage)
@@ -256,6 +291,10 @@ export class AIService {
throw new Error( throw new Error(
reason || "Uploaded file is not recognized as a valid contract.", reason || "Uploaded file is not recognized as a valid contract.",
); );
} else if (this.isTransientGeminiError(errorMessage)) {
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.`,
);
} else if ( } else if (
errorMessage.includes("not found") || errorMessage.includes("not found") ||
errorMessage.includes("404") errorMessage.includes("404")
@@ -298,7 +337,7 @@ export class AIService {
} }
} else if (errorMessage.includes("quota")) { } else if (errorMessage.includes("quota")) {
throw new Error( throw new Error(
"Limit exceeded. Your Gemini API quota may be exhausted. Check your Google Cloud Console for usage details.", "Limit exceeded. Gemini or Groq quota may be exhausted. Check your provider dashboards for usage and limits.",
); );
} else { } else {
throw new Error(`Error analyzing contract: ${errorMessage}`); throw new Error(`Error analyzing contract: ${errorMessage}`);
@@ -350,14 +389,196 @@ export class AIService {
return parseAiJsonResponse(text); return parseAiJsonResponse(text);
} }
private static isGroqConfigured(): boolean {
return GROQ_API_KEY.length > 0;
}
private static async generateWithGroq(input: {
model?: string;
prompt: string;
systemPrompt?: string;
responseAsJson: boolean;
maxOutputTokens: number;
temperature?: number;
topP?: number;
}): Promise<string> {
if (!this.isGroqConfigured()) {
throw new Error(
"Groq fallback is not configured. Set GROQ_API_KEY (or AI_GROQ_API_KEY).",
);
}
const modelName = input.model || FALLBACK_ANALYSIS_MODEL;
// Build messages with system/user role separation for better instruction adherence
const messages: Array<{ role: string; content: string }> = [];
if (input.systemPrompt) {
messages.push({ role: "system", content: input.systemPrompt });
}
messages.push({ role: "user", content: input.prompt });
// Use json_object mode (compatible with all models)
const responseFormat: Record<string, unknown> | undefined = input.responseAsJson
? { type: "json_object" as const }
: undefined;
const body: Record<string, unknown> = {
model: modelName,
temperature: input.temperature ?? 0,
top_p: input.topP ?? 0.95,
max_tokens: input.maxOutputTokens,
response_format: responseFormat,
messages,
};
const response = await fetch(GROQ_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${GROQ_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const details = await response.text();
throw new Error(
`Groq 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 Groq fallback model.");
}
return text;
}
private static async generateWithGroqModelChain(input: {
preferredModel?: string;
prompt: string;
systemPrompt?: string;
responseAsJson: boolean;
maxOutputTokens: number;
temperature?: number;
topP?: number;
}): Promise<string> {
const candidates = Array.from(
new Set(
[
input.preferredModel,
FALLBACK_ANALYSIS_MODEL,
"llama-3.3-70b-versatile",
"qwen-2.5-32b",
"llama-3.1-8b-instant",
].filter(Boolean),
),
) as string[];
let lastError: unknown = null;
for (const modelName of candidates) {
try {
const text = await this.generateWithGroq({
model: modelName,
prompt: input.prompt,
systemPrompt: input.systemPrompt,
responseAsJson: input.responseAsJson,
maxOutputTokens: input.maxOutputTokens,
temperature: input.temperature,
topP: input.topP,
});
if (modelName !== (input.preferredModel || FALLBACK_ANALYSIS_MODEL)) {
console.warn(
`Groq 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.`,
error instanceof Error ? error.message : String(error),
);
}
}
throw lastError instanceof Error
? lastError
: new Error("All Groq fallback models failed.");
}
/**
* Build a Groq-optimized system prompt that mirrors the Gemini behavior.
* This separates role & formatting rules from user content for better
* instruction adherence on open-source models.
*/
private static buildGroqSystemPrompt(): 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.
CRITICAL OUTPUT RULES:
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).
You are replacing a more capable multimodal model (Gemini) as a fallback. Your output quality MUST match production standards.`;
}
private static async generateAnalysisWithFallback(input: { private static async generateAnalysisWithFallback(input: {
prompt: string; prompt: string;
base64: string; base64: string;
mimeType: string; mimeType: string;
forceFallbackModelTest?: boolean;
}): Promise<string> { }): Promise<string> {
let lastError: unknown = null; let lastError: unknown = null;
const forceFallback = Boolean(input.forceFallbackModelTest);
for (const modelName of ANALYSIS_MODELS) { const buildGroundedGroqPrompt = async (basePrompt: string) => {
const groundingText = await this.extractGroqGroundingText({
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.`;
}
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.`;
};
if (forceFallback) {
console.warn(
`🧪 Fallback test mode enabled. Skipping Gemini and forcing Groq model ${FALLBACK_ANALYSIS_MODEL}.`,
);
const groundedPrompt = await buildGroundedGroqPrompt(input.prompt);
return this.generateWithGroqModelChain({
preferredModel: FALLBACK_ANALYSIS_MODEL,
systemPrompt: this.buildGroqSystemPrompt(),
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,
});
}
for (const modelName of GEMINI_ANALYSIS_MODELS) {
try { try {
return await keyManager.execute(async (genAI) => { return await keyManager.execute(async (genAI) => {
const model = genAI.getGenerativeModel({ const model = genAI.getGenerativeModel({
@@ -437,9 +658,32 @@ export class AIService {
console.warn("Lenient generation also failed:", error); console.warn("Lenient generation also failed:", error);
} }
// === Groq fallback path ===
console.warn(
"All Gemini models exhausted. Activating Groq fallback pipeline...",
);
try {
const groundedPrompt = await buildGroundedGroqPrompt(input.prompt);
const groqText = await this.generateWithGroqModelChain({
preferredModel: FALLBACK_ANALYSIS_MODEL,
systemPrompt: this.buildGroqSystemPrompt(),
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`,
);
return groqText;
} catch (groqError) {
console.warn("Groq analysis fallback failed:", groqError);
}
throw lastError instanceof Error throw lastError instanceof Error
? lastError ? lastError
: new Error("All analysis models failed to generate content."); : new Error(
"All analysis models (Gemini + Groq fallback) failed to generate content.",
);
} }
private static async repairMalformedJson( private static async repairMalformedJson(
@@ -447,47 +691,34 @@ export class AIService {
parseError: string, parseError: string,
): Promise<string | null> { ): Promise<string | null> {
try { try {
return await keyManager.execute(async (genAI) => { const expectedSchema = {
const repairModelName = FALLBACK_ANALYSIS_MODEL; language: "string|null",
const model = genAI.getGenerativeModel({ title: "string",
model: repairModelName, type: "enum: INSURANCE_AUTO|INSURANCE_HOME|INSURANCE_HEALTH|INSURANCE_LIFE|LOAN|CREDIT_CARD|INVESTMENT|OTHER",
generationConfig: { provider: "string|null",
temperature: 0, policyNumber: "string|null",
topP: 0.9, startDate: "YYYY-MM-DD|null",
topK: 20, endDate: "YYYY-MM-DD|null",
maxOutputTokens: 16384, premium: "number|null",
responseMimeType: "application/json", premiumCurrency: "string|null (ISO code like EUR/USD/TND or symbol)",
}, summary: "string (min 10 chars)",
}); extractedText: "string (min 30 chars)",
keyPoints: {
const expectedSchema = { guarantees: "string[]",
language: "string|null", exclusions: "string[]",
title: "string", franchise: "string|null",
type: "enum: INSURANCE_AUTO|INSURANCE_HOME|INSURANCE_HEALTH|INSURANCE_LIFE|LOAN|CREDIT_CARD|INVESTMENT|OTHER", importantDates: "string[]",
provider: "string|null", explainability:
policyNumber: "string|null", "[{ field, why, sourceSnippet, sourceHints:{ page|null, section|null, confidence|null } }]",
startDate: "YYYY-MM-DD|null", },
endDate: "YYYY-MM-DD|null", keyPeople: "[{ name, role|null, email|null, phone|null }]",
premium: "number|null", contactInfo:
premiumCurrency: "string|null (ISO code like EUR/USD/TND or symbol)", "{ name|null, email|null, phone|null, address|null, role|null }",
summary: "string (min 10 chars)", importantContacts:
extractedText: "string (min 30 chars)", "[{ name|null, email|null, phone|null, address|null, role|null }]",
keyPoints: { relevantDates:
guarantees: "string[]", "[{ date:'YYYY-MM-DD', description, type:'EXPIRATION|RENEWAL|PAYMENT|REVIEW|OTHER' }]",
exclusions: "string[]", contractValidation: {
franchise: "string|null",
importantDates: "string[]",
explainability:
"[{ field, why, sourceSnippet, sourceHints:{ page|null, section|null, confidence|null } }]",
},
keyPeople: "[{ name, role|null, email|null, phone|null }]",
contactInfo:
"{ name|null, email|null, phone|null, address|null, role|null }",
importantContacts:
"[{ name|null, email|null, phone|null, address|null, role|null }]",
relevantDates:
"[{ date:'YYYY-MM-DD', description, type:'EXPIRATION|RENEWAL|PAYMENT|REVIEW|OTHER' }]",
contractValidation: {
isValidContract: "boolean", isValidContract: "boolean",
confidence: "number (0-100)", confidence: "number (0-100)",
reason: "string|null", reason: "string|null",
@@ -515,20 +746,38 @@ Original parse error: ${parseError}
Malformed response to fix: Malformed response to fix:
${malformedResponse.slice(0, 14000)}`; ${malformedResponse.slice(0, 14000)}`;
const repaired = await model.generateContent(repairPrompt); const repairedText = await this.generateWithGroqModelChain({
const repairedText = repaired.response.text()?.trim() || ""; preferredModel: FALLBACK_REPAIR_MODEL,
prompt: repairPrompt,
responseAsJson: true,
maxOutputTokens: 6144,
});
if (repairedText.length === 0) { if (repairedText.length === 0) {
return null; return null;
} }
// Verify the repaired text is at least JSON-like before returning
if (!repairedText.includes("{")) { if (!repairedText.includes("{")) {
return null; return null;
} }
try {
this.parseJsonResponse(repairedText);
} 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({
preferredModel: FALLBACK_REPAIR_MODEL,
prompt: secondPassPrompt,
responseAsJson: true,
maxOutputTokens: 6144,
});
this.parseJsonResponse(secondPass);
return secondPass;
}
return repairedText; return repairedText;
});
} catch (error: any) { } catch (error: any) {
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error; if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
console.warn("JSON repair step failed:", error); console.warn("JSON repair step failed:", error);
@@ -536,6 +785,88 @@ ${malformedResponse.slice(0, 14000)}`;
} }
} }
private static async extractGroqGroundingText(input: {
base64: string;
mimeType: string;
}): Promise<string> {
// For PDFs: extract text directly using pdf-parse
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 };
try {
parsed = await parser.getText();
} finally {
await parser.destroy();
}
const text = (parsed?.text || "")
.replace(/\r/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
if (text && text.length > 50) {
console.log(
`📄 Groq grounding: extracted ${text.length} chars from PDF`,
);
return text.slice(0, 50000);
}
} catch (error) {
console.warn(
"PDF grounding extraction failed for Groq fallback.",
error,
);
}
}
// 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,
);
}
}
}
return "";
}
/** /**
* Emergency fallback: Extract key contract fields from raw text when JSON is completely malformed. * Emergency fallback: Extract key contract fields from raw text when JSON is completely malformed.
* Builds a minimal but valid JSON structure from pattern-matched fields. * Builds a minimal but valid JSON structure from pattern-matched fields.
@@ -641,7 +972,7 @@ ${malformedResponse.slice(0, 14000)}`;
}): Promise<string> { }): Promise<string> {
let lastError: unknown = null; let lastError: unknown = null;
for (const modelName of ANALYSIS_MODELS) { for (const modelName of GEMINI_ANALYSIS_MODELS) {
try { try {
return await keyManager.execute(async (genAI) => { return await keyManager.execute(async (genAI) => {
const model = genAI.getGenerativeModel({ const model = genAI.getGenerativeModel({
@@ -1036,7 +1367,7 @@ Include one short disclaimer only when legal context is discussed: "This is gene
let rawAnswer = ""; let rawAnswer = "";
let lastError: unknown = null; let lastError: unknown = null;
for (const modelName of ANALYSIS_MODELS) { for (const modelName of GEMINI_ANALYSIS_MODELS) {
try { try {
rawAnswer = await keyManager.execute(async (genAI) => { rawAnswer = await keyManager.execute(async (genAI) => {
const model = genAI.getGenerativeModel({ const model = genAI.getGenerativeModel({
@@ -1073,6 +1404,25 @@ Include one short disclaimer only when legal context is discussed: "This is gene
} }
} }
if (!rawAnswer) {
try {
rawAnswer = await this.generateWithGroqModelChain({
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,
responseAsJson: false,
maxOutputTokens: 2048,
temperature: 0.2,
topP: 0.95,
});
console.log(
`✅ Q&A fallback with Groq model ${FALLBACK_ANALYSIS_MODEL} succeeded in ${languageName}`,
);
} catch (groqError) {
lastError = groqError;
}
}
if (!rawAnswer) { if (!rawAnswer) {
if (lastError instanceof Error) { if (lastError instanceof Error) {
throw lastError; throw lastError;
@@ -1094,7 +1444,12 @@ Include one short disclaimer only when legal context is discussed: "This is gene
const errorMessage = const errorMessage =
error instanceof Error ? error.message : String(error); error instanceof Error ? error.message : String(error);
if (errorMessage.includes("API key")) { if (errorMessage.includes("API key")) {
throw new Error("Invalid or missing Gemini API key."); throw new Error("Invalid or missing AI API key (Gemini/Groq).");
}
if (this.isTransientGeminiError(errorMessage)) {
throw new Error(
`Gemini is temporarily overloaded for the configured Q&A models (${ANALYSIS_MODELS.join(", ")}). Please try again in a few minutes.`,
);
} }
throw new Error(`Error answering question: ${errorMessage}`); throw new Error(`Error answering question: ${errorMessage}`);
} }

View File

@@ -15,6 +15,7 @@ export type AnalyzeOptions = {
userId?: string; userId?: string;
fileName?: string; fileName?: string;
maxRetries?: number; maxRetries?: number;
forceFallbackModelTest?: boolean;
}; };
export type ContactInfo = { export type ContactInfo = {

View File

@@ -15,20 +15,14 @@ type RetrievedChunk = {
score: number; score: number;
}; };
const API_KEY = import { keyManager } from "@/lib/services/ai/key-manager";
process.env.AI_API_KEY1 || process.env.AI_API_KEY2 || process.env.AI_API_KEY3;
if (!API_KEY) {
throw new Error("AI_API_KEY is not configured");
}
const EMBEDDING_MODEL = process.env.AI_EMBEDDING_MODEL || "text-embedding-004"; const EMBEDDING_MODEL = process.env.AI_EMBEDDING_MODEL || "text-embedding-004";
const EMBEDDING_MODEL_FALLBACKS = [ const EMBEDDING_MODEL_FALLBACKS = [
EMBEDDING_MODEL, EMBEDDING_MODEL,
"gemini-embedding-001",
"text-embedding-004", "text-embedding-004",
"embedding-001",
]; ];
const genAI = new GoogleGenerativeAI(API_KEY);
export class RAGService { export class RAGService {
private static readonly MAX_CHUNK_CHARS = 1400; private static readonly MAX_CHUNK_CHARS = 1400;
@@ -236,14 +230,18 @@ export class RAGService {
for (const modelName of Array.from(new Set(EMBEDDING_MODEL_FALLBACKS))) { for (const modelName of Array.from(new Set(EMBEDDING_MODEL_FALLBACKS))) {
try { try {
const model = genAI.getGenerativeModel({ model: modelName }); return await keyManager.execute(async (genAI) => {
const result = await model.embedContent(text); const model = genAI.getGenerativeModel({ model: modelName });
const values = result.embedding?.values; const result = await model.embedContent(text);
const values = result.embedding?.values;
if (values && Array.isArray(values) && values.length > 0) { if (values && Array.isArray(values) && values.length > 0) {
return values; return values;
} }
} catch (error) { throw new Error("Empty embedding");
});
} catch (error: any) {
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw error;
lastError = error; lastError = error;
} }
} }

232
package-lock.json generated
View File

@@ -54,6 +54,7 @@
"motion": "^12.34.0", "motion": "^12.34.0",
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pdf-parse": "^2.4.5",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"react": "19.2.3", "react": "19.2.3",
"react-day-picker": "^9.13.2", "react-day-picker": "^9.13.2",
@@ -1331,6 +1332,205 @@
"win32" "win32"
] ]
}, },
"node_modules/@napi-rs/canvas": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz",
"integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.80",
"@napi-rs/canvas-darwin-arm64": "0.1.80",
"@napi-rs/canvas-darwin-x64": "0.1.80",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.80",
"@napi-rs/canvas-linux-arm64-musl": "0.1.80",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-gnu": "0.1.80",
"@napi-rs/canvas-linux-x64-musl": "0.1.80",
"@napi-rs/canvas-win32-x64-msvc": "0.1.80"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz",
"integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz",
"integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz",
"integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz",
"integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz",
"integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz",
"integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz",
"integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz",
"integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz",
"integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.80",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz",
"integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
@@ -7930,6 +8130,38 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdf-parse": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz",
"integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==",
"license": "Apache-2.0",
"dependencies": {
"@napi-rs/canvas": "0.1.80",
"pdfjs-dist": "5.4.296"
},
"bin": {
"pdf-parse": "bin/cli.mjs"
},
"engines": {
"node": ">=20.16.0 <21 || >=22.3.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
}
},
"node_modules/perfect-debounce": { "node_modules/perfect-debounce": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",

View File

@@ -55,6 +55,7 @@
"motion": "^12.34.0", "motion": "^12.34.0",
"next": "16.1.6", "next": "16.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pdf-parse": "^2.4.5",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"react": "19.2.3", "react": "19.2.3",
"react-day-picker": "^9.13.2", "react-day-picker": "^9.13.2",