     1→/**
     2→ * Call List Smart Ranker
     3→ * Computes priority scores with explainable reasons
     4→ *
     5→ * Priority Score Formula (0-1000):
     6→ *   urgency (35%) + intent (25%) + timing (20%) + pattern (15%) + base (5%)
     7→ */
     8→
     9→const { supabase } = require('../config/database');
    10→
    11→// In-memory cache for ranking context (5-minute TTL)
    12→let rankingContextCache = null;
    13→let rankingContextCacheTime = 0;
    14→const RANKING_CONTEXT_TTL = 5 * 60 * 1000; // 5 minutes
    15→
    16→// Weight configuration
    17→const WEIGHTS = {
    18→  urgency: 350,   // Max 350 points
    19→  intent: 250,    // Max 250 points
    20→  timing: 200,    // Max 200 points
    21→  pattern: 150,   // Max 150 points
    22→  base: 50        // Max 50 points
    23→};
    24→
    25→// ============================================
    26→// URGENCY SCORING (0-350 points)
    27→// How urgent is action needed?
    28→// ============================================
    29→function computeUrgencyScore(lead, callbacks = []) {
    30→  let score = 0;
    31→  let reason = null;
    32→  let warning = null;
    33→
    34→  const now = Date.now();
    35→
    36→  // Check for recent inbound activity
    37→  if (lead.last_inbound_at) {
    38→    const inboundTime = new Date(lead.last_inbound_at).getTime();
    39→    const minutesAgo = Math.floor((now - inboundTime) / (1000 * 60));
    40→    const hoursAgo = Math.floor(minutesAgo / 60);
    41→
    42→    if (lead.last_inbound_type === 'call_inbound') {
    43→      // Inbound calls are high priority
    44→      const callCount = lead.metadata?.inbound_call_count || 1;
    45→      if (callCount >= 3) {
    46→        score = 350;
    47→        reason = `Called you ${callCount} times - wants you!`;
    48→      } else if (callCount >= 2) {
    49→        score = 320;
    50→        reason = `Called you back twice - high intent!`;
    51→      } else {
    52→        score = 300;
    53→        reason = `Called you back - respond now!`;
    54→      }
    55→    } else if (lead.last_inbound_type === 'sms_inbound') {
    56→      // SMS recency scoring
    57→      if (minutesAgo < 15) {
    58→        score = 350;
    59→        reason = `Replied ${minutesAgo} mins ago - strike now!`;
    60→      } else if (minutesAgo < 60) {
    61→        score = 300;
    62→        reason = `Replied ${minutesAgo} mins ago - still hot`;
    63→      } else if (hoursAgo < 4) {
    64→        score = 250;
    65→        reason = `Replied ${hoursAgo}hr${hoursAgo > 1 ? 's' : ''} ago - follow up`;
    66→      } else if (hoursAgo < 24) {
    67→        score = 150;
    68→        reason = `Replied ${hoursAgo} hrs ago - needs response`;
    69→      } else {
    70→        score = 80;
    71→        reason = `Replied ${Math.floor(hoursAgo / 24)} day${hoursAgo >= 48 ? 's' : ''} ago`;
    72→      }
    73→    }
    74→  }
    75→
    76→  // Check for overdue/due callbacks
    77→  // Note: callbacks are now pre-filtered by lead_id in rankLeadsForCallList for O(1) lookup
    78→  if (callbacks.length > 0) {
    79→    const leadCallbacks = callbacks;
    80→    const urgentCallback = leadCallbacks.sort((a, b) =>
    81→      new Date(a.scheduled_at) - new Date(b.scheduled_at)
    82→    )[0];
    83→
    84→    const scheduledTime = new Date(urgentCallback.scheduled_at).getTime();
    85→    const hoursOverdue = Math.floor((now - scheduledTime) / (1000 * 60 * 60));
    86→
    87→    if (hoursOverdue > 0 && hoursOverdue < 10000) {
    88→      // Callback is overdue (sanity check: less than ~1 year)
    89→      const callbackScore = Math.min(320, 250 + (hoursOverdue * 10));
    90→      if (callbackScore > score) {
    91→        score = callbackScore;
    92→        reason = `Callback ${hoursOverdue}hr${hoursOverdue > 1 ? 's' : ''} overdue!`;
    93→        warning = 'callback_overdue';
    94→      }
    95→    } else if (scheduledTime <= now + (24 * 60 * 60 * 1000)) {
    96→      // Callback due today
    97→      if (score < 200) {
    98→        score = 200;
    99→        reason = `Callback scheduled for today`;
   100→      }
   101→    }
   102→  }
   103→
   104→  // Check for hot lead staleness
   105→  if (lead.score >= 70 && lead.last_contact_at) {
   106→    const lastContactTime = new Date(lead.last_contact_at).getTime();
   107→    const hoursSinceContact = Math.floor((now - lastContactTime) / (1000 * 60 * 60));
   108→
   109→    if (hoursSinceContact >= 48) {
   110→      // Critical staleness - boost urgency
   111→      const stalenessScore = Math.min(350, 280 + (hoursSinceContact - 48) * 2);
   112→      if (stalenessScore > score || !reason) {
   113→        score = Math.max(score, stalenessScore);
   114→        reason = `URGENT: Hot lead ${hoursSinceContact}hrs without contact!`;
   115→        warning = 'stale_48hrs';
   116→      } else if (!warning) {
   117→        warning = 'stale_48hrs';
   118→      }
   119→    } else if (hoursSinceContact >= 24) {
   120→      // Warning staleness
   121→      if (score < 250) {
   122→        score = 250;
   123→        reason = `Hot lead going cold - ${hoursSinceContact}hrs stale`;
   124→        warning = 'going_cold';
   125→      } else if (!warning) {
   126→        warning = 'going_cold';
   127→      }
   128→    }
   129→  }
   130→
   131→  return { score, reason, warning };
   132→}
   133→
   134→// ============================================
   135→// INTENT SCORING (0-250 points)
   136→// How interested are they?
   137→// ============================================
   138→function computeIntentScore(lead) {
   139→  let score = 0;
   140→  let reason = null;
   141→
   142→  // High intent flag (3+ inbound calls)
   143→  if (lead.is_high_intent || lead.metadata?.is_high_intent) {
   144→    score = 250;
   145→    reason = `High intent - called you 3+ times`;
   146→    return { score, reason };
   147→  }
   148→
   149→  // Multiple inbound calls
   150→  const inboundCallCount = lead.metadata?.inbound_call_count || 0;
   151→  if (inboundCallCount >= 2) {
   152→    score = 200;
   153→    reason = `Called back ${inboundCallCount} times`;
   154→    return { score, reason };
   155→  }
   156→
   157→  // Check for recent buying signals (last 7 days)
   158→  const buyingSignals = lead.metadata?.buying_signals || [];
   159→  const now = Date.now();
   160→  const sevenDaysAgo = now - (7 * 24 * 60 * 60 * 1000);
   161→  const recentSignals = buyingSignals.filter(s => {
   162→    const signalDate = new Date(s.detected_at).getTime();
   163→    return signalDate > sevenDaysAgo;
   164→  });
   165→
   166→  if (recentSignals.length > 0) {
   167→    // Calculate buying signal bonus (max 150 points)
   168→    const signalBonus = recentSignals.reduce((sum, s) => {
   169→      const signalScore = s.type === 'high' ? 100 : s.type === 'medium' ? 50 : 20;
   170→      return sum + signalScore;
   171→    }, 0);
   172→
   173→    const cappedBonus = Math.min(150, signalBonus);
   174→    score = Math.max(score, 100 + cappedBonus); // Base 100 + bonus up to 150 = max 250
   175→
   176→    // High buying signal = top priority
   177→    const hasHighSignal = recentSignals.some(s => s.type === 'high');
   178→    if (hasHighSignal) {
   179→      score = 250;
   180→      reason = `🔥 HIGH buying signal - ready to close!`;
   181→      return { score, reason };
   182→    }
   183→
   184→    // Medium signals
   185→    const mediumCount = recentSignals.filter(s => s.type === 'medium').length;
   186→    if (mediumCount >= 2) {
   187→      score = Math.max(score, 200);
   188→      reason = `🔥 ${mediumCount} buying signals - actively interested`;
   189→    } else if (mediumCount === 1) {
   190→      score = Math.max(score, 180);
   191→      if (!reason) reason = `🔥 Buying signal detected - considering`;
   192→    }
   193→
   194→    // Low signals (better than nothing)
   195→    const lowCount = recentSignals.filter(s => s.type === 'low').length;
   196→    if (lowCount > 0 && !reason) {
   197→      score = Math.max(score, 120);
   198→      reason = `🔥 ${lowCount} buying signal${lowCount > 1 ? 's' : ''} - curious`;
   199→    }
   200→  }
   201→
   202→  // Check last SMS intent
   203→  if (lead.metadata?.last_sms_intent === 'positive' && score < 200) {
   204→    score = Math.max(score, 200);
   205→    if (!reason) reason = `Positive reply - interested`;
   206→  } else if (lead.metadata?.last_sms_intent === 'question' && score < 150) {
   207→    score = Math.max(score, 150);
   208→    if (!reason) reason = `Asked a question - engaged`;
   209→  }
   210→
   211→  // Platform activity bonuses
   212→  if (lead.metadata?.recent_platform_activity) {
   213→    const activity = lead.metadata.recent_platform_activity;
   214→    if (activity === 'job_posted') {
   215→      score = Math.max(score, 180);
   216→      if (!reason) reason = `Posted a job on RateRight`;
   217→    } else if (activity === 'login') {
   218→      score = Math.max(score, 100);
   219→      if (!reason) reason = `Active on platform`;
   220→    }
   221→  }
   222→
   223→  return { score, reason };
   224→}
   225→
   226→// ============================================
   227→// TIMING SCORING (0-200 points)
   228→// Is now the right time to call?
   229→// ============================================
   230→function computeTimingScore(lead, context) {
   231→  let score = 0;
   232→  let reason = null;
   233→
   234→  const currentHour = context.currentHour;
   235→  const currentDay = context.currentDay; // 0=Sun, 1=Mon, etc.
   236→
   237→  // Check if current hour matches best conversion hours
   238→  if (context.bestHours && context.bestHours.length > 0) {
   239→    if (context.bestHours[0] === currentHour) {
   240→      score = 200;
   241→      reason = `Best time to call - ${formatHour(currentHour)} slot!`;
   242→    } else if (context.bestHours.includes(currentHour)) {
   243→      score = 150;
   244→      reason = `Good calling window - ${formatHour(currentHour)}`;
   245→    }
   246→  }
   247→
   248→  // Check if lead has personal best contact time
   249→  if (lead.metadata?.best_contact_hour === currentHour) {
   250→    score = Math.max(score, 180);
   251→    reason = `${lead.first_name}'s best hour - ${formatHour(currentHour)}`;
   252→  }
   253→
   254→  // Prime calling hours bonus
   255→  if (score === 0) {
   256→    if (currentHour >= 9 && currentHour <= 11) {
   257→      score = 80;
   258→      reason = `Morning calling window`;
   259→    } else if (currentHour >= 14 && currentHour <= 16) {
   260→      score = 80;
   261→      reason = `Afternoon calling window`;
   262→    }
   263→  }
   264→
   265→  // Day of week matching
   266→  if (lead.metadata?.best_contact_day === currentDay) {
   267→    score = Math.min(200, score + 50);
   268→  }
   269→
   270→  return { score, reason };
   271→}
   272→
   273→// ============================================
   274→// PATTERN SCORING (0-150 points)
   275→// Does this match winning patterns?
   276→// ============================================
   277→function computePatternScore(lead, context) {
   278→  let score = 0;
   279→  let reason = null;
   280→
   281→  if (!context.winningPatterns || context.winningPatterns.length === 0) {
   282→    return { score, reason };
   283→  }
   284→
   285→  for (const pattern of context.winningPatterns) {
   286→    if (pattern.pattern_type === 'trade' && pattern.criteria?.trade) {
   287→      if (lead.trade === pattern.criteria.trade) {
   288→        const rate = Math.round((pattern.conversion_rate || 0) * 100);
   289→        score = 150;
   290→        reason = `${lead.trade} converts ${rate}%`;
   291→        break;
   292→      }
   293→    }
   294→
   295→    if (pattern.pattern_type === 'source' && pattern.criteria?.source) {
   296→      if (lead.source === pattern.criteria.source) {
   297→        score = Math.max(score, 100);
   298→        if (!reason) reason = `${lead.source} leads convert well`;
   299→      }
   300→    }
   301→  }
   302→
   303→  // Similar to recent conversion bonus
   304→  if (context.recentConversions && context.recentConversions.length > 0) {
   305→    const similar = context.recentConversions.find(c =>
   306→      c.trade === lead.trade || c.source === lead.source
   307→    );
   308→    if (similar && score < 80) {
   309→      score = 80;
   310→      reason = `Similar to recent win`;
   311→    }
   312→  }
   313→
   314→  return { score, reason };
   315→}
   316→
   317→// ============================================
   318→// BASE SCORING (0-50 points)
   319→// Underlying lead score
   320→// ============================================
   321→function computeBaseScore(lead) {
   322→  const leadScore = lead.score || 0;
   323→
   324→  if (leadScore >= 90) return { score: 50, reason: null };
   325→  if (leadScore >= 70) return { score: 40, reason: null };
   326→  if (leadScore >= 50) return { score: 30, reason: null };
   327→  if (leadScore >= 20) return { score: 15, reason: null };
   328→  return { score: 5, reason: null };
   329→}
   330→
   331→// ============================================
   332→// MAIN PRIORITY COMPUTATION
   333→// ============================================
   334→async function computeLeadPriority(lead, context, callbacks = []) {
   335→  // Compute all factor scores
   336→  const urgency = computeUrgencyScore(lead, callbacks);
   337→  const intent = computeIntentScore(lead);
   338→  const timing = computeTimingScore(lead, context);
   339→  const pattern = computePatternScore(lead, context);
   340→  const base = computeBaseScore(lead);
   341→
   342→  // Calculate total score
   343→  const totalScore = urgency.score + intent.score + timing.score + pattern.score + base.score;
   344→
   345→  // Determine primary reason (highest scoring factor with a reason)
   346→  const factors = [
   347→    { type: 'urgency', ...urgency },
   348→    { type: 'intent', ...intent },
   349→    { type: 'timing', ...timing },
   350→    { type: 'pattern', ...pattern }
   351→  ].filter(f => f.reason);
   352→
   353→  // Sort by score to find primary reason
   354→  factors.sort((a, b) => b.score - a.score);
   355→
   356→  const primaryFactor = factors[0] || { type: 'score', reason: getScoreReason(lead.score) };
   357→
   358→  return {
   359→    priority_score: Math.round(totalScore),
   360→    priority_reason: primaryFactor.reason,
   361→    priority_type: primaryFactor.type,
   362→    priority_warning: urgency.warning,
   363→    priority_factors: {
   364→      urgency: urgency.score,
   365→      intent: intent.score,
   366→      timing: timing.score,
   367→      pattern: pattern.score,
   368→      base: base.score
   369→    }
   370→  };
   371→}
   372→
   373→// ============================================
   374→// BATCH PROCESSING
   375→// ============================================
   376→async function rankLeadsForCallList(leads, context, callbacks = []) {
   377→  const rankedLeads = [];
   378→
   379→  // Build callback map for O(1) lookup instead of O(N) filter per lead
   380→  const callbackMap = new Map();
   381→  for (const cb of callbacks) {
   382→    if (cb.status === 'pending' && cb.scheduled_at && new Date(cb.scheduled_at).getFullYear() >= 2000) {
   383→      if (!callbackMap.has(cb.lead_id)) {
   384→        callbackMap.set(cb.lead_id, []);
   385→      }
   386→      callbackMap.get(cb.lead_id).push(cb);
   387→    }
   388→  }
   389→
   390→  for (const lead of leads) {
   391→    // Pass pre-filtered callbacks for this lead (O(1) lookup)
   392→    const leadCallbacks = callbackMap.get(lead.id) || [];
   393→    const priority = await computeLeadPriority(lead, context, leadCallbacks);
   394→    rankedLeads.push({
   395→      ...lead,
   396→      ...priority
   397→    });
   398→  }
   399→
   400→  // Sort by priority score descending
   401→  rankedLeads.sort((a, b) => b.priority_score - a.priority_score);
   402→
   403→  return rankedLeads;
   404→}
   405→
   406→// ============================================
   407→// CONTEXT BUILDER (with 5-minute caching)
   408→// ============================================
   409→async function buildRankingContext() {
   410→  // Check cache first
   411→  const now = Date.now();
   412→  if (rankingContextCache && (now - rankingContextCacheTime) < RANKING_CONTEXT_TTL) {
   413→    // Return cached context with fresh time values
   414→    return {
   415→      ...rankingContextCache,
   416→      currentHour: new Date().getHours(),
   417→      currentDay: new Date().getDay()
   418→    };
   419→  }
   420→
   421→  const context = {
   422→    currentHour: new Date().getHours(),
   423→    currentDay: new Date().getDay(),
   424→    bestHours: [],
   425→    winningPatterns: [],
   426→    recentConversions: []
   427→  };
   428→
   429→  if (!supabase) return context;
   430→
   431→  // Fetch winning patterns and recent conversions in parallel
   432→  const sevenDaysAgo = new Date();
   433→  sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
   434→
   435→  const [patternsResult, conversionsResult] = await Promise.all([
   436→    supabase
   437→      .from('winning_patterns')
   438→      .select('*')
   439→      .eq('is_active', true),
   440→    supabase
   441→      .from('conversion_patterns')
   442→      .select('lead_trade, lead_source, winning_channel')
   443→      .gte('created_at', sevenDaysAgo.toISOString())
   444→      .limit(10)
   445→  ]);
   446→
   447→  const patterns = patternsResult.data;
   448→  const conversions = conversionsResult.data;
   449→
   450→  if (patterns) {
   451→    context.winningPatterns = patterns;
   452→
   453→    // Extract best hours from timing patterns
   454→    const timingPattern = patterns.find(p => p.pattern_type === 'timing');
   455→    if (timingPattern?.criteria?.hours) {
   456→      context.bestHours = timingPattern.criteria.hours;
   457→    }
   458→  }
   459→
   460→  if (conversions) {
   461→    context.recentConversions = conversions.map(c => ({
   462→      trade: c.lead_trade,
   463→      source: c.lead_source,
   464→      channel: c.winning_channel
   465→    }));
   466→  }
   467→
   468→  // Update cache
   469→  rankingContextCache = context;
   470→  rankingContextCacheTime = now;
   471→
   472→  return context;
   473→}
   474→
   475→// ============================================
   476→// UPDATE SINGLE LEAD PRIORITY
   477→// (Called on events like SMS inbound)
   478→// ============================================
   479→async function refreshLeadPriority(leadId) {
   480→  if (!supabase) return { success: false, error: 'Database not configured' };
   481→
   482→  try {
   483→    // Fetch lead
   484→    const { data: lead, error: leadError } = await supabase
   485→      .from('leads')
   486→      .select('*')
   487→      .eq('id', leadId)
   488→      .single();
   489→
   490→    if (leadError || !lead) {
   491→      return { success: false, error: 'Lead not found' };
   492→    }
   493→
   494→    // Fetch callbacks for this lead
   495→    const { data: callbacks } = await supabase
   496→      .from('callbacks')
   497→      .select('*')
   498→      .eq('lead_id', leadId)
   499→      .eq('status', 'pending');
   500→
   501→    // Build context
   502→    const context = await buildRankingContext();
   503→
   504→    // Compute priority
   505→    const priority = await computeLeadPriority(lead, context, callbacks || []);
   506→
   507→    // Update lead
   508→    const { error: updateError } = await supabase
   509→      .from('leads')
   510→      .update({
   511→        call_list_score: priority.priority_score,
   512→        call_list_reason: priority.priority_reason,
   513→        call_list_reason_type: priority.priority_type,
   514→        call_list_warning: priority.priority_warning,
   515→        call_list_updated_at: new Date().toISOString()
   516→      })
   517→      .eq('id', leadId);
   518→
   519→    if (updateError) {
   520→      return { success: false, error: updateError.message };
   521→    }
   522→
   523→    return { success: true, priority };
   524→  } catch (error) {
   525→    console.error('[CallListRanker] Error refreshing lead priority:', error);
   526→    return { success: false, error: error.message };
   527→  }
   528→}
   529→
   530→// ============================================
   531→// HELPERS
   532→// ============================================
   533→function formatHour(hour) {
   534→  if (hour === 0) return '12am';
   535→  if (hour < 12) return `${hour}am`;
   536→  if (hour === 12) return '12pm';
   537→  return `${hour - 12}pm`;
   538→}
   539→
   540→function getScoreReason(score) {
   541→  if (score >= 90) return 'Very hot lead';
   542→  if (score >= 70) return 'Hot lead';
   543→  if (score >= 50) return 'Warm lead';
   544→  if (score >= 20) return 'Cool lead';
   545→  return 'Follow-up needed';
   546→}
   547→
   548→module.exports = {
   549→  computeLeadPriority,
   550→  rankLeadsForCallList,
   551→  buildRankingContext,
   552→  refreshLeadPriority,
   553→  computeUrgencyScore,
   554→  computeIntentScore,
   555→  computeTimingScore,
   556→  computePatternScore,
   557→  computeBaseScore
   558→};
   559→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→/**
     2→ * Call Analytics Service
     3→ * Advanced call quality metrics: talk ratio, filler words, questions, sentiment
     4→ */
     5→
     6→const { supabase, supabaseAdmin } = require('../config/database');
     7→
     8→// Use admin client for tables with RLS
     9→const db = supabaseAdmin || supabase;
    10→
    11→// Filler words to detect
    12→const FILLER_WORDS = [
    13→  'um', 'uh', 'like', 'you know', 'basically', 'actually',
    14→  'literally', 'sort of', 'kind of', 'i mean', 'right',
    15→  'so', 'well', 'anyway', 'whatever'
    16→];
    17→
    18→// Open question starters
    19→const OPEN_QUESTION_STARTERS = [
    20→  'what', 'why', 'how', 'tell me', 'describe', 'explain',
    21→  'can you tell', 'could you describe', 'what do you think'
    22→];
    23→
    24→/**
    25→ * Analyze a call and store metrics
    26→ */
    27→async function analyzeCall(data) {
    28→  const {
    29→    communicationId,
    30→    leadId,
    31→    userId,
    32→    transcript,
    33→    transcriptWords = [],
    34→    durationSeconds = 0
    35→  } = data;
    36→
    37→  try {
    38→    // Calculate all metrics
    39→    const talkRatio = calculateTalkRatio(transcriptWords);
    40→    const monologues = detectMonologues(transcriptWords);
    41→    const fillerAnalysis = countFillerWords(transcriptWords, transcript);
    42→    const questionAnalysis = analyzeQuestions(transcriptWords, transcript);
    43→    const paceAnalysis = calculatePace(transcriptWords, durationSeconds);
    44→    const sentimentAnalysis = analyzeSentiment(transcriptWords, transcript);
    45→
    46→    // Calculate quality score
    47→    const qualityScore = calculateQualityScore({
    48→      talkRatio,
    49→      monologues,
    50→      fillerAnalysis,
    51→      questionAnalysis,
    52→      sentimentAnalysis,
    53→      durationSeconds
    54→    });
    55→
    56→    // Generate coaching points
    57→    const coachingPoints = generateCoachingPoints({
    58→      talkRatio,
    59→      monologues,
    60→      fillerAnalysis,
    61→      questionAnalysis,
    62→      qualityScore
    63→    });
    64→
    65→    // Build analytics record
    66→    const analyticsRecord = {
    67→      communication_id: communicationId,
    68→      lead_id: leadId,
    69→      user_id: userId,
    70→
    71→      agent_talk_time_seconds: talkRatio.agentTime,
    72→      caller_talk_time_seconds: talkRatio.callerTime,
    73→      talk_listen_ratio: talkRatio.ratio,
    74→
    75→      longest_agent_monologue_seconds: monologues.longestSeconds,
    76→      monologue_count: monologues.count,
    77→
    78→      filler_word_count: fillerAnalysis.total,
    79→      filler_words_per_minute: fillerAnalysis.perMinute,
    80→      filler_word_breakdown: fillerAnalysis.breakdown,
    81→
    82→      agent_questions: questionAnalysis.agentTotal,
    83→      caller_questions: questionAnalysis.callerTotal,
    84→      open_questions: questionAnalysis.openQuestions,
    85→      closed_questions: questionAnalysis.closedQuestions,
    86→
    87→      sentiment_timeline: sentimentAnalysis.timeline,
    88→      sentiment_trend: sentimentAnalysis.trend,
    89→      overall_sentiment_score: sentimentAnalysis.overall,
    90→
    91→      agent_words_per_minute: paceAnalysis.agentWPM,
    92→      caller_words_per_minute: paceAnalysis.callerWPM,
    93→      total_word_count: paceAnalysis.totalWords,
    94→
    95→      call_quality_score: qualityScore.total,
    96→      quality_breakdown: qualityScore.breakdown,
    97→      coaching_points: coachingPoints,
    98→
    99→      transcript_words: transcriptWords.length > 0 ? transcriptWords : null,
   100→      call_duration_seconds: durationSeconds
   101→    };
   102→
   103→    // Upsert analytics record
   104→    const { data: analytics, error } = await db
   105→      .from('call_analytics')
   106→      .upsert(analyticsRecord, { onConflict: 'communication_id' })
   107→      .select()
   108→      .single();
   109→
   110→    if (error) {
   111→      console.error('Error saving call analytics:', error);
   112→      return { success: false, error: error.message };
   113→    }
   114→
   115→    // Create coaching alerts if needed
   116→    await createCoachingAlerts(analytics, { talkRatio, monologues, fillerAnalysis, questionAnalysis });
   117→
   118→    console.log(`Call analytics saved for communication ${communicationId}:`, {
   119→      qualityScore: qualityScore.total,
   120→      talkRatio: talkRatio.ratio,
   121→      fillerWords: fillerAnalysis.total,
   122→      questions: questionAnalysis.agentTotal
   123→    });
   124→
   125→    return { success: true, analytics };
   126→  } catch (error) {
   127→    console.error('Error in analyzeCall:', error);
   128→    return { success: false, error: error.message };
   129→  }
   130→}
   131→
   132→/**
   133→ * Calculate talk/listen ratio from word-level data
   134→ */
   135→function calculateTalkRatio(words) {
   136→  if (!words || words.length === 0) {
   137→    return { agentTime: 0, callerTime: 0, ratio: 0.5 };
   138→  }
   139→
   140→  let agentTime = 0;
   141→  let callerTime = 0;
   142→
   143→  words.forEach(w => {
   144→    const duration = (w.end || 0) - (w.start || 0);
   145→    if (w.speaker === 'agent') {
   146→      agentTime += duration;
   147→    } else {
   148→      callerTime += duration;
   149→    }
   150→  });
   151→
   152→  const total = agentTime + callerTime || 1;
   153→  const ratio = parseFloat((agentTime / total).toFixed(3));
   154→
   155→  return {
   156→    agentTime: Math.round(agentTime),
   157→    callerTime: Math.round(callerTime),
   158→    ratio
   159→  };
   160→}
   161→
   162→/**
   163→ * Detect monologues (>60s of continuous talking)
   164→ */
   165→function detectMonologues(words) {
   166→  if (!words || words.length === 0) {
   167→    return { count: 0, longestSeconds: 0, monologues: [] };
   168→  }
   169→
   170→  const monologues = [];
   171→  let currentSpeaker = null;
   172→  let currentStart = null;
   173→  let currentDuration = 0;
   174→
   175→  words.forEach((w, idx) => {
   176→    if (w.speaker !== currentSpeaker) {
   177→      // Speaker changed - check if previous was a monologue
   178→      if (currentSpeaker === 'agent' && currentDuration > 60) {
   179→        monologues.push({
   180→          start: currentStart,
   181→          duration: currentDuration,
   182→          speaker: currentSpeaker
   183→        });
   184→      }
   185→      currentSpeaker = w.speaker;
   186→      currentStart = w.start;
   187→      currentDuration = 0;
   188→    }
   189→    currentDuration += (w.end || 0) - (w.start || 0);
   190→  });
   191→
   192→  // Check final segment
   193→  if (currentSpeaker === 'agent' && currentDuration > 60) {
   194→    monologues.push({
   195→      start: currentStart,
   196→      duration: currentDuration,
   197→      speaker: currentSpeaker
   198→    });
   199→  }
   200→
   201→  const longestSeconds = monologues.length > 0
   202→    ? Math.round(Math.max(...monologues.map(m => m.duration)))
   203→    : 0;
   204→
   205→  return {
   206→    count: monologues.length,
   207→    longestSeconds,
   208→    monologues
   209→  };
   210→}
   211→
   212→/**
   213→ * Count filler words in transcript
   214→ */
   215→function countFillerWords(words, transcript = '') {
   216→  const breakdown = {};
   217→  let total = 0;
   218→
   219→  // Use full transcript text for pattern matching
   220→  const text = transcript.toLowerCase() || words.map(w => w.word).join(' ').toLowerCase();
   221→
   222→  FILLER_WORDS.forEach(filler => {
   223→    const regex = new RegExp(`\\b${filler}\\b`, 'gi');
   224→    const matches = text.match(regex);
   225→    const count = matches ? matches.length : 0;
   226→    if (count > 0) {
   227→      breakdown[filler] = count;
   228→      total += count;
   229→    }
   230→  });
   231→
   232→  // Calculate words per minute
   233→  const wordCount = words.length || text.split(/\s+/).length;
   234→  const durationMinutes = words.length > 0
   235→    ? (words[words.length - 1]?.end || 0) / 60
   236→    : wordCount / 150; // Assume 150 WPM if no timing
   237→
   238→  const perMinute = durationMinutes > 0
   239→    ? parseFloat((total / durationMinutes).toFixed(2))
   240→    : 0;
   241→
   242→  return { total, breakdown, perMinute };
   243→}
   244→
   245→/**
   246→ * Analyze questions asked
   247→ */
   248→function analyzeQuestions(words, transcript = '') {
   249→  const text = transcript || words.map(w => w.word).join(' ');
   250→  const sentences = text.split(/[.!?]+/).filter(s => s.trim());
   251→
   252→  let agentTotal = 0;
   253→  let callerTotal = 0;
   254→  let openQuestions = 0;
   255→  let closedQuestions = 0;
   256→
   257→  // Simple heuristic: questions end with ?
   258→  const questions = text.match(/[^.!?]*\?/g) || [];
   259→
   260→  questions.forEach(q => {
   261→    const questionLower = q.toLowerCase().trim();
   262→
   263→    // Check if it's an open question
   264→    const isOpen = OPEN_QUESTION_STARTERS.some(starter =>
   265→      questionLower.startsWith(starter) || questionLower.includes(starter)
   266→    );
   267→
   268→    if (isOpen) {
   269→      openQuestions++;
   270→    } else {
   271→      closedQuestions++;
   272→    }
   273→
   274→    // Assume agent asks most questions (can be refined with speaker detection)
   275→    agentTotal++;
   276→  });
   277→
   278→  return {
   279→    agentTotal,
   280→    callerTotal,
   281→    openQuestions,
   282→    closedQuestions,
   283→    totalQuestions: questions.length
   284→  };
   285→}
   286→
   287→/**
   288→ * Calculate speaking pace
   289→ */
   290→function calculatePace(words, durationSeconds) {
   291→  if (!words || words.length === 0) {
   292→    return { agentWPM: 0, callerWPM: 0, totalWords: 0 };
   293→  }
   294→
   295→  const agentWords = words.filter(w => w.speaker === 'agent');
   296→  const callerWords = words.filter(w => w.speaker === 'caller');
   297→
   298→  const duration = durationSeconds || (words[words.length - 1]?.end || 60);
   299→  const minutes = duration / 60 || 1;
   300→
   301→  return {
   302→    agentWPM: Math.round(agentWords.length / minutes),
   303→    callerWPM: Math.round(callerWords.length / minutes),
   304→    totalWords: words.length
   305→  };
   306→}
   307→
   308→/**
   309→ * Analyze sentiment from transcript
   310→ */
   311→function analyzeSentiment(words, transcript = '') {
   312→  // Simple keyword-based sentiment analysis
   313→  const positiveWords = ['great', 'excellent', 'perfect', 'love', 'amazing', 'happy', 'interested', 'yes', 'definitely', 'sounds good'];
   314→  const negativeWords = ['no', 'not', 'never', 'problem', 'issue', 'expensive', 'too much', 'can\'t', 'won\'t', 'don\'t'];
   315→
   316→  const text = (transcript || words.map(w => w.word).join(' ')).toLowerCase();
   317→
   318→  let positiveCount = 0;
   319→  let negativeCount = 0;
   320→
   321→  positiveWords.forEach(word => {
   322→    const matches = text.match(new RegExp(`\\b${word}\\b`, 'gi'));
   323→    if (matches) positiveCount += matches.length;
   324→  });
   325→
   326→  negativeWords.forEach(word => {
   327→    const matches = text.match(new RegExp(`\\b${word}\\b`, 'gi'));
   328→    if (matches) negativeCount += matches.length;
   329→  });
   330→
   331→  const total = positiveCount + negativeCount || 1;
   332→  const overall = parseFloat((positiveCount / total).toFixed(2));
   333→
   334→  // Simple timeline - split text into thirds and analyze each
   335→  const thirds = text.length > 0 ? [
   336→    text.slice(0, text.length / 3),
   337→    text.slice(text.length / 3, 2 * text.length / 3),
   338→    text.slice(2 * text.length / 3)
   339→  ] : [];
   340→
   341→  const timeline = thirds.map((segment, idx) => {
   342→    let pos = 0, neg = 0;
   343→    positiveWords.forEach(w => { if (segment.includes(w)) pos++; });
   344→    negativeWords.forEach(w => { if (segment.includes(w)) neg++; });
   345→    const score = (pos + neg) > 0 ? pos / (pos + neg) : 0.5;
   346→    return {
   347→      segment: idx + 1,
   348→      sentiment: score > 0.6 ? 'positive' : score < 0.4 ? 'negative' : 'neutral',
   349→      score: parseFloat(score.toFixed(2))
   350→    };
   351→  });
   352→
   353→  // Determine trend
   354→  let trend = 'stable';
   355→  if (timeline.length >= 2) {
   356→    const first = timeline[0]?.score || 0.5;
   357→    const last = timeline[timeline.length - 1]?.score || 0.5;
   358→    if (last - first > 0.2) trend = 'improving';
   359→    else if (first - last > 0.2) trend = 'declining';
   360→  }
   361→
   362→  return { overall, timeline, trend };
   363→}
   364→
   365→/**
   366→ * Calculate overall quality score (0-100)
   367→ */
   368→function calculateQualityScore(metrics) {
   369→  const { talkRatio, monologues, fillerAnalysis, questionAnalysis, sentimentAnalysis, durationSeconds } = metrics;
   370→
   371→  const breakdown = {};
   372→
   373→  // Talk ratio: optimal is 35-45% agent (25 points)
   374→  const ratio = talkRatio.ratio;
   375→  if (ratio >= 0.35 && ratio <= 0.45) {
   376→    breakdown.talk_ratio = 25;
   377→  } else if (ratio >= 0.30 && ratio <= 0.55) {
   378→    breakdown.talk_ratio = 20;
   379→  } else if (ratio >= 0.25 && ratio <= 0.60) {
   380→    breakdown.talk_ratio = 15;
   381→  } else {
   382→    breakdown.talk_ratio = Math.max(0, 10 - Math.abs(ratio - 0.4) * 50);
   383→  }
   384→
   385→  // Filler words: fewer is better (15 points)
   386→  const fillerPerMin = fillerAnalysis.perMinute || 0;
   387→  if (fillerPerMin <= 2) {
   388→    breakdown.filler = 15;
   389→  } else if (fillerPerMin <= 5) {
   390→    breakdown.filler = 12;
   391→  } else if (fillerPerMin <= 8) {
   392→    breakdown.filler = 8;
   393→  } else {
   394→    breakdown.filler = Math.max(0, 5 - fillerPerMin);
   395→  }
   396→
   397→  // Questions: 4-8 questions per 10 min is optimal (20 points)
   398→  const questionsNormalized = durationSeconds > 0
   399→    ? (questionAnalysis.agentTotal / durationSeconds) * 600 // per 10 min
   400→    : questionAnalysis.agentTotal;
   401→  if (questionsNormalized >= 4 && questionsNormalized <= 8) {
   402→    breakdown.questions = 20;
   403→  } else if (questionsNormalized >= 2 && questionsNormalized <= 12) {
   404→    breakdown.questions = 15;
   405→  } else if (questionsNormalized >= 1) {
   406→    breakdown.questions = 10;
   407→  } else {
   408→    breakdown.questions = 5;
   409→  }
   410→
   411→  // Open questions bonus (5 points)
   412→  const openRatio = questionAnalysis.totalQuestions > 0
   413→    ? questionAnalysis.openQuestions / questionAnalysis.totalQuestions
   414→    : 0;
   415→  breakdown.open_questions = Math.round(openRatio * 5);
   416→
   417→  // Monologues: fewer is better (15 points)
   418→  if (monologues.count === 0) {
   419→    breakdown.monologues = 15;
   420→  } else if (monologues.count === 1 && monologues.longestSeconds < 90) {
   421→    breakdown.monologues = 12;
   422→  } else if (monologues.count <= 2) {
   423→    breakdown.monologues = 8;
   424→  } else {
   425→    breakdown.monologues = Math.max(0, 5 - monologues.count);
   426→  }
   427→
   428→  // Sentiment: improving or positive is better (20 points)
   429→  if (sentimentAnalysis.trend === 'improving') {
   430→    breakdown.sentiment = 20;
   431→  } else if (sentimentAnalysis.trend === 'stable' && sentimentAnalysis.overall >= 0.5) {
   432→    breakdown.sentiment = 15;
   433→  } else if (sentimentAnalysis.trend === 'stable') {
   434→    breakdown.sentiment = 10;
   435→  } else {
   436→    breakdown.sentiment = 5;
   437→  }
   438→
   439→  // Calculate total
   440→  const total = Object.values(breakdown).reduce((sum, val) => sum + val, 0);
   441→
   442→  return {
   443→    total: Math.min(100, Math.round(total)),
   444→    breakdown
   445→  };
   446→}
   447→
   448→/**
   449→ * Generate coaching points based on metrics
   450→ */
   451→function generateCoachingPoints(metrics) {
   452→  const { talkRatio, monologues, fillerAnalysis, questionAnalysis, qualityScore } = metrics;
   453→  const points = [];
   454→
   455→  // Talk ratio coaching
   456→  if (talkRatio.ratio > 0.55) {
   457→    points.push({
   458→      area: 'talk_ratio',
   459→      priority: 'high',
   460→      message: 'You talked more than the customer. Try to listen more and let them explain their needs.',
   461→      metric: `${Math.round(talkRatio.ratio * 100)}% talk time`
   462→    });
   463→  } else if (talkRatio.ratio < 0.25) {
   464→    points.push({
   465→      area: 'talk_ratio',
   466→      priority: 'medium',
   467→      message: 'You may not be sharing enough value. Make sure to explain benefits clearly.',
   468→      metric: `${Math.round(talkRatio.ratio * 100)}% talk time`
   469→    });
   470→  }
   471→
   472→  // Monologue coaching
   473→  if (monologues.count > 0) {
   474→    points.push({
   475→      area: 'monologues',
   476→      priority: monologues.longestSeconds > 90 ? 'high' : 'medium',
   477→      message: `You had ${monologues.count} long monologue(s). Break up your talking with questions to keep them engaged.`,
   478→      metric: `Longest: ${monologues.longestSeconds}s`
   479→    });
   480→  }
   481→
   482→  // Filler word coaching
   483→  if (fillerAnalysis.perMinute > 5) {
   484→    const topFillers = Object.entries(fillerAnalysis.breakdown)
   485→      .sort((a, b) => b[1] - a[1])
   486→      .slice(0, 3)
   487→      .map(([word]) => word)
   488→      .join(', ');
   489→
   490→    points.push({
   491→      area: 'filler_words',
   492→      priority: fillerAnalysis.perMinute > 8 ? 'high' : 'medium',
   493→      message: `You used ${fillerAnalysis.total} filler words. Most common: ${topFillers}. Practice pausing instead.`,
   494→      metric: `${fillerAnalysis.perMinute}/min`
   495→    });
   496→  }
   497→
   498→  // Questions coaching
   499→  if (questionAnalysis.agentTotal < 3) {
   500→    points.push({
   501→      area: 'questions',
   502→      priority: 'high',
   503→      message: 'Ask more questions to understand their needs. Aim for 4-8 questions per call.',
   504→      metric: `Only ${questionAnalysis.agentTotal} questions asked`
   505→    });
   506→  }
   507→
   508→  // Open questions coaching
   509→  if (questionAnalysis.totalQuestions > 2 && questionAnalysis.openQuestions < questionAnalysis.closedQuestions) {
   510→    points.push({
   511→      area: 'open_questions',
   512→      priority: 'low',
   513→      message: 'Try using more open questions (what, why, how) to get richer information.',
   514→      metric: `${questionAnalysis.openQuestions} open vs ${questionAnalysis.closedQuestions} closed`
   515→    });
   516→  }
   517→
   518→  return points;
   519→}
   520→
   521→/**
   522→ * Create coaching alerts for calls that need review
   523→ */
   524→async function createCoachingAlerts(analytics, metrics) {
   525→  const alerts = [];
   526→  const { talkRatio, monologues, fillerAnalysis, questionAnalysis } = metrics;
   527→
   528→  // Low quality score alert
   529→  if (analytics.call_quality_score < 50) {
   530→    alerts.push({
   531→      call_analytics_id: analytics.id,
   532→      communication_id: analytics.communication_id,
   533→      user_id: analytics.user_id,
   534→      lead_id: analytics.lead_id,
   535→      alert_type: 'low_quality',
   536→      severity: analytics.call_quality_score < 30 ? 'high' : 'medium',
   537→      message: `Call quality score is ${analytics.call_quality_score}/100. Review needed.`,
   538→      metric_value: analytics.call_quality_score,
   539→      threshold_value: 50
   540→    });
   541→  }
   542→
   543→  // Long monologue alert
   544→  if (monologues.longestSeconds > 90) {
   545→    alerts.push({
   546→      call_analytics_id: analytics.id,
   547→      communication_id: analytics.communication_id,
   548→      user_id: analytics.user_id,
   549→      lead_id: analytics.lead_id,
   550→      alert_type: 'long_monologue',
   551→      severity: monologues.longestSeconds > 120 ? 'high' : 'medium',
   552→      message: `Longest monologue was ${monologues.longestSeconds} seconds. Coach on engagement.`,
   553→      metric_value: monologues.longestSeconds,
   554→      threshold_value: 90
   555→    });
   556→  }
   557→
   558→  // High filler words alert
   559→  if (fillerAnalysis.perMinute > 8) {
   560→    alerts.push({
   561→      call_analytics_id: analytics.id,
   562→      communication_id: analytics.communication_id,
   563→      user_id: analytics.user_id,
   564→      lead_id: analytics.lead_id,
   565→      alert_type: 'filler_words',
   566→      severity: fillerAnalysis.perMinute > 12 ? 'high' : 'medium',
   567→      message: `${fillerAnalysis.perMinute} filler words per minute. Coach on confident delivery.`,
   568→      metric_value: fillerAnalysis.perMinute,
   569→      threshold_value: 8
   570→    });
   571→  }
   572→
   573→  // Few questions alert
   574→  if (questionAnalysis.agentTotal < 2) {
   575→    alerts.push({
   576→      call_analytics_id: analytics.id,
   577→      communication_id: analytics.communication_id,
   578→      user_id: analytics.user_id,
   579→      lead_id: analytics.lead_id,
   580→      alert_type: 'few_questions',
   581→      severity: 'medium',
   582→      message: `Only ${questionAnalysis.agentTotal} questions asked. Coach on discovery.`,
   583→      metric_value: questionAnalysis.agentTotal,
   584→      threshold_value: 4
   585→    });
   586→  }
   587→
   588→  // Insert alerts if any
   589→  if (alerts.length > 0) {
   590→    const { error } = await db
   591→      .from('coaching_alerts')
   592→      .insert(alerts);
   593→
   594→    if (error) {
   595→      console.error('Error creating coaching alerts:', error);
   596→    }
   597→  }
   598→
   599→  return alerts;
   600→}
   601→
   602→/**
   603→ * Get call analytics for a communication
   604→ */
   605→async function getCallAnalytics(communicationId) {
   606→  const { data, error } = await db
   607→    .from('call_analytics')
   608→    .select('*')
   609→    .eq('communication_id', communicationId)
   610→    .single();
   611→
   612→  if (error && error.code !== 'PGRST116') {
   613→    console.error('Error fetching call analytics:', error);
   614→    return null;
   615→  }
   616→
   617→  return data;
   618→}
   619→
   620→/**
   621→ * Get analytics for all calls from a lead
   622→ */
   623→async function getLeadCallAnalytics(leadId) {
   624→  const { data, error } = await db
   625→    .from('call_analytics')
   626→    .select('*')
   627→    .eq('lead_id', leadId)
   628→    .order('created_at', { ascending: false });
   629→
   630→  if (error) {
   631→    console.error('Error fetching lead call analytics:', error);
   632→    return [];
   633→  }
   634→
   635→  return data || [];
   636→}
   637→
   638→/**
   639→ * Get team quality trends
   640→ */
   641→async function getQualityTrends(options = {}) {
   642→  const { userId, startDate, endDate } = options;
   643→
   644→  try {
   645→    let query = db
   646→      .from('call_analytics')
   647→      .select('user_id, call_quality_score, talk_listen_ratio, filler_word_count, agent_questions, created_at')
   648→      .order('created_at', { ascending: false });
   649→
   650→    if (userId) {
   651→      query = query.eq('user_id', userId);
   652→    }
   653→    if (startDate) {
   654→      query = query.gte('created_at', startDate);
   655→    }
   656→    if (endDate) {
   657→      query = query.lte('created_at', endDate);
   658→    }
   659→
   660→    const { data, error } = await query.limit(100);
   661→
   662→    if (error) {
   663→      console.error('Error fetching quality trends:', error);
   664→      return { success: false, error: error.message };
   665→    }
   666→
   667→    // Group by day
   668→    const byDay = {};
   669→    (data || []).forEach(row => {
   670→      const day = row.created_at.split('T')[0];
   671→      if (!byDay[day]) {
   672→        byDay[day] = [];
   673→      }
   674→      byDay[day].push(row);
   675→    });
   676→
   677→    const trends = Object.entries(byDay).map(([day, calls]) => ({
   678→      day,
   679→      calls: calls.length,
   680→      avgQualityScore: Math.round(calls.reduce((s, c) => s + (c.call_quality_score || 0), 0) / calls.length),
   681→      avgTalkRatio: parseFloat((calls.reduce((s, c) => s + (c.talk_listen_ratio || 0), 0) / calls.length).toFixed(2)),
   682→      avgFillerWords: Math.round(calls.reduce((s, c) => s + (c.filler_word_count || 0), 0) / calls.length),
   683→      avgQuestions: Math.round(calls.reduce((s, c) => s + (c.agent_questions || 0), 0) / calls.length)
   684→    }));
   685→
   686→    return { success: true, trends };
   687→  } catch (error) {
   688→    console.error('Error in getQualityTrends:', error);
   689→    return { success: false, error: error.message };
   690→  }
   691→}
   692→
   693→/**
   694→ * Get coaching alerts (unreviewed)
   695→ */
   696→async function getCoachingAlerts(options = {}) {
   697→  const { userId, limit = 20, includeReviewed = false } = options;
   698→
   699→  let query = db
   700→    .from('coaching_alerts')
   701→    .select(`
   702→      *,
   703→      call_analytics (
   704→        call_quality_score,
   705→        call_duration_seconds
   706→      )
   707→    `)
   708→    .order('created_at', { ascending: false })
   709→    .limit(limit);
   710→
   711→  if (!includeReviewed) {
   712→    query = query.eq('is_reviewed', false);
   713→  }
   714→  if (userId) {
   715→    query = query.eq('user_id', userId);
   716→  }
   717→
   718→  const { data, error } = await query;
   719→
   720→  if (error) {
   721→    console.error('Error fetching coaching alerts:', error);
   722→    return [];
   723→  }
   724→
   725→  return data || [];
   726→}
   727→
   728→/**
   729→ * Mark a coaching alert as reviewed
   730→ */
   731→async function reviewCoachingAlert(alertId, reviewedBy, notes = null) {
   732→  const { error } = await db
   733→    .from('coaching_alerts')
   734→    .update({
   735→      is_reviewed: true,
   736→      reviewed_at: new Date().toISOString(),
   737→      reviewed_by: reviewedBy,
   738→      review_notes: notes
   739→    })
   740→    .eq('id', alertId);
   741→
   742→  if (error) {
   743→    console.error('Error reviewing coaching alert:', error);
   744→    return { success: false, error: error.message };
   745→  }
   746→
   747→  return { success: true };
   748→}
   749→
   750→/**
   751→ * Get quality leaderboard
   752→ */
   753→async function getQualityLeaderboard(days = 7) {
   754→  try {
   755→    const startDate = new Date();
   756→    startDate.setDate(startDate.getDate() - days);
   757→
   758→    const { data, error } = await db
   759→      .from('call_analytics')
   760→      .select('user_id, call_quality_score')
   761→      .gte('created_at', startDate.toISOString())
   762→      .not('user_id', 'is', null);
   763→
   764→    if (error) {
   765→      console.error('Error fetching leaderboard:', error);
   766→      return [];
   767→    }
   768→
   769→    // Group by user
   770→    const byUser = {};
   771→    (data || []).forEach(row => {
   772→      if (!byUser[row.user_id]) {
   773→        byUser[row.user_id] = [];
   774→      }
   775→      byUser[row.user_id].push(row.call_quality_score);
   776→    });
   777→
   778→    // Calculate averages
   779→    const leaderboard = Object.entries(byUser).map(([userId, scores]) => ({
   780→      userId,
   781→      calls: scores.length,
   782→      avgScore: Math.round(scores.reduce((a, b) => a + b, 0) / scores.length),
   783→      bestScore: Math.max(...scores),
   784→      worstScore: Math.min(...scores)
   785→    }));
   786→
   787→    return leaderboard.sort((a, b) => b.avgScore - a.avgScore);
   788→  } catch (error) {
   789→    console.error('Error in getQualityLeaderboard:', error);
   790→    return [];
   791→  }
   792→}
   793→
   794→module.exports = {
   795→  analyzeCall,
   796→  getCallAnalytics,
   797→  getLeadCallAnalytics,
   798→  getQualityTrends,
   799→  getCoachingAlerts,
   800→  reviewCoachingAlert,
   801→  getQualityLeaderboard
   802→};
   803→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


     1→/**
     2→ * Prompt Management Service
     3→ * Manages AI prompt versions, tracks performance, and handles evolution
     4→ *
     5→ * Part of the 0.1% Self-Improvement Loop
     6→ */
     7→
     8→const { supabase, supabaseAdmin } = require('../config/database');
     9→
    10→const db = supabaseAdmin || supabase;
    11→
    12→/**
    13→ * Default prompts used across the system
    14→ * These are the baseline versions that get evolved
    15→ */
    16→const DEFAULT_PROMPTS = {
    17→  'friction_analysis': {
    18→    name: 'Friction Analysis',
    19→    category: 'intelligence',
    20→    template: `Analyze these friction points and provide actionable recommendations:
    21→
    22→FRICTION POINTS:
    23→{friction_points}
    24→
    25→USER BEHAVIOR DATA:
    26→{behavior_data}
    27→
    28→Provide:
    29→1. Root cause analysis for top 3 friction points
    30→2. Quick wins (can fix in < 1 hour)
    31→3. Patterns across friction points
    32→4. Priority ranking with effort vs impact
    33→
    34→Be specific and actionable. Reference actual data.`
    35→  },
    36→  'weekly_excellence': {
    37→    name: 'Weekly Excellence Analysis',
    38→    category: 'intelligence',
    39→    template: `Analyze this week's system performance data:
    40→
    41→FRICTION POINTS: {friction_count} detected
    42→TOP LOCATIONS: {top_locations}
    43→
    44→UNUSED FEATURES:
    45→{unused_features}
    46→
    47→AI QUALITY:
    48→- Suggestions made: {suggestions_made}
    49→- Implemented: {implemented}
    50→- Success rate: {success_rate}%
    51→
    52→Provide:
    53→1. TOP 3 QUICK WINS (high impact, low effort fixes)
    54→2. PATTERN ANALYSIS (what's causing friction)
    55→3. AI EFFECTIVENESS assessment
    56→4. PRIORITY ACTIONS for next week
    57→
    58→Keep each section to 2-3 bullets. Be specific.`
    59→  },
    60→  'monthly_deep_dive': {
    61→    name: 'Monthly Deep Dive',
    62→    category: 'intelligence',
    63→    template: `Analyze this monthly system performance data and provide strategic insights:
    64→
    65→MONTH-OVER-MONTH TRENDS:
    66→{trends}
    67→
    68→FRICTION ACTIVITY:
    69→- New friction points detected: {friction_detected}
    70→- Friction points resolved: {friction_resolved}
    71→- Top friction locations: {top_friction_locations}
    72→
    73→AI SUGGESTION EFFECTIVENESS:
    74→- Total suggestions: {total_suggestions}
    75→- Implementation rate: {implementation_rate}%
    76→- Success rate of implemented: {success_rate}%
    77→- Pending suggestions: {pending_suggestions}
    78→
    79→Provide:
    80→1. TOP 3 WINS this month (what improved)
    81→2. TOP 3 CONCERNS (what needs attention)
    82→3. STRATEGIC RECOMMENDATION for next month
    83→4. AI SYSTEM HEALTH assessment (is the system improving itself?)
    84→
    85→Keep each section to 2-3 bullet points. Be specific and actionable.`
    86→  },
    87→  'objection_response': {
    88→    name: 'Objection Response',
    89→    category: 'sales_copilot',
    90→    template: `Generate a response to this sales objection:
    91→
    92→OBJECTION: {objection}
    93→LEAD CONTEXT: {lead_context}
    94→PREVIOUS SUCCESSFUL RESPONSES: {successful_responses}
    95→
    96→Provide a concise, natural response that:
    97→1. Acknowledges the concern
    98→2. Addresses it directly
    99→3. Moves toward next steps
   100→
   101→Keep it conversational, not scripted.`
   102→  },
   103→  'call_prep_intel': {
   104→    name: 'Call Prep Intelligence',
   105→    category: 'sales_copilot',
   106→    template: `Prepare intelligence brief for this sales call:
   107→
   108→LEAD: {lead_name}
   109→TRADE: {trade}
   110→PREVIOUS INTERACTIONS: {interactions}
   111→SIMILAR SUCCESSFUL CONVERSIONS: {similar_wins}
   112→
   113→Provide:
   114→1. KEY TALKING POINTS (what to emphasize)
   115→2. POTENTIAL OBJECTIONS (based on similar leads)
   116→3. WINNING APPROACH (what worked with similar leads)
   117→4. RECOMMENDED OPENER
   118→
   119→Keep it actionable and specific to this lead.`
   120→  }
   121→};
   122→
   123→/**
   124→ * Get the active prompt version for a given prompt type
   125→ */
   126→async function getActivePrompt(promptType) {
   127→  // First check database for custom/evolved version
   128→  const { data, error } = await db
   129→    .from('prompt_versions')
   130→    .select('*')
   131→    .eq('prompt_type', promptType)
   132→    .eq('is_active', true)
   133→    .single();
   134→
   135→  if (data && !error) {
   136→    // Log usage for tracking
   137→    await logPromptUsage(data.id, promptType);
   138→    return {
   139→      template: data.template,
   140→      version: data.version,
   141→      id: data.id
   142→    };
   143→  }
   144→
   145→  // Fall back to default
   146→  const defaultPrompt = DEFAULT_PROMPTS[promptType];
   147→  if (!defaultPrompt) {
   148→    throw new Error(`Unknown prompt type: ${promptType}`);
   149→  }
   150→
   151→  return {
   152→    template: defaultPrompt.template,
   153→    version: '1.0.0',
   154→    id: null
   155→  };
   156→}
   157→
   158→/**
   159→ * Log prompt usage for analytics
   160→ */
   161→async function logPromptUsage(promptVersionId, promptType) {
   162→  if (!promptVersionId) return;
   163→
   164→  await db
   165→    .from('prompt_versions')
   166→    .update({
   167→      usage_count: db.raw('usage_count + 1'),
   168→      last_used_at: new Date().toISOString()
   169→    })
   170→    .eq('id', promptVersionId)
   171→    .catch(() => {}); // Silent fail - don't break main flow
   172→}
   173→
   174→/**
   175→ * Fill a prompt template with variables
   176→ */
   177→function fillTemplate(template, variables) {
   178→  let filled = template;
   179→  Object.entries(variables).forEach(([key, value]) => {
   180→    const placeholder = `{${key}}`;
   181→    const stringValue = typeof value === 'object'
   182→      ? JSON.stringify(value, null, 2)
   183→      : String(value);
   184→    filled = filled.split(placeholder).join(stringValue);
   185→  });
   186→  return filled;
   187→}
   188→
   189→/**
   190→ * Record the outcome of a prompt usage (for scoring)
   191→ */
   192→async function recordPromptOutcome(promptVersionId, outcome) {
   193→  if (!promptVersionId) return;
   194→
   195→  const { success, suggestion_id, feedback } = outcome;
   196→
   197→  // Update prompt version stats
   198→  const updateField = success ? 'success_count' : 'failure_count';
   199→
   200→  await db
   201→    .from('prompt_versions')
   202→    .update({
   203→      [updateField]: db.raw(`${updateField} + 1`),
   204→      updated_at: new Date().toISOString()
   205→    })
   206→    .eq('id', promptVersionId);
   207→
   208→  // Log detailed outcome
   209→  await db
   210→    .from('prompt_outcomes')
   211→    .insert({
   212→      prompt_version_id: promptVersionId,
   213→      suggestion_id,
   214→      success,
   215→      feedback,
   216→      created_at: new Date().toISOString()
   217→    });
   218→}
   219→
   220→/**
   221→ * Get performance metrics for a prompt type
   222→ */
   223→async function getPromptPerformance(promptType) {
   224→  const { data: versions, error } = await db
   225→    .from('prompt_versions')
   226→    .select('*')
   227→    .eq('prompt_type', promptType)
   228→    .order('created_at', { ascending: false })
   229→    .limit(10);
   230→
   231→  if (error || !versions) {
   232→    return { versions: [], best: null };
   233→  }
   234→
   235→  // Calculate success rate for each version
   236→  const withRates = versions.map(v => ({
   237→    ...v,
   238→    successRate: v.usage_count > 0
   239→      ? Math.round((v.success_count / v.usage_count) * 100)
   240→      : null,
   241→    totalOutcomes: v.success_count + v.failure_count
   242→  }));
   243→
   244→  // Find best performing (with minimum sample size)
   245→  const qualified = withRates.filter(v => v.totalOutcomes >= 5);
   246→  const best = qualified.length > 0
   247→    ? qualified.reduce((a, b) => (a.successRate > b.successRate ? a : b))
   248→    : null;
   249→
   250→  return { versions: withRates, best };
   251→}
   252→
   253→/**
   254→ * Create a new prompt version (used during evolution)
   255→ */
   256→async function createPromptVersion(promptType, template, metadata = {}) {
   257→  // Get current version number
   258→  const { data: current } = await db
   259→    .from('prompt_versions')
   260→    .select('version')
   261→    .eq('prompt_type', promptType)
   262→    .order('created_at', { ascending: false })
   263→    .limit(1)
   264→    .single();
   265→
   266→  // Calculate new version
   267→  let newVersion = '1.0.0';
   268→  if (current?.version) {
   269→    const parts = current.version.split('.').map(Number);
   270→    parts[2]++; // Increment patch version
   271→    if (parts[2] >= 10) {
   272→      parts[2] = 0;
   273→      parts[1]++;
   274→    }
   275→    newVersion = parts.join('.');
   276→  }
   277→
   278→  const defaultPrompt = DEFAULT_PROMPTS[promptType] || {};
   279→
   280→  const { data, error } = await db
   281→    .from('prompt_versions')
   282→    .insert({
   283→      prompt_type: promptType,
   284→      name: metadata.name || defaultPrompt.name || promptType,
   285→      category: metadata.category || defaultPrompt.category || 'general',
   286→      template,
   287→      version: newVersion,
   288→      is_active: false, // New versions start inactive
   289→      evolution_reason: metadata.evolution_reason,
   290→      parent_version_id: metadata.parent_version_id,
   291→      created_at: new Date().toISOString()
   292→    })
   293→    .select()
   294→    .single();
   295→
   296→  if (error) {
   297→    console.error('[PromptManagement] Failed to create version:', error);
   298→    throw error;
   299→  }
   300→
   301→  return data;
   302→}
   303→
   304→/**
   305→ * Activate a prompt version (and deactivate others)
   306→ */
   307→async function activatePromptVersion(versionId, promptType) {
   308→  // Deactivate all versions of this type
   309→  await db
   310→    .from('prompt_versions')
   311→    .update({ is_active: false })
   312→    .eq('prompt_type', promptType);
   313→
   314→  // Activate the specified version
   315→  const { error } = await db
   316→    .from('prompt_versions')
   317→    .update({
   318→      is_active: true,
   319→      activated_at: new Date().toISOString()
   320→    })
   321→    .eq('id', versionId);
   322→
   323→  if (error) {
   324→    console.error('[PromptManagement] Failed to activate version:', error);
   325→    throw error;
   326→  }
   327→}
   328→
   329→/**
   330→ * Get all prompt types and their current status
   331→ */
   332→async function getAllPromptStatus() {
   333→  const status = {};
   334→
   335→  for (const [type, config] of Object.entries(DEFAULT_PROMPTS)) {
   336→    const { data: activeVersion } = await db
   337→      .from('prompt_versions')
   338→      .select('*')
   339→      .eq('prompt_type', type)
   340→      .eq('is_active', true)
   341→      .single();
   342→
   343→    const { data: versionCount } = await db
   344→      .from('prompt_versions')
   345→      .select('id', { count: 'exact' })
   346→      .eq('prompt_type', type);
   347→
   348→    status[type] = {
   349→      name: config.name,
   350→      category: config.category,
   351→      currentVersion: activeVersion?.version || '1.0.0 (default)',
   352→      totalVersions: versionCount?.length || 0,
   353→      isEvolved: !!activeVersion,
   354→      successRate: activeVersion
   355→        ? Math.round((activeVersion.success_count / Math.max(1, activeVersion.usage_count)) * 100)
   356→        : null,
   357→      usageCount: activeVersion?.usage_count || 0
   358→    };
   359→  }
   360→
   361→  return status;
   362→}
   363→
   364→module.exports = {
   365→  DEFAULT_PROMPTS,
   366→  getActivePrompt,
   367→  fillTemplate,
   368→  recordPromptOutcome,
   369→  getPromptPerformance,
   370→  createPromptVersion,
   371→  activatePromptVersion,
   372→  getAllPromptStatus
   373→};
   374→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


