From 6ec598a25a159a0bc0b4d02985d2a43aae670209 Mon Sep 17 00:00:00 2001 From: AtharvaKalekar Date: Sat, 26 Sep 2026 04:04:35 +0530 Subject: [PATCH 1/2] fix(ai): ground chatbot on real user data and enforce egress verification guardrails --- src/services/ai.service.js | 159 +++++++++++++++------------- src/services/aiGuardrail.service.js | 30 +++--- test/ai_chat_grounding.test.js | 5 +- 3 files changed, 103 insertions(+), 91 deletions(-) diff --git a/src/services/ai.service.js b/src/services/ai.service.js index bf3568f..3accd59 100644 --- a/src/services/ai.service.js +++ b/src/services/ai.service.js @@ -1,3 +1,4 @@ +import mongoose from 'mongoose'; import { TransactionModel } from '../models/transaction.model.js'; import { ClaimModel } from '../models/claim.model.js'; import { BudgetModel } from '../models/budget.model.js'; @@ -22,13 +23,17 @@ export const aiService = { const query = (prompt || '').trim().toLowerCase(); // 1. Fetch User's Real Grounding Financial Context (SCRUM-172) - const [transactions, claims, budgets, groupExpenses, settlements] = await Promise.all([ - TransactionModel.find({ userId }).sort({ date: -1 }).limit(50).lean().catch(() => []), - ClaimModel.find({ userId }).sort({ submittedAt: -1 }).limit(20).lean().catch(() => []), - BudgetModel.find({ userId }).lean().catch(() => []), - GroupExpense.find({ 'allocations.memberId': userId }).sort({ createdAt: -1 }).limit(10).lean().catch(() => []), - Settlement.find({ $or: [{ fromMemberId: userId }, { toMemberId: userId }] }).sort({ createdAt: -1 }).limit(10).lean().catch(() => []), - ]); + const isDbConnected = mongoose.connection && mongoose.connection.readyState === 1; + + const [transactions, claims, budgets, groupExpenses, settlements] = isDbConnected + ? await Promise.all([ + TransactionModel.find({ userId: String(userId) }).sort({ date: -1 }).limit(50).lean().catch(() => []), + ClaimModel.find({ userId: String(userId) }).sort({ submittedAt: -1 }).limit(20).lean().catch(() => []), + BudgetModel.find({ userId: String(userId) }).lean().catch(() => []), + GroupExpense.find({ 'allocations.memberId': String(userId) }).sort({ createdAt: -1 }).limit(10).lean().catch(() => []), + Settlement.find({ $or: [{ fromMemberId: String(userId) }, { toMemberId: String(userId) }] }).sort({ createdAt: -1 }).limit(10).lean().catch(() => []), + ]) + : [[], [], [], [], []]; // Calculate aggregated metrics for grounding const totalTransactionsAmount = transactions.reduce((acc, t) => acc + (t.amount || 0), 0); @@ -71,12 +76,15 @@ export const aiService = { // 3. Fallback to Grounded Intelligence Engine (SCRUM-172 Response Grounding & SCRUM-194 Guardrails) let rawResult; + if (isReimbursementQuery) { rawResult = { alertTag: 'REIMBURSEMENT AUDIT', alertSub: `${claims.length} Claims Total`, intro: `Here is the current status of your corporate expense claims:`, - reply: `You have submitted ${claims.length} claims totaling ₹${totalClaimAmount.toLocaleString('en-IN')}. ${approvedClaims.length} approved, ${pendingClaims.length} pending review.`, + reply: claims.length > 0 + ? `You have submitted ${claims.length} claims totaling ₹${totalClaimAmount.toLocaleString('en-IN')}. ${approvedClaims.length} approved, ${pendingClaims.length} pending review.` + : 'You currently have 0 corporate reimbursement claims submitted in your connected account.', items: claims.slice(0, 3).map((c) => ({ icon: 'receipt', title: c.title || 'Reimbursement Claim', @@ -93,49 +101,45 @@ export const aiService = { alertTag: 'GROUP SPLIT BALANCES', alertSub: 'Active Group Expenses', intro: `Here is your current split expense summary across active groups:`, - reply: `You have ${groupExpenses.length} active shared expenses and ${settlements.length} recent settlements.`, - items: [ - { - icon: 'people', - title: 'Indiranagar Trip & Dinner', - diff: '₹450 Owed to You', - diffColor: '#10B981', - desc: 'Rahul and 2 others owe you for dinner payment.', - }, - { - icon: 'people', - title: 'Apartment Wi-Fi & Utilities', - diff: '₹200 You Owe', - diffColor: '#EF4444', - desc: 'Pending settlement to Priya for monthly fiber bill.', - }, - ], - tipBox: 'Sending a gentle reminder to group members speeds up settlements by 40%.', + reply: groupExpenses.length > 0 + ? `You have ${groupExpenses.length} active shared expenses and ${settlements.length} recent settlements.` + : 'You currently have 0 active shared split expenses or pending group settlements.', + items: groupExpenses.slice(0, 3).map((g) => ({ + icon: 'people', + title: g.title || 'Shared Group Expense', + diff: `₹${(g.totalAmount || 0).toLocaleString('en-IN')}`, + diffColor: '#10B981', + desc: `Group: ${g.groupName || 'Split Expense'}`, + })), + tipBox: groupExpenses.length > 0 ? 'Sending a gentle reminder to group members speeds up settlements by 40%.' : 'Create a split group to easily share expenses with friends.', badgeSuccess: 'Split calculations balanced against shared ledger.', footerNote: `Computed across ${groupExpenses.length} split groups`, }; } else if (isSubscriptionQuery) { + const subKeywords = ['netflix', 'spotify', 'gym', 'prime', 'youtube', 'apple', 'adobe', 'icloud', 'recurring', 'sub']; + const userSubs = transactions.filter((t) => { + const titleLower = (t.title || '').toLowerCase(); + return subKeywords.some((kw) => titleLower.includes(kw)); + }); + + const subTotal = userSubs.reduce((acc, t) => acc + (t.amount || 0), 0); + rawResult = { alertTag: 'RECURRING COMMITMENTS', alertSub: 'Active Autopay', - intro: 'You have 3 active auto-detected recurring subscriptions totaling ₹1,298/mo:', - reply: 'Active subscriptions: Netflix (₹199/mo), Spotify (₹119/mo), Gym (₹980/mo).', - items: [ - { - icon: 'movie', - title: 'Netflix India', - diff: '₹199/mo', - diffColor: '#E50914', - desc: 'Next billing date: 18th of this month • Auto-debit active', - }, - { - icon: 'music', - title: 'Spotify Premium', - diff: '₹119/mo', - diffColor: '#1DB954', - desc: 'Next billing date: 24th of this month • Auto-debit active', - }, - ], + intro: userSubs.length > 0 + ? `You have ${userSubs.length} active recurring subscriptions totaling ₹${subTotal.toLocaleString('en-IN')}:` + : 'Subscription Scan Result:', + reply: userSubs.length > 0 + ? `Active subscriptions detected in your transactions totaling ₹${subTotal.toLocaleString('en-IN')}.` + : 'No active recurring subscriptions detected in your transaction history.', + items: userSubs.slice(0, 3).map((s) => ({ + icon: 'movie', + title: s.title || 'Recurring Subscription', + diff: `₹${(s.amount || 0).toLocaleString('en-IN')}`, + diffColor: '#E50914', + desc: `Category: ${s.category || 'Subscriptions'} • Auto-debit active`, + })), badgeSuccess: 'No unauthorized recurring charges detected in your ledger.', footerAction: 'Manage Auto-mandates →', }; @@ -143,50 +147,53 @@ export const aiService = { rawResult = { alertTag: 'SAVINGS GOAL TRACKER', alertSub: 'Monthly Progress', - intro: 'Analysis for your ₹10,000 Monthly Savings Goal:', - reply: `Based on your current monthly spend velocity of ₹${totalTransactionsAmount.toLocaleString('en-IN')}, you are on track to save ₹8,500 this month.`, - items: [ + intro: 'Analysis for your Monthly Savings Goal:', + reply: `Based on your current total spend of ₹${totalTransactionsAmount.toLocaleString('en-IN')}, your financial ledger has recorded ${transactions.length} transactions this month.`, + items: transactions.length > 0 ? [ { icon: 'savings', - title: 'Current Savings Pace', - diff: '85% Achieved', + title: 'Current Spend Pace', + diff: `₹${totalTransactionsAmount.toLocaleString('en-IN')}`, diffColor: '#F59E0B', - desc: `Saved ₹8,500 out of ₹10,000 target. ₹1,500 gap remaining for month end.`, + desc: `Computed across ${transactions.length} ledger transactions.`, }, - ], - tipBox: 'Cutting weekend food delivery by 2 orders will close the ₹1,500 gap completely.', - badgeSuccess: 'Grounded against your monthly salary and fixed expenses.', - footerNote: 'Updated live from your synchronized budget model', + ] : [], + tipBox: transactions.length > 0 ? 'Tracking category caps weekly helps maximize monthly savings.' : 'Add your monthly target budget to track savings goals.', + badgeSuccess: 'Grounded against your synchronized expense data.', + footerNote: 'Updated live from your budget model', + }; + } else if (transactions.length === 0) { + rawResult = { + alertTag: 'REAL-TIME LEDGER GROUNDING', + alertSub: '0 Recorded Transactions', + intro: 'Welcome to FinTrack AI Assistant!', + reply: 'You currently have 0 recorded transactions or claims in your account. Add income/expense transactions or link your account to view real-time spend velocity analytics and AI financial insights grounded in your actual data.', + items: [], + tipBox: 'Tip: Tap "+ Add Transaction" to get real-time category spending analytics.', + badgeSuccess: '100% Grounded against your verified account.', + footerNote: '0 ledger entries recorded', }; } else { // Default / Category spend analysis query (SCRUM-172) - const foodSpend = categoryTotals['Food & Dining'] || categoryTotals['Food'] || 3240; - const transitSpend = categoryTotals['Transport'] || categoryTotals['Transit'] || 1420; + const sortedCategories = Object.entries(categoryTotals).sort((a, b) => b[1] - a[1]); + const topCatName = sortedCategories.length > 0 ? sortedCategories[0][0] : 'General'; + const topCatAmt = sortedCategories.length > 0 ? sortedCategories[0][1] : 0; rawResult = { alertTag: 'SPEND VELOCITY ANALYSIS', alertSub: 'Real-time Ledger Grounding', - intro: `Here is the grounded breakdown of your spending based on ${transactions.length || 28} verified transactions:`, - reply: `Total recorded spend is ₹${(totalTransactionsAmount || 8450).toLocaleString('en-IN')}. Top spending category is Food & Dining at ₹${foodSpend.toLocaleString('en-IN')}.`, - items: [ - { - icon: 'restaurant', - title: 'Food & Dining', - diff: `₹${foodSpend.toLocaleString('en-IN')} (+18% MoM)`, - diffColor: '#EF4444', - desc: 'Food delivery and restaurant dining accounted for your largest spend category.', - }, - { - icon: 'transit', - title: 'Transit & Mobility', - diff: `₹${transitSpend.toLocaleString('en-IN')}`, - diffColor: '#F59E0B', - desc: 'Uber & auto rides during weekend peak hours.', - }, - ], - tipBox: 'Setting a Category Budget limit of ₹3,000 on Dining out can help you save ~₹1,200 next month.', + intro: `Here is the grounded breakdown of your spending based on ${transactions.length} verified transactions:`, + reply: `Total recorded spend is ₹${totalTransactionsAmount.toLocaleString('en-IN')}. Top spending category is ${topCatName} at ₹${topCatAmt.toLocaleString('en-IN')}.`, + items: sortedCategories.slice(0, 3).map(([cat, amt]) => ({ + icon: cat.toLowerCase().includes('food') || cat.toLowerCase().includes('dining') ? 'restaurant' : cat.toLowerCase().includes('transport') || cat.toLowerCase().includes('transit') ? 'transit' : 'receipt', + title: cat, + diff: `₹${amt.toLocaleString('en-IN')}`, + diffColor: '#FF6B00', + desc: `Total recorded spending in ${cat}.`, + })), + tipBox: sortedCategories.length > 0 ? `Setting a budget limit on ${topCatName} can help you optimize monthly savings.` : 'Add transactions to see category velocity breakdown.', badgeSuccess: '100% Grounded against verified bank transactions.', - footerNote: `Computed across ${transactions.length || 28} verified ledger entries`, + footerNote: `Computed across ${transactions.length} verified ledger entries`, }; } diff --git a/src/services/aiGuardrail.service.js b/src/services/aiGuardrail.service.js index 1ec3404..034e23f 100644 --- a/src/services/aiGuardrail.service.js +++ b/src/services/aiGuardrail.service.js @@ -64,20 +64,24 @@ export const aiGuardrailService = { response.isGrounded = true; // If response has items, verify they don't contain hallucinated non-existent transaction data - if (Array.isArray(response.items) && response.items.length > 0 && groundedTransactions.length > 0) { - response.items = response.items.filter((item) => { - if (!item.title) { - return true; - } - // Verify merchant/title or category matches at least one real transaction in context - const titleLower = item.title.toLowerCase(); - const existsInGrounding = groundedTransactions.some((t) => { - const tTitle = (t.title || '').toLowerCase(); - const tCat = (t.category || '').toLowerCase(); - return tTitle.includes(titleLower) || titleLower.includes(tTitle) || titleLower.includes(tCat); + if (Array.isArray(response.items) && response.items.length > 0) { + if (groundedTransactions.length > 0) { + response.items = response.items.filter((item) => { + if (!item.title) { + return true; + } + // Verify merchant/title or category matches at least one real transaction in context + const titleLower = item.title.toLowerCase(); + return groundedTransactions.some((t) => { + const tTitle = (t.title || '').toLowerCase(); + const tCat = (t.category || '').toLowerCase(); + return tTitle.includes(titleLower) || titleLower.includes(tTitle) || titleLower.includes(tCat); + }); }); - return existsInGrounding; - }); + } else { + // If user has 0 transactions in Mongo, purge unverified fallback items + response.items = response.items.filter((item) => item.isVerified === true); + } } return response; diff --git a/test/ai_chat_grounding.test.js b/test/ai_chat_grounding.test.js index 052d111..182571a 100644 --- a/test/ai_chat_grounding.test.js +++ b/test/ai_chat_grounding.test.js @@ -8,13 +8,13 @@ async function runTests() { const result1 = await aiService.processChatQuery('user_test_1', 'Where did I overspend this month?'); assert.strictEqual(typeof result1.intro, 'string'); assert.strictEqual(typeof result1.reply, 'string'); - assert.ok(result1.alertTag.includes('SPEND') || result1.alertTag.includes('INSIGHT')); + assert.ok(result1.alertTag.includes('SPEND') || result1.alertTag.includes('INSIGHT') || result1.alertTag.includes('GROUNDING')); console.log('✓ SCRUM-171/172 Intent & Data Grounding Test 1 Passed:', result1.alertTag); // Test 2: Subscriptions Query Intent const result2 = await aiService.processChatQuery('user_test_1', 'What subscriptions do I have active?'); assert.strictEqual(result2.alertTag, 'RECURRING COMMITMENTS'); - assert.ok(result2.items.length > 0); + assert.strictEqual(typeof result2.reply, 'string'); console.log('✓ SCRUM-171 Intent Handling Test 2 (Subscriptions) Passed:', result2.alertTag); // Test 3: Reimbursement Claims Query Intent @@ -28,6 +28,7 @@ async function runTests() { console.log('✓ SCRUM-171/172 Intent Handling Test 4 (Savings Goal) Passed:', result4.alertTag); console.log('ALL SCRUM-174 AI CHAT & GROUNDING TESTS PASSED CLEANLY! ✅'); + process.exit(0); } runTests().catch((err) => { From b5f4b7737f484e2a97db649e4474ed0658fb0f9f Mon Sep 17 00:00:00 2001 From: AtharvaKalekar Date: Sat, 26 Sep 2026 04:11:46 +0530 Subject: [PATCH 2/2] fix(notifications): set creator notification to system type and standardize ACCEPTED actionStatus --- src/services/split.service.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/services/split.service.js b/src/services/split.service.js index d909927..c3b420e 100644 --- a/src/services/split.service.js +++ b/src/services/split.service.js @@ -94,7 +94,8 @@ export class SplitService { userId: createdBy, title: `Group "${group.title}" Created`, body: invited.length > 0 ? `Invitations sent to ${invitedNames}.` : `Group "${group.title}" created.`, - type: 'invitation', + type: 'system', + actionStatus: 'ACCEPTED', data: { groupId: group._id.toString(), groupName: group.title, @@ -268,9 +269,10 @@ export class SplitService { await group.save(); try { + const finalStatus = normalizedAction === 'ACCEPT' ? 'ACCEPTED' : 'DECLINED'; await Notification.updateMany( { 'data.groupId': groupId, userId: { $in: [userId, member.memberId] } }, - { actionStatus: normalizedAction, isRead: true } + { actionStatus: finalStatus, isRead: true } ); } catch (notifErr) { console.warn('Invitation notification update warning:', notifErr.message);