Add AI parsing capabilities for debt information and update database schema

- Implemented AI-powered parsing for debt emails using Google's Gemini model.
- Enhanced Postmark webhook to extract debt details including amount, vendor, description, due date, and legitimacy.
- Updated database schema to include new columns for AI-extracted data.
- Added environment variable requirements and updated package dependencies.
- Created migration script for new database columns and indexes.
This commit is contained in:
2025-06-07 00:59:09 -03:00
parent a4bb8d0892
commit 7a8790d564
7 changed files with 529 additions and 97 deletions

View File

@@ -1,98 +1,176 @@
import type { APIRoute } from 'astro';
import { supabase } from '../../lib/supabase';
import type { APIRoute } from "astro";
import { supabase } from "../../lib/supabase";
import { generateObject } from "ai";
import {
createGoogleGenerativeAI,
google,
type GoogleGenerativeAIProviderOptions,
} from "@ai-sdk/google";
import { z } from "zod";
// Schema for debt information extraction
const debtSchema = z.object({
amount: z.number().min(0).describe("The debt amount in dollars"),
vendor: z.string().describe("The name or identifier of the vendor/creditor"),
description: z.string().describe("Brief description of what the debt is for"),
dueDate: z.string().optional().describe("Due date if mentioned (ISO format)"),
isDebtCollection: z
.boolean()
.describe("Whether this appears to be a debt collection notice"),
});
// Function to parse debt information using AI
async function parseDebtWithAI(emailText: string, fromEmail: string) {
try {
// Check if Google API key is available
const googleApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
if (!googleApiKey) {
console.warn(
"Google API key not configured, falling back to regex parsing"
);
throw new Error("No Google API key configured");
}
const result = await generateObject({
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system: `You are an expert at analyzing debt collection and billing emails.
Extract key debt information from the email content.
Look for monetary amounts, creditor information, what the debt is for, and due dates.
If this doesn't appear to be a legitimate debt or billing notice, set amount to 0.
Be very accurate with amounts - look for dollar signs and numbers carefully.`,
prompt: `Parse this email for debt information:
From: ${fromEmail}
Content: ${emailText}`,
schema: debtSchema,
});
return result.object;
} catch (error) {
console.error("AI parsing error:", error);
// Fallback to regex if AI fails
const amountMatch = emailText.match(/\$(\d+(?:,\d{3})*(?:\.\d{2})?)/);
return {
amount: amountMatch ? parseFloat(amountMatch[1].replace(/,/g, "")) : 0,
vendor: fromEmail || "unknown",
description: "Failed to parse with AI - using regex fallback",
isDebtCollection: amountMatch ? true : false,
};
}
}
export const POST: APIRoute = async ({ request }) => {
try {
const data = await request.json();
// Check for opt-out keywords
const optOutKeywords = ['STOP', 'UNSUBSCRIBE', 'OPT-OUT', 'REMOVE'];
const textBody = data.TextBody || '';
const hasOptOut = optOutKeywords.some(keyword =>
textBody.toUpperCase().includes(keyword)
);
try {
const data = await request.json();
if (hasOptOut) {
// Log opt-out and don't process further
const { error } = await supabase.from('debts').insert({
vendor: data.FromFull?.Email || 'unknown',
amount: 0,
raw_email: textBody,
status: 'opted_out'
});
// Check for opt-out keywords
const optOutKeywords = ["STOP", "UNSUBSCRIBE", "OPT-OUT", "REMOVE"];
const textBody = data.TextBody || "";
const fromEmail = data.FromFull?.Email || "unknown";
if (error) {
console.error('Error logging opt-out:', error);
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
const hasOptOut = optOutKeywords.some((keyword) =>
textBody.toUpperCase().includes(keyword)
);
return new Response('Opt-out processed', { status: 200 });
}
if (hasOptOut) {
// Log opt-out and don't process further
const { error } = await supabase.from("debts").insert({
vendor: fromEmail,
amount: 0,
raw_email: textBody,
status: "opted_out",
});
// Extract debt amount using regex
const amountMatch = textBody.match(/\$(\d+(?:,\d{3})*(?:\.\d{2})?)/);
const amount = amountMatch ? parseFloat(amountMatch[1].replace(/,/g, '')) : 0;
if (error) {
console.error("Error logging opt-out:", error);
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
// Insert debt record
const { data: insertedDebt, error: insertError } = await supabase
.from('debts')
.insert({
vendor: data.FromFull?.Email || 'unknown',
amount: amount,
raw_email: textBody,
status: 'received'
})
.select()
.single();
return new Response("Opt-out processed", { status: 200 });
}
if (insertError) {
console.error('Error inserting debt:', insertError);
return new Response(JSON.stringify({ error: insertError.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
// Parse debt information using AI
const debtInfo = await parseDebtWithAI(textBody, fromEmail);
// Log the email receipt
await supabase.from('audit_logs').insert({
debt_id: insertedDebt.id,
action: 'email_received',
details: {
vendor: data.FromFull?.Email,
amount: amount,
subject: data.Subject
}
});
// Insert debt record with AI-extracted information
const { data: insertedDebt, error: insertError } = await supabase
.from("debts")
.insert({
vendor: debtInfo.vendor,
amount: debtInfo.amount,
raw_email: textBody,
status: "received",
description: debtInfo.description,
due_date: debtInfo.dueDate,
metadata: {
isDebtCollection: debtInfo.isDebtCollection,
subject: data.Subject,
fromEmail: fromEmail,
},
})
.select()
.single();
// Trigger negotiation function
if (amount > 0) {
const negotiateUrl = `${import.meta.env.SUPABASE_URL}/functions/v1/negotiate`;
try {
await fetch(negotiateUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${import.meta.env.SUPABASE_ANON_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ record: insertedDebt })
});
} catch (negotiateError) {
console.error('Error triggering negotiation:', negotiateError);
// Don't fail the webhook if negotiation fails
}
}
if (insertError) {
console.error("Error inserting debt:", insertError);
return new Response(JSON.stringify({ error: insertError.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
return new Response('OK', { status: 200 });
// Log the email receipt
await supabase.from("audit_logs").insert({
debt_id: insertedDebt.id,
action: "email_received",
details: {
vendor: debtInfo.vendor,
amount: debtInfo.amount,
subject: data.Subject,
aiParsed: true,
},
});
} catch (error) {
console.error('Postmark webhook error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};
// Trigger negotiation function if this is a legitimate debt
if (debtInfo.amount > 0 && debtInfo.isDebtCollection) {
// Access environment variables through Astro runtime
const supabaseUrl = process.env.SUPABASE_URL;
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY;
if (supabaseUrl && supabaseAnonKey) {
const negotiateUrl = `${supabaseUrl}/functions/v1/negotiate`;
try {
await fetch(negotiateUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${supabaseAnonKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ record: insertedDebt }),
});
} catch (negotiateError) {
console.error("Error triggering negotiation:", negotiateError);
// Don't fail the webhook if negotiation fails
}
} else {
console.warn(
"Supabase environment variables not configured for negotiation trigger"
);
}
}
return new Response("OK", { status: 200 });
} catch (error) {
console.error("Postmark webhook error:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
};