Adds AI-driven conversation tracking to debt negotiation

Introduces comprehensive conversation history with a new table and UI for tracking all negotiation emails, AI analysis, and financial outcomes. Enhances real-time updates, manages negotiation rounds, and supports new statuses for negotiation lifecycle. Integrates AI-powered extraction and response analysis to automate intent detection and outcome calculations, improving transparency and automation of debt resolution.
This commit is contained in:
2025-06-08 00:32:04 -03:00
parent 0c6f72761d
commit bddc3a344d
14 changed files with 2568 additions and 135 deletions

View File

@@ -0,0 +1,754 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
import { generateObject } from "https://esm.sh/ai@3.4.7";
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
import { z } from "https://esm.sh/zod@3.22.4";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
// Schema for AI response analysis
const responseAnalysisSchema = z.object({
intent: z.enum([
"acceptance",
"rejection",
"counter_offer",
"request_info",
"unclear",
])
.describe("The primary intent of the response"),
sentiment: z.enum(["positive", "negative", "neutral"])
.describe("Overall sentiment of the response"),
confidence: z.number().min(0).max(1)
.describe("Confidence in the intent classification"),
extractedTerms: z.object({
proposedAmount: z.number().optional().describe(
"Any amount mentioned in response",
),
proposedPaymentPlan: z.string().optional().describe(
"Payment plan details if mentioned",
),
paymentTerms: z.object({
monthlyAmount: z.number().optional().describe("Monthly payment amount"),
numberOfPayments: z.number().optional().describe(
"Number of payments/installments",
),
totalAmount: z.number().optional().describe("Total amount to be paid"),
interestRate: z.number().optional().describe(
"Interest rate if applicable",
),
paymentFrequency: z.string().optional().describe(
"Payment frequency (monthly, weekly, etc.)",
),
}).optional().describe("Structured payment plan terms"),
deadline: z.string().optional().describe("Any deadline mentioned"),
conditions: z.array(z.string()).optional().describe(
"Any conditions or requirements mentioned",
),
}).describe("Key terms extracted from the response"),
reasoning: z.string().describe("Explanation of the analysis"),
suggestedNextAction: z.enum([
"accept_offer",
"send_counter",
"request_clarification",
"escalate_to_user",
"mark_settled",
]).describe("Recommended next action"),
requiresUserReview: z.boolean().describe(
"Whether this response needs human review",
),
});
interface EmailResponseData {
debtId: string;
fromEmail: string;
subject: string;
body: string;
messageId?: string;
}
// AI-powered response analysis
async function analyzeEmailResponse(
debtId: string,
fromEmail: string,
subject: string,
body: string,
originalNegotiation?: any,
) {
try {
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
if (!googleApiKey) {
console.warn("Google API key not configured, using fallback analysis");
return getFallbackAnalysis(body);
}
console.log("Analyzing email with AI:", {
fromEmail,
subject,
bodyLength: body.length,
});
console.log({
debtId,
fromEmail,
subject,
body,
originalNegotiation,
});
const system =
`You are an expert financial analysis AI. Your sole function is to meticulously analyze creditor emails and populate a structured JSON object that conforms to the provided schema.
Your entire output MUST be a single, valid JSON object. Do not include any markdown, explanations, or conversational text outside of the JSON structure itself.
--- FIELD-BY-FIELD INSTRUCTIONS ---
1. **intent**: Classify the creditor's primary intent.
- "acceptance": Clear agreement to our original proposal.
- "rejection": Clear refusal of our proposal without offering new terms.
- "counter_offer": Proposes ANY new financial terms (different amount, payment plan, etc.). This is the most common intent besides acceptance.
- "request_info": Asks for more information (e.g., "Can you provide proof of hardship?").
- "unclear": The purpose of the email cannot be determined.
2. **sentiment**: Classify the emotional tone of the email.
- "positive": Cooperative, polite, agreeable language.
- "negative": Hostile, demanding, or threatening language.
- "neutral": Strictly professional, factual, and devoid of emotional language.
3. **confidence**: Provide a score from 0.0 to 1.0 for your "intent" classification. (e.g., 0.95).
4. **extractedTerms**:
- **proposedAmount**: Extract a single, lump-sum settlement amount if offered. If the offer is a payment plan, this field should be null.
- **proposedPaymentPlan**: Capture the payment plan offer as a descriptive string, exactly as the creditor states it. e.g., "$100 a month for 12 months". If no plan is mentioned, this is null.
- **paymentTerms**: If a payment plan is mentioned, break it down into its structured components here. If no plan is mentioned, this entire object should be null.
- **monthlyAmount**: The specific amount for each payment.
- **numberOfPayments**: The number of installments.
- **totalAmount**: The total payout of the plan, ONLY if explicitly stated (e.g., "...totaling $1200").
- **interestRate**: The interest rate as a number (e.g., for "5% interest", extract 5).
- **paymentFrequency**: The frequency (e.g., "monthly", "weekly", "bi-weekly").
- **deadline**: Extract any specific date or timeframe for action (e.g., "by June 30th", "within 10 days").
- **conditions**: Extract all non-financial requirements as an array of strings. Example: ["Payment must be via certified funds", "A settlement agreement must be signed"]. If none, use an empty array [].
5. **reasoning**: Briefly explain the logic behind your "intent" and "suggestedNextAction" classifications, referencing parts of the email.
6. **suggestedNextAction**: Recommend the most logical business action.
- "accept_offer": The creditor's offer matches or is better than our goal.
- "send_counter": The creditor made a counter-offer that we should negotiate.
- "request_clarification": The email is ambiguous or missing key information.
- "escalate_to_user": The response is hostile, contains legal threats, is complex, or requires a human decision.
- "mark_settled": The email confirms the debt is fully settled and no further action is needed.
7. **requiresUserReview**: Set to 'true' if intent is "unclear", sentiment is "negative", confidence is below 0.85, the email contains unusual legal language, or the "suggestedNextAction" is "escalate_to_user". Otherwise, set to 'false'.`;
const prompt =
`Analyze the following email and extract the financial details and intent, populating the JSON object according to your system instructions.
--- EMAIL TO ANALYZE ---
From: ${fromEmail}
Subject: ${subject}
Body: ${body}
${
originalNegotiation
? `--- ORIGINAL CONTEXT FOR YOUR ANALYSIS ---
Our Negotiation Strategy: ${originalNegotiation.strategy}
Our Proposed Amount: $${originalNegotiation.proposedAmount || "N/A"}
Our Proposed Terms: ${originalNegotiation.terms || "N/A"}
Our Reasoning: ${originalNegotiation.reasoning || "N/A"}
Our Latest Email Body: ${originalNegotiation.body || "N/A"}
`
: ""
}`;
console.log("AI Analysis System:", system);
console.log("AI Analysis Prompt:", prompt);
const result = await generateObject({
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system,
prompt,
schema: responseAnalysisSchema,
});
console.log("AI Analysis Result:", JSON.stringify(result.object, null, 2));
console.log(
"Extracted Terms:",
JSON.stringify(result.object.extractedTerms, null, 2),
);
return result.object;
} catch (error) {
console.error("AI response analysis error:", error);
console.log("Falling back to regex-based analysis");
return getFallbackAnalysis(body);
}
}
// Calculate financial outcome when offer is accepted
function calculateFinancialOutcome(debt: any, analysis: any): any {
try {
const originalAmount = debt.amount || 0;
let acceptedAmount = originalAmount;
let paymentStructure = null;
let financialBenefit = null;
// Try to extract accepted amount from AI analysis
if (analysis.extractedTerms?.proposedAmount) {
acceptedAmount = analysis.extractedTerms.proposedAmount;
} else if (analysis.extractedTerms?.paymentTerms?.totalAmount) {
acceptedAmount = analysis.extractedTerms.paymentTerms.totalAmount;
} else if (debt.metadata?.prospectedSavings?.amount) {
// Fall back to original negotiation terms if no specific amount mentioned
acceptedAmount = originalAmount - debt.metadata.prospectedSavings.amount;
}
// Analyze payment structure if present
if (analysis.extractedTerms?.paymentTerms) {
const terms = analysis.extractedTerms.paymentTerms;
paymentStructure = {
type: "installment_plan",
monthlyAmount: terms.monthlyAmount,
numberOfPayments: terms.numberOfPayments,
totalAmount: terms.totalAmount || acceptedAmount,
frequency: terms.paymentFrequency || "monthly",
interestRate: terms.interestRate || 0,
};
// Calculate time value and cash flow benefits
if (terms.monthlyAmount && terms.numberOfPayments) {
const totalPayments = terms.monthlyAmount * terms.numberOfPayments;
const timeToComplete = terms.numberOfPayments; // in months
financialBenefit = {
type: "payment_restructuring",
principalReduction: Math.max(0, originalAmount - totalPayments),
cashFlowRelief: {
monthlyReduction: originalAmount - terms.monthlyAmount,
extendedTermMonths: timeToComplete,
totalCashFlowBenefit: (originalAmount - terms.monthlyAmount) *
timeToComplete,
},
timeValueBenefit: calculateTimeValueBenefit(
originalAmount,
terms.monthlyAmount,
timeToComplete,
),
};
}
}
// Calculate actual savings (principal reduction)
const actualSavings = Math.max(0, originalAmount - acceptedAmount);
// Determine the primary financial benefit
if (actualSavings > 0) {
financialBenefit = {
type: "principal_reduction",
amount: actualSavings,
percentage: (actualSavings / originalAmount * 100).toFixed(2),
description: `${
((actualSavings / originalAmount) * 100).toFixed(1)
}% principal reduction`,
};
} else if (paymentStructure && paymentStructure.monthlyAmount) {
// No principal reduction but payment restructuring
const monthlyReduction = originalAmount - paymentStructure.monthlyAmount;
financialBenefit = {
type: "payment_restructuring",
monthlyReduction: monthlyReduction,
extendedTermMonths: paymentStructure.numberOfPayments,
description:
`Payment restructured to $${paymentStructure.monthlyAmount}/month over ${paymentStructure.numberOfPayments} months`,
cashFlowBenefit: monthlyReduction > 0
? `$${monthlyReduction}/month cash flow relief`
: "Extended payment terms",
};
}
console.log(
`Financial outcome: Original: $${originalAmount}, Accepted: $${acceptedAmount}, Savings: $${actualSavings}`,
);
return {
actualSavings,
acceptedAmount,
paymentStructure,
financialBenefit,
originalAmount,
};
} catch (error) {
console.error("Error calculating financial outcome:", error);
// Return basic fallback
return {
actualSavings: debt.projected_savings || 0,
acceptedAmount: debt.amount,
paymentStructure: null,
financialBenefit: null,
originalAmount: debt.amount,
};
}
}
// Calculate time value benefit of extended payment terms
function calculateTimeValueBenefit(
originalAmount: number,
monthlyPayment: number,
months: number,
): any {
// Simple present value calculation assuming 5% annual discount rate
const monthlyRate = 0.05 / 12;
let presentValue = 0;
for (let i = 1; i <= months; i++) {
presentValue += monthlyPayment / Math.pow(1 + monthlyRate, i);
}
const timeValueBenefit = originalAmount - presentValue;
return {
presentValueOfPayments: presentValue.toFixed(2),
timeValueBenefit: timeValueBenefit.toFixed(2),
effectiveDiscount: ((timeValueBenefit / originalAmount) * 100).toFixed(2),
};
}
// Fallback analysis when AI is unavailable
function getFallbackAnalysis(
body: string,
): typeof responseAnalysisSchema._type {
const lowerBody = body.toLowerCase();
// Simple keyword-based analysis
let intent:
| "acceptance"
| "rejection"
| "counter_offer"
| "request_info"
| "unclear" = "unclear";
let sentiment: "positive" | "negative" | "neutral" = "neutral";
if (
lowerBody.includes("accept") || lowerBody.includes("agree") ||
lowerBody.includes("approved")
) {
intent = "acceptance";
sentiment = "positive";
} else if (
lowerBody.includes("reject") || lowerBody.includes("decline") ||
lowerBody.includes("denied")
) {
intent = "rejection";
sentiment = "negative";
} else if (
lowerBody.includes("counter") || lowerBody.includes("instead") ||
lowerBody.includes("however")
) {
intent = "counter_offer";
sentiment = "neutral";
} else if (
lowerBody.includes("information") || lowerBody.includes("clarify") ||
lowerBody.includes("details")
) {
intent = "request_info";
sentiment = "neutral";
}
// Enhanced extraction using multiple regex patterns
const extractFinancialTerms = (text: string) => {
// Extract dollar amounts
const amountMatches = text.match(/\$(\d+(?:,\d{3})*(?:\.\d{2})?)/g);
const amounts =
amountMatches?.map((match) => parseFloat(match.replace(/[$,]/g, ""))) ||
[];
// Extract monthly payment patterns
const monthlyMatch = text.match(
/\$?(\d+(?:,\d{3})*(?:\.\d{2})?)\s*(?:per month|\/month|monthly)/i,
);
const monthlyAmount = monthlyMatch
? parseFloat(monthlyMatch[1].replace(/,/g, ""))
: undefined;
// Extract number of payments/months
const paymentsMatch = text.match(
/(\d+)\s*(?:months?|payments?|installments?)/i,
);
const numberOfPayments = paymentsMatch
? parseInt(paymentsMatch[1])
: undefined;
// Extract total amount patterns
const totalMatch = text.match(
/(?:total|totaling|total amount)\s*(?:of\s*)?\$?(\d+(?:,\d{3})*(?:\.\d{2})?)/i,
);
const totalAmount = totalMatch
? parseFloat(totalMatch[1].replace(/,/g, ""))
: undefined;
// Determine payment frequency
let paymentFrequency = undefined;
if (text.match(/monthly|per month|\/month/i)) paymentFrequency = "monthly";
else if (text.match(/weekly|per week|\/week/i)) paymentFrequency = "weekly";
else if (text.match(/bi-weekly|biweekly|every two weeks/i)) {
paymentFrequency = "bi-weekly";
}
return {
proposedAmount: amounts.length > 0 ? amounts[0] : undefined,
monthlyAmount,
numberOfPayments,
totalAmount,
paymentFrequency,
allAmounts: amounts,
};
};
const terms = extractFinancialTerms(body);
console.log(
"Fallback Analysis - Extracted Terms:",
JSON.stringify(terms, null, 2),
);
const result = {
intent,
sentiment,
confidence: 0.6, // Lower confidence for fallback
extractedTerms: {
proposedAmount: terms.proposedAmount,
proposedPaymentPlan: terms.monthlyAmount ? "payment plan" : undefined,
paymentTerms: (terms.monthlyAmount || terms.numberOfPayments)
? {
monthlyAmount: terms.monthlyAmount,
numberOfPayments: terms.numberOfPayments,
totalAmount: terms.totalAmount,
paymentFrequency: terms.paymentFrequency,
}
: undefined,
deadline: undefined,
conditions: [],
},
reasoning:
`Generated using enhanced keyword-based fallback analysis. Found ${terms.allAmounts.length} amounts.`,
suggestedNextAction: intent === "acceptance"
? "mark_settled"
: intent === "rejection"
? "escalate_to_user"
: intent === "counter_offer"
? "send_counter"
: "escalate_to_user",
requiresUserReview: true, // Always require review for fallback
};
console.log("Fallback Analysis Result:", JSON.stringify(result, null, 2));
return result;
}
serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const supabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? "",
{
global: {
headers: { Authorization: req.headers.get("Authorization")! },
},
},
);
const { debtId, fromEmail, subject, body, messageId }: EmailResponseData =
await req.json();
if (!debtId || !fromEmail || !body) {
return new Response(
JSON.stringify({
error: "Missing required fields: debtId, fromEmail, body",
}),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Get the debt record and original negotiation context
const { data: debt, error: debtError } = await supabaseClient
.from("debts")
.select("*")
.eq("id", debtId)
.single();
if (debtError || !debt) {
return new Response(
JSON.stringify({ error: "Debt record not found" }),
{
status: 404,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
// Analyze the response using AI
const analysis = await analyzeEmailResponse(
debtId,
fromEmail,
subject,
body,
debt.metadata?.aiEmail,
);
// Store the conversation message
const { error: messageError } = await supabaseClient
.from("conversation_messages")
.update({
debt_id: debtId,
message_type: analysis.intent === "acceptance"
? "acceptance"
: analysis.intent === "rejection"
? "rejection"
: analysis.intent === "counter_offer"
? "counter_offer"
: "response_received",
direction: "inbound",
subject,
body,
from_email: fromEmail,
to_email: debt.metadata?.toEmail || debt.metadata?.fromEmail,
ai_analysis: analysis,
})
.eq("message_id", messageId);
if (messageError) {
console.error("Error storing conversation message:", messageError);
}
// Determine next status and actions based on analysis
let newStatus = debt.status;
let newNegotiationRound = debt.negotiation_round || 1;
let financialOutcome = null;
let shouldAutoRespond = false;
let nextAction = null;
switch (analysis.intent) {
case "acceptance":
newStatus = "accepted";
nextAction = "mark_settled";
// Calculate financial outcome when offer is accepted
financialOutcome = calculateFinancialOutcome(debt, analysis);
break;
case "rejection":
newStatus = "rejected";
nextAction = "escalate_to_user";
break;
case "counter_offer":
newStatus = "counter_negotiating";
newNegotiationRound += 1;
shouldAutoRespond = !analysis.requiresUserReview &&
analysis.confidence > 0.8;
nextAction = analysis.suggestedNextAction;
break;
case "request_info":
newStatus = "awaiting_response";
nextAction = "escalate_to_user";
break;
default:
newStatus = "awaiting_response";
nextAction = "escalate_to_user";
}
// Update debt record
const updateData: any = {
status: newStatus,
negotiation_round: newNegotiationRound,
conversation_count: (debt.conversation_count || 0) + 1,
last_message_at: new Date().toISOString(),
metadata: {
...debt.metadata,
lastResponse: {
analysis,
receivedAt: new Date().toISOString(),
fromEmail,
subject,
},
},
};
// Add financial outcome if offer was accepted
if (analysis.intent === "acceptance" && financialOutcome) {
updateData.actual_savings = financialOutcome.actualSavings;
updateData.status = "settled"; // Set final status here instead of separate update
updateData.metadata.financialOutcome = {
...financialOutcome,
calculatedAt: new Date().toISOString(),
};
// Keep backward compatibility with actualSavings field
updateData.metadata.actualSavings = {
amount: financialOutcome.actualSavings,
calculatedAt: new Date().toISOString(),
originalAmount: financialOutcome.originalAmount,
acceptedAmount: financialOutcome.acceptedAmount,
savingsPercentage: financialOutcome.originalAmount > 0
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
100).toFixed(2)
: 0,
};
}
const { error: updateError } = await supabaseClient
.from("debts")
.update(updateData)
.eq("id", debtId);
if (updateError) {
console.error("Error updating debt:", updateError);
}
// Log the action
const auditDetails: any = {
intent: analysis.intent,
sentiment: analysis.sentiment,
confidence: analysis.confidence,
fromEmail,
subject,
suggestedAction: analysis.suggestedNextAction,
requiresReview: analysis.requiresUserReview,
};
// Add financial outcome to audit log if offer was accepted
if (analysis.intent === "acceptance" && financialOutcome) {
auditDetails.financialOutcome = financialOutcome;
auditDetails.actualSavings = financialOutcome.actualSavings;
auditDetails.originalAmount = financialOutcome.originalAmount;
auditDetails.acceptedAmount = financialOutcome.acceptedAmount;
auditDetails.savingsPercentage = financialOutcome.originalAmount > 0
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
100).toFixed(2)
: 0;
}
await supabaseClient
.from("audit_logs")
.insert({
debt_id: debtId,
action: analysis.intent === "acceptance"
? "offer_accepted"
: "response_analyzed",
details: {
...auditDetails,
nextAction,
shouldAutoRespond,
negotiationRound: newNegotiationRound,
reasoning: analysis.reasoning,
},
});
// If this is an acceptance, mark as settled
if (analysis.intent === "acceptance") {
// await supabaseClient
// .from("debts")
// .update({ status: "settled" })
// .eq("id", debtId);
await supabaseClient.from("audit_logs").insert({
debt_id: debtId,
action: "debt_settled",
details: {
finalAmount: financialOutcome?.acceptedAmount || debt.amount,
actualSavings: financialOutcome?.actualSavings || 0,
settlementTerms: analysis.extractedTerms,
},
});
}
// If auto-response is recommended and confidence is high, trigger negotiation
if (
shouldAutoRespond && analysis.confidence > 0.8 &&
analysis.intent === "counter_offer"
) {
try {
const negotiateUrl = `${
Deno.env.get("SUPABASE_URL")
}/functions/v1/negotiate`;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY");
if (negotiateUrl && serviceKey) {
await fetch(negotiateUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${serviceKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
record: {
...debt,
status: newStatus,
conversation_count: (debt.conversation_count || 0) + 1,
negotiation_round: newNegotiationRound,
},
counterOfferContext: {
previousResponse: body,
extractedTerms: analysis.extractedTerms,
sentiment: analysis.sentiment,
},
}),
});
await supabaseClient.from("audit_logs").insert({
debt_id: debtId,
action: "auto_counter_triggered",
details: {
confidence: analysis.confidence,
extractedTerms: analysis.extractedTerms,
},
});
}
} catch (autoResponseError) {
console.error("Error triggering auto-response:", autoResponseError);
}
}
const responseData: any = {
success: true,
analysis,
newStatus,
negotiationRound: newNegotiationRound,
};
// Include financial outcome in response if offer was accepted
if (analysis.intent === "acceptance" && financialOutcome) {
responseData.financialOutcome = financialOutcome;
responseData.actualSavings = financialOutcome.actualSavings;
responseData.savingsCalculated = true;
responseData.originalAmount = financialOutcome.originalAmount;
responseData.acceptedAmount = financialOutcome.acceptedAmount;
responseData.savingsPercentage = financialOutcome.originalAmount > 0
? (financialOutcome.actualSavings / financialOutcome.originalAmount *
100).toFixed(2)
: 0;
}
return new Response(
JSON.stringify(responseData),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
} catch (error) {
console.error("Error in analyze-response function:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
},
);
}
});

View File

@@ -31,11 +31,14 @@ interface DebtRecord {
amount: number;
raw_email: string;
negotiated_plan?: string;
projected_savings?: number;
conversation_count?: number;
metadata?: {
aiEmail?: {
subject: string;
body: string;
strategy: string;
confidence?: number;
};
toEmail?: string;
fromEmail?: string;
@@ -243,11 +246,20 @@ Deno.serve(async (req) => {
body,
);
// Update debt status to sent - using authenticated client
// Update debt status to sent and preserve negotiating state
const { data: currentDebt } = await supabaseClient
.from("debts")
.select("conversation_count, negotiation_round")
.eq("id", debtId)
.single();
const { error: updateError } = await supabaseClient
.from("debts")
.update({
status: "sent",
conversation_count: (currentDebt?.conversation_count || 0) + 1,
last_message_at: new Date().toISOString(),
prospected_savings: debt.projected_savings || 0, // Store prospected savings when sent
metadata: {
...debt.metadata,
emailSent: {
@@ -257,6 +269,14 @@ Deno.serve(async (req) => {
from: fromEmail,
subject: subject,
},
prospectedSavings: {
amount: debt.projected_savings || 0,
percentage: debt.amount > 0
? ((debt.projected_savings || 0) / debt.amount * 100).toFixed(2)
: 0,
calculatedAt: new Date().toISOString(),
strategy: debt.metadata?.aiEmail?.strategy || "unknown",
},
},
})
.eq("id", debtId);
@@ -265,20 +285,35 @@ Deno.serve(async (req) => {
console.error("Error updating debt status:", updateError);
}
// Log the action - using authenticated client
await supabaseClient
.from("audit_logs")
.insert({
debt_id: debtId,
action: "email_sent",
details: {
messageId: emailResult.MessageID,
to: toEmail,
from: fromEmail,
subject: subject,
strategy: debt.metadata.aiEmail.strategy,
},
});
// Record the sent email in conversation history
await supabaseClient.from("conversation_messages").insert({
debt_id: debtId,
message_type: "negotiation_sent",
direction: "outbound",
subject: subject,
body: body,
from_email: fromEmail,
to_email: toEmail,
message_id: emailResult.MessageID,
ai_analysis: {
strategy: debt.metadata.aiEmail.strategy,
confidence: debt.metadata.aiEmail.confidence,
projectedSavings: debt.projected_savings,
},
});
// Log the email sending
await supabaseClient.from("audit_logs").insert({
debt_id: debtId,
action: "email_sent",
details: {
to: toEmail,
subject: subject,
postmarkMessageId: emailResult.MessageID,
conversationRound: currentDebt?.negotiation_round || 1,
strategy: debt.metadata.aiEmail.strategy,
},
});
return new Response(
JSON.stringify({

View File

@@ -0,0 +1,170 @@
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { generateObject } from "https://esm.sh/ai@3.4.7";
import { createGoogleGenerativeAI } from "https://esm.sh/@ai-sdk/google@0.0.52";
import { z } from "https://esm.sh/zod@3.22.4";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
};
// Same schema as analyze-response
const responseAnalysisSchema = z.object({
intent: z.enum([
"acceptance",
"rejection",
"counter_offer",
"request_info",
"unclear",
]).describe("The primary intent of the response"),
sentiment: z.enum(["positive", "negative", "neutral"])
.describe("Overall sentiment of the response"),
confidence: z.number().min(0).max(1)
.describe("Confidence in the intent classification"),
extractedTerms: z.object({
proposedAmount: z.number().optional().describe(
"Any amount mentioned in response",
),
proposedPaymentPlan: z.string().optional().describe(
"Payment plan details if mentioned",
),
paymentTerms: z.object({
monthlyAmount: z.number().optional().describe("Monthly payment amount"),
numberOfPayments: z.number().optional().describe(
"Number of payments/installments",
),
totalAmount: z.number().optional().describe("Total amount to be paid"),
interestRate: z.number().optional().describe(
"Interest rate if applicable",
),
paymentFrequency: z.string().optional().describe(
"Payment frequency (monthly, weekly, etc.)",
),
}).optional().describe("Structured payment plan terms"),
deadline: z.string().optional().describe("Any deadline mentioned"),
conditions: z.array(z.string()).optional().describe(
"Any conditions or requirements mentioned",
),
}).describe("Key terms extracted from the response"),
reasoning: z.string().describe("Explanation of the analysis"),
suggestedNextAction: z.enum([
"accept_offer",
"send_counter",
"request_clarification",
"escalate_to_user",
"mark_settled",
]).describe("Recommended next action"),
requiresUserReview: z.boolean().describe(
"Whether this response needs human review",
),
});
serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response("ok", { headers: corsHeaders });
}
try {
const { testEmail } = await req.json();
if (!testEmail) {
return new Response(
JSON.stringify({ error: "testEmail is required" }),
{
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
const googleApiKey = Deno.env.get("GOOGLE_GENERATIVE_AI_API_KEY");
if (!googleApiKey) {
return new Response(
JSON.stringify({ error: "Google API key not configured" }),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
console.log("Testing extraction with email:", testEmail);
const result = await generateObject({
model: createGoogleGenerativeAI({
apiKey: googleApiKey,
})("gemini-2.5-flash-preview-04-17"),
system: `You are an expert financial analyst specializing in debt collection and negotiation responses.
Your job is to carefully analyze creditor responses and extract ALL financial terms mentioned.
CRITICAL: Always extract financial information when present. Look for:
AMOUNTS:
- Any dollar amounts mentioned ($1,000, $500, etc.)
- Settlement offers or counter-offers
- Monthly payment amounts
- Total payment amounts
PAYMENT PLANS:
- Monthly payment amounts (e.g., "$200 per month", "$150/month")
- Number of payments/installments (e.g., "12 months", "24 payments")
- Payment frequency (monthly, weekly, bi-weekly)
- Total amounts for payment plans
- Interest rates if mentioned
EXTRACT EVERYTHING: Even if amounts seem obvious, always include them in extractedTerms.`,
prompt: `Analyze this test email and extract ALL financial terms:
EMAIL: ${testEmail}
EXTRACTION REQUIREMENTS:
1. Find ANY dollar amounts mentioned in the email
2. Look for payment plan details (monthly amounts, number of payments)
3. Identify payment frequency (monthly, weekly, etc.)
4. Extract total amounts if mentioned
5. Note any interest rates or fees
6. Capture all conditions and requirements
EXAMPLES OF WHAT TO EXTRACT:
- "We can accept $250 per month" → monthlyAmount: 250
- "for 18 months" → numberOfPayments: 18
- "totaling $4,500" → totalAmount: 4500
- "settlement of $3,200" → proposedAmount: 3200
- "monthly payments" → paymentFrequency: "monthly"
Be thorough and extract ALL financial information present in the email.`,
schema: responseAnalysisSchema,
});
console.log("AI Analysis Result:", JSON.stringify(result.object, null, 2));
return new Response(
JSON.stringify({
success: true,
analysis: result.object,
extractedTerms: result.object.extractedTerms,
debug: {
emailLength: testEmail.length,
hasGoogleAPI: !!googleApiKey,
timestamp: new Date().toISOString()
}
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
console.error("Error in test-extraction function:", error);
return new Response(
JSON.stringify({
error: "Internal server error",
details: error.message
}),
{
status: 500,
headers: { ...corsHeaders, "Content-Type": "application/json" },
}
);
}
});

View File

@@ -0,0 +1,84 @@
-- Enhanced conversation tracking and negotiation flow
-- This migration adds comprehensive conversation tracking and improved status management
-- Create conversation_messages table to track all email exchanges
CREATE TABLE IF NOT EXISTS conversation_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
debt_id uuid REFERENCES debts(id) ON DELETE CASCADE NOT NULL,
message_type text NOT NULL CHECK (message_type IN ('initial_debt', 'negotiation_sent', 'response_received', 'counter_offer', 'acceptance', 'rejection')),
direction text NOT NULL CHECK (direction IN ('inbound', 'outbound')),
subject text,
body text NOT NULL,
from_email text,
to_email text,
message_id text, -- Postmark message ID for outbound, email ID for inbound
ai_analysis jsonb DEFAULT '{}'::jsonb, -- AI analysis of the message content
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- Add indexes for performance
CREATE INDEX IF NOT EXISTS idx_conversation_messages_debt_id ON conversation_messages(debt_id);
CREATE INDEX IF NOT EXISTS idx_conversation_messages_created_at ON conversation_messages(created_at);
CREATE INDEX IF NOT EXISTS idx_conversation_messages_type ON conversation_messages(message_type);
-- Update debts table status constraint to include new statuses
ALTER TABLE debts
DROP CONSTRAINT IF EXISTS debts_status_check;
ALTER TABLE debts
ADD CONSTRAINT debts_status_check
CHECK (status IN (
'received',
'negotiating',
'approved',
'sent',
'awaiting_response',
'counter_negotiating',
'accepted',
'rejected',
'settled',
'failed',
'opted_out'
));
-- Add conversation tracking fields to debts table
ALTER TABLE debts ADD COLUMN IF NOT EXISTS conversation_count integer DEFAULT 0;
ALTER TABLE debts ADD COLUMN IF NOT EXISTS last_message_at timestamptz DEFAULT now();
ALTER TABLE debts ADD COLUMN IF NOT EXISTS negotiation_round integer DEFAULT 1;
ALTER TABLE debts ADD COLUMN IF NOT EXISTS prospected_savings numeric DEFAULT 0;
ALTER TABLE debts ADD COLUMN IF NOT EXISTS actual_savings numeric DEFAULT 0;
-- Create indexes for new columns
CREATE INDEX IF NOT EXISTS idx_debts_last_message_at ON debts(last_message_at);
CREATE INDEX IF NOT EXISTS idx_debts_negotiation_round ON debts(negotiation_round);
-- Enable RLS on conversation_messages
ALTER TABLE conversation_messages ENABLE ROW LEVEL SECURITY;
-- Add RLS policy for conversation_messages (users can only see their own debt conversations)
CREATE POLICY "Users can view their own conversation messages" ON conversation_messages
FOR SELECT USING (
debt_id IN (
SELECT id FROM debts WHERE user_id = auth.uid()
)
);
CREATE POLICY "Users can insert their own conversation messages" ON conversation_messages
FOR INSERT WITH CHECK (
debt_id IN (
SELECT id FROM debts WHERE user_id = auth.uid()
)
);
-- Enable real-time for conversation_messages
ALTER PUBLICATION supabase_realtime ADD TABLE conversation_messages;
-- Add comments for documentation
COMMENT ON TABLE conversation_messages IS 'Tracks all email exchanges in debt negotiations';
COMMENT ON COLUMN conversation_messages.message_type IS 'Type of message in the negotiation flow';
COMMENT ON COLUMN conversation_messages.direction IS 'Whether message was sent (outbound) or received (inbound)';
COMMENT ON COLUMN conversation_messages.ai_analysis IS 'AI analysis results including intent, sentiment, and extracted terms';
COMMENT ON COLUMN debts.conversation_count IS 'Total number of messages in this debt conversation';
COMMENT ON COLUMN debts.last_message_at IS 'Timestamp of the most recent message in conversation';
COMMENT ON COLUMN debts.negotiation_round IS 'Current round of negotiation (increments with each back-and-forth)';