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

View File

@@ -293,7 +293,7 @@ export default function DashboardPage() {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
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">
<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 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">
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">Total Files</p>
@@ -583,7 +583,7 @@ export default function DashboardPage() {
</span>
</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">
<p className="text-xs text-muted-foreground">Completed Samples</p>
<p className="mt-1 text-2xl font-semibold text-foreground">
@@ -648,9 +648,9 @@ export default function DashboardPage() {
</Card>
{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 && (
<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">
<BarChart3 className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium">
@@ -664,7 +664,7 @@ export default function DashboardPage() {
)}
{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">
<CheckCircle2 className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium">Processing Status</h2>
@@ -681,7 +681,7 @@ export default function DashboardPage() {
)}
{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">
<FileText className="h-4 w-4 text-primary" />
<h2 className="text-sm font-medium">
@@ -694,7 +694,7 @@ export default function DashboardPage() {
</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">
<Sparkles className="h-4 w-4 text-primary" />
<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
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, {
userId: contract.userId,
fileName: contract.fileName,
maxRetries: 3,
forceFallbackModelTest,
});
// Validate results

View File

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

View File

@@ -1,10 +1,12 @@
"use client";
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";
// Social Icon Component
// ==========================================
// Composant : Icône Sociale Premium
// ==========================================
function SocialIcon({
icon: Icon,
href,
@@ -20,13 +22,20 @@ function SocialIcon({
target="_blank"
rel="noopener noreferrer"
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>
);
}
// ==========================================
// Composant Principal : Footer
// ==========================================
export function Footer() {
const { ref, isVisible } = useScrollAnimation<HTMLElement>({
threshold: 0.1,
@@ -42,60 +51,66 @@ export function Footer() {
<footer
id="footer"
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
className="max-w-7xl mx-auto"
className="relative z-10 max-w-7xl mx-auto"
style={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? "translateY(0)" : "translateY(20px)",
transition: "all 0.6s ease-out",
transform: isVisible ? "translateY(0)" : "translateY(30px)",
transition: "all 0.8s cubic-bezier(0.2, 0.8, 0.2, 1)",
}}
>
{/* Main Content */}
<div className="flex flex-col items-center space-y-8">
{/* Logo */}
<a href="#" className="inline-flex items-center gap-2 group">
<div className="flex flex-col items-center space-y-10">
{/* Logo & Nom de la marque avec Lueur */}
<a href="#" className="inline-flex flex-col items-center gap-3 group">
<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
src="/LexiChain.png"
alt="LexiChain Logo"
width={48}
height={48}
className="
relative z-10
w-12 h-12 object-contain
transition-all duration-300 ease-out
group-hover:scale-110
"
width={56}
height={56}
className="relative z-10 w-14 h-14 object-contain transition-transform duration-500 ease-out group-hover:scale-110"
/>
</div>
<span className="text-xl font-bold tracking-tight text-foreground/90 group-hover:text-foreground transition-colors duration-300">
LexiChain
</span>
</a>
{/* Navigation Links */}
<nav className="flex items-center gap-8">
{/* Liens de Navigation avec animation de soulignement */}
<nav className="flex items-center gap-8 md:gap-12">
{navLinks.map((link) => (
<a
key={link.label}
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}
{/* 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>
))}
</nav>
{/* Divider */}
<div className="w-full max-w-4xl h-px bg-slate-200 dark:bg-slate-800" />
{/* Séparateur minimaliste */}
<div className="w-full max-w-md h-px bg-gradient-to-r from-transparent via-border to-transparent" />
{/* Bottom Row */}
<div className="flex items-center gap-8">
{/* Copyright */}
<p className="text-sm text-slate-500 dark:text-slate-500">
© LexiChain
{/* Section Inférieure (Copyright & Réseaux) */}
<div className="flex flex-col md:flex-row items-center justify-between w-full max-w-4xl gap-6">
<p className="text-sm font-medium text-muted-foreground/60 flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-primary/80 animate-pulse" />
© {new Date().getFullYear()} LexiChain. All rights reserved.
</p>
{/* Social Icons */}
<div className="flex items-center gap-4">
<SocialIcon
icon={Twitter}

View File

@@ -1,21 +1,15 @@
"use client";
import { useEffect, useState, useRef } from "react";
import {
Target,
Zap,
Shield,
Lock,
TrendingUp,
Award,
Check,
} from "lucide-react";
import { Target, Zap, Shield, Lock, TrendingUp } from "lucide-react";
import { BentoGrid } from "@/components/ui/bento-grid";
import { Spotlight } from "@/components/ui/spotlight-new";
import { GlowingEffect } from "@/components/ui/glowing-effect";
import { useScrollAnimation } from "@/hooks/useScrollAnimation";
// Count Up Hook
// ==========================================
// Hooks (Kept your optimized hook)
// ==========================================
function useCountUp(
end: number,
duration: number = 2000,
@@ -26,63 +20,44 @@ function useCountUp(
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!startOnView) {
return;
}
if (!startOnView) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !hasStarted) {
setHasStarted(true);
}
if (entry.isIntersecting && !hasStarted) setHasStarted(true);
},
{ threshold: 0.5 },
);
if (ref.current) {
observer.observe(ref.current);
}
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [hasStarted, startOnView]);
useEffect(() => {
if (!hasStarted) return;
if (!hasStarted || end === 0) return;
let startTime: number | null = null;
let animationFrame: number;
const animate = (timestamp: number) => {
if (!startTime) startTime = timestamp;
const progress = Math.min((timestamp - startTime) / duration, 1);
// Ease out cubic
const easeOut = 1 - Math.pow(1 - progress, 3);
const easeOut = 1 - Math.pow(1 - progress, 4); // Smoother cubic out
const nextValue = end * easeOut;
const formattedValue = Number.isInteger(end)
setCount(
Number.isInteger(end)
? Math.floor(nextValue)
: Number(nextValue.toFixed(1));
setCount(formattedValue);
if (progress < 1) {
animationFrame = requestAnimationFrame(animate);
}
: Number(nextValue.toFixed(1)),
);
if (progress < 1) animationFrame = requestAnimationFrame(animate);
};
animationFrame = requestAnimationFrame(animate);
return () => {
if (animationFrame) {
cancelAnimationFrame(animationFrame);
}
};
return () => cancelAnimationFrame(animationFrame);
}, [hasStarted, end, duration]);
return { count, ref };
}
// Stat Card Component
interface StatCardProps {
// ==========================================
// Types
// ==========================================
interface StatItem {
value: string;
numericValue?: number;
suffix?: string;
@@ -91,7 +66,6 @@ interface StatCardProps {
icon: React.ElementType;
gradient: string;
className?: string;
cardColor?: string;
glowColor?: string;
spotlight?: {
gradientFirst?: string;
@@ -100,11 +74,13 @@ interface StatCardProps {
duration?: number;
xOffset?: number;
};
delay: number;
additional?: string;
isText?: boolean;
}
// ==========================================
// High-Impact Stat Card
// ==========================================
function StatCard({
value,
numericValue,
@@ -114,107 +90,112 @@ function StatCard({
icon: Icon,
gradient,
className,
cardColor,
glowColor,
spotlight,
delay,
additional,
isText = false,
}: StatCardProps) {
}: StatItem & { delay: number }) {
const { ref: scrollRef, isVisible } = useScrollAnimation<HTMLDivElement>({
threshold: 0.3,
});
const { count, ref: countRef } = useCountUp(numericValue || 0, 2000);
const { count, ref: countRef } = useCountUp(numericValue || 0, 2500);
return (
<div
ref={scrollRef}
className={`relative ${className || ""}`}
className={`relative group h-full ${className || ""}`}
style={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? "translateY(0)" : "translateY(30px)",
transition: `all 0.6s ease-out ${delay}s`,
transform: isVisible
? "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
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"}`}
>
{/* Glowing Effect */}
className={`absolute -inset-0.5 rounded-3xl blur-2xl opacity-0 group-hover:opacity-40 transition-opacity duration-700 ${gradient}`}
/>
{/* The Card Body:
Gradient border wrapper + inner glassmorphism
*/}
<div className="relative h-full rounded-[22px] p-[1px] bg-gradient-to-b from-white/20 via-white/5 to-transparent overflow-hidden">
<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">
<GlowingEffect
disabled={false}
blur={40}
spread={60}
proximity={80}
variant="default"
borderWidth={2}
className="opacity-0 group-hover:opacity-100 transition-opacity duration-500"
className="opacity-0 group-hover:opacity-100 transition-opacity duration-700"
/>
<div className="absolute inset-0">
<div className="absolute inset-0 pointer-events-none mix-blend-screen">
<Spotlight
gradientFirst={spotlight?.gradientFirst}
gradientSecond={spotlight?.gradientSecond}
gradientThird={spotlight?.gradientThird}
{...spotlight}
duration={spotlight?.duration ?? 8}
xOffset={spotlight?.xOffset ?? 120}
/>
</div>
{/* Hover Glow */}
{glowColor && (
<div
className={`absolute -inset-1 ${glowColor} opacity-0 group-hover:opacity-20 blur-2xl transition-opacity duration-500 rounded-2xl`}
{/* Majestic Background Icon */}
<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">
<Icon
className={`w-56 h-56 ${glowColor?.replace("bg-", "text-")}`}
aria-hidden="true"
/>
)}
{/* 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 className="relative z-10 flex flex-col h-full">
<div>
{/* Icon */}
{/* Vibrant Badge */}
<div
className={`inline-flex items-center gap-2 rounded-full ${gradient} px-4 py-2 text-foreground shadow-lg`}
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" />
<span className="text-xs font-semibold tracking-widest uppercase text-foreground/80">
<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>
{/* Value */}
<div ref={countRef} className="mt-6">
{/* Massive Gradient Text */}
<div ref={countRef} className="mt-8 mb-2">
{isText ? (
<span className="text-4xl md:text-5xl lg:text-6xl font-semibold text-foreground tracking-tight">
<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-4xl md:text-5xl lg:text-6xl font-semibold text-foreground tracking-tight">
<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)
{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>
{/* Label */}
<p className="mt-4 text-base md:text-lg font-medium text-foreground/90">
<p className="text-lg font-medium text-foreground/80 tracking-wide">
{label}
</p>
</div>
{/* Additional Info */}
{/* Glowing Additional Info */}
{additional && (
<div className="flex items-center gap-2 pt-6">
<TrendingUp className="w-4 h-4 text-emerald-500" />
<span className="text-sm text-muted-foreground">
<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>
@@ -222,35 +203,17 @@ function StatCard({
</div>
</div>
</div>
</div>
);
}
// ==========================================
// Main Component
// ==========================================
export function Stats() {
const { ref: headerRef, isVisible: headerVisible } =
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[] = [
{
value: "99.9",
@@ -258,35 +221,27 @@ export function Stats() {
suffix: "%",
label: "OCR + AI Accuracy",
icon: Target,
gradient: "bg-gradient-to-r from-blue-500 to-indigo-600",
cardColor:
"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",
gradient: "bg-gradient-to-r from-cyan-500 to-blue-600",
glowColor: "bg-cyan-500",
className: "md:col-span-2",
spotlight: {
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%)",
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%)",
"radial-gradient(68% 68% at 55% 31%, hsla(217, 100%, 60%, 0.4) 0, transparent 80%)",
},
additional: "+0.3% this month",
},
{
value: "< 3",
label: "Average AI Response Time",
value: "< 10",
label: "Avg. AI Response Time",
icon: Zap,
gradient: "bg-gradient-to-r from-amber-500 to-orange-600",
cardColor:
"bg-gradient-to-br from-amber-50/80 to-orange-50/80 dark:from-amber-950/30 dark:to-orange-950/30",
glowColor: "bg-amber-500",
gradient: "bg-gradient-to-r from-orange-500 to-amber-600",
glowColor: "bg-orange-500",
isText: true,
spotlight: {
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",
isText: true,
additional: "Under 10 seconds",
},
{
value: "100",
@@ -294,93 +249,80 @@ export function Stats() {
suffix: "%",
label: "Blockchain Verified",
icon: Shield,
gradient: "bg-gradient-to-r from-emerald-500 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",
gradient: "bg-gradient-to-r from-emerald-400 to-teal-600",
glowColor: "bg-emerald-500",
spotlight: {
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",
label: "Full European Compliance",
label: "European Compliance",
icon: Lock,
gradient: "bg-gradient-to-r from-violet-500 to-purple-600",
cardColor:
"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",
gradient: "bg-gradient-to-r from-fuchsia-500 to-purple-600",
glowColor: "bg-fuchsia-500",
className: "md:col-span-2",
isText: true,
spotlight: {
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",
isText: true,
additional: "ISO 27001 Certified",
},
];
return (
<section
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 */}
<div className="absolute inset-0 bg-gradient-to-br from-background via-muted/40 to-background">
{/* Grid Pattern Overlay */}
<div className="absolute inset-0 grid-pattern opacity-15" />
{/* Massive Aurora Background Effect
This replaces the simple gradient with deep, colorful glowing orbs
*/}
<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 */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top,hsla(var(--primary),0.2)_0%,transparent_55%)]" />
</div>
{/* Grid Pattern */}
<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 className="relative max-w-7xl mx-auto">
<div className="relative max-w-7xl mx-auto z-10">
{/* Section Header */}
<div
ref={headerRef}
className="text-center mb-14"
className="flex flex-col items-center text-center mb-24"
style={{
opacity: headerVisible ? 1 : 0,
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 */}
<div className="relative inline-flex mb-8">
<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">
Corporate-Grade Results, Measured.
<h2 className="text-5xl md:text-6xl lg:text-7xl font-bold text-foreground tracking-tighter leading-[1.1]">
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>
<p className="mt-4 text-base md:text-lg text-muted-foreground max-w-2xl mx-auto">
Transparent performance benchmarks that prove reliability, accuracy,
<p className="mt-8 text-xl text-foreground/60 max-w-2xl mx-auto font-medium">
Transparent performance benchmarks proving reliability, accuracy,
and compliance at enterprise scale.
</p>
</div>
{/* 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) => (
<StatCard
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}
/>
<StatCard key={stat.label} {...stat} delay={index * 0.15} />
))}
</BentoGrid>
</div>

View File

@@ -19,13 +19,30 @@ 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 || "";
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(
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 = {
contractValidation?: {
isValidContract?: boolean;
@@ -72,6 +89,21 @@ const isAdaptiveKeyPoints = (
};
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.
* This keeps responses focused on what matters most for each contract family.
@@ -116,6 +148,8 @@ export class AIService {
keyManager.resetKeys();
try {
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.
const response = await fetch(fileUrl);
@@ -168,6 +202,7 @@ export class AIService {
prompt: `${basePrompt}${correctionHint}`,
base64,
mimeType,
forceFallbackModelTest,
});
if (!text) {
@@ -247,7 +282,7 @@ export class AIService {
// Better error messages
if (errorMessage.includes("API key")) {
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:")) {
const reason = String(errorMessage)
@@ -256,6 +291,10 @@ export class AIService {
throw new Error(
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 (
errorMessage.includes("not found") ||
errorMessage.includes("404")
@@ -298,7 +337,7 @@ export class AIService {
}
} else if (errorMessage.includes("quota")) {
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 {
throw new Error(`Error analyzing contract: ${errorMessage}`);
@@ -350,14 +389,196 @@ export class AIService {
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: {
prompt: string;
base64: string;
mimeType: string;
forceFallbackModelTest?: boolean;
}): Promise<string> {
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 {
return await keyManager.execute(async (genAI) => {
const model = genAI.getGenerativeModel({
@@ -437,9 +658,32 @@ export class AIService {
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
? 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(
@@ -447,19 +691,6 @@ export class AIService {
parseError: string,
): Promise<string | null> {
try {
return await keyManager.execute(async (genAI) => {
const repairModelName = FALLBACK_ANALYSIS_MODEL;
const model = genAI.getGenerativeModel({
model: repairModelName,
generationConfig: {
temperature: 0,
topP: 0.9,
topK: 20,
maxOutputTokens: 16384,
responseMimeType: "application/json",
},
});
const expectedSchema = {
language: "string|null",
title: "string",
@@ -515,20 +746,38 @@ Original parse error: ${parseError}
Malformed response to fix:
${malformedResponse.slice(0, 14000)}`;
const repaired = await model.generateContent(repairPrompt);
const repairedText = repaired.response.text()?.trim() || "";
const repairedText = await this.generateWithGroqModelChain({
preferredModel: FALLBACK_REPAIR_MODEL,
prompt: repairPrompt,
responseAsJson: true,
maxOutputTokens: 6144,
});
if (repairedText.length === 0) {
return null;
}
// Verify the repaired text is at least JSON-like before returning
if (!repairedText.includes("{")) {
return null;
}
return repairedText;
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;
} catch (error: any) {
if (error.message?.includes("CRITICAL_KEY_EXHAUSTION")) throw 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.
* Builds a minimal but valid JSON structure from pattern-matched fields.
@@ -641,7 +972,7 @@ ${malformedResponse.slice(0, 14000)}`;
}): Promise<string> {
let lastError: unknown = null;
for (const modelName of ANALYSIS_MODELS) {
for (const modelName of GEMINI_ANALYSIS_MODELS) {
try {
return await keyManager.execute(async (genAI) => {
const model = genAI.getGenerativeModel({
@@ -1036,7 +1367,7 @@ Include one short disclaimer only when legal context is discussed: "This is gene
let rawAnswer = "";
let lastError: unknown = null;
for (const modelName of ANALYSIS_MODELS) {
for (const modelName of GEMINI_ANALYSIS_MODELS) {
try {
rawAnswer = await keyManager.execute(async (genAI) => {
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 (lastError instanceof Error) {
throw lastError;
@@ -1094,7 +1444,12 @@ 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 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}`);
}

View File

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

View File

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

232
package-lock.json generated
View File

@@ -54,6 +54,7 @@
"motion": "^12.34.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"pdf-parse": "^2.4.5",
"prisma": "^6.19.2",
"react": "19.2.3",
"react-day-picker": "^9.13.2",
@@ -1331,6 +1332,205 @@
"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": {
"version": "0.2.12",
"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==",
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",

View File

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