PreRelease v2
This commit is contained in:
@@ -1,530 +0,0 @@
|
||||
/**
|
||||
* Contract Server Actions
|
||||
*
|
||||
* Handles all contract-related operations including:
|
||||
* - Saving uploaded contracts
|
||||
* - Retrieving contracts
|
||||
* - Analyzing contracts with AI
|
||||
* - Deleting contracts
|
||||
* - Asking questions about contracts
|
||||
*
|
||||
* Each action integrates with:
|
||||
* - Clerk for authentication
|
||||
* - Contract service for database operations
|
||||
* - AI service for document analysis
|
||||
* - Notification service for user feedback
|
||||
*
|
||||
* All operations include comprehensive error handling and notification creation.
|
||||
*/
|
||||
|
||||
"use server";
|
||||
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
ContractService,
|
||||
saveContract as savePendingContract,
|
||||
} from "@/lib/services/contract.service";
|
||||
import { AIService } from "@/lib/services/ai.service";
|
||||
import { NotificationService } from "@/lib/services/notification.service";
|
||||
|
||||
/**
|
||||
* Saves a new contract after UploadThing upload
|
||||
*
|
||||
* Steps:
|
||||
* 1. Get authenticated user from Clerk
|
||||
* 2. Get internal user ID from database
|
||||
* 3. Save contract to database with UPLOADED status
|
||||
* 4. Create success notification for the user
|
||||
* 5. Revalidate dashboard and contacts pages
|
||||
*
|
||||
* @param data - Contract file metadata from UploadThing
|
||||
* @returns Success status with contract data or error message
|
||||
*/
|
||||
export async function saveContract(data: {
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
}) {
|
||||
try {
|
||||
// Get authenticated user
|
||||
const { userId: clerkId } = await auth();
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
// Save contract
|
||||
const result = await savePendingContract(data);
|
||||
|
||||
if (result.success && result.contract) {
|
||||
// Get internal user ID for notification
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (user) {
|
||||
// Create success notification
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "SUCCESS",
|
||||
title: "📄 Contract Uploaded",
|
||||
message: `"${data.fileName}" has been uploaded successfully. Click "Analyze" to extract contract details.`,
|
||||
contractId: result.contract.id,
|
||||
actionType: "UPLOAD_SUCCESS",
|
||||
icon: "FileCheck",
|
||||
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/contacts");
|
||||
revalidatePath("/dashboard");
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
console.error("Save contract error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all contracts for the authenticated user
|
||||
*
|
||||
* Steps:
|
||||
* 1. Query database for contracts matching filter criteria
|
||||
* 2. Serialize data: convert Decimal to number, dates to ISO strings
|
||||
* 3. Return paginated/filtered contract list
|
||||
*
|
||||
* Supported Filters:
|
||||
* - status: UPLOADED, PROCESSING, COMPLETED, FAILED
|
||||
* - type: INSURANCE_AUTO, INSURANCE_HOME, etc.
|
||||
* - search: Searches title, provider, policyNumber, fileName
|
||||
* - userId: Auto-filtered to authenticated user
|
||||
*
|
||||
* @param filters - Filter criteria
|
||||
* @returns Array of contracts with serialized data
|
||||
*/
|
||||
export async function getContracts(filters?: Record<string, unknown>) {
|
||||
try {
|
||||
const contracts = await ContractService.getAll(filters);
|
||||
|
||||
// Serialize contracts: convert Decimal to number, dates to ISO strings
|
||||
const serializedContracts = contracts.map((contract: any) => ({
|
||||
id: contract.id,
|
||||
fileName: contract.fileName,
|
||||
fileSize: contract.fileSize,
|
||||
mimeType: contract.mimeType,
|
||||
status: contract.status,
|
||||
createdAt: contract.createdAt?.toISOString() || new Date().toISOString(),
|
||||
fileUrl: contract.fileUrl,
|
||||
// AI Analysis fields
|
||||
title: contract.title || null,
|
||||
type: contract.type || null,
|
||||
provider: contract.provider || null,
|
||||
policyNumber: contract.policyNumber || null,
|
||||
startDate: contract.startDate ? contract.startDate.toISOString() : null,
|
||||
endDate: contract.endDate ? contract.endDate.toISOString() : null,
|
||||
premium: contract.premium
|
||||
? parseFloat(contract.premium.toString())
|
||||
: null,
|
||||
summary: contract.summary || null,
|
||||
keyPoints: contract.keyPoints || null,
|
||||
}));
|
||||
|
||||
return { success: true, contracts: serializedContracts };
|
||||
} catch (error: unknown) {
|
||||
console.error("Get contracts error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single contract by ID
|
||||
*
|
||||
* @param id - Contract ID
|
||||
* @returns Contract details or error
|
||||
*/
|
||||
export async function getContract(id: string) {
|
||||
try {
|
||||
const contract = await ContractService.getById(id);
|
||||
return { success: true, contract };
|
||||
} catch (error: unknown) {
|
||||
console.error("Get contract error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a contract from both cloud storage and database
|
||||
*
|
||||
* Steps:
|
||||
* 1. Get authenticated user from Clerk
|
||||
* 2. Get internal user ID from database
|
||||
* 3. Verify user owns the contract
|
||||
* 4. Delete file from UploadThing cloud storage
|
||||
* 5. Delete contract record from database
|
||||
* 6. Create success notification
|
||||
* 7. Revalidate pages
|
||||
*
|
||||
* @param id - Contract ID to delete
|
||||
* @returns Success status or error message
|
||||
*
|
||||
* Security: Only the contract owner can delete their contracts
|
||||
*/
|
||||
export async function deleteContract(id: string) {
|
||||
try {
|
||||
// Get authenticated user
|
||||
const { userId: clerkId } = await auth();
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
// Get contract to verify ownership and get title
|
||||
const contract = await ContractService.getById(id);
|
||||
const contractTitle = contract.title || contract.fileName;
|
||||
|
||||
// Get internal user ID
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user || contract.userId !== user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized: Contract not found or does not belong to you",
|
||||
};
|
||||
}
|
||||
|
||||
// Delete contract (handles both storage and database)
|
||||
await ContractService.delete(id);
|
||||
|
||||
if (user) {
|
||||
// Create success notification
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "SUCCESS",
|
||||
title: "🗑️ Contract Deleted",
|
||||
message: `"${contractTitle}" has been permanently deleted.`,
|
||||
actionType: "DELETE_SUCCESS",
|
||||
icon: "Trash2",
|
||||
expiresIn: 24 * 60 * 60 * 1000, // 24 hours
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath("/contacts");
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return { success: true, message: "Contract deleted successfully" };
|
||||
} catch (error: unknown) {
|
||||
console.error("Delete error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves dashboard statistics for the authenticated user
|
||||
*
|
||||
* Returns:
|
||||
* - Total contracts count
|
||||
* - Status breakdown (uploaded, processing, completed, failed)
|
||||
* - Contract type distribution
|
||||
* - AI learning telemetry data
|
||||
*
|
||||
* @returns Statistics object or error
|
||||
*/
|
||||
export async function getContractStats() {
|
||||
try {
|
||||
const stats = await ContractService.getStats();
|
||||
return { success: true, stats };
|
||||
} catch (error: unknown) {
|
||||
console.error("Stats error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyzes a contract using AI service
|
||||
*
|
||||
* Steps:
|
||||
* 1. Authenticate user
|
||||
* 2. Get contract details
|
||||
* 3. Update status to PROCESSING
|
||||
* 4. Call AI service to analyze contract
|
||||
* 5. Validate AI results
|
||||
* 6. Save results to database with COMPLETED status
|
||||
* 7. Create success notification
|
||||
* 8. Return analysis results or error
|
||||
*
|
||||
* On Error:
|
||||
* - Detects if contract is invalid vs analysis failed
|
||||
* - Saves failure reason to database
|
||||
* - Creates error notification
|
||||
* - Returns appropriate error code for UI handling
|
||||
*
|
||||
* @param id - Contract ID to analyze
|
||||
* @returns Success with analysis results or error with error code
|
||||
*
|
||||
* Error Codes:
|
||||
* - INVALID_CONTRACT: File is not a valid contract document
|
||||
* - ANALYSIS_ERROR: Analysis failed during processing
|
||||
*/
|
||||
export async function analyzeContractAction(id: string) {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
if (!clerkId) {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
// Get internal user ID
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
if (!user) {
|
||||
return { success: false, error: "User not found" };
|
||||
}
|
||||
|
||||
// Get contract
|
||||
const contract = await ContractService.getById(id);
|
||||
|
||||
// Verify ownership
|
||||
if (contract.userId !== user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized: Contract not found or does not belong to you",
|
||||
};
|
||||
}
|
||||
|
||||
// Update status to PROCESSING
|
||||
await ContractService.updateStatus(id, "PROCESSING");
|
||||
|
||||
// Create processing notification
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "INFO",
|
||||
title: "⏳ Analyzing Contract",
|
||||
message: `"${contract.fileName}" is being analyzed. This may take a few seconds...`,
|
||||
contractId: id,
|
||||
actionType: "ANALYSIS_STARTED",
|
||||
icon: "Loader",
|
||||
});
|
||||
|
||||
// Analyze with AI
|
||||
const aiResults = await AIService.analyzeContract(contract.fileUrl, {
|
||||
userId: contract.userId,
|
||||
fileName: contract.fileName,
|
||||
maxRetries: 3,
|
||||
});
|
||||
|
||||
// Validate results
|
||||
if (!AIService.validateAnalysis(aiResults)) {
|
||||
console.error("❌ AI validation failed");
|
||||
await ContractService.markFailed(
|
||||
id,
|
||||
"AI validation failed. The file may be incomplete or not a valid contract.",
|
||||
);
|
||||
|
||||
// Create error notification
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "ERROR",
|
||||
title: "❌ Analysis Failed",
|
||||
message:
|
||||
"The AI could not validate the analysis result. The file may be incomplete or corrupted.",
|
||||
contractId: id,
|
||||
actionType: "ANALYSIS_FAILED",
|
||||
icon: "AlertCircle",
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: "AI analysis validation failed. Please try again.",
|
||||
errorCode: "ANALYSIS_ERROR",
|
||||
};
|
||||
}
|
||||
|
||||
// Save AI results to database (convert nulls to undefined for optional fields)
|
||||
await ContractService.updateWithAIResults(id, {
|
||||
...aiResults,
|
||||
provider: aiResults.provider ?? undefined,
|
||||
policyNumber: aiResults.policyNumber ?? undefined,
|
||||
startDate: aiResults.startDate ?? undefined,
|
||||
endDate: aiResults.endDate ?? undefined,
|
||||
premium: aiResults.premium ?? undefined,
|
||||
});
|
||||
|
||||
// Create success notification with extracted info
|
||||
const contractTitle = aiResults.title || "Contract";
|
||||
const contractProvider = aiResults.provider || "Unknown Provider";
|
||||
const endDate = aiResults.endDate
|
||||
? new Date(aiResults.endDate).toLocaleDateString()
|
||||
: "N/A";
|
||||
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "SUCCESS",
|
||||
title: "✅ Contract Analyzed",
|
||||
message: `"${contractTitle}" from ${contractProvider} (Expires: ${endDate}) has been successfully analyzed and saved.`,
|
||||
contractId: id,
|
||||
actionType: "ANALYSIS_SUCCESS",
|
||||
icon: "CheckCircle2",
|
||||
expiresIn: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
});
|
||||
|
||||
revalidatePath("/contacts");
|
||||
revalidatePath("/dashboard");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Contract analyzed successfully!",
|
||||
contract: aiResults,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
console.error("Analyze error:", error);
|
||||
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
const user = clerkId && (await ContractService.getUserByClerkId(clerkId));
|
||||
|
||||
// Update contract status to FAILED
|
||||
const reason =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
await ContractService.markFailed(id, reason);
|
||||
|
||||
// Create error notification
|
||||
if (user) {
|
||||
const contract = await ContractService.getById(id);
|
||||
await NotificationService.create({
|
||||
userId: user.id,
|
||||
type: "ERROR",
|
||||
title: "❌ Analysis Failed",
|
||||
message: reason,
|
||||
contractId: id,
|
||||
actionType: "ANALYSIS_ERROR",
|
||||
icon: "AlertCircle",
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to update status or create notification", e);
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error occurred";
|
||||
|
||||
// Detect if contract is invalid vs analysis failed
|
||||
const invalidContractSignals = [
|
||||
"not recognized as a valid contract",
|
||||
"contract confidence is too low",
|
||||
"does not contain enough contract-specific signals",
|
||||
"uploaded file is not recognized as a contract",
|
||||
"invalid_contract",
|
||||
];
|
||||
const normalizedError = errorMessage.toLowerCase();
|
||||
const isInvalidContract = invalidContractSignals.some((signal) =>
|
||||
normalizedError.includes(signal),
|
||||
);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
errorCode: isInvalidContract ? "INVALID_CONTRACT" : "ANALYSIS_ERROR",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks a question about a specific contract using AI
|
||||
*
|
||||
* Steps:
|
||||
* 1. Authenticate user
|
||||
* 2. Validate question is not empty
|
||||
* 3. Retrieve contract details
|
||||
* 4. Call AI service with contract context
|
||||
* 5. Return answer or error
|
||||
*
|
||||
* The AI uses the contract's extracted data to provide contextual answers about:
|
||||
* - Contract terms and conditions
|
||||
* - Dates and expiration information
|
||||
* - Coverage details
|
||||
* - Renewal terms
|
||||
* - Specific clauses and provisions
|
||||
*
|
||||
* @param id - Contract ID
|
||||
* @param question - User's question about the contract
|
||||
* @returns AI-generated answer or error
|
||||
*
|
||||
* Example Questions:
|
||||
* - "When does this insurance expire?"
|
||||
* - "What is the coverage limit?"
|
||||
* - "What are the exclusions?"
|
||||
* - "How much is the premium?"
|
||||
*/
|
||||
export async function askContractQuestionAction(id: string, question: string) {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
if (!clerkId) {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
const trimmedQuestion = question.trim();
|
||||
if (!trimmedQuestion) {
|
||||
return { success: false, error: "Question cannot be empty" };
|
||||
}
|
||||
|
||||
// Get contract details
|
||||
const contract = await ContractService.getById(id);
|
||||
|
||||
// Get internal user ID
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
if (!user || contract.userId !== user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized: Contract not found or does not belong to you",
|
||||
};
|
||||
}
|
||||
|
||||
// Ask AI about contract with full context
|
||||
const answer = await AIService.askAboutContract({
|
||||
question: trimmedQuestion,
|
||||
contract: {
|
||||
fileName: contract.fileName,
|
||||
title: contract.title,
|
||||
type: contract.type,
|
||||
provider: contract.provider,
|
||||
policyNumber: contract.policyNumber,
|
||||
startDate: contract.startDate,
|
||||
endDate: contract.endDate,
|
||||
premium: contract.premium
|
||||
? parseFloat(contract.premium.toString())
|
||||
: null,
|
||||
summary: contract.summary,
|
||||
keyPoints:
|
||||
(contract.keyPoints as Record<string, unknown> | null) ?? null,
|
||||
extractedText: contract.extractedText,
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, answer };
|
||||
} catch (error: unknown) {
|
||||
console.error("Ask contract question error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
/**
|
||||
* Notification Server Actions
|
||||
*
|
||||
* Handles all notification-related server actions including:
|
||||
* - Fetching notifications
|
||||
* - Marking notifications as read
|
||||
* - Deleting notifications
|
||||
* - Checking for deadline notifications
|
||||
*
|
||||
* These actions are called from client components and provide real-time
|
||||
* notification management with Clerk authentication.
|
||||
*/
|
||||
|
||||
"use server";
|
||||
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import { ContractService } from "@/lib/services/contract.service";
|
||||
import { NotificationService } from "@/lib/services/notification.service";
|
||||
|
||||
const isNotificationTableMissingError = (error: unknown): boolean => {
|
||||
if (!error || typeof error !== "object") return false;
|
||||
|
||||
const maybePrismaError = error as {
|
||||
code?: string;
|
||||
meta?: { table?: string };
|
||||
message?: string;
|
||||
};
|
||||
|
||||
if (maybePrismaError.code !== "P2021") return false;
|
||||
|
||||
const tableFromMeta = maybePrismaError.meta?.table ?? "";
|
||||
const message = maybePrismaError.message ?? "";
|
||||
|
||||
return (
|
||||
tableFromMeta.includes("Notification") ||
|
||||
message.includes("public.Notification")
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all unread notifications for the current authenticated user
|
||||
*
|
||||
* Uses Clerk authentication to get the current user's ID
|
||||
*
|
||||
* @param limit - Maximum number of notifications to return (default: 10)
|
||||
* @returns Object with success status and notifications array
|
||||
*
|
||||
* Steps:
|
||||
* 1. Authenticate user via Clerk
|
||||
* 2. Get internal user ID from database using Clerk ID
|
||||
* 3. Fetch unread notifications from database
|
||||
* 4. Include contract details (title, endDate)
|
||||
* 5. Return sorted by creation date (newest first)
|
||||
*
|
||||
* Example usage in component:
|
||||
* ```typescript
|
||||
* const { unreadNotifications } = await getNotifications();
|
||||
* ```
|
||||
*/
|
||||
export async function getNotifications(limit: number = 10) {
|
||||
try {
|
||||
// Step 1: Authenticate user
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Get internal user ID from database
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
// Step 3: Fetch unread notifications
|
||||
const result = await NotificationService.getUnread(user.id, limit);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Get notifications error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches complete notification history for the current user
|
||||
*
|
||||
* Returns both read and unread notifications for notification center/log view
|
||||
*
|
||||
* @param limit - Maximum number of notifications to return (default: 50)
|
||||
* @returns Object with success status and all notifications array
|
||||
*/
|
||||
export async function getAllNotifications(limit: number = 50) {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await NotificationService.getAll(user.id, limit);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Get all notifications error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets count of unread notifications for badge display
|
||||
*
|
||||
* Used to show badge count on notification icon in the UI
|
||||
*
|
||||
* @returns Object with unread notification count
|
||||
*
|
||||
* Example usage:
|
||||
* ```typescript
|
||||
* const { data } = await getUnreadNotificationCount();
|
||||
* // Display data.count as badge on notification bell icon
|
||||
* ```
|
||||
*/
|
||||
export async function getUnreadNotificationCount() {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await NotificationService.getUnreadCount(user.id);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
data: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Get unread count error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a single notification as read
|
||||
*
|
||||
* @param notificationId - The ID of the notification to mark as read
|
||||
* @returns Object with success status
|
||||
*
|
||||
* Security: Verifies the notification belongs to the current user
|
||||
*/
|
||||
export async function markNotificationAsRead(notificationId: string) {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
// Verify notification belongs to user
|
||||
const notification = await prisma.notification.findUnique({
|
||||
where: { id: notificationId },
|
||||
});
|
||||
|
||||
if (!notification || notification.userId !== user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await NotificationService.markAsRead(notificationId);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Mark notification as read error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks all unread notifications as read for the current user
|
||||
*
|
||||
* @returns Object with success status and count of updated notifications
|
||||
*/
|
||||
export async function markAllNotificationsAsRead() {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await NotificationService.markAllAsRead(user.id);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
data: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Mark all notifications as read error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a notification
|
||||
*
|
||||
* @param notificationId - The ID of the notification to delete
|
||||
* @returns Object with success status
|
||||
*
|
||||
* Security: Verifies the notification belongs to the current user before deletion
|
||||
*/
|
||||
export async function deleteNotification(notificationId: string) {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
// Verify notification belongs to user
|
||||
const notification = await prisma.notification.findUnique({
|
||||
where: { id: notificationId },
|
||||
});
|
||||
|
||||
if (!notification || notification.userId !== user.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await NotificationService.delete(notificationId);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Delete notification error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for upcoming contract deadlines and creates notifications
|
||||
*
|
||||
* Scans all user's contracts and creates DEADLINE type notifications for:
|
||||
* - 30 days before expiration (CRITICAL - 🔴)
|
||||
* - 15 days before expiration (WARNING - 🟠)
|
||||
* - 7 days before expiration (URGENT - 🟡)
|
||||
*
|
||||
* @returns Object with success status and count of created notifications
|
||||
*
|
||||
* Should be called:
|
||||
* - On dashboard page load (to refresh deadline list)
|
||||
* - Once per day at a scheduled time via cron job
|
||||
* - When monitoring upcoming deadlines
|
||||
*
|
||||
* Example usage:
|
||||
* ```typescript
|
||||
* // In dashboard page effect
|
||||
* useEffect(() => {
|
||||
* checkDeadlineNotifications();
|
||||
* }, []);
|
||||
* ```
|
||||
*/
|
||||
export async function checkDeadlineNotifications() {
|
||||
try {
|
||||
const { userId: clerkId } = await auth();
|
||||
|
||||
if (!clerkId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
};
|
||||
}
|
||||
|
||||
const user = await ContractService.getUserByClerkId(clerkId);
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
success: false,
|
||||
error: "User not found",
|
||||
};
|
||||
}
|
||||
|
||||
const result = await NotificationService.checkUpcomingDeadlines(user.id);
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
return {
|
||||
success: true,
|
||||
data: { count: 0, contractIds: [] },
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Check deadline notifications error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { getUserStats } from "@/lib/services/stats.service";
|
||||
|
||||
export async function getStatsAction() {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
return await getUserStats(userId);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
// Server action to manually sync current user to database
|
||||
"use server";
|
||||
|
||||
import { auth } from "@clerk/nextjs/server";
|
||||
import { clerkClient } from "@clerk/nextjs/server";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
|
||||
export async function syncCurrentUser() {
|
||||
try {
|
||||
const { userId } = await auth();
|
||||
|
||||
if (!userId) {
|
||||
return { success: false, error: "Not authenticated" };
|
||||
}
|
||||
|
||||
// Get user details from Clerk
|
||||
const clerk = await clerkClient();
|
||||
const user = await clerk.users.getUser(userId);
|
||||
|
||||
// Create or update user in database
|
||||
await prisma.user.upsert({
|
||||
where: { clerkId: userId },
|
||||
create: {
|
||||
clerkId: userId,
|
||||
email: user.emailAddresses[0]?.emailAddress || "",
|
||||
firstName: user.firstName || null,
|
||||
lastName: user.lastName || null,
|
||||
imageUrl: user.imageUrl || null,
|
||||
},
|
||||
update: {
|
||||
email: user.emailAddresses[0]?.emailAddress || "",
|
||||
firstName: user.firstName || null,
|
||||
lastName: user.lastName || null,
|
||||
imageUrl: user.imageUrl || null,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `User ${user.emailAddresses[0]?.emailAddress} synced successfully!`,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Sync error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,21 @@
|
||||
// src/lib/services/ai.service.ts
|
||||
import { GoogleGenerativeAI } from "@google/generative-ai";
|
||||
import { prisma } from "@/lib/db/prisma";
|
||||
import {
|
||||
AnalyzeOptions,
|
||||
ContractPrecheckResult,
|
||||
NormalizedAnalysis,
|
||||
} from "@/lib/services/ai/analysis.types";
|
||||
import {
|
||||
buildAnalysisPrompt,
|
||||
buildPrevalidationPrompt,
|
||||
} from "@/lib/services/ai/analysis.prompt";
|
||||
import { parseJsonResponse as parseAiJsonResponse } from "@/lib/services/ai/analysis.parser";
|
||||
import { normalizeAnalysis as normalizeAiAnalysis } from "@/lib/services/ai/analysis.normalizer";
|
||||
|
||||
// Read API key from environment once at module load.
|
||||
const API_KEY = process.env.AI_API_KEY;
|
||||
const API_KEY =
|
||||
process.env.AI_API_KEY || process.env.AI_API_KEY2 || process.env.AI_API_KEY3;
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error("❌ AI_API_KEY is missing from environment variables");
|
||||
@@ -14,45 +26,14 @@ if (!API_KEY) {
|
||||
// Initialize Gemini
|
||||
const genAI = new GoogleGenerativeAI(API_KEY);
|
||||
|
||||
// Runtime options used by analysis.
|
||||
type AnalyzeOptions = {
|
||||
userId?: string;
|
||||
fileName?: string;
|
||||
maxRetries?: number;
|
||||
};
|
||||
const PRIMARY_ANALYSIS_MODEL =
|
||||
process.env.AI_MODEL_PRIMARY || "gemini-2.5-flash";
|
||||
const FALLBACK_ANALYSIS_MODEL =
|
||||
process.env.AI_MODEL_FALLBACK || "gemini-2.0-flash";
|
||||
|
||||
// Canonical shape returned by this service after normalization and validation.
|
||||
type NormalizedAnalysis = {
|
||||
title: string;
|
||||
type:
|
||||
| "INSURANCE_AUTO"
|
||||
| "INSURANCE_HOME"
|
||||
| "INSURANCE_HEALTH"
|
||||
| "INSURANCE_LIFE"
|
||||
| "LOAN"
|
||||
| "CREDIT_CARD"
|
||||
| "INVESTMENT"
|
||||
| "OTHER";
|
||||
provider: string | null;
|
||||
policyNumber: string | null;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
premium: number | null;
|
||||
summary: string;
|
||||
keyPoints: {
|
||||
guarantees: string[];
|
||||
exclusions: string[];
|
||||
franchise: string | null;
|
||||
importantDates: string[];
|
||||
};
|
||||
extractedText: string;
|
||||
};
|
||||
|
||||
type ContractPrecheckResult = {
|
||||
isValidContract: boolean;
|
||||
confidence: number;
|
||||
reason: string | null;
|
||||
};
|
||||
const ANALYSIS_MODELS = Array.from(
|
||||
new Set([PRIMARY_ANALYSIS_MODEL, FALLBACK_ANALYSIS_MODEL]),
|
||||
);
|
||||
|
||||
export class AIService {
|
||||
/**
|
||||
@@ -127,21 +108,9 @@ export class AIService {
|
||||
);
|
||||
}
|
||||
|
||||
// Step 3: Configure model for deterministic, JSON-centric extraction.
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: "gemini-2.5-flash",
|
||||
generationConfig: {
|
||||
temperature: 0.1, // Low for consistency
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
maxOutputTokens: 8192,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Step 4: Build adaptive extraction context from previously analyzed contracts.
|
||||
const adaptiveContext = await this.buildAdaptiveContext(options?.userId);
|
||||
const basePrompt = this.buildPrompt({
|
||||
const basePrompt = buildAnalysisPrompt({
|
||||
adaptiveContext,
|
||||
fileName: options?.fileName,
|
||||
});
|
||||
@@ -158,17 +127,12 @@ export class AIService {
|
||||
: `\n\nCORRECTION MODE:\nYour previous response was invalid.\nReason: ${lastValidationError || "Invalid structure"}.\nReturn JSON only and keep every required field.\nPrevious invalid response:\n${previousRawResponse.slice(0, 2000)}`;
|
||||
|
||||
// Step 5: Ask model to extract strict JSON from the uploaded file.
|
||||
const result = await model.generateContent([
|
||||
`${basePrompt}${correctionHint}`,
|
||||
{
|
||||
inlineData: {
|
||||
data: base64,
|
||||
mimeType: mimeType,
|
||||
},
|
||||
},
|
||||
]);
|
||||
const text = await this.generateAnalysisWithFallback({
|
||||
prompt: `${basePrompt}${correctionHint}`,
|
||||
base64,
|
||||
mimeType,
|
||||
});
|
||||
|
||||
const text = result.response.text();
|
||||
if (!text) {
|
||||
lastValidationError = "No content in AI response";
|
||||
continue;
|
||||
@@ -178,7 +142,38 @@ export class AIService {
|
||||
|
||||
try {
|
||||
// Step 6: Parse and normalize output into canonical structure.
|
||||
const parsed = this.parseJsonResponse(text);
|
||||
let parsed: unknown;
|
||||
|
||||
try {
|
||||
parsed = this.parseJsonResponse(text);
|
||||
} catch (parseError) {
|
||||
console.warn(
|
||||
"Initial JSON parse failed. Attempting repair with fallback model...",
|
||||
);
|
||||
const repaired = await this.repairMalformedJson(
|
||||
text,
|
||||
parseError instanceof Error
|
||||
? parseError.message
|
||||
: "Invalid JSON response",
|
||||
);
|
||||
|
||||
if (!repaired) {
|
||||
// Emergency fallback: try to extract key fields from raw text
|
||||
console.warn(
|
||||
"Repair model failed. Attempting emergency field extraction...",
|
||||
);
|
||||
const emergency = this.emergencyExtractFields(text);
|
||||
if (emergency) {
|
||||
console.log("✅ Emergency extraction succeeded");
|
||||
parsed = this.parseJsonResponse(emergency);
|
||||
} else {
|
||||
throw parseError;
|
||||
}
|
||||
} else {
|
||||
parsed = this.parseJsonResponse(repaired);
|
||||
}
|
||||
}
|
||||
|
||||
const normalized = this.normalizeAnalysis(parsed);
|
||||
|
||||
// Step 7: Reject non-contract uploads with explicit error.
|
||||
@@ -225,7 +220,7 @@ export class AIService {
|
||||
error.message?.includes("404")
|
||||
) {
|
||||
throw new Error(
|
||||
"Invalid Gemini model. Ensure 'gemini-2.5-flash' is available in your Google Cloud project.",
|
||||
`Invalid Gemini model configuration. Current models: ${ANALYSIS_MODELS.join(", ")}. Check model availability in your Gemini account.`,
|
||||
);
|
||||
} else if (
|
||||
error.message?.includes("fetch") &&
|
||||
@@ -234,7 +229,11 @@ export class AIService {
|
||||
throw new Error(
|
||||
"Download failed. Check if the file URL is correct and accessible.",
|
||||
);
|
||||
} else if (error.message?.includes("JSON")) {
|
||||
} else if (
|
||||
error.message?.includes("JSON") ||
|
||||
error.message?.includes("No complete JSON object") ||
|
||||
error.message?.includes("parse failed")
|
||||
) {
|
||||
console.error("❌ Raw response that failed to parse:", error);
|
||||
console.error("Full error message:", error.message);
|
||||
|
||||
@@ -253,7 +252,7 @@ export class AIService {
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Error parsing AI response. The response may not be valid JSON. Check console for details.",
|
||||
"AI returned a malformed response format. Please retry analysis; if it fails again, the file may require OCR cleanup.",
|
||||
);
|
||||
}
|
||||
} else if (error.message?.includes("quota")) {
|
||||
@@ -267,88 +266,13 @@ export class AIService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build extraction prompt with strict schema + anti-hallucination instructions.
|
||||
* Prompt generation has been moved to lib/services/ai/analysis.prompt.ts.
|
||||
*/
|
||||
private static buildPrompt(input?: {
|
||||
adaptiveContext?: string;
|
||||
fileName?: string;
|
||||
}): string {
|
||||
return `You are an expert in BFSI contract analysis (Banking, Financial Services, Insurance).
|
||||
|
||||
Document name: ${input?.fileName ?? "Unknown"}
|
||||
|
||||
${input?.adaptiveContext ?? ""}
|
||||
|
||||
Analyze this contract document and extract ALL important information in the EXACT JSON format below:
|
||||
|
||||
{
|
||||
"title": "Descriptive contract title (e.g., Allianz Car Insurance)",
|
||||
"type": "INSURANCE_AUTO",
|
||||
"provider": "Name of the company or financial institution",
|
||||
"policyNumber": "Policy number or contract number",
|
||||
"startDate": "2024-01-01",
|
||||
"endDate": "2024-12-31",
|
||||
"premium": 1200.50,
|
||||
"summary": "Clear and concise summary of the contract in a maximum of 3–4 sentences, covering the main guarantees and conditions",
|
||||
"keyPoints": {
|
||||
"guarantees": ["List of main guarantees or coverages provided"],
|
||||
"exclusions": ["List of important exclusions to be aware of"],
|
||||
"franchise": "Deductible amount or description (e.g., €500)",
|
||||
"importantDates": ["Key dates and important deadlines"]
|
||||
},
|
||||
"contractValidation": {
|
||||
"isValidContract": true,
|
||||
"confidence": 88,
|
||||
"reason": "Short reason if invalid, otherwise null"
|
||||
},
|
||||
"extractedText": "Full text extracted from the document with all details"
|
||||
}
|
||||
|
||||
CRITICAL INSTRUCTIONS:
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
TYPE — Must be EXACTLY one of the following values:
|
||||
|
||||
INSURANCE_AUTO (car insurance)
|
||||
|
||||
INSURANCE_HOME (home insurance)
|
||||
|
||||
INSURANCE_HEALTH (health insurance/mutual)
|
||||
|
||||
INSURANCE_LIFE (life insurance)
|
||||
|
||||
LOAN (bank loan)
|
||||
|
||||
CREDIT_CARD (credit card)
|
||||
|
||||
INVESTMENT (investment account)
|
||||
|
||||
OTHER (other type)
|
||||
|
||||
DATES — Strict format YYYY-MM-DD (e.g., 2024-01-15)
|
||||
|
||||
PREMIUM — Decimal number only (e.g., 1200.50, no text)
|
||||
|
||||
NULL — If information does not exist, use null (not an empty string "")
|
||||
|
||||
CONTRACT VALIDATION — Determine whether this document is truly a contract/policy/loan agreement.
|
||||
- contractValidation.isValidContract must be false for invoices, receipts, ID cards, blank scans, random photos, marketing flyers, or unrelated files.
|
||||
- confidence must be an integer from 0 to 100.
|
||||
- reason must explain why invalid when isValidContract is false.
|
||||
|
||||
EXTRACTED TEXT — Must contain ALL visible text from the document
|
||||
|
||||
SUMMARY — Maximum 4 sentences, clear and informative
|
||||
|
||||
RESPONSE — Respond ONLY with valid JSON, no text before or after, no markdown
|
||||
|
||||
QUALITY GUARDRAILS:
|
||||
- Never invent provider names, policy numbers, dates, or premium values.
|
||||
- If uncertain, use null for that field.
|
||||
- Keep extractedText raw and faithful to the visible document content.
|
||||
- For summary and key points, prioritize practical legal and business implications.
|
||||
|
||||
NOW ANALYZE THE DOCUMENT:`;
|
||||
return buildAnalysisPrompt(input);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,86 +306,232 @@ NOW ANALYZE THE DOCUMENT:`;
|
||||
}
|
||||
|
||||
private static parseJsonResponse(text: string): unknown {
|
||||
if (!text || typeof text !== "string" || text.trim().length === 0) {
|
||||
throw new Error("AI response is empty or invalid.");
|
||||
}
|
||||
return parseAiJsonResponse(text);
|
||||
}
|
||||
|
||||
// Remove potential markdown wrappers, comments, and extra whitespace
|
||||
let cleanJson = text
|
||||
.replace(/```json[\s\n]*/, "") // Remove opening markdown
|
||||
.replace(/```[\s\n]*$/, "") // Remove closing markdown
|
||||
.replace(/\/\/.*$/gm, "") // Remove JavaScript comments
|
||||
.trim();
|
||||
private static async generateAnalysisWithFallback(input: {
|
||||
prompt: string;
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
}): Promise<string> {
|
||||
let lastError: unknown = null;
|
||||
|
||||
// Check for common issues that indicate incomplete/corrupted response
|
||||
const responsePreview = cleanJson.substring(0, 200);
|
||||
console.log("🔍 AI Response preview:", responsePreview);
|
||||
for (const modelName of ANALYSIS_MODELS) {
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: modelName,
|
||||
generationConfig: {
|
||||
temperature: 0.1,
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
maxOutputTokens: 16384,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// Try direct parse first
|
||||
try {
|
||||
const result = JSON.parse(cleanJson);
|
||||
console.log("✅ JSON parsed successfully on first attempt");
|
||||
return result;
|
||||
} catch (firstError) {
|
||||
console.warn(
|
||||
"⚠️ First JSON parse failed:",
|
||||
(firstError as Error).message,
|
||||
);
|
||||
}
|
||||
const result = await model.generateContent([
|
||||
input.prompt,
|
||||
{
|
||||
inlineData: {
|
||||
data: input.base64,
|
||||
mimeType: input.mimeType,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// Fallback 1: Try removing non-JSON text (explanations before/after JSON)
|
||||
try {
|
||||
const firstCurly = cleanJson.indexOf("{");
|
||||
const lastCurly = cleanJson.lastIndexOf("}");
|
||||
|
||||
if (firstCurly === -1 || lastCurly === -1 || firstCurly >= lastCurly) {
|
||||
throw new Error(
|
||||
"No JSON object wrapper found (missing { or }). Response may be incomplete.",
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure we get complete closing braces for nested objects
|
||||
let braceCount = 0;
|
||||
let endIndex = firstCurly;
|
||||
|
||||
for (let i = firstCurly; i < cleanJson.length; i++) {
|
||||
if (cleanJson[i] === "{") braceCount++;
|
||||
if (cleanJson[i] === "}") braceCount--;
|
||||
if (braceCount === 0) {
|
||||
endIndex = i;
|
||||
break;
|
||||
const text = result.response.text();
|
||||
if (text && text.trim().length > 0) {
|
||||
console.log(`✅ Analysis with model ${modelName} succeeded`);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
const jsonSlice = cleanJson.slice(firstCurly, endIndex + 1);
|
||||
console.log("📝 Extracted JSON slice length:", jsonSlice.length);
|
||||
|
||||
const result = JSON.parse(jsonSlice);
|
||||
console.log("✅ JSON parsed successfully after text removal");
|
||||
return result;
|
||||
} catch (fallbackError) {
|
||||
console.error(
|
||||
"❌ JSON fallback parsing failed:",
|
||||
(fallbackError as Error).message,
|
||||
);
|
||||
console.error("Full raw response:", cleanJson.substring(0, 500));
|
||||
|
||||
// Last resort: Check for common formatting issues
|
||||
if (cleanJson.includes('\\n"') || cleanJson.includes('\\"')) {
|
||||
throw new Error(
|
||||
"Response contains escaped quotes or newlines that couldn't be parsed. The contract may have corrupted text.",
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
console.warn(
|
||||
`Analysis with model ${modelName} failed. Trying next model.`,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!cleanJson.includes('"type"') && !cleanJson.includes('"title"')) {
|
||||
throw new Error(
|
||||
"Response is missing expected contract fields. It may not be a valid contract document.",
|
||||
);
|
||||
// All primary models failed. Try with more lenient generation settings as last resort
|
||||
console.warn(
|
||||
"All standard models failed. Trying with lenient generation config...",
|
||||
);
|
||||
try {
|
||||
const fallbackModel = genAI.getGenerativeModel({
|
||||
model: PRIMARY_ANALYSIS_MODEL,
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.9,
|
||||
topK: 20,
|
||||
maxOutputTokens: 16384,
|
||||
// Don't enforce JSON format; let model produce raw output
|
||||
},
|
||||
});
|
||||
|
||||
const result = await fallbackModel.generateContent([
|
||||
input.prompt,
|
||||
{
|
||||
inlineData: {
|
||||
data: input.base64,
|
||||
mimeType: input.mimeType,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const text = result.response.text();
|
||||
if (text && text.trim().length > 0) {
|
||||
console.log("✅ Lenient generation succeeded");
|
||||
return text;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Lenient generation also failed:", error);
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error("All analysis models failed to generate content.");
|
||||
}
|
||||
|
||||
private static async repairMalformedJson(
|
||||
malformedResponse: string,
|
||||
parseError: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
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",
|
||||
type: "enum: INSURANCE_AUTO|INSURANCE_HOME|INSURANCE_HEALTH|INSURANCE_LIFE|LOAN|CREDIT_CARD|INVESTMENT|OTHER",
|
||||
provider: "string|null",
|
||||
policyNumber: "string|null",
|
||||
startDate: "YYYY-MM-DD|null",
|
||||
endDate: "YYYY-MM-DD|null",
|
||||
premium: "number|null",
|
||||
premiumCurrency: "string|null (ISO code like EUR/USD/TND or symbol)",
|
||||
summary: "string (min 10 chars)",
|
||||
extractedText: "string (min 30 chars)",
|
||||
keyPoints: {
|
||||
guarantees: "string[]",
|
||||
exclusions: "string[]",
|
||||
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",
|
||||
confidence: "number (0-100)",
|
||||
reason: "string|null",
|
||||
},
|
||||
};
|
||||
|
||||
const repairPrompt = `You are a JSON repair engine for contract analysis.
|
||||
Fix the malformed JSON response below and return ONLY valid, parseable JSON conforming to this schema:
|
||||
|
||||
${JSON.stringify(expectedSchema, null, 2)}
|
||||
|
||||
Rules:
|
||||
1. Return ONLY the JSON object, no markdown, no explanations.
|
||||
2. Preserve all values from the original response as accurately as possible.
|
||||
3. Fix structural issues: missing braces, unescaped quotes, trailing commas, unmatched brackets.
|
||||
4. For null/missing fields, use null value or empty array [] as appropriate.
|
||||
5. Ensure all required text fields (title, summary, extractedText) have content.
|
||||
6. All numeric values must be valid numbers.
|
||||
7. All dates must be in YYYY-MM-DD format.
|
||||
8. If type is unclear, use "OTHER".
|
||||
9. Preserve explainability and evidence snippets when present.
|
||||
|
||||
Original parse error: ${parseError}
|
||||
|
||||
Malformed response to fix:
|
||||
${malformedResponse.slice(0, 14000)}`;
|
||||
|
||||
const repaired = await model.generateContent(repairPrompt);
|
||||
const repairedText = repaired.response.text()?.trim() || "";
|
||||
|
||||
if (repairedText.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Failed to parse AI response as JSON: ${(fallbackError as Error).message}`,
|
||||
// Verify the repaired text is at least JSON-like before returning
|
||||
if (!repairedText.includes("{")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return repairedText;
|
||||
} catch (error) {
|
||||
console.warn("JSON repair step failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private static emergencyExtractFields(rawText: string): string | null {
|
||||
try {
|
||||
const titleMatch = rawText.match(
|
||||
/["']?title["']?\s*:\s*["']([^"']{5,200})/i,
|
||||
);
|
||||
const summaryMatch = rawText.match(
|
||||
/summary["']?\s*:\s*["']([^"']{10,500})/i,
|
||||
);
|
||||
const extractedMatch = rawText.match(
|
||||
/extractedText["']?\s*:\s*["']([^"']{30,})/i,
|
||||
);
|
||||
|
||||
if (!titleMatch || !summaryMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const emergency = {
|
||||
title: titleMatch[1]?.slice(0, 200) || "Contract",
|
||||
type: "OTHER",
|
||||
provider: null,
|
||||
policyNumber: null,
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
premium: null,
|
||||
premiumCurrency: null,
|
||||
summary: summaryMatch[1]?.slice(0, 500) || "Contract analysis",
|
||||
extractedText:
|
||||
extractedMatch?.[1]?.slice(0, 12000) || rawText.slice(0, 12000),
|
||||
keyPoints: {
|
||||
guarantees: [],
|
||||
exclusions: [],
|
||||
franchise: null,
|
||||
importantDates: [],
|
||||
},
|
||||
contractValidation: {
|
||||
isValidContract: true,
|
||||
confidence: 50,
|
||||
reason: "Emergency partial extraction due to response malformation",
|
||||
},
|
||||
};
|
||||
|
||||
return JSON.stringify(emergency);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,44 +546,24 @@ NOW ANALYZE THE DOCUMENT:`;
|
||||
mimeType: string;
|
||||
fileName?: string;
|
||||
}): Promise<ContractPrecheckResult> {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: "gemini-2.5-flash",
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.9,
|
||||
topK: 20,
|
||||
maxOutputTokens: 350,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
});
|
||||
const rawText = await this.generatePrevalidationWithFallback(input);
|
||||
|
||||
const result = await model.generateContent([
|
||||
`You are validating whether an uploaded document is a legal/financial contract.
|
||||
let raw: any;
|
||||
try {
|
||||
raw = this.parseJsonResponse(rawText || "{}");
|
||||
} catch (error) {
|
||||
// If prevalidation JSON is malformed, assume it's a contract with moderate confidence
|
||||
console.warn(
|
||||
"Prevalidation JSON parse failed, assuming contract with moderate confidence",
|
||||
);
|
||||
return {
|
||||
isValidContract: true,
|
||||
confidence: 60,
|
||||
reason:
|
||||
"Prevalidation response was malformed, but document appears contract-like",
|
||||
};
|
||||
}
|
||||
|
||||
File name: ${input.fileName ?? "Unknown"}
|
||||
|
||||
Return ONLY JSON:
|
||||
{
|
||||
"isValidContract": true,
|
||||
"confidence": 0,
|
||||
"reason": null
|
||||
}
|
||||
|
||||
Rules:
|
||||
- isValidContract=false for invoices, receipts, identity cards, random photos/screenshots, blank pages, flyers, or unrelated files.
|
||||
- confidence is an integer from 0 to 100.
|
||||
- reason must be concise and user-friendly when invalid.
|
||||
- If valid, reason can be null.
|
||||
`,
|
||||
{
|
||||
inlineData: {
|
||||
data: input.base64,
|
||||
mimeType: input.mimeType,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const raw = this.parseJsonResponse(result.response.text() || "{}");
|
||||
const maybe = raw as Partial<ContractPrecheckResult>;
|
||||
|
||||
const isValidContract = Boolean(maybe.isValidContract);
|
||||
@@ -532,95 +582,55 @@ Rules:
|
||||
};
|
||||
}
|
||||
|
||||
private static async generatePrevalidationWithFallback(input: {
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
fileName?: string;
|
||||
}): Promise<string> {
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const modelName of ANALYSIS_MODELS) {
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: modelName,
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.9,
|
||||
topK: 20,
|
||||
maxOutputTokens: 350,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await model.generateContent([
|
||||
buildPrevalidationPrompt(input.fileName),
|
||||
{
|
||||
inlineData: {
|
||||
data: input.base64,
|
||||
mimeType: input.mimeType,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const text = result.response.text();
|
||||
if (text && text.trim().length > 0) {
|
||||
return text;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
console.warn(
|
||||
`Pre-validation with model ${modelName} failed. Trying next model.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError instanceof Error
|
||||
? lastError
|
||||
: new Error("All pre-validation models failed to generate content.");
|
||||
}
|
||||
|
||||
private static normalizeAnalysis(input: any): NormalizedAnalysis {
|
||||
// Ensure contract type belongs to supported enum.
|
||||
const validTypes = new Set([
|
||||
"INSURANCE_AUTO",
|
||||
"INSURANCE_HOME",
|
||||
"INSURANCE_HEALTH",
|
||||
"INSURANCE_LIFE",
|
||||
"LOAN",
|
||||
"CREDIT_CARD",
|
||||
"INVESTMENT",
|
||||
"OTHER",
|
||||
]);
|
||||
|
||||
const type =
|
||||
typeof input?.type === "string" && validTypes.has(input.type)
|
||||
? input.type
|
||||
: null;
|
||||
|
||||
if (!type) {
|
||||
throw new Error("Contract type is missing or invalid.");
|
||||
}
|
||||
|
||||
const title = String(input?.title || "").trim();
|
||||
const summary = String(input?.summary || "").trim();
|
||||
const extractedText = String(input?.extractedText || "").trim();
|
||||
|
||||
if (title.length < 3) {
|
||||
throw new Error("Title is missing or too short.");
|
||||
}
|
||||
if (summary.length < 10) {
|
||||
throw new Error("Summary is missing or too short.");
|
||||
}
|
||||
if (extractedText.length < 50) {
|
||||
throw new Error("Extracted text is missing or too short.");
|
||||
}
|
||||
|
||||
// Helper: normalize unknown primitive into string|null.
|
||||
const toStringOrNull = (value: unknown): string | null => {
|
||||
const normalized = String(value ?? "").trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
};
|
||||
|
||||
// Helper: accept only strict ISO date values.
|
||||
const toDateOrNull = (value: unknown): string | null => {
|
||||
const candidate = String(value ?? "").trim();
|
||||
if (!candidate) return null;
|
||||
|
||||
const isIsoDate = /^\d{4}-\d{2}-\d{2}$/.test(candidate);
|
||||
return isIsoDate ? candidate : null;
|
||||
};
|
||||
|
||||
// Helper: sanitize array values into non-empty text list.
|
||||
const toStringList = (value: unknown): string[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => String(item ?? "").trim())
|
||||
.filter((item) => item.length > 0);
|
||||
};
|
||||
|
||||
// Premium must be numeric and non-negative.
|
||||
const premiumValue =
|
||||
input?.premium === null || input?.premium === undefined
|
||||
? null
|
||||
: Number(input.premium);
|
||||
|
||||
const premium =
|
||||
premiumValue !== null &&
|
||||
Number.isFinite(premiumValue) &&
|
||||
premiumValue >= 0
|
||||
? Number(premiumValue.toFixed(2))
|
||||
: null;
|
||||
|
||||
return {
|
||||
title,
|
||||
type,
|
||||
provider: toStringOrNull(input?.provider),
|
||||
policyNumber: toStringOrNull(input?.policyNumber),
|
||||
startDate: toDateOrNull(input?.startDate),
|
||||
endDate: toDateOrNull(input?.endDate),
|
||||
premium,
|
||||
summary,
|
||||
keyPoints: {
|
||||
guarantees: toStringList(input?.keyPoints?.guarantees),
|
||||
exclusions: toStringList(input?.keyPoints?.exclusions),
|
||||
franchise: toStringOrNull(input?.keyPoints?.franchise),
|
||||
importantDates: toStringList(input?.keyPoints?.importantDates),
|
||||
},
|
||||
extractedText,
|
||||
};
|
||||
return normalizeAiAnalysis(input);
|
||||
}
|
||||
|
||||
private static async buildAdaptiveContext(userId?: string): Promise<string> {
|
||||
@@ -643,6 +653,7 @@ Rules:
|
||||
provider: true,
|
||||
policyNumber: true,
|
||||
summary: true,
|
||||
keyPoints: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -680,6 +691,49 @@ Rules:
|
||||
.slice(0, 4)
|
||||
.map((value) => value.replace(/[A-Za-z0-9]/g, "X"));
|
||||
|
||||
const allExplainability = examples
|
||||
.flatMap((item) => {
|
||||
const maybeExplainability = (item.keyPoints as any)?.explainability;
|
||||
return Array.isArray(maybeExplainability) ? maybeExplainability : [];
|
||||
})
|
||||
.slice(0, 120);
|
||||
|
||||
const explainabilityByField = count(
|
||||
allExplainability
|
||||
.map((entry: any) => String(entry?.field ?? "").trim())
|
||||
.filter((value: string) => value.length > 0),
|
||||
);
|
||||
|
||||
const confidenceValues = allExplainability
|
||||
.map((entry: any) => Number(entry?.sourceHints?.confidence))
|
||||
.filter((value: number) => Number.isFinite(value));
|
||||
|
||||
const avgEvidenceConfidence = confidenceValues.length
|
||||
? Math.round(
|
||||
confidenceValues.reduce(
|
||||
(sum: number, value: number) => sum + value,
|
||||
0,
|
||||
) / confidenceValues.length,
|
||||
)
|
||||
: null;
|
||||
|
||||
const learnedLanguages = count(
|
||||
examples
|
||||
.map((item) => (item.keyPoints as any)?.aiMeta?.language)
|
||||
.map((value) => String(value ?? "").trim())
|
||||
.filter((value: string) => value.length > 0),
|
||||
);
|
||||
|
||||
const learnedKeyRoles = count(
|
||||
examples
|
||||
.flatMap((item) => {
|
||||
const people = (item.keyPoints as any)?.aiMeta?.keyPeople;
|
||||
return Array.isArray(people) ? people : [];
|
||||
})
|
||||
.map((person: any) => String(person?.role ?? "").trim())
|
||||
.filter((value: string) => value.length > 0),
|
||||
);
|
||||
|
||||
const avgSummaryLength =
|
||||
examples
|
||||
.map((item) => item.summary?.length ?? 0)
|
||||
@@ -690,6 +744,10 @@ Rules:
|
||||
- Frequent provider naming patterns: ${topProviders.join(", ") || "N/A"}
|
||||
- Example policy number shape patterns: ${policyPatterns.join(", ") || "N/A"}
|
||||
- Typical summary length target: around ${Math.round(avgSummaryLength)} characters.
|
||||
- Dominant learned languages: ${learnedLanguages.join(", ") || "N/A"}
|
||||
- Most evidenced fields: ${explainabilityByField.join(", ") || "N/A"}
|
||||
- Average evidence confidence: ${avgEvidenceConfidence ?? "N/A"}
|
||||
- Frequent key roles identified: ${learnedKeyRoles.join(", ") || "N/A"}
|
||||
|
||||
Use this context only as formatting guidance. Do not force it if current document content differs.`;
|
||||
}
|
||||
@@ -711,7 +769,7 @@ Use this context only as formatting guidance. Do not force it if current documen
|
||||
const modelReason = String(raw?.contractValidation?.reason ?? "").trim();
|
||||
|
||||
const legalSignalRegex =
|
||||
/contract|agreement|policy|terms|clause|premium|coverage|insured|insurer|loan|borrower|credit|beneficiary|liability/i;
|
||||
/contract|agreement|policy|terms|clause|premium|coverage|insured|insurer|loan|borrower|credit|beneficiary|liability|lease|service|supplier|client|vendor|annex|appendix|signature|party|contrat|assurance|banque|credit|emprunteur|garantie|echeance|duree|clause/i;
|
||||
const hasLegalSignals = legalSignalRegex.test(normalized.extractedText);
|
||||
const hasStructuredSignal =
|
||||
Boolean(normalized.provider) ||
|
||||
@@ -732,6 +790,16 @@ Use this context only as formatting guidance. Do not force it if current documen
|
||||
);
|
||||
}
|
||||
|
||||
// For generic contracts mapped to OTHER, keep a lighter heuristic so valid non-BFSI contracts pass.
|
||||
if (normalized.type === "OTHER") {
|
||||
if (!hasLegalSignals && normalized.extractedText.length < 120) {
|
||||
throw new Error(
|
||||
"INVALID_CONTRACT:Uploaded file does not contain enough contract-specific signals.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasLegalSignals && !hasStructuredSignal) {
|
||||
throw new Error(
|
||||
"INVALID_CONTRACT:Uploaded file does not contain enough contract-specific signals.",
|
||||
@@ -794,20 +862,10 @@ Use this context only as formatting guidance. Do not force it if current documen
|
||||
summary?: string | null;
|
||||
keyPoints?: Record<string, unknown> | null;
|
||||
extractedText?: string | null;
|
||||
language?: string | null; // NEW: contract's detected language
|
||||
};
|
||||
}) {
|
||||
try {
|
||||
// Configure fast Q&A model tuned for concise answers.
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: "gemini-2.5-flash",
|
||||
generationConfig: {
|
||||
temperature: 0.2,
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
maxOutputTokens: 2048,
|
||||
},
|
||||
});
|
||||
|
||||
// Keep context bounded to avoid overlong prompts and token waste.
|
||||
const extractedTextSnippet = (input.contract.extractedText || "")
|
||||
.slice(0, 12000)
|
||||
@@ -816,10 +874,28 @@ Use this context only as formatting guidance. Do not force it if current documen
|
||||
input.contract.type,
|
||||
);
|
||||
|
||||
const prompt = `You are a senior BFSI contract advisor.
|
||||
// Detect contract language for multilingual response
|
||||
const contractLanguage = input.contract.language || "en";
|
||||
const languageName =
|
||||
{
|
||||
en: "English",
|
||||
fr: "French",
|
||||
de: "German",
|
||||
es: "Spanish",
|
||||
it: "Italian",
|
||||
pt: "Portuguese",
|
||||
nl: "Dutch",
|
||||
pl: "Polish",
|
||||
ja: "Japanese",
|
||||
zh: "Chinese",
|
||||
ar: "Arabic",
|
||||
}[contractLanguage] || "English";
|
||||
|
||||
const prompt = `You are a senior BFSI contract advisor. IMPORTANT: Respond entirely in ${languageName} to match the contract language.
|
||||
|
||||
Contract metadata:
|
||||
- File: ${input.contract.fileName}
|
||||
- Language: ${languageName}
|
||||
- Title: ${input.contract.title ?? "N/A"}
|
||||
- Type: ${input.contract.type ?? "N/A"}
|
||||
- Provider: ${input.contract.provider ?? "N/A"}
|
||||
@@ -837,12 +913,13 @@ ${JSON.stringify(input.contract.keyPoints ?? {}, null, 2)}
|
||||
Extracted Text:
|
||||
${extractedTextSnippet || "N/A"}
|
||||
|
||||
User question:
|
||||
User question (${languageName}):
|
||||
${input.question}
|
||||
|
||||
Instructions:
|
||||
- RESPOND ENTIRELY IN ${languageName}. This is critical.
|
||||
- Write in clear, professional, business-oriented plain text.
|
||||
- Do NOT use markdown or special formatting symbols, including: **, __, #, *, -, backticks.
|
||||
- Do NOT use markdown or special formatting symbols, including: **, __, #, *, -, backticks with one exception: you can use | for separators if needed for clarity
|
||||
- Do NOT quote large raw excerpts from extracted text unless strictly necessary.
|
||||
- Synthesize and explain the implications in practical terms instead of copying file content.
|
||||
- Base your answer ONLY on the provided contract content.
|
||||
@@ -852,21 +929,54 @@ Instructions:
|
||||
- For legal context, use wording like: "Under general EU/US legal principles..." and avoid citing specific article numbers unless explicitly present in the contract content.
|
||||
- Never claim certainty where the contract text is ambiguous.
|
||||
- Keep the answer concise, executive, and decision-oriented.
|
||||
- Use the same language preference throughout (${languageName}).
|
||||
|
||||
Response structure:
|
||||
Response structure (in ${languageName}):
|
||||
1) Direct answer in one sentence.
|
||||
2) Business impact in one to two sentences (risk, cost, operational effect).
|
||||
3) General legal context in one to two sentences when relevant.
|
||||
4) Recommended next step in one sentence.
|
||||
|
||||
Compliance note:
|
||||
Compliance note (in ${languageName}):
|
||||
Include one short disclaimer only when legal context is discussed: "This is general information, not formal legal advice."`;
|
||||
|
||||
// Execute completion and sanitize styling artifacts from response.
|
||||
const result = await model.generateContent(prompt);
|
||||
const rawAnswer = result.response.text()?.trim();
|
||||
// Execute completion with model fallback and sanitize styling artifacts.
|
||||
let rawAnswer = "";
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const modelName of ANALYSIS_MODELS) {
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: modelName,
|
||||
generationConfig: {
|
||||
temperature: 0.2,
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
maxOutputTokens: 2048,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
rawAnswer = result.response.text()?.trim() || "";
|
||||
|
||||
if (rawAnswer) {
|
||||
console.log(
|
||||
`✅ Q&A with model ${modelName} succeeded in ${languageName}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
console.warn(
|
||||
`Q&A with model ${modelName} failed. Trying next model.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!rawAnswer) {
|
||||
if (lastError instanceof Error) {
|
||||
throw lastError;
|
||||
}
|
||||
throw new Error("No response generated");
|
||||
}
|
||||
|
||||
|
||||
222
lib/services/ai/analysis.normalizer.ts
Normal file
222
lib/services/ai/analysis.normalizer.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
NormalizedAnalysis,
|
||||
SUPPORTED_CONTRACT_TYPES,
|
||||
SupportedContractType,
|
||||
ContactInfo,
|
||||
KeyPerson,
|
||||
ExplainabilityItem,
|
||||
} from "./analysis.types";
|
||||
|
||||
function mapContractType(rawType: unknown): SupportedContractType {
|
||||
const value = String(rawType ?? "")
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.replace(/\s+/g, "_");
|
||||
|
||||
if (SUPPORTED_CONTRACT_TYPES.includes(value as SupportedContractType)) {
|
||||
return value as SupportedContractType;
|
||||
}
|
||||
|
||||
const aliases: Record<string, SupportedContractType> = {
|
||||
AUTO_INSURANCE: "INSURANCE_AUTO",
|
||||
HOME_INSURANCE: "INSURANCE_HOME",
|
||||
HEALTH_INSURANCE: "INSURANCE_HEALTH",
|
||||
LIFE_INSURANCE: "INSURANCE_LIFE",
|
||||
MORTGAGE: "LOAN",
|
||||
CREDIT: "LOAN",
|
||||
CARD_CREDIT: "CREDIT_CARD",
|
||||
};
|
||||
|
||||
return aliases[value] ?? "OTHER";
|
||||
}
|
||||
|
||||
function toStringOrNull(value: unknown): string | null {
|
||||
const normalized = String(value ?? "").trim();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeCurrency(value: unknown): string | null {
|
||||
const raw = String(value ?? "")
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (!raw) return null;
|
||||
|
||||
const symbolMap: Record<string, string> = {
|
||||
"€": "EUR",
|
||||
$: "USD",
|
||||
"£": "GBP",
|
||||
};
|
||||
|
||||
if (symbolMap[raw]) {
|
||||
return symbolMap[raw];
|
||||
}
|
||||
|
||||
// Accept ISO-like 3-letter currencies and common BFSI currencies.
|
||||
if (/^[A-Z]{3}$/.test(raw)) {
|
||||
return raw;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function toDateOrNull(value: unknown): string | null {
|
||||
const candidate = String(value ?? "").trim();
|
||||
if (!candidate) return null;
|
||||
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const parsed = new Date(candidate);
|
||||
if (Number.isNaN(parsed.getTime())) return null;
|
||||
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function toStringList(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => String(item ?? "").trim())
|
||||
.filter((item) => item.length > 0)
|
||||
.slice(0, 25);
|
||||
}
|
||||
|
||||
function parseContactInfo(input: any): ContactInfo {
|
||||
return {
|
||||
name: toStringOrNull(input?.name),
|
||||
email: toStringOrNull(input?.email),
|
||||
phone: toStringOrNull(input?.phone),
|
||||
address: toStringOrNull(input?.address),
|
||||
role: toStringOrNull(input?.role),
|
||||
};
|
||||
}
|
||||
|
||||
function parseKeyPeople(input: any): KeyPerson[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
return input.slice(0, 10).map((person) => ({
|
||||
name: String(person?.name ?? "").trim() || "Unknown",
|
||||
role: toStringOrNull(person?.role),
|
||||
email: toStringOrNull(person?.email),
|
||||
phone: toStringOrNull(person?.phone),
|
||||
}));
|
||||
}
|
||||
|
||||
function parseRelevantDates(input: any): Array<{
|
||||
date: string;
|
||||
description: string;
|
||||
type: "EXPIRATION" | "RENEWAL" | "PAYMENT" | "REVIEW" | "OTHER";
|
||||
}> {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
return input.slice(0, 15).map((dateObj) => {
|
||||
const dateStr = toDateOrNull(dateObj?.date);
|
||||
const type = String(dateObj?.type ?? "OTHER").toUpperCase();
|
||||
const isValidType = [
|
||||
"EXPIRATION",
|
||||
"RENEWAL",
|
||||
"PAYMENT",
|
||||
"REVIEW",
|
||||
"OTHER",
|
||||
].includes(type);
|
||||
|
||||
return {
|
||||
date: dateStr || "0000-01-01",
|
||||
description:
|
||||
String(dateObj?.description ?? "")
|
||||
.trim()
|
||||
.slice(0, 200) || "Important date",
|
||||
type: (isValidType ? type : "OTHER") as
|
||||
| "EXPIRATION"
|
||||
| "RENEWAL"
|
||||
| "PAYMENT"
|
||||
| "REVIEW"
|
||||
| "OTHER",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseExplainability(input: any): ExplainabilityItem[] {
|
||||
if (!Array.isArray(input)) return [];
|
||||
|
||||
return input
|
||||
.slice(0, 30)
|
||||
.map((item) => {
|
||||
const field = String(item?.field ?? "").trim();
|
||||
const why = String(item?.why ?? "").trim();
|
||||
const sourceSnippet = String(item?.sourceSnippet ?? "").trim();
|
||||
|
||||
if (!field || !why || !sourceSnippet) return null;
|
||||
|
||||
const confidenceRaw = Number(item?.sourceHints?.confidence);
|
||||
const confidence = Number.isFinite(confidenceRaw)
|
||||
? Math.max(0, Math.min(100, Math.round(confidenceRaw)))
|
||||
: null;
|
||||
|
||||
return {
|
||||
field: field.slice(0, 80),
|
||||
why: why.slice(0, 260),
|
||||
sourceSnippet: sourceSnippet.slice(0, 480),
|
||||
sourceHints: {
|
||||
page: toStringOrNull(item?.sourceHints?.page),
|
||||
section: toStringOrNull(item?.sourceHints?.section),
|
||||
confidence,
|
||||
},
|
||||
} as ExplainabilityItem;
|
||||
})
|
||||
.filter((value): value is ExplainabilityItem => value !== null);
|
||||
}
|
||||
|
||||
export function normalizeAnalysis(input: any): NormalizedAnalysis {
|
||||
const title = String(input?.title || "").trim() || "Untitled Contract";
|
||||
const summary = String(input?.summary || "").trim();
|
||||
const extractedText = String(input?.extractedText || "").trim();
|
||||
|
||||
if (summary.length < 10) {
|
||||
throw new Error("Summary is missing or too short.");
|
||||
}
|
||||
|
||||
if (extractedText.length < 30) {
|
||||
throw new Error("Extracted text is missing or too short.");
|
||||
}
|
||||
|
||||
const premiumValue =
|
||||
input?.premium === null || input?.premium === undefined
|
||||
? null
|
||||
: Number(input.premium);
|
||||
|
||||
const premium =
|
||||
premiumValue !== null && Number.isFinite(premiumValue) && premiumValue >= 0
|
||||
? Number(premiumValue.toFixed(2))
|
||||
: null;
|
||||
|
||||
const language = toStringOrNull(input?.language) || "en";
|
||||
|
||||
return {
|
||||
title,
|
||||
type: mapContractType(input?.type),
|
||||
provider: toStringOrNull(input?.provider),
|
||||
policyNumber: toStringOrNull(input?.policyNumber),
|
||||
startDate: toDateOrNull(input?.startDate),
|
||||
endDate: toDateOrNull(input?.endDate),
|
||||
premium,
|
||||
premiumCurrency: normalizeCurrency(input?.premiumCurrency),
|
||||
summary,
|
||||
keyPoints: {
|
||||
guarantees: toStringList(input?.keyPoints?.guarantees),
|
||||
exclusions: toStringList(input?.keyPoints?.exclusions),
|
||||
franchise: toStringOrNull(input?.keyPoints?.franchise),
|
||||
importantDates: toStringList(input?.keyPoints?.importantDates),
|
||||
explainability: parseExplainability(input?.keyPoints?.explainability),
|
||||
},
|
||||
extractedText: extractedText.slice(0, 12000),
|
||||
language,
|
||||
keyPeople: parseKeyPeople(input?.keyPeople),
|
||||
contactInfo: parseContactInfo(input?.contactInfo),
|
||||
importantContacts: Array.isArray(input?.importantContacts)
|
||||
? input.importantContacts
|
||||
.slice(0, 10)
|
||||
.map((c: any) => parseContactInfo(c))
|
||||
: [],
|
||||
relevantDates: parseRelevantDates(input?.relevantDates),
|
||||
};
|
||||
}
|
||||
110
lib/services/ai/analysis.parser.ts
Normal file
110
lib/services/ai/analysis.parser.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
function stripMarkdownFences(value: string): string {
|
||||
return value
|
||||
.replace(/^```json\s*/i, "")
|
||||
.replace(/^```\s*/i, "")
|
||||
.replace(/\s*```$/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractBalancedJson(text: string): string | null {
|
||||
let start = -1;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
const stack: string[] = [];
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
|
||||
if (start === -1) {
|
||||
if (char === "{" || char === "[") {
|
||||
start = i;
|
||||
stack.push(char);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inString) {
|
||||
if (!escaped && char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (!escaped && char === '"') {
|
||||
inString = false;
|
||||
}
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "{" || char === "[") {
|
||||
stack.push(char);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "}" || char === "]") {
|
||||
const last = stack[stack.length - 1];
|
||||
const isMatch =
|
||||
(last === "{" && char === "}") || (last === "[" && char === "]");
|
||||
|
||||
if (!isMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
stack.pop();
|
||||
|
||||
if (stack.length === 0 && start !== -1) {
|
||||
return text.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeLooseJson(value: string): string {
|
||||
return value
|
||||
.replace(/[\u201C\u201D]/g, '"')
|
||||
.replace(/[\u2018\u2019]/g, "'")
|
||||
.replace(/,\s*([}\]])/g, "$1")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseJsonResponse(text: string): unknown {
|
||||
if (!text || typeof text !== "string" || text.trim().length === 0) {
|
||||
throw new Error("AI response is empty.");
|
||||
}
|
||||
|
||||
const cleaned = stripMarkdownFences(text);
|
||||
|
||||
try {
|
||||
return JSON.parse(cleaned);
|
||||
} catch {
|
||||
// continue to robust fallback
|
||||
}
|
||||
|
||||
const extracted = extractBalancedJson(cleaned);
|
||||
if (!extracted) {
|
||||
throw new Error(
|
||||
`No complete JSON object found in AI response. Preview: ${cleaned.slice(0, 220)}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(extracted);
|
||||
} catch {
|
||||
const sanitized = sanitizeLooseJson(extracted);
|
||||
try {
|
||||
return JSON.parse(sanitized);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "unknown parse error";
|
||||
throw new Error(
|
||||
`JSON parse failed after recovery attempts: ${message}. Preview: ${sanitized.slice(0, 220)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
165
lib/services/ai/analysis.prompt.ts
Normal file
165
lib/services/ai/analysis.prompt.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
export function buildAnalysisPrompt(input?: {
|
||||
adaptiveContext?: string;
|
||||
fileName?: string;
|
||||
}): string {
|
||||
return `You are an expert in contract analysis for BFSI and general legal/business contracts.
|
||||
You support multi-language analysis and will automatically detect the contract language.
|
||||
|
||||
Document name: ${input?.fileName ?? "Unknown"}
|
||||
|
||||
${input?.adaptiveContext ?? ""}
|
||||
|
||||
Analyze this contract document completely and return JSON in the EXACT structure below.
|
||||
CRITICAL: Your response must be VALID, PARSEABLE JSON only. Do not include markdown, backticks, or explanations.
|
||||
|
||||
{
|
||||
"language": "en",
|
||||
"title": "Descriptive contract title",
|
||||
"type": "INSURANCE_AUTO",
|
||||
"provider": "Company or institution name",
|
||||
"policyNumber": "Policy/contract/reference number",
|
||||
"startDate": "2024-01-01",
|
||||
"endDate": "2025-12-31",
|
||||
"premium": 1200.50,
|
||||
"premiumCurrency": "TND",
|
||||
"summary": "Professional, comprehensive 4-6 sentence summary in the contract's language. Include: main parties, key obligations, coverage/benefits, exclusions, important deadlines, key contacts. Use **bold** for: names, numbers, dates, amounts, important terms.",
|
||||
"keyPoints": {
|
||||
"guarantees": ["**Main Benefit 1**: Description", "**Main Benefit 2**: Description"],
|
||||
"exclusions": ["**Exclusion 1**: Description with impact", "**Exclusion 2**: Description"],
|
||||
"franchise": "**Deductible/Penalty**: €150 per claim or equivalent",
|
||||
"importantDates": ["**Renewal Date**: 31 December annually", "**Payment Deadline**: 15th of each month"],
|
||||
"explainability": [
|
||||
{
|
||||
"field": "endDate",
|
||||
"why": "Extracted as contract expiration because the clause explicitly sets validity end.",
|
||||
"sourceSnippet": "Durée du prêt: échéance finale fixée au 10 avril 2044.",
|
||||
"sourceHints": { "page": "1", "section": "Durée/Échéancier", "confidence": 92 }
|
||||
},
|
||||
{
|
||||
"field": "premium",
|
||||
"why": "Detected monetary obligation from insurance/fee clause.",
|
||||
"sourceSnippet": "Coût total estimé de 18 240,00 TND.",
|
||||
"sourceHints": { "page": "2", "section": "Coût / Prime", "confidence": 88 }
|
||||
}
|
||||
]
|
||||
},
|
||||
"keyPeople": [
|
||||
{"name": "**John Smith**", "role": "Policy Holder", "email": "john@example.com", "phone": "+33612345678"},
|
||||
{"name": "**Jane Doe**", "role": "Insurance Agent", "email": "jane@insurer.com", "phone": "+33987654321"}
|
||||
],
|
||||
"contactInfo": {
|
||||
"name": "**Policy Holder Name**",
|
||||
"email": "holder@email.com",
|
||||
"phone": "+33612345678",
|
||||
"address": "123 Main Street, City, Postal Code",
|
||||
"role": "Insured Person"
|
||||
},
|
||||
"importantContacts": [
|
||||
{"name": "**Claims Department**", "email": "claims@insurer.com", "phone": "+33800000000"},
|
||||
{"name": "**Customer Service**", "email": "support@insurer.com", "phone": "+33800111111"}
|
||||
],
|
||||
"relevantDates": [
|
||||
{"date": "2025-12-31", "description": "**Policy Expiration Date**", "type": "EXPIRATION"},
|
||||
{"date": "2025-10-31", "description": "**Renewal Notice Deadline** (60 days before expiration)", "type": "RENEWAL"},
|
||||
{"date": "1970-01-15", "description": "**Monthly Payment Due Date**", "type": "PAYMENT"}
|
||||
],
|
||||
"extractedText": "Most relevant extracted text, preserving original structure and keywords. Include key clauses, definitions, obligations. Max 12000 chars.",
|
||||
"contractValidation": {
|
||||
"isValidContract": true,
|
||||
"confidence": 88,
|
||||
"reason": null
|
||||
}
|
||||
}
|
||||
|
||||
TYPE must be one of:
|
||||
INSURANCE_AUTO, INSURANCE_HOME, INSURANCE_HEALTH, INSURANCE_LIFE, LOAN, CREDIT_CARD, INVESTMENT, OTHER
|
||||
|
||||
CRITICAL FIELD EXTRACTION RULES:
|
||||
|
||||
1. **Language Detection**: Detect and return the contract's primary language (en, fr, de, es, it, pt, etc.). If mixed, return dominant language.
|
||||
|
||||
2. **Summary (VERY IMPORTANT)**:
|
||||
- Write 4-6 comprehensive sentences covering: parties involved, contract scope, key obligations, main coverage/benefits, critical exclusions, important deadlines
|
||||
- Use **Party Name** for persons/entities mentioned
|
||||
- Use **number** for all quantities, dates, amounts, percentages
|
||||
- Use **YYYY-MM-DD** format for dates with **bold**
|
||||
- Language: Professional business French, English, or contract's native language
|
||||
- MUST be detailed enough that reader understands contract without opening PDF
|
||||
|
||||
3. **Key People Extraction**:
|
||||
- Extract all named individuals: policy holders, insured parties, beneficiaries, signatories, agents, brokers
|
||||
- Include roles, contact methods when visible in contract
|
||||
- Use **bold** for names: {"name": "**John Smith**", ...}
|
||||
|
||||
4. **Contact Information**:
|
||||
- contactInfo: Details of PRIMARY policy holder or contract party
|
||||
- importantContacts: Agent, broker, support teams, claims department with **bold** for names
|
||||
|
||||
5. **Relevant Dates**:
|
||||
- Extract ALL dates with business meaning: expiration, renewal, payment due dates, review dates
|
||||
- For recurring dates (monthly, annually): show pattern like "1970-01-15" for "15th of each month"
|
||||
- Include type: EXPIRATION, RENEWAL, PAYMENT, REVIEW, or OTHER
|
||||
- Each date must have clear **bold** description explaining its significance
|
||||
|
||||
6. **Key Points**:
|
||||
- Use **bold** for: benefit names, exclusion types, monetary amounts, coverage limits
|
||||
- Example: "**Motor Coverage**: Collision and theft protection up to **€50,000**"
|
||||
- Make exclusions explicit and impactful
|
||||
- Include franchise/deductible with bold currency and amount
|
||||
|
||||
7. **Guarantees & Exclusions**:
|
||||
- Be specific: "**Theft Coverage** includes keys, GPS, and aftermarket electronics"
|
||||
- For exclusions, explain impact: "**Mechanical wear excluded** - means breakdowns in years 3+ not covered"
|
||||
|
||||
8. **Email/Phone Extraction**: If present in contract, extract:
|
||||
- Email addresses in format: contact@domain.com
|
||||
- Phone numbers with country code: +33 for France, +44 for UK, etc.
|
||||
|
||||
9. **Explainability (MANDATORY)**:
|
||||
- In keyPoints.explainability, include at least 6 items for critical fields when available:
|
||||
title, provider, policyNumber, startDate, endDate, premium, key obligations, key exclusions.
|
||||
- Each item MUST contain:
|
||||
- field: exact extracted field name
|
||||
- why: one sentence explaining extraction logic
|
||||
- sourceSnippet: short verbatim quote from document supporting the field
|
||||
- sourceHints.page: page number if inferable, otherwise null
|
||||
- sourceHints.section: section title/heading when inferable, otherwise null
|
||||
- sourceHints.confidence: 0..100 confidence for that field extraction
|
||||
- Keep sourceSnippet short (max 280 chars) but sufficiently specific to audit.
|
||||
- Never invent snippet text not present in document.
|
||||
|
||||
Field Type Rules:
|
||||
- dates: ISO format YYYY-MM-DD or null. For recurring patterns, use canonical date (e.g., "0000-01-15" for "15th each month")
|
||||
- premium: Positive number or null. NO currency symbols and NO currency conversion.
|
||||
- premiumCurrency: Use the exact currency mentioned in contract (e.g., TND, EUR, USD, MAD, DZD, GBP, CHF). Never convert currency.
|
||||
- keyPeople, contactInfo arrays: Can include null values for missing fields
|
||||
- type: MUST be one of the 8 contract types. Default to OTHER if unsure.
|
||||
- confidence: 1-100, higher for clear data, lower for ambiguous/partial info
|
||||
|
||||
Validation:
|
||||
- isValidContract: true for all actual contracts (even type=OTHER), false only for non-contract files
|
||||
- reason: null if valid, brief explanation if invalid
|
||||
|
||||
MUST return VALID JSON parseable with JSON.parse() in ONE line of pure JSON.`;
|
||||
}
|
||||
|
||||
export function buildPrevalidationPrompt(fileName?: string): string {
|
||||
return `You are validating whether an uploaded document is a legal/financial contract in any language.
|
||||
|
||||
File name: ${fileName ?? "Unknown"}
|
||||
|
||||
Return ONLY JSON (no markdown, no backticks, no explanations):
|
||||
{
|
||||
"isValidContract": true,
|
||||
"confidence": 0,
|
||||
"reason": null
|
||||
}
|
||||
|
||||
Rules:
|
||||
- isValidContract=false for invoices, receipts, identity cards, random photos/screenshots, blank pages, flyers, or unrelated files
|
||||
- confidence is an integer from 0 to 100
|
||||
- reason must be concise and user-friendly when invalid
|
||||
- If valid, reason can be null
|
||||
- This must be valid JSON parseable with JSON.parse()
|
||||
- Return ONLY the JSON object, nothing else`;
|
||||
}
|
||||
80
lib/services/ai/analysis.types.ts
Normal file
80
lib/services/ai/analysis.types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export const SUPPORTED_CONTRACT_TYPES = [
|
||||
"INSURANCE_AUTO",
|
||||
"INSURANCE_HOME",
|
||||
"INSURANCE_HEALTH",
|
||||
"INSURANCE_LIFE",
|
||||
"LOAN",
|
||||
"CREDIT_CARD",
|
||||
"INVESTMENT",
|
||||
"OTHER",
|
||||
] as const;
|
||||
|
||||
export type SupportedContractType = (typeof SUPPORTED_CONTRACT_TYPES)[number];
|
||||
|
||||
export type AnalyzeOptions = {
|
||||
userId?: string;
|
||||
fileName?: string;
|
||||
maxRetries?: number;
|
||||
};
|
||||
|
||||
export type ContactInfo = {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
address?: string | null;
|
||||
role?: string | null;
|
||||
};
|
||||
|
||||
export type KeyPerson = {
|
||||
name: string;
|
||||
role?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
};
|
||||
|
||||
export type ExplainabilityItem = {
|
||||
field: string;
|
||||
why: string;
|
||||
sourceSnippet: string;
|
||||
sourceHints?: {
|
||||
page?: string | null;
|
||||
section?: string | null;
|
||||
confidence?: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type NormalizedAnalysis = {
|
||||
title: string;
|
||||
type: SupportedContractType;
|
||||
provider: string | null;
|
||||
policyNumber: string | null;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
premium: number | null;
|
||||
premiumCurrency?: string | null;
|
||||
summary: string;
|
||||
keyPoints: {
|
||||
guarantees: string[];
|
||||
exclusions: string[];
|
||||
franchise: string | null;
|
||||
importantDates: string[];
|
||||
explainability?: ExplainabilityItem[];
|
||||
};
|
||||
extractedText: string;
|
||||
// New enhanced fields
|
||||
language?: string | null;
|
||||
keyPeople: KeyPerson[];
|
||||
contactInfo: ContactInfo;
|
||||
importantContacts: ContactInfo[];
|
||||
relevantDates: Array<{
|
||||
date: string;
|
||||
description: string;
|
||||
type: "EXPIRATION" | "RENEWAL" | "PAYMENT" | "REVIEW" | "OTHER";
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ContractPrecheckResult = {
|
||||
isValidContract: boolean;
|
||||
confidence: number;
|
||||
reason: string | null;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
ContractType,
|
||||
} from "@/types/contract.types";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { NotificationService } from "@/lib/services/notification.service";
|
||||
|
||||
const utapi = new UTApi();
|
||||
|
||||
@@ -124,7 +125,7 @@ export class ContractService {
|
||||
return null;
|
||||
};
|
||||
|
||||
return await prisma.contract.update({
|
||||
const contract = await prisma.contract.update({
|
||||
where: { id },
|
||||
data: {
|
||||
title: aiResults.title,
|
||||
@@ -140,6 +141,23 @@ export class ContractService {
|
||||
status: "COMPLETED",
|
||||
},
|
||||
});
|
||||
|
||||
// Check for upcoming deadlines after contract is completed
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: contract.userId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
await NotificationService.checkUpcomingDeadlines(user.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to check upcoming deadlines:", error);
|
||||
// Don't fail the contract update if deadline check fails
|
||||
}
|
||||
|
||||
return contract;
|
||||
}
|
||||
|
||||
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -70,6 +70,46 @@ interface NotificationResponse {
|
||||
}
|
||||
|
||||
export class NotificationService {
|
||||
static async cleanupReadNonDeadline(
|
||||
userId: string,
|
||||
): Promise<NotificationResponse> {
|
||||
try {
|
||||
const result = await prisma.notification.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
read: true,
|
||||
NOT: {
|
||||
type: "DEADLINE",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Cleaned ${result.count} seen non-deadline notifications`,
|
||||
data: { count: result.count },
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
warnMissingNotificationTableOnce();
|
||||
return {
|
||||
success: true,
|
||||
message: "Notification table missing. Read cleanup skipped.",
|
||||
data: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
console.error("Error cleaning read non-deadline notifications:", error);
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to cleanup seen notifications",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new notification for a user
|
||||
*
|
||||
@@ -162,6 +202,9 @@ export class NotificationService {
|
||||
limit: number = 10,
|
||||
): Promise<NotificationResponse> {
|
||||
try {
|
||||
// Enforce retention policy on each read path.
|
||||
await this.cleanupReadNonDeadline(userId);
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: {
|
||||
userId,
|
||||
@@ -220,6 +263,9 @@ export class NotificationService {
|
||||
limit: number = 50,
|
||||
): Promise<NotificationResponse> {
|
||||
try {
|
||||
// Enforce retention policy on each read path.
|
||||
await this.cleanupReadNonDeadline(userId);
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: {
|
||||
userId,
|
||||
@@ -282,6 +328,21 @@ export class NotificationService {
|
||||
data: { read: true },
|
||||
});
|
||||
|
||||
if (notification.type !== "DEADLINE") {
|
||||
await prisma.notification.delete({
|
||||
where: { id: notificationId },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Notification marked as read and auto-deleted",
|
||||
data: {
|
||||
id: notificationId,
|
||||
deleted: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: notification,
|
||||
@@ -322,10 +383,13 @@ export class NotificationService {
|
||||
data: { read: true },
|
||||
});
|
||||
|
||||
const cleanup = await this.cleanupReadNonDeadline(userId);
|
||||
const cleanupCount = cleanup.success ? cleanup.data?.count || 0 : 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Marked ${result.count} notifications as read`,
|
||||
data: { count: result.count },
|
||||
message: `Marked ${result.count} notifications as read and auto-deleted ${cleanupCount} non-deadline items`,
|
||||
data: { count: result.count, deletedCount: cleanupCount },
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNotificationTableMissingError(error)) {
|
||||
@@ -446,6 +510,9 @@ export class NotificationService {
|
||||
*/
|
||||
static async getUnreadCount(userId: string): Promise<NotificationResponse> {
|
||||
try {
|
||||
// Enforce retention policy on count path as well.
|
||||
await this.cleanupReadNonDeadline(userId);
|
||||
|
||||
const count = await prisma.notification.count({
|
||||
where: {
|
||||
userId,
|
||||
|
||||
Reference in New Issue
Block a user