TodoWrite

Audit batch 1: challengesServiceV2.js, aiCallScoring.js, activationAI.js, challengesService.js

Auditing batch 1: challengesServiceV2, aiCallScoring, activationAI, challengesService

Audit batch 2: badgesService.js, callListRanker.js, callAnalytics.js, promptManagement.js

Auditing batch 2: badgesService, callListRanker, callAnalytics, promptManagement

Audit batch 3: learning.js, duelsService.js, dealIntelligence.js, selfImprovement.js

Auditing batch 3: learning, duelsService, dealIntelligence, selfImprovement

Audit batch 4: platformSync.js, perplexity.js, repPerformance.js, compliance.js

Auditing batch 4: platformSync, perplexity, repPerformance, compliance

Audit batch 5: openai.js, criticalAlerts.js, leaderboardService.js, deepgram.js

Auditing batch 5: openai, criticalAlerts, leaderboardService, deepgram

Audit batch 6: scoring.js, pushNotifications.js, weeklyIntelligence.js

Auditing batch 6: scoring, pushNotifications, weeklyIntelligence

Audit batch 7: twilioMedia.js, twilioMediaSetup.js, templates.js, slack.js

Auditing batch 7: twilioMedia, twilioMediaSetup, templates, slack

Audit batch 8: xpService.js, wisdom.js, stageDiagnosis.js, stageCalculator.js

Auditing batch 8: xpService, wisdom, stageDiagnosis, stageCalculator

Audit batch 9: twimlWithStream.js, qualityAudit.js, ai.js, notifications.js

Auditing batch 9: twimlWithStream, qualityAudit, ai, notifications

Audit batch 10: taskExecutor.js, taskParser.js, exportService.js, webhookDelivery.js

Auditing batch 10: taskExecutor, taskParser, exportService, webhookDelivery

Audit batch 11: enrichment.js, jobLocking.js, twilio.js, cache.js

Auditing batch 11: enrichment, jobLocking, twilio, cache

Compile comprehensive audit report


Todos have been modified successfully. Ensure that you continue to use the todo list to track your progress. Please proceed with the current tasks if applicable


     1→/**
     2→ * Learning Service
     3→ * Captures conversion patterns and learns from successful sales
     4→ */
     5→
     6→const { supabase } = require('../config/database');
     7→
     8→/**
     9→ * Capture conversion pattern when a lead converts
    10→ * Pulls all historical data to understand what worked
    11→ */
    12→async function captureConversionPattern(leadId, conversionType = 'manual', convertedBy = null) {
    13→  try {
    14→    // Get lead data
    15→    const { data: lead, error: leadError } = await supabase
    16→      .from('leads')
    17→      .select('*')
    18→      .eq('id', leadId)
    19→      .single();
    20→
    21→    if (leadError || !lead) {
    22→      console.error('Failed to get lead for pattern capture:', leadError);
    23→      return { success: false, error: 'Lead not found' };
    24→    }
    25→
    26→    // Fetch communications, copilot sessions, and notes in parallel (uses allSettled for reliability)
    27→    const results = await Promise.allSettled([
    28→      supabase
    29→        .from('communications')
    30→        .select('*')
    31→        .eq('lead_id', leadId)
    32→        .order('created_at', { ascending: true }),
    33→      supabase
    34→        .from('copilot_sessions')
    35→        .select('*')
    36→        .eq('lead_id', leadId)
    37→        .order('created_at', { ascending: true }),
    38→      supabase
    39→        .from('lead_notes')
    40→        .select('*')
    41→        .eq('lead_id', leadId)
    42→        .order('created_at', { ascending: true })
    43→    ]);
    44→
    45→    // Extract with fallbacks for any failed queries
    46→    const communications = results[0].status === 'fulfilled' ? results[0].value.data : [];
    47→    const copilotSessions = results[1].status === 'fulfilled' ? results[1].value.data : [];
    48→    const notes = results[2].status === 'fulfilled' ? results[2].value.data : [];
    49→
    50→    // Calculate timeline metrics
    51→    const comms = communications || [];
    52→    const firstContact = comms[0]?.created_at;
    53→    const lastContact = comms[comms.length - 1]?.created_at;
    54→    const daysToConvert = firstContact
    55→      ? Math.ceil((new Date() - new Date(firstContact)) / (1000 * 60 * 60 * 24))
    56→      : 0;
    57→
    58→    // Count touchpoints
    59→    const calls = comms.filter(c => c.type === 'call_outbound' || c.type === 'call');
    60→    const smsOut = comms.filter(c => c.type === 'sms_outbound');
    61→    const smsIn = comms.filter(c => c.type === 'sms_inbound');
    62→
    63→    // Calculate call metrics
    64→    const totalCallDuration = calls.reduce((sum, c) => sum + (c.metadata?.duration_seconds || 0), 0);
    65→    const avgCallDuration = calls.length > 0 ? Math.round(totalCallDuration / calls.length) : 0;
    66→
    67→    // Extract objections from communications and copilot sessions
    68→    const objections = [];
    69→    comms.forEach(c => {
    70→      if (c.metadata?.objections) {
    71→        c.metadata.objections.forEach(obj => {
    72→          objections.push({
    73→            objection: obj,
    74→            type: c.metadata?.objection_type || 'unknown',
    75→            source: 'communication',
    76→            timestamp: c.created_at,
    77→          });
    78→        });
    79→      }
    80→    });
    81→
    82→    // Extract objections from copilot sessions
    83→    const sessions = copilotSessions || [];
    84→    const copilotSuggestionsUsed = [];
    85→    let aiSuggestionsShown = 0;
    86→    let aiSuggestionsHelpful = 0;
    87→
    88→    sessions.forEach(session => {
    89→      if (session.suggestions_shown) {
    90→        session.suggestions_shown.forEach(suggestion => {
    91→          aiSuggestionsShown++;
    92→          if (suggestion.was_helpful) aiSuggestionsHelpful++;
    93→
    94→          copilotSuggestionsUsed.push({
    95→            trigger: suggestion.trigger,
    96→            response: suggestion.suggestion?.response,
    97→            key_point: suggestion.suggestion?.key_point,
    98→            objection_type: suggestion.suggestion?.objection_type,
    99→            was_helpful: suggestion.was_helpful,
   100→            timestamp: suggestion.timestamp,
   101→          });
   102→
   103→          if (suggestion.trigger) {
   104→            objections.push({
   105→              objection: suggestion.trigger,
   106→              type: suggestion.suggestion?.objection_type || 'unknown',
   107→              response_used: suggestion.suggestion?.response,
   108→              was_overcome: true, // If they converted, objections were overcome
   109→              source: 'copilot',
   110→              timestamp: suggestion.timestamp,
   111→            });
   112→          }
   113→        });
   114→      }
   115→
   116→      if (session.objections_detected) {
   117→        session.objections_detected.forEach(obj => {
   118→          if (!objections.find(o => o.objection === obj)) {
   119→            objections.push({
   120→              objection: obj,
   121→              type: 'unknown',
   122→              source: 'copilot_detected',
   123→              was_overcome: true,
   124→            });
   125→          }
   126→        });
   127→      }
   128→    });
   129→
   130→    // Find winning messages (SMS that got positive responses)
   131→    const winningMessages = [];
   132→    for (let i = 0; i < comms.length - 1; i++) {
   133→      const current = comms[i];
   134→      const next = comms[i + 1];
   135→
   136→      if (current.type === 'sms_outbound' && next.type === 'sms_inbound') {
   137→        const intent = next.metadata?.intent;
   138→        if (intent === 'positive' || intent === 'question') {
   139→          winningMessages.push({
   140→            message_sent: current.content,
   141→            response_received: next.content,
   142→            response_intent: intent,
   143→            sent_at: current.created_at,
   144→          });
   145→        }
   146→      }
   147→    }
   148→
   149→    // Determine winning channel
   150→    const lastComm = comms[comms.length - 1];
   151→    const winningChannel = lastComm?.type?.includes('call') ? 'call' :
   152→      lastComm?.type?.includes('sms') ? 'sms' : 'unknown';
   153→
   154→    // Determine best contact time
   155→    const successfulCalls = calls.filter(c =>
   156→      c.metadata?.outcome === 'interested' ||
   157→      c.metadata?.outcome === 'converted' ||
   158→      c.metadata?.outcome === 'callback_scheduled'
   159→    );
   160→    const bestHour = successfulCalls.length > 0
   161→      ? new Date(successfulCalls[0].created_at).getHours()
   162→      : null;
   163→    const bestContactTime = bestHour !== null
   164→      ? (bestHour < 12 ? 'morning' : bestHour < 17 ? 'afternoon' : 'evening')
   165→      : null;
   166→
   167→    // Build communication timeline
   168→    const communicationTimeline = comms.map(c => ({
   169→      type: c.type,
   170→      direction: c.direction,
   171→      timestamp: c.created_at,
   172→      content: c.content?.substring(0, 200),
   173→      outcome: c.metadata?.outcome || c.metadata?.intent,
   174→    }));
   175→
   176→    // Conversion timestamp info
   177→    const now = new Date();
   178→    const conversionHour = now.getHours();
   179→    const conversionDayOfWeek = now.getDay();
   180→
   181→    // Build the pattern record
   182→    const pattern = {
   183→      lead_id: leadId,
   184→      lead_name: `${lead.first_name || ''} ${lead.last_name || ''}`.trim(),
   185→      lead_company: lead.company,
   186→      lead_trade: lead.trade || lead.metadata?.trade,
   187→      lead_source: lead.source,
   188→      lead_score_at_conversion: lead.score,
   189→
   190→      days_to_convert: daysToConvert,
   191→      total_touchpoints: comms.length,
   192→      calls_made: calls.length,
   193→      sms_sent: smsOut.length,
   194→      sms_received: smsIn.length,
   195→
   196→      winning_channel: winningChannel,
   197→      first_contact_type: comms[0]?.type,
   198→      final_contact_type: lastComm?.type,
   199→
   200→      total_call_duration_seconds: totalCallDuration,
   201→      avg_call_duration_seconds: avgCallDuration,
   202→      calls_to_connect: calls.filter(c => c.metadata?.outcome === 'no_answer').length + 1,
   203→
   204→      objections_raised: objections,
   205→      objection_count: objections.length,
   206→      objections_overcome: objections.filter(o => o.was_overcome).length,
   207→
   208→      intel_briefs_viewed: lead.ai_intel_brief ? 1 : 0,
   209→      copilot_sessions: sessions.length,
   210→      ai_suggestions_shown: aiSuggestionsShown,
   211→      ai_suggestions_helpful: aiSuggestionsHelpful,
   212→
   213→      best_contact_time: bestContactTime,
   214→      best_contact_day: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][conversionDayOfWeek],
   215→      conversion_hour: conversionHour,
   216→      conversion_day_of_week: conversionDayOfWeek,
   217→
   218→      winning_messages: winningMessages,
   219→      communication_timeline: communicationTimeline,
   220→      copilot_suggestions_used: copilotSuggestionsUsed,
   221→
   222→      conversion_type: conversionType,
   223→      converted_by: convertedBy,
   224→    };
   225→
   226→    // Insert the pattern
   227→    const { data, error } = await supabase
   228→      .from('conversion_patterns')
   229→      .insert(pattern)
   230→      .select()
   231→      .single();
   232→
   233→    if (error) {
   234→      console.error('Failed to save conversion pattern:', error);
   235→      return { success: false, error: error.message };
   236→    }
   237→
   238→    // Also update objection_responses table with successful responses
   239→    await updateObjectionResponseEffectiveness(objections, lead);
   240→
   241→    console.log(`Captured conversion pattern for lead ${leadId}:`, {
   242→      days: daysToConvert,
   243→      touchpoints: comms.length,
   244→      objections: objections.length,
   245→      aiSuggestions: aiSuggestionsShown,
   246→    });
   247→
   248→    return { success: true, pattern: data };
   249→  } catch (error) {
   250→    console.error('Error capturing conversion pattern:', error);
   251→    return { success: false, error: error.message };
   252→  }
   253→}
   254→
   255→/**
   256→ * Update objection_responses table with effectiveness data
   257→ */
   258→async function updateObjectionResponseEffectiveness(objections, lead) {
   259→  for (const obj of objections) {
   260→    if (!obj.response_used) continue;
   261→
   262→    // Check if this response already exists
   263→    const { data: existing } = await supabase
   264→      .from('objection_responses')
   265→      .select('*')
   266→      .eq('response_text', obj.response_used)
   267→      .single();
   268→
   269→    if (existing) {
   270→      // Update existing record
   271→      await supabase
   272→        .from('objection_responses')
   273→        .update({
   274→          times_shown: (existing.times_shown || 0) + 1,
   275→          led_to_conversion: (existing.led_to_conversion || 0) + 1,
   276→          effectiveness_score: calculateEffectivenessScore(
   277→            existing.times_helpful + 1,
   278→            existing.times_not_helpful,
   279→            existing.led_to_conversion + 1
   280→          ),
   281→          updated_at: new Date().toISOString(),
   282→        })
   283→        .eq('id', existing.id);
   284→    } else {
   285→      // Create new response record
   286→      await supabase
   287→        .from('objection_responses')
   288→        .insert({
   289→          objection_type: obj.type,
   290→          objection_text: obj.objection,
   291→          response_text: obj.response_used,
   292→          times_shown: 1,
   293→          led_to_conversion: 1,
   294→          effectiveness_score: 0.5, // Start at 50%
   295→          lead_trade: lead.trade || lead.metadata?.trade,
   296→          lead_source: lead.source,
   297→          source: 'learned',
   298→        });
   299→    }
   300→  }
   301→}
   302→
   303→/**
   304→ * Calculate effectiveness score for an objection response
   305→ */
   306→function calculateEffectivenessScore(helpful, notHelpful, conversions) {
   307→  // Weight: conversions matter most, then helpful/not helpful
   308→  const totalFeedback = helpful + notHelpful;
   309→  const feedbackScore = totalFeedback > 0 ? helpful / totalFeedback : 0.5;
   310→  const conversionWeight = Math.min(conversions / 10, 1); // Cap at 10 conversions
   311→
   312→  // Blend feedback (60%) with conversion success (40%)
   313→  return Math.min(0.999, (feedbackScore * 0.6) + (conversionWeight * 0.4));
   314→}
   315→
   316→/**
   317→ * Record feedback on a copilot suggestion
   318→ */
   319→async function recordSuggestionFeedback(data) {
   320→  const {
   321→    copilotSessionId,
   322→    leadId,
   323→    objectionDetected,
   324→    objectionType,
   325→    suggestionResponse,
   326→    suggestionKeyPoint,
   327→    wasHelpful,
   328→    feedbackNote,
   329→    userEmail,
   330→  } = data;
   331→
   332→  // Insert feedback record
   333→  const { error: feedbackError } = await supabase
   334→    .from('suggestion_feedback')
   335→    .insert({
   336→      copilot_session_id: copilotSessionId,
   337→      lead_id: leadId,
   338→      objection_detected: objectionDetected,
   339→      objection_type: objectionType,
   340→      suggestion_response: suggestionResponse,
   341→      suggestion_key_point: suggestionKeyPoint,
   342→      was_helpful: wasHelpful,
   343→      feedback_note: feedbackNote,
   344→      user_email: userEmail,
   345→    });
   346→
   347→  if (feedbackError) {
   348→    console.error('Failed to record feedback:', feedbackError);
   349→    return { success: false, error: feedbackError.message };
   350→  }
   351→
   352→  // Update objection_responses table
   353→  if (suggestionResponse) {
   354→    const { data: existing } = await supabase
   355→      .from('objection_responses')
   356→      .select('*')
   357→      .eq('response_text', suggestionResponse)
   358→      .single();
   359→
   360→    if (existing) {
   361→      const updates = wasHelpful
   362→        ? { times_helpful: (existing.times_helpful || 0) + 1 }
   363→        : { times_not_helpful: (existing.times_not_helpful || 0) + 1 };
   364→
   365→      updates.effectiveness_score = calculateEffectivenessScore(
   366→        (existing.times_helpful || 0) + (wasHelpful ? 1 : 0),
   367→        (existing.times_not_helpful || 0) + (wasHelpful ? 0 : 1),
   368→        existing.led_to_conversion || 0
   369→      );
   370→      updates.updated_at = new Date().toISOString();
   371→
   372→      await supabase
   373→        .from('objection_responses')
   374→        .update(updates)
   375→        .eq('id', existing.id);
   376→    }
   377→  }
   378→
   379→  return { success: true };
   380→}
   381→
   382→/**
   383→ * Get effective objection responses for a given type
   384→ * Used by the AI to weight responses
   385→ */
   386→async function getEffectiveResponses(objectionType, trade = null) {
   387→  let query = supabase
   388→    .from('objection_responses')
   389→    .select('*')
   390→    .eq('objection_type', objectionType)
   391→    .gt('effectiveness_score', 0.3)
   392→    .order('effectiveness_score', { ascending: false })
   393→    .limit(5);
   394→
   395→  if (trade) {
   396→    query = query.or(`lead_trade.eq.${trade},lead_trade.is.null`);
   397→  }
   398→
   399→  const { data, error } = await query;
   400→
   401→  if (error) {
   402→    console.error('Failed to get effective responses:', error);
   403→    return [];
   404→  }
   405→
   406→  return data || [];
   407→}
   408→
   409→/**
   410→ * Get similar conversion patterns for a lead
   411→ * Used by Intel Brief to show similar wins
   412→ */
   413→async function getSimilarWins(lead) {
   414→  const { data, error } = await supabase
   415→    .from('conversion_patterns')
   416→    .select('*')
   417→    .order('created_at', { ascending: false })
   418→    .limit(20);
   419→
   420→  if (error || !data) return [];
   421→
   422→  // Score similarity
   423→  const scored = data.map(pattern => {
   424→    let score = 0;
   425→
   426→    // Same trade is a strong match
   427→    if (pattern.lead_trade && lead.trade && pattern.lead_trade === lead.trade) {
   428→      score += 40;
   429→    }
   430→
   431→    // Same source is good
   432→    if (pattern.lead_source && lead.source && pattern.lead_source === lead.source) {
   433→      score += 20;
   434→    }
   435→
   436→    // Similar score range
   437→    const scoreDiff = Math.abs((pattern.lead_score_at_conversion || 50) - (lead.score || 50));
   438→    if (scoreDiff < 10) score += 15;
   439→    else if (scoreDiff < 20) score += 10;
   440→
   441→    // Recent conversions more relevant
   442→    const daysAgo = Math.ceil((new Date() - new Date(pattern.created_at)) / (1000 * 60 * 60 * 24));
   443→    if (daysAgo < 7) score += 15;
   444→    else if (daysAgo < 30) score += 10;
   445→
   446→    return { ...pattern, similarity_score: score };
   447→  });
   448→
   449→  // Return top 3 most similar
   450→  return scored
   451→    .filter(p => p.similarity_score > 20)
   452→    .sort((a, b) => b.similarity_score - a.similarity_score)
   453→    .slice(0, 3)
   454→    .map(p => ({
   455→      company: p.lead_company,
   456→      trade: p.lead_trade,
   457→      days_to_convert: p.days_to_convert,
   458→      touchpoints: p.total_touchpoints,
   459→      winning_channel: p.winning_channel,
   460→      key_objections: p.objections_raised?.slice(0, 2).map(o => o.objection) || [],
   461→      similarity_score: p.similarity_score,
   462→    }));
   463→}
   464→
   465→module.exports = {
   466→  captureConversionPattern,
   467→  recordSuggestionFeedback,
   468→  getEffectiveResponses,
   469→  getSimilarWins,
   470→};
   471→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>