Files

796 lines
22 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Plaid Local Learning Demo - RuVector Edge</title>
<style>
:root {
--bg: #0a0a0f;
--card: #12121a;
--border: #2a2a3a;
--text: #e0e0e8;
--text-dim: #8888a0;
--accent: #6366f1;
--accent-glow: rgba(99, 102, 241, 0.3);
--success: #22c55e;
--warning: #f59e0b;
--error: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
header {
text-align: center;
margin-bottom: 3rem;
}
h1 {
font-size: 2.5rem;
background: linear-gradient(135deg, var(--accent), #a855f7);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 0.5rem;
}
.subtitle {
color: var(--text-dim);
font-size: 1.1rem;
}
.privacy-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
color: var(--success);
padding: 0.5rem 1rem;
border-radius: 2rem;
margin-top: 1rem;
font-size: 0.9rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 1.5rem;
}
.card h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.stat {
background: rgba(99, 102, 241, 0.05);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 0.5rem;
padding: 1rem;
text-align: center;
}
.stat-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--accent);
}
.stat-label {
font-size: 0.8rem;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.05em;
}
button {
background: var(--accent);
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-size: 1rem;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 20px var(--accent-glow);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
button.secondary {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
}
button.danger {
background: var(--error);
}
.patterns-list {
max-height: 300px;
overflow-y: auto;
}
.pattern-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid var(--border);
}
.pattern-item:last-child {
border-bottom: none;
}
.pattern-category {
font-weight: 500;
}
.pattern-amount {
color: var(--accent);
font-weight: 600;
}
.confidence-bar {
width: 60px;
height: 4px;
background: var(--border);
border-radius: 2px;
overflow: hidden;
}
.confidence-fill {
height: 100%;
background: var(--accent);
transition: width 0.3s;
}
.heatmap {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-top: 1rem;
}
.heatmap-cell {
aspect-ratio: 1;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
color: var(--text-dim);
}
.heatmap-label {
font-size: 0.7rem;
color: var(--text-dim);
text-align: center;
margin-top: 0.25rem;
}
.transaction-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
input, select {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 0.75rem;
border-radius: 0.5rem;
font-size: 1rem;
width: 100%;
}
input:focus, select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
label {
display: block;
font-size: 0.9rem;
color: var(--text-dim);
margin-bottom: 0.25rem;
}
.result-card {
background: rgba(99, 102, 241, 0.05);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: 0.5rem;
padding: 1rem;
margin-top: 1rem;
}
.result-card.anomaly {
background: rgba(239, 68, 68, 0.05);
border-color: rgba(239, 68, 68, 0.3);
}
.result-card.normal {
background: rgba(34, 197, 94, 0.05);
border-color: rgba(34, 197, 94, 0.3);
}
.log {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.5rem;
padding: 1rem;
font-family: 'Fira Code', monospace;
font-size: 0.85rem;
max-height: 200px;
overflow-y: auto;
}
.log-entry {
padding: 0.25rem 0;
border-bottom: 1px solid var(--border);
}
.log-entry:last-child {
border-bottom: none;
}
.log-time {
color: var(--text-dim);
}
.log-success {
color: var(--success);
}
.log-info {
color: var(--accent);
}
footer {
text-align: center;
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid var(--border);
color: var(--text-dim);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.loading {
animation: pulse 1.5s infinite;
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🧠 Plaid Local Learning</h1>
<p class="subtitle">Privacy-preserving financial intelligence powered by RuVector Edge</p>
<div class="privacy-badge">
🔒 100% Browser-Local • No Data Leaves Your Device
</div>
</header>
<div class="grid">
<!-- Stats Card -->
<div class="card">
<h2>📊 Learning Statistics</h2>
<div class="stats-grid">
<div class="stat">
<div class="stat-value" id="stat-patterns">0</div>
<div class="stat-label">Patterns Learned</div>
</div>
<div class="stat">
<div class="stat-value" id="stat-version">0</div>
<div class="stat-label">Learning Version</div>
</div>
<div class="stat">
<div class="stat-value" id="stat-index">0</div>
<div class="stat-label">Index Size</div>
</div>
<div class="stat">
<div class="stat-value" id="stat-qvalues">0</div>
<div class="stat-label">Q-Values</div>
</div>
</div>
<div style="margin-top: 1rem; display: flex; gap: 0.5rem;">
<button id="btn-init" onclick="initLearner()">
⚡ Initialize
</button>
<button class="secondary" onclick="refreshStats()">
🔄 Refresh
</button>
</div>
</div>
<!-- Patterns Card -->
<div class="card">
<h2>🎯 Learned Spending Patterns</h2>
<div class="patterns-list" id="patterns-list">
<p style="color: var(--text-dim); text-align: center; padding: 2rem;">
Process transactions to learn patterns
</p>
</div>
</div>
<!-- Transaction Input -->
<div class="card">
<h2>💳 Test Transaction</h2>
<div class="transaction-form">
<div class="form-row">
<div>
<label>Amount ($)</label>
<input type="number" id="tx-amount" value="45.99" step="0.01">
</div>
<div>
<label>Date</label>
<input type="date" id="tx-date" value="2024-03-15">
</div>
</div>
<div>
<label>Merchant Name</label>
<input type="text" id="tx-merchant" value="Starbucks">
</div>
<div>
<label>Category</label>
<select id="tx-category">
<option value="Food and Drink">Food and Drink</option>
<option value="Shopping">Shopping</option>
<option value="Transportation">Transportation</option>
<option value="Entertainment">Entertainment</option>
<option value="Bills">Bills</option>
<option value="Healthcare">Healthcare</option>
</select>
</div>
<div style="display: flex; gap: 0.5rem;">
<button onclick="analyzeTransaction()">
🔍 Analyze
</button>
<button class="secondary" onclick="addToLearning()">
Add & Learn
</button>
</div>
</div>
<div id="analysis-result"></div>
</div>
<!-- Temporal Heatmap -->
<div class="card">
<h2>📅 Spending Heatmap</h2>
<p style="color: var(--text-dim); font-size: 0.9rem; margin-bottom: 1rem;">
Day-of-week spending patterns (learned from your transactions)
</p>
<div class="heatmap" id="heatmap">
<!-- Generated by JS -->
</div>
<div class="heatmap-label">Sun → Sat</div>
</div>
<!-- Sample Data -->
<div class="card">
<h2>📦 Load Sample Data</h2>
<p style="color: var(--text-dim); margin-bottom: 1rem;">
Load sample transactions to see the learning in action.
</p>
<button onclick="loadSampleData()">
📥 Load 50 Sample Transactions
</button>
<div style="margin-top: 1rem;">
<button class="danger" onclick="clearAllData()">
🗑️ Clear All Data
</button>
</div>
</div>
<!-- Activity Log -->
<div class="card">
<h2>📝 Activity Log</h2>
<div class="log" id="activity-log">
<div class="log-entry">
<span class="log-time">[--:--:--]</span>
<span class="log-info">Ready to initialize...</span>
</div>
</div>
</div>
</div>
<footer>
<p>Powered by <strong>RuVector Edge</strong> • WASM-based ML • Zero server dependencies</p>
<p style="margin-top: 0.5rem; font-size: 0.85rem;">
Your financial data never leaves this browser. All learning happens locally.
</p>
</footer>
</div>
<script type="module">
import init, {
PlaidLocalLearner,
WasmHnswIndex,
WasmSpikingNetwork,
} from './ruvector_edge.js';
// Global state
let learner = null;
let isInitialized = false;
// Make functions available globally
window.initLearner = initLearner;
window.refreshStats = refreshStats;
window.analyzeTransaction = analyzeTransaction;
window.addToLearning = addToLearning;
window.loadSampleData = loadSampleData;
window.clearAllData = clearAllData;
// Logging helper
function log(message, type = 'info') {
const logEl = document.getElementById('activity-log');
const time = new Date().toLocaleTimeString();
const typeClass = type === 'success' ? 'log-success' : 'log-info';
logEl.innerHTML = `
<div class="log-entry">
<span class="log-time">[${time}]</span>
<span class="${typeClass}">${message}</span>
</div>
` + logEl.innerHTML;
}
// Initialize the learner
async function initLearner() {
const btn = document.getElementById('btn-init');
btn.disabled = true;
btn.innerHTML = '<span class="loading">⏳ Loading WASM...</span>';
try {
await init();
log('WASM module loaded');
// Create learner instance
learner = new PlaidLocalLearner();
log('PlaidLocalLearner created');
// Try to load existing state from IndexedDB
try {
const stateJson = localStorage.getItem('plaid_learner_state');
if (stateJson) {
learner.loadState(stateJson);
log('Previous learning state restored', 'success');
}
} catch (e) {
log('Starting with fresh state');
}
isInitialized = true;
btn.innerHTML = '✅ Initialized';
btn.style.background = 'var(--success)';
refreshStats();
updateHeatmap();
} catch (error) {
console.error(error);
log(`Error: ${error.message}`, 'error');
btn.innerHTML = '❌ Error';
btn.disabled = false;
}
}
// Refresh statistics display
function refreshStats() {
if (!isInitialized) return;
try {
const stats = learner.getStats();
document.getElementById('stat-patterns').textContent = stats.patterns_count;
document.getElementById('stat-version').textContent = stats.version;
document.getElementById('stat-index').textContent = stats.index_size;
document.getElementById('stat-qvalues').textContent = stats.q_values_count;
// Update patterns list
const patterns = learner.getPatternsSummary();
const listEl = document.getElementById('patterns-list');
if (patterns.length === 0) {
listEl.innerHTML = `
<p style="color: var(--text-dim); text-align: center; padding: 2rem;">
Process transactions to learn patterns
</p>
`;
} else {
listEl.innerHTML = patterns.map(p => `
<div class="pattern-item">
<div>
<span class="pattern-category">${p.category}</span>
<div style="font-size: 0.8rem; color: var(--text-dim);">
${p.frequency_days.toFixed(0)} day avg frequency
</div>
</div>
<div style="text-align: right;">
<span class="pattern-amount">$${p.avg_amount.toFixed(2)}</span>
<div class="confidence-bar">
<div class="confidence-fill" style="width: ${p.confidence * 100}%"></div>
</div>
</div>
</div>
`).join('');
}
log('Stats refreshed');
} catch (e) {
log(`Error refreshing stats: ${e.message}`);
}
}
// Update heatmap visualization
function updateHeatmap() {
if (!isInitialized) return;
try {
const heatmap = learner.getTemporalHeatmap();
const maxVal = Math.max(...heatmap.day_of_week, 1);
const heatmapEl = document.getElementById('heatmap');
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
heatmapEl.innerHTML = heatmap.day_of_week.map((val, i) => {
const intensity = val / maxVal;
const color = `rgba(99, 102, 241, ${0.1 + intensity * 0.9})`;
return `
<div class="heatmap-cell" style="background: ${color}">
${days[i]}
</div>
`;
}).join('');
} catch (e) {
console.error('Heatmap error:', e);
}
}
// Create transaction object from form
function getTransactionFromForm() {
return {
transaction_id: 'tx_' + Date.now(),
account_id: 'acc_demo',
amount: parseFloat(document.getElementById('tx-amount').value),
date: document.getElementById('tx-date').value,
name: document.getElementById('tx-merchant').value,
merchant_name: document.getElementById('tx-merchant').value,
category: [document.getElementById('tx-category').value],
pending: false,
payment_channel: 'online',
};
}
// Analyze a single transaction
function analyzeTransaction() {
if (!isInitialized) {
log('Please initialize first');
return;
}
const tx = getTransactionFromForm();
const txJson = JSON.stringify(tx);
try {
// Detect anomaly
const anomaly = learner.detectAnomaly(txJson);
// Predict category
const prediction = learner.predictCategory(txJson);
// Get budget recommendation
const budget = learner.getBudgetRecommendation(
tx.category[0],
tx.amount,
200 // Default budget
);
const resultEl = document.getElementById('analysis-result');
const isAnomaly = anomaly.is_anomaly;
resultEl.innerHTML = `
<div class="result-card ${isAnomaly ? 'anomaly' : 'normal'}">
<h3 style="margin-bottom: 0.5rem;">
${isAnomaly ? '⚠️ Anomaly Detected' : '✅ Normal Transaction'}
</h3>
<p style="margin-bottom: 0.5rem;">${anomaly.reason}</p>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-top: 1rem;">
<div>
<div style="font-size: 0.8rem; color: var(--text-dim);">Anomaly Score</div>
<div style="font-weight: 600;">${anomaly.anomaly_score.toFixed(2)}</div>
</div>
<div>
<div style="font-size: 0.8rem; color: var(--text-dim);">Expected Amount</div>
<div style="font-weight: 600;">$${anomaly.expected_amount.toFixed(2)}</div>
</div>
<div>
<div style="font-size: 0.8rem; color: var(--text-dim);">Trend</div>
<div style="font-weight: 600;">${budget.trend}</div>
</div>
</div>
</div>
`;
log(`Analyzed: ${tx.merchant_name} - $${tx.amount}`, 'success');
} catch (e) {
log(`Analysis error: ${e.message}`);
}
}
// Add transaction to learning
function addToLearning() {
if (!isInitialized) {
log('Please initialize first');
return;
}
const tx = getTransactionFromForm();
try {
const insights = learner.processTransactions(JSON.stringify([tx]));
log(`Learned from transaction: ${tx.merchant_name}`, 'success');
// Save state
const stateJson = learner.saveState();
localStorage.setItem('plaid_learner_state', stateJson);
refreshStats();
updateHeatmap();
} catch (e) {
log(`Learning error: ${e.message}`);
}
}
// Load sample transactions
function loadSampleData() {
if (!isInitialized) {
log('Please initialize first');
return;
}
const categories = ['Food and Drink', 'Shopping', 'Transportation', 'Entertainment', 'Bills'];
const merchants = {
'Food and Drink': ['Starbucks', 'Chipotle', 'Whole Foods', 'McDonalds', 'Subway'],
'Shopping': ['Amazon', 'Target', 'Walmart', 'Best Buy', 'Nike'],
'Transportation': ['Uber', 'Lyft', 'Shell Gas', 'Metro', 'Parking'],
'Entertainment': ['Netflix', 'Spotify', 'AMC Theaters', 'Steam', 'Apple TV'],
'Bills': ['Electric Co', 'Water Utility', 'Internet Provider', 'Phone Bill', 'Insurance'],
};
const amounts = {
'Food and Drink': [5, 50],
'Shopping': [10, 200],
'Transportation': [5, 80],
'Entertainment': [10, 50],
'Bills': [50, 300],
};
const transactions = [];
const today = new Date();
for (let i = 0; i < 50; i++) {
const category = categories[Math.floor(Math.random() * categories.length)];
const merchant = merchants[category][Math.floor(Math.random() * 5)];
const [min, max] = amounts[category];
const amount = min + Math.random() * (max - min);
const date = new Date(today);
date.setDate(date.getDate() - Math.floor(Math.random() * 90));
transactions.push({
transaction_id: `tx_sample_${i}`,
account_id: 'acc_demo',
amount: parseFloat(amount.toFixed(2)),
date: date.toISOString().split('T')[0],
name: merchant,
merchant_name: merchant,
category: [category],
pending: false,
payment_channel: 'online',
});
}
try {
const insights = learner.processTransactions(JSON.stringify(transactions));
log(`Loaded ${insights.transactions_processed} sample transactions`, 'success');
// Save state
const stateJson = learner.saveState();
localStorage.setItem('plaid_learner_state', stateJson);
refreshStats();
updateHeatmap();
} catch (e) {
log(`Error loading sample data: ${e.message}`);
}
}
// Clear all data
function clearAllData() {
if (!confirm('This will delete all learned patterns. Are you sure?')) return;
if (isInitialized) {
learner.clear();
}
localStorage.removeItem('plaid_learner_state');
// Clear IndexedDB
indexedDB.deleteDatabase('plaid_local_learning');
log('All data cleared', 'success');
refreshStats();
updateHeatmap();
document.getElementById('analysis-result').innerHTML = '';
}
// Auto-initialize on page load
window.addEventListener('DOMContentLoaded', () => {
log('Page loaded. Click Initialize to start.');
});
</script>
</body>
</html>