     1→const OpenAI = require('openai');
     2→const { supabase } = require('../config/database');
     3→const { getEffectiveResponses, getSimilarWins } = require('./learning');
     4→const complianceService = require('./compliance');
     5→
     6→// Initialize OpenAI client (only if API key is available)
     7→let openai = null;
     8→if (process.env.OPENAI_API_KEY) {
     9→  openai = new OpenAI({
    10→    apiKey: process.env.OPENAI_API_KEY,
    11→    timeout: 60000,   // 60 seconds - AI calls can be slow
    12→    maxRetries: 2,    // Retry on timeout/transient errors
    13→  });
    14→} else {
    15→  console.warn('OPENAI_API_KEY not set - AI features will be disabled');
    16→}
    17→
    18→// Model selection
    19→const MODELS = {
    20→  REASONING: 'gpt-4o',      // Complex analysis, briefs, patterns
    21→  SIMPLE: 'gpt-4o-mini',    // Message drafting, note cleanup
    22→  TRANSCRIPTION: 'whisper-1',
    23→};
    24→
    25→/**
    26→ * Generate Pre-Call Intelligence Brief
    27→ */
    28→async function generateIntelBrief(lead, communications = [], similarWins = []) {
    29→  if (!openai) {
    30→    return { success: false, error: 'AI features not configured - OPENAI_API_KEY not set' };
    31→  }
    32→
    33→  const prompt = `You are an elite sales intelligence AI for RateRight, Australia's worker marketplace.
    34→
    35→WHAT RATERIGHT IS:
    36→- A marketplace that connects contractors DIRECTLY with workers (not an agency)
    37→- 9.9% platform fee vs 30-40% agency margins - massive savings
    38→- Contractors post jobs, workers apply - direct relationship
    39→- Rating system for worker reliability - see before you book
    40→- Same-day matching - urgent jobs sorted in hours
    41→- No lock-in contracts - use us when you need us
    42→
    43→Generate a 30-second pre-call intelligence brief for this lead.
    44→
    45→LEAD DATA:
    46→- Name: ${lead.first_name} ${lead.last_name || ''}
    47→- Company: ${lead.company || 'Unknown'}
    48→- Source: ${lead.source || 'Unknown'}
    49→- Current Score: ${lead.score || 0}
    50→- Status: ${lead.status || 'new'}
    51→- Last Contact: ${lead.last_contact_at || 'Never'}
    52→- Last Contact Type: ${lead.last_contact_type || 'None'}
    53→
    54→COMMUNICATION HISTORY:
    55→${communications.slice(0, 10).map(c => `- [${c.direction}] ${c.content?.substring(0, 100) || 'No content'}`).join('\n') || 'No previous communications'}
    56→
    57→SIMILAR LEADS WHO CONVERTED:
    58→${similarWins.slice(0, 3).map(w => `- ${w.company}: Converted after ${w.touchpoints} touchpoints, key factor: ${w.key_factor}`).join('\n') || 'No similar data yet'}
    59→
    60→IMPORTANT GUIDELINES:
    61→- Keep opening lines PROFESSIONAL - reference their work, company, or industry only
    62→- NEVER mention personal topics (family, hobbies, trips) unless they explicitly mentioned it in previous conversations
    63→- Focus on business value and their specific pain points
    64→- Respect professional boundaries - we're building trust, not faking familiarity
    65→
    66→Generate a JSON response with:
    67→{
    68→  "summary": "30-second executive summary of who this lead is and why they matter",
    69→  "savings_pitch": "Estimated savings calculation or value proposition to lead with",
    70→  "opening_line": "Professional opening line referencing their work/company - NOT personal topics",
    71→  "objections": [
    72→    {"objection": "Likely objection", "probability": 0.8, "counter": "How to handle it"}
    73→  ],
    74→  "talking_points": ["Key point 1", "Key point 2", "Key point 3"],
    75→  "similar_wins": "How similar leads were won",
    76→  "recommended_approach": "Overall strategy for this call"
    77→}`;
    78→
    79→  try {
    80→    const response = await openai.chat.completions.create({
    81→      model: MODELS.REASONING,
    82→      messages: [{ role: 'user', content: prompt }],
    83→      response_format: { type: 'json_object' },
    84→      temperature: 0.7,
    85→    });
    86→
    87→    const brief = JSON.parse(response.choices[0].message.content);
    88→    return { success: true, brief };
    89→  } catch (error) {
    90→    console.error('Intel brief generation error:', error);
    91→    return { success: false, error: error.message };
    92→  }
    93→}
    94→
    95→/**
    96→ * Process Post-Call Summary from transcript or notes
    97→ */
    98→async function generatePostCallSummary(lead, transcript, callDuration = 0) {
    99→  if (!openai) {
   100→    return { success: false, error: 'AI features not configured - OPENAI_API_KEY not set' };
   101→  }
   102→
   103→  const prompt = `You are an elite sales AI assistant analyzing a call with a potential customer for RateRight, Australia's worker marketplace (NOT an agency - a direct marketplace connecting contractors with workers at 9.9% fee vs 30-40% agency margins).
   104→
   105→LEAD: ${lead.first_name} ${lead.last_name || ''} from ${lead.company || 'Unknown company'}
   106→CALL DURATION: ${Math.floor(callDuration / 60)} minutes ${callDuration % 60} seconds
   107→
   108→CALL TRANSCRIPT/NOTES:
   109→${transcript}
   110→
   111→Analyze this call and generate a JSON response:
   112→{
   113→  "summary": "2-3 sentence summary of the call",
   114→  "key_points": ["Important point 1", "Important point 2", "Important point 3"],
   115→  "sentiment": "positive|neutral|negative",
   116→  "sentiment_score": 0.0 to 1.0,
   117→  "outcome": "interested|callback|not_now|converted|lost",
   118→  "objections_raised": ["Any objections mentioned"],
   119→  "buying_signals": [
   120→    {
   121→      "phrase": "The exact phrase they said (e.g., 'when can you start?', 'sign me up')",
   122→      "signal_type": "high|medium|low",
   123→      "timestamp_approx": "early|middle|late (when in the call it happened)"
   124→    }
   125→  ],
   126→  "buying_signal_score": 0-100 (overall buying intent: 0=none, 30=low interest, 60=considering, 80=ready to buy),
   127→  "next_step": "Specific recommended next action",
   128→  "follow_up_date": "YYYY-MM-DD or null",
   129→  "score_adjustment": -20 to +30 (how much to adjust lead score),
   130→  "status_recommendation": "new|engaged|callback|converted|lost",
   131→  "coaching_note": "One tip for improving future calls like this"
   132→}
   133→
   134→BUYING SIGNAL DETECTION:
   135→Look for phrases indicating purchase intent:
   136→- HIGH: "when can you start?", "sign me up", "what's the next step?", "let's do it", "I'm ready", "send me the details"
   137→- MEDIUM: "that's reasonable", "sounds fair", "have a job starting", "need workers", "how does it compare?"
   138→- LOW: "tell me more", "what does it cost?", "sounds interesting", "might be useful"
   139→
   140→Include ALL buying signals detected, even subtle ones.`;
   141→
   142→  try {
   143→    const response = await openai.chat.completions.create({
   144→      model: MODELS.REASONING,
   145→      messages: [{ role: 'user', content: prompt }],
   146→      response_format: { type: 'json_object' },
   147→      temperature: 0.5,
   148→    });
   149→
   150→    const summary = JSON.parse(response.choices[0].message.content);
   151→    return { success: true, summary };
   152→  } catch (error) {
   153→    console.error('Post-call summary error:', error);
   154→    return { success: false, error: error.message };
   155→  }
   156→}
   157→
   158→/**
   159→ * Draft contextual SMS message
   160→ * @param {Object} lead - Lead data
   161→ * @param {string} userPrompt - User's request
   162→ * @param {Array} communications - Recent communications
   163→ * @param {string} currentMessage - Optional current message to refine
   164→ * @param {Object} options - { tone, count, includeContext }
   165→ */
   166→async function draftMessage(lead, userPrompt, communications = [], currentMessage = null, options = {}) {
   167→  if (!openai) {
   168→    return { success: false, error: 'AI features not configured - OPENAI_API_KEY not set' };
   169→  }
   170→
   171→  const { tone = null, count = 1, includeContext = true, skipComplianceCheck = false } = options;
   172→
   173→  // Get brand guidelines for AI context
   174→  const guidelines = await complianceService.getGuidelines();
   175→
   176→  const recentMessages = communications
   177→    .filter(c => c.type === 'sms' || c.type === 'sms_inbound' || c.type === 'sms_outbound')
   178→    .slice(0, 5)
   179→    .map(c => `[${c.direction}]: ${c.content}`)
   180→    .join('\n');
   181→
   182→  // Detect waiting status
   183→  const lastMsg = communications?.[0];
   184→  const waitingOnUs = lastMsg?.direction === 'inbound';
   185→
   186→  // Different prompt for refinement vs new draft
   187→  const isRefinement = !!currentMessage;
   188→
   189→  // Build tone instruction
   190→  const toneInstruction = tone
   191→    ? `Use a ${tone} tone.`
   192→    : 'Use a friendly, conversational tone.';
   193→
   194→  // For generating multiple suggestions
   195→  const generateMultiple = count > 1;
   196→
   197→  // Build brand voice rules for injection
   198→  const brandVoiceRules = `
   199→BRAND VOICE RULES (MUST FOLLOW):
   200→- Tone: ${guidelines.tone_description}
   201→- Use words like: ${(guidelines.tone_keywords || []).join(', ')}
   202→- NEVER use these words: ${(guidelines.banned_words || []).join(', ')}
   203→- NEVER use these phrases: ${(guidelines.banned_phrases || []).join(', ')}
   204→- Max 1 exclamation mark per message
   205→- Keep it conversational, not corporate
   206→- Always include a clear next step
   207→- For contractors: RateRight is FREE (workers pay 9.9% fee)
   208→- For workers: mention the 9.9% platform fee honestly`;
   209→
   210→  const prompt = isRefinement
   211→    ? `You are refining an SMS message for a RateRight sales rep.
   212→${brandVoiceRules}
   213→
   214→LEAD: ${lead.first_name} ${lead.last_name || ''}
   215→COMPANY: ${lead.company || 'Unknown'}
   216→
   217→CURRENT MESSAGE:
   218→"${currentMessage}"
   219→
   220→USER'S REFINEMENT REQUEST: "${userPrompt}"
   221→
   222→Modify the message according to the user's request. Keep it under 160 chars if possible, max 320 chars.
   223→- Maintain the core intent of the original message
   224→- Apply the requested changes
   225→- ${toneInstruction}
   226→
   227→Respond with JSON:
   228→{
   229→  "message": "The refined SMS to send",
   230→  "tone": "${tone || 'friendly'}",
   231→  "char_count": 123
   232→}`
   233→    : `You are writing SMS messages for a RateRight sales rep.
   234→${brandVoiceRules}
   235→
   236→LEAD: ${lead.first_name} ${lead.last_name || ''}
   237→COMPANY: ${lead.company || 'Unknown'}
   238→LEAD TYPE: ${lead.lead_type || 'unknown'}
   239→STATUS: ${lead.status || 'new'}
   240→${waitingOnUs ? 'NOTE: They are waiting for our reply!' : ''}
   241→
   242→${includeContext ? `RECENT SMS HISTORY:\n${recentMessages || 'No previous messages'}` : ''}
   243→
   244→USER REQUEST: "${userPrompt}"
   245→
   246→${generateMultiple ? `Generate exactly ${count} different message options, each with a slightly different approach.` : 'Generate a single message.'}
   247→Each message should be under 160 chars if possible, max 320 chars.
   248→- Be conversational, not salesy
   249→- ${toneInstruction}
   250→- Include a clear next step or question
   251→- Sign off naturally (no formal signatures)
   252→${waitingOnUs ? '- Acknowledge their message before moving forward' : ''}
   253→
   254→Respond with JSON:
   255→${generateMultiple ? `{
   256→  "suggestions": [
   257→    { "message": "First option SMS", "tone": "${tone || 'friendly'}", "char_count": 123 },
   258→    { "message": "Second option SMS", "tone": "${tone || 'friendly'}", "char_count": 123 },
   259→    { "message": "Third option SMS", "tone": "${tone || 'friendly'}", "char_count": 123 }
   260→  ]
   261→}` : `{
   262→  "message": "The SMS to send",
   263→  "tone": "${tone || 'friendly'}",
   264→  "char_count": 123
   265→}`}`;
   266→
   267→  try {
   268→    const response = await openai.chat.completions.create({
   269→      model: MODELS.SIMPLE,
   270→      messages: [{ role: 'user', content: prompt }],
   271→      response_format: { type: 'json_object' },
   272→      temperature: 0.8,
   273→    });
   274→
   275→    const result = JSON.parse(response.choices[0].message.content);
   276→
   277→    // Normalize response format
   278→    let suggestions = result.suggestions || [result];
   279→
   280→    // Post-generation compliance check (unless skipped)
   281→    if (!skipComplianceCheck) {
   282→      const checkedSuggestions = await Promise.all(
   283→        suggestions.map(async (suggestion) => {
   284→          const compliance = await complianceService.checkCompliance(suggestion.message, {
   285→            skipToneCheck: true // Already enforced in prompt
   286→          });
   287→
   288→          return {
   289→            ...suggestion,
   290→            compliance: {
   291→              passed: compliance.passed,
   292→              score: compliance.score,
   293→              issues: compliance.issues,
   294→              autoFixed: compliance.autoFixed
   295→            },
   296→            // If auto-fixed version available, include it
   297→            fixedMessage: compliance.autoFixed || null,
   298→            needsReview: !compliance.passed && !compliance.autoFixed
   299→          };
   300→        })
   301→      );
   302→      suggestions = checkedSuggestions;
   303→    }
   304→
   305→    return {
   306→      success: true,
   307→      draft: suggestions[0], // Backwards compatible
   308→      suggestions,
   309→      context: {
   310→        leadName: lead.first_name,
   311→        company: lead.company,
   312→        score: lead.score,
   313→        waitingOnUs
   314→      },
   315→      guidelinesApplied: true
   316→    };
   317→  } catch (error) {
   318→    console.error('Message draft error:', error);
   319→    return { success: false, error: error.message };
   320→  }
   321→}
   322→
   323→/**
   324→ * Transcribe audio using Whisper
   325→ */
   326→async function transcribeAudio(audioBuffer, filename = 'audio.webm') {
   327→  if (!openai) {
   328→    return { success: false, error: 'AI features not configured - OPENAI_API_KEY not set' };
   329→  }
   330→
   331→  try {
   332→    // Convert Buffer to a File-like object for OpenAI SDK
   333→    const file = await OpenAI.toFile(audioBuffer, filename, {
   334→      type: 'audio/webm',
   335→    });
   336→
   337→    const response = await openai.audio.transcriptions.create({
   338→      file,
   339→      model: MODELS.TRANSCRIPTION,
   340→      language: 'en',
   341→      response_format: 'verbose_json',
   342→    });
   343→
   344→    return {
   345→      success: true,
   346→      transcript: response.text,
   347→      segments: response.segments || [],
   348→      duration: response.duration,
   349→    };
   350→  } catch (error) {
   351→    console.error('Transcription error:', error);
   352→    return { success: false, error: error.message };
   353→  }
   354→}
   355→
   356→/**
   357→ * Parse callback time specification
   358→ * Handles: "tomorrow", "thursday", "in 3 days", "next week", "at 2pm"
   359→ */
   360→function parseCallbackTime(timeSpec) {
   361→  const lower = timeSpec.toLowerCase().trim();
   362→  const now = new Date();
   363→  let targetDate = new Date(now);
   364→  let targetTime = '09:00'; // Default 9am
   365→  let description = '';
   366→
   367→  // Extract time if specified: "at 2pm", "at 14:00", "2pm"
   368→  const timeMatch = lower.match(/(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
   369→  if (timeMatch) {
   370→    let hours = parseInt(timeMatch[1], 10);
   371→    const minutes = timeMatch[2] ? timeMatch[2] : '00';
   372→    const ampm = timeMatch[3]?.toLowerCase();
   373→
   374→    if (ampm === 'pm' && hours < 12) hours += 12;
   375→    if (ampm === 'am' && hours === 12) hours = 0;
   376→
   377→    targetTime = `${hours.toString().padStart(2, '0')}:${minutes}`;
   378→  }
   379→
   380→  // Parse the date part
   381→  const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
   382→
   383→  if (lower.includes('tomorrow')) {
   384→    targetDate.setDate(now.getDate() + 1);
   385→    description = 'tomorrow';
   386→  } else if (lower.includes('today')) {
   387→    description = 'today';
   388→  } else if (lower.includes('next week')) {
   389→    targetDate.setDate(now.getDate() + 7);
   390→    description = 'next week';
   391→  } else if (lower.match(/in\s+(\d+)\s+days?/)) {
   392→    const match = lower.match(/in\s+(\d+)\s+days?/);
   393→    const days = parseInt(match[1], 10);
   394→    targetDate.setDate(now.getDate() + days);
   395→    description = `in ${days} day${days > 1 ? 's' : ''}`;
   396→  } else {
   397→    // Check for day names: "monday", "tuesday", etc.
   398→    for (let i = 0; i < dayNames.length; i++) {
   399→      if (lower.includes(dayNames[i])) {
   400→        const currentDay = now.getDay();
   401→        let daysUntil = i - currentDay;
   402→        if (daysUntil <= 0) daysUntil += 7; // Next occurrence
   403→        targetDate.setDate(now.getDate() + daysUntil);
   404→        description = dayNames[i].charAt(0).toUpperCase() + dayNames[i].slice(1);
   405→        break;
   406→      }
   407→    }
   408→  }
   409→
   410→  // If no date was parsed, return null
   411→  if (!description) {
   412→    return null;
   413→  }
   414→
   415→  // Add time to description if not default
   416→  if (targetTime !== '09:00') {
   417→    const [h, m] = targetTime.split(':');
   418→    const hour = parseInt(h, 10);
   419→    const ampm = hour >= 12 ? 'pm' : 'am';
   420→    const displayHour = hour > 12 ? hour - 12 : (hour === 0 ? 12 : hour);
   421→    description += ` at ${displayHour}${m !== '00' ? ':' + m : ''}${ampm}`;
   422→  }
   423→
   424→  return {
   425→    date: targetDate.toISOString().split('T')[0], // YYYY-MM-DD
   426→    time: targetTime,
   427→    description,
   428→  };
   429→}
   430→
   431→/**
   432→ * Parse voice command intent
   433→ */
   434→async function parseVoiceCommand(transcript) {
   435→  if (!openai) {
   436→    return { success: false, error: 'AI features not configured - OPENAI_API_KEY not set' };
   437→  }
   438→
   439→  // Check for explicit idea/strategy prefixes first (fast path)
   440→  const lowerTranscript = transcript.toLowerCase().trim();
   441→  const explicitPrefixes = [
   442→    { prefix: 'idea:', category: 'idea' },
   443→    { prefix: 'idea ', category: 'idea' },
   444→    { prefix: 'strategy:', category: 'strategy' },
   445→    { prefix: 'strategy ', category: 'strategy' },
   446→    { prefix: 'what if:', category: 'experiment' },
   447→    { prefix: 'what if ', category: 'experiment' },
   448→    { prefix: 'remember:', category: 'reminder' },
   449→    { prefix: 'remember ', category: 'reminder' },
   450→    { prefix: 'reminder:', category: 'reminder' },
   451→    { prefix: 'reminder ', category: 'reminder' },
   452→  ];
   453→
   454→  for (const { prefix, category } of explicitPrefixes) {
   455→    if (lowerTranscript.startsWith(prefix)) {
   456→      const content = transcript.slice(prefix.length).trim();
   457→      return {
   458→        success: true,
   459→        parsed: {
   460→          intent: 'capture_idea',
   461→          confidence: 1.0,
   462→          data: {
   463→            content,
   464→            category,
   465→            ai_detected: false,
   466→          },
   467→          confirmation: `Got it! Saved your ${category}: "${content}"`,
   468→        },
   469→      };
   470→    }
   471→  }
   472→
   473→  // Check for "works for [company] on [project]" - contractor extraction (fast path)
   474→  // Different from "works at" - this implies they work FOR someone (contractor relationship)
   475→  const worksForWithProjectPatterns = [
   476→    /works?\s+for\s+(.+?)\s+on\s+(?:the\s+)?(.+)/i,
   477→    /(?:he|she|they)\s+works?\s+for\s+(.+?)\s+on\s+(?:the\s+)?(.+)/i,
   478→    /working\s+for\s+(.+?)\s+on\s+(?:the\s+)?(.+)/i,
   479→  ];
   480→
   481→  for (const pattern of worksForWithProjectPatterns) {
   482→    const match = transcript.match(pattern);
   483→    if (match) {
   484→      const companyName = match[1].trim().replace(/[.!?]$/, '');
   485→      const projectName = match[2].trim().replace(/[.!?]$/, '');
   486→      return {
   487→        success: true,
   488→        parsed: {
   489→          intent: 'extract_contractor',
   490→          confidence: 1.0,
   491→          data: {
   492→            company_name: companyName,
   493→            project_name: projectName,
   494→            trigger_search: true,
   495→          },
   496→          confirmation: `Searching for decision maker at "${companyName}" (${projectName})...`,
   497→        },
   498→      };
   499→    }
   500→  }
   501→
   502→  // Check for "works for [company]" without project (contractor extraction)
   503→  const worksForPatterns = [
   504→    /works?\s+for\s+(.+)/i,
   505→    /(?:he|she|they)\s+works?\s+for\s+(.+)/i,
   506→    /working\s+for\s+(.+)/i,
   507→  ];
   508→
   509→  for (const pattern of worksForPatterns) {
   510→    const match = lowerTranscript.match(pattern);
   511→    if (match) {
   512→      // Skip if contains "on" - that's handled by worksForWithProjectPatterns above
   513→      if (match[1].toLowerCase().includes(' on ')) continue;
   514→      const companyName = match[1].trim().replace(/[.!?]$/, '');
   515→      return {
   516→        success: true,
   517→        parsed: {
   518→          intent: 'extract_contractor',
   519→          confidence: 1.0,
   520→          data: {
   521→            company_name: companyName,
   522→            project_name: null,
   523→            trigger_search: true,
   524→          },
   525→          confirmation: `Searching for decision maker at "${companyName}"...`,
   526→        },
   527→      };
   528→    }
   529→  }
   530→
   531→  // Check for "works at" company commands (fast path) - sets company on lead
   532→  const worksAtPatterns = [
   533→    /works?\s+at\s+(.+)/i,
   534→    /they\s+work\s+at\s+(.+)/i,
   535→    /company\s+is\s+(.+)/i,
   536→    /from\s+(.+)\s+company/i,
   537→  ];
   538→
   539→  for (const pattern of worksAtPatterns) {
   540→    const match = lowerTranscript.match(pattern);
   541→    if (match) {
   542→      const companyName = match[1].trim().replace(/[.!?]$/, '');
   543→      return {
   544→        success: true,
   545→        parsed: {
   546→          intent: 'set_company',
   547→          confidence: 1.0,
   548→          data: {
   549→            company_name: companyName,
   550→            trigger_research: true,
   551→          },
   552→          confirmation: `Updated company to "${companyName}" and researching...`,
   553→        },
   554→      };
   555→    }
   556→  }
   557→
   558→  // ═══════════════════════════════════════════════════════════════════════════
   559→  // QUICK ACTION PATTERNS (context-aware - use current lead)
   560→  // ═══════════════════════════════════════════════════════════════════════════
   561→
   562→  // Mark tier patterns: "mark hot", "mark them warm", "he's hot", "this one's cold"
   563→  const markTierPatterns = [
   564→    /^mark\s+(?:this\s+)?(?:one\s+)?(?:as\s+)?(hot|warm|cold)$/i,
   565→    /^mark\s+(?:them|him|her)\s+(?:as\s+)?(hot|warm|cold)$/i,
   566→    /^(?:he|she|they|this\s+one)'?s?\s+(hot|warm|cold)$/i,
   567→    /^(hot|warm|cold)\s+lead$/i,
   568→  ];
   569→
   570→  for (const pattern of markTierPatterns) {
   571→    const match = lowerTranscript.match(pattern);
   572→    if (match) {
   573→      const tier = match[1].toLowerCase();
   574→      const scoreMap = { hot: 75, warm: 55, cold: 15 };
   575→      return {
   576→        success: true,
   577→        parsed: {
   578→          intent: 'mark_tier',
   579→          confidence: 1.0,
   580→          data: {
   581→            tier,
   582→            score: scoreMap[tier],
   583→          },
   584→          confirmation: `Marked as ${tier}`,
   585→        },
   586→      };
   587→    }
   588→  }
   589→
   590→  // Mark status patterns: "mark engaged", "mark as converted", "lost this one"
   591→  const markStatusPatterns = [
   592→    /^mark\s+(?:this\s+)?(?:one\s+)?(?:as\s+)?(engaged|converted|lost|new|callback)$/i,
   593→    /^mark\s+(?:them|him|her)\s+(?:as\s+)?(engaged|converted|lost|new|callback)$/i,
   594→    /^(?:they|this\s+one)\s+(?:is\s+)?(engaged|converted|lost)$/i,
   595→    /^(lost|converted)\s+(?:this\s+one|them)$/i,
   596→  ];
   597→
   598→  for (const pattern of markStatusPatterns) {
   599→    const match = lowerTranscript.match(pattern);
   600→    if (match) {
   601→      const status = match[1].toLowerCase();
   602→      return {
   603→        success: true,
   604→        parsed: {
   605→          intent: 'mark_status',
   606→          confidence: 1.0,
   607→          data: { status },
   608→          confirmation: `Marked as ${status}`,
   609→        },
   610→      };
   611→    }
   612→  }
   613→
   614→  // Skip patterns: "skip", "skip this one", "next", "pass"
   615→  const skipPatterns = [
   616→    /^skip(?:\s+this)?(?:\s+one)?$/i,
   617→    /^next(?:\s+lead)?$/i,
   618→    /^pass(?:\s+on\s+this)?(?:\s+one)?$/i,
   619→  ];
   620→
   621→  for (const pattern of skipPatterns) {
   622→    if (pattern.test(lowerTranscript)) {
   623→      return {
   624→        success: true,
   625→        parsed: {
   626→          intent: 'skip_lead',
   627→          confidence: 1.0,
   628→          data: {},
   629→          confirmation: 'Skipping to next lead',
   630→        },
   631→      };
   632→    }
   633→  }
   634→
   635→  // Call patterns: "call next", "call this one", "dial them"
   636→  const callPatterns = [
   637→    /^call\s+(?:this\s+one|them|him|her|next)$/i,
   638→    /^dial\s+(?:this\s+one|them|him|her|next)$/i,
   639→    /^ring\s+(?:this\s+one|them|him|her)$/i,
   640→  ];
   641→
   642→  for (const pattern of callPatterns) {
   643→    if (pattern.test(lowerTranscript)) {
   644→      return {
   645→        success: true,
   646→        parsed: {
   647→          intent: 'call_lead',
   648→          confidence: 1.0,
   649→          data: {},
   650→          confirmation: 'Starting call...',
   651→        },
   652→      };
   653→    }
   654→  }
   655→
   656→  // Send follow-up patterns: "send follow up", "send a quick text", "text them"
   657→  const followupPatterns = [
   658→    /^send\s+(?:a\s+)?(?:follow[\s-]?up|quick\s+(?:text|sms|message))$/i,
   659→    /^text\s+(?:them|him|her)$/i,
   660→    /^(?:quick\s+)?sms$/i,
   661→    /^message\s+(?:them|him|her)$/i,
   662→  ];
   663→
   664→  for (const pattern of followupPatterns) {
   665→    if (pattern.test(lowerTranscript)) {
   666→      return {
   667→        success: true,
   668→        parsed: {
   669→          intent: 'send_followup',
   670→          confidence: 1.0,
   671→          data: {},
   672→          confirmation: 'Opening SMS...',
   673→        },
   674→      };
   675→    }
   676→  }
   677→
   678→  // Note patterns: "note: [content]", "add note [content]"
   679→  const notePatterns = [
   680→    /^note[:\s]+(.+)$/i,
   681→    /^add\s+note[:\s]+(.+)$/i,
   682→    /^(?:make\s+a\s+)?note\s+(?:that\s+)?(.+)$/i,
   683→  ];
   684→
   685→  for (const pattern of notePatterns) {
   686→    const match = transcript.match(pattern);
   687→    if (match) {
   688→      const content = match[1].trim();
   689→      return {
   690→        success: true,
   691→        parsed: {
   692→          intent: 'add_note',
   693→          confidence: 1.0,
   694→          data: { note_content: content },
   695→          confirmation: `Note added: "${content.substring(0, 30)}${content.length > 30 ? '...' : ''}"`,
   696→        },
   697→      };
   698→    }
   699→  }
   700→
   701→  // Callback patterns: "callback tomorrow", "call back thursday at 2pm", "remind me in 3 days"
   702→  const callbackPatterns = [
   703→    /^(?:call\s*back|callback|follow\s*up|remind\s+me)\s+(.+)$/i,
   704→    /^schedule\s+(?:a\s+)?(?:call\s*back|callback|follow\s*up)\s+(?:for\s+)?(.+)$/i,
   705→  ];
   706→
   707→  for (const pattern of callbackPatterns) {
   708→    const match = lowerTranscript.match(pattern);
   709→    if (match) {
   710→      const timeSpec = match[1].trim();
   711→      const parsed = parseCallbackTime(timeSpec);
   712→      if (parsed) {
   713→        return {
   714→          success: true,
   715→          parsed: {
   716→            intent: 'schedule_callback',
   717→            confidence: 1.0,
   718→            data: {
   719→              callback_date: parsed.date,
   720→              callback_time: parsed.time,
   721→              time_description: parsed.description,
   722→            },
   723→            confirmation: `Callback scheduled for ${parsed.description}`,
   724→          },
   725→        };
   726→      }
   727→    }
   728→  }
   729→
   730→  // ═══════════════════════════════════════════════════════════════════════════
   731→  // END QUICK ACTION PATTERNS - Fall through to GPT for complex commands
   732→  // ═══════════════════════════════════════════════════════════════════════════
   733→
   734→  const prompt = `Parse this voice command from a sales rep using the RateRight CRM:
   735→
   736→"${transcript}"
   737→
   738→Identify the intent and extract data.
   739→
   740→IMPORTANT: If the command sounds like the user is capturing a thought, insight, or idea for later (even without saying "idea" or "strategy"), classify it as "capture_idea".
   741→
   742→Examples of capture_idea:
   743→- "we should try morning calls" → strategy
   744→- "offer referral bonus to plumbers" → idea
   745→- "test sending SMS before calls" → experiment
   746→- "don't forget to follow up with John" → reminder
   747→
   748→Examples of extract_contractor (when a worker mentions who they work FOR):
   749→- "works for ABC Construction on the Sydney project" → extract_contractor
   750→- "he works for Built Homes" → extract_contractor
   751→- "she's working for Smith Builders on Barangaroo" → extract_contractor
   752→
   753→Respond with JSON:
   754→{
   755→  "intent": "update_lead|schedule_callback|draft_message|get_recommendation|mark_status|add_note|capture_idea|set_company|extract_contractor|unknown",
   756→  "confidence": 0.0 to 1.0,
   757→  "data": {
   758→    "lead_name": "if mentioned",
   759→    "status": "if changing status",
   760→    "callback_date": "if scheduling",
   761→    "message_request": "if drafting",
   762→    "note_content": "if adding note",
   763→    "content": "if capture_idea - the actual idea/strategy text",
   764→    "category": "if capture_idea - one of: idea, strategy, experiment, reminder",
   765→    "company_name": "if set_company or extract_contractor - the company name",
   766→    "project_name": "if extract_contractor - the project/site name (optional)",
   767→    "trigger_research": true,
   768→    "trigger_search": true,
   769→    "ai_detected": true,
   770→    "other": "any other extracted data"
   771→  },
   772→  "confirmation": "Human-readable confirmation of what will be done"
   773→}`;
   774→
   775→  try {
   776→    const response = await openai.chat.completions.create({
   777→      model: MODELS.SIMPLE,
   778→      messages: [{ role: 'user', content: prompt }],
   779→      response_format: { type: 'json_object' },
   780→      temperature: 0.3,
   781→    });
   782→
   783→    const parsed = JSON.parse(response.choices[0].message.content);
   784→    return { success: true, parsed };
   785→  } catch (error) {
   786→    console.error('Voice command parse error:', error);
   787→    return { success: false, error: error.message };
   788→  }
   789→}
   790→
   791→/**
   792→ * Generate real-time objection handling suggestion
   793→ */
   794→async function getObjectionResponse(objection, leadContext) {
   795→  if (!openai) {
   796→    return { success: false, error: 'AI features not configured - OPENAI_API_KEY not set' };
   797→  }
   798→
   799→  // Calculate potential savings for this lead
   800→  const weeklyWorkerCost = 45 * 40; // $1,800
   801→  const agencyMarkup = weeklyWorkerCost * 0.35; // $630
   802→  const raterightFee = weeklyWorkerCost * 0.099; // $178
   803→  const weeklySavings = Math.round(agencyMarkup - raterightFee); // ~$450
   804→  const yearlySavingsPerWorker = weeklySavings * 52; // ~$23,400
   805→
   806→  // Detect likely objection type from keywords for learning lookup
   807→  const lowerObjection = objection.toLowerCase();
   808→  let likelyType = 'other';
   809→  if (lowerObjection.includes('price') || lowerObjection.includes('expensive') || lowerObjection.includes('cost') || lowerObjection.includes('cheap')) {
   810→    likelyType = 'price';
   811→  } else if (lowerObjection.includes('think') || lowerObjection.includes('later') || lowerObjection.includes('busy') || lowerObjection.includes('time')) {
   812→    likelyType = 'timing';
   813→  } else if (lowerObjection.includes('already') || lowerObjection.includes('happy') || lowerObjection.includes('sorted') || lowerObjection.includes('agency')) {
   814→    likelyType = 'competition';
   815→  } else if (lowerObjection.includes('trust') || lowerObjection.includes('never heard') || lowerObjection.includes('scam') || lowerObjection.includes('legit')) {
   816→    likelyType = 'trust';
   817→  }
   818→
   819→  // Query learning system for proven responses
   820→  let provenResponses = [];
   821→  try {
   822→    provenResponses = await getEffectiveResponses(likelyType, leadContext.trade);
   823→  } catch (err) {
   824→    console.warn('Could not fetch effective responses:', err.message);
   825→  }
   826→
   827→  // Build proven responses section for prompt
   828→  const provenSection = provenResponses.length > 0
   829→    ? `\n\nPROVEN RESPONSES (these have worked before - use or adapt):
   830→${provenResponses.slice(0, 3).map((r, i) => `${i + 1}. "${r.response_text}" (${Math.round(r.effectiveness_score * 100)}% effective)`).join('\n')}`
   831→    : '';
   832→
   833→  // Build communication history summary
   834→  const recentCommsText = leadContext.recentMessages?.length > 0
   835→    ? leadContext.recentMessages.slice(0, 3).map(m =>
   836→        `${m.direction === 'inbound' ? 'They said' : 'We said'}: "${m.content}"`
   837→      ).join('\n')
   838→    : 'First conversation';
   839→
   840→  // Get brand guidelines for tone consistency
   841→  const guidelines = await complianceService.getGuidelines();
   842→
   843→  const prompt = `You're a sales coach whispering in the ear of a rep who's on a LIVE call right now. Be direct, specific, and useful. No fluff.${provenSection}
   844→
   845→BRAND VOICE (IMPORTANT!):
   846→- Tone: ${guidelines.tone_description}
   847→- Use words like: ${(guidelines.tone_keywords || []).slice(0, 5).join(', ')}
   848→- NEVER use: ${(guidelines.banned_words || []).join(', ')}
   849→- For contractors: RateRight is FREE (workers pay 9.9%)
   850→- For workers: mention the 9.9% platform fee honestly
   851→
   852→THE SITUATION:
   853→${leadContext.name} from ${leadContext.company}${leadContext.trade ? ` (${leadContext.trade} trade)` : ''} just said:
   854→"${objection}"
   855→
   856→WHAT WE KNOW ABOUT THEM:
   857→- Lead score: ${leadContext.score}/100 ${leadContext.score >= 70 ? '(hot - close this!)' : leadContext.score >= 50 ? '(warm - good potential)' : '(needs warming up)'}
   858→- Recent conversation:
   859→${recentCommsText}
   860→${leadContext.previousObjections?.length > 0 ? `- They've objected before about: ${leadContext.previousObjections.join(', ')}` : ''}
   861→
   862→THE MATH (use this!):
   863→- Agencies charge 30-40% margin on workers
   864→- RateRight charges just 9.9%
   865→- That's $${weeklySavings}/week savings per worker
   866→- Or $${yearlySavingsPerWorker.toLocaleString()}/year per full-time worker
   867→- For a team of 5 workers = $${(yearlySavingsPerWorker * 5).toLocaleString()}/year back in their pocket
   868→
   869→WHAT RATERIGHT IS (GET THIS RIGHT!):
   870→- A MARKETPLACE, not an agency - contractors post jobs, workers apply directly
   871→- 9.9% platform fee vs 30-40% agency margins - massive savings
   872→- Direct relationship between you and the worker - no middleman
   873→- Rating system - see worker reliability before you hire
   874→- Same-day matching - post a job, get applicants within hours
   875→- No lock-in contracts - use it when you need it
   876→
   877→OBJECTION PLAYBOOK:
   878→- PRICE: "Agencies take 30-40% cut. On RateRight you post the job, workers apply direct, and we take just 9.9%. You're paying $${agencyMarkup}/week in agency fat - with us that's $${weeklySavings} back in your pocket."
   879→- ALREADY HAVE WORKERS: "Perfect - keep them! But what happens when Dave calls in sick Monday morning and you've got a concrete pour? Post a job on RateRight, get applicants in hours."
   880→- NEED TO THINK: "Totally fair. What's the main thing you'd want to check? Happy to send you some numbers to look at."
   881→- HAPPY WITH CURRENT AGENCY: "What are you paying in margins? Because agencies take 30-40% on top. With RateRight you deal direct with workers, see their ratings, and pay just 9.9%. No middleman."
   882→- TOO BUSY: "I get it - that's exactly why tradies use us. 5 minutes to post a job, workers come to you. No hunting, no agencies, just blokes applying direct."
   883→- CONTRACTS/LOCKED IN: "No contracts. Post one job, try it out. Most blokes try us once and stick around because the direct connection just works better."
   884→
   885→YOUR JOB RIGHT NOW:
   886→Give the rep ONE thing to say that's natural, specific to ${leadContext.name}'s situation, and moves the conversation forward. Sound like a mate giving advice, not a script.
   887→
   888→Respond with JSON:
   889→{
   890→  "response": "Exactly what to say next (1-2 sentences, natural Aussie tone)",
   891→  "key_point": "The one thing to make sure lands",
   892→  "follow_up_question": "Question to ask after (keep them talking)",
   893→  "objection_type": "price|timing|competition|trust|capacity|other"
   894→}`;
   895→
   896→  try {
   897→    const response = await openai.chat.completions.create({
   898→      model: MODELS.REASONING, // Use GPT-4o for better coaching
   899→      messages: [{ role: 'user', content: prompt }],
   900→      response_format: { type: 'json_object' },
   901→      temperature: 0.8,
   902→      max_tokens: 400,
   903→    });
   904→
   905→    const suggestion = JSON.parse(response.choices[0].message.content);
   906→
   907→    // Add the savings data to the response for the UI
   908→    suggestion.savings = {
   909→      weekly: weeklySavings,
   910→      yearly: yearlySavingsPerWorker,
   911→    };
   912→
   913→    // Add effectiveness data from learning system
   914→    if (provenResponses.length > 0) {
   915→      // Find best matching proven response for this objection type
   916→      const bestMatch = provenResponses.find(r => r.objection_type === suggestion.objection_type)
   917→        || provenResponses[0];
   918→
   919→      suggestion.effectiveness = {
   920→        successRate: Math.round((bestMatch.effectiveness_score || 0.5) * 100),
   921→        timesUsed: bestMatch.times_shown || 0,
   922→        conversions: bestMatch.led_to_conversion || 0,
   923→        source: 'learned', // Indicates this comes from actual usage data
   924→      };
   925→    } else {
   926→      // No proven data yet - show as new suggestion
   927→      suggestion.effectiveness = {
   928→        successRate: null,
   929→        timesUsed: 0,
   930→        conversions: 0,
   931→        source: 'ai_generated',
   932→      };
   933→    }
   934→
   935→    return { success: true, suggestion };
   936→  } catch (error) {
   937→    console.error('Objection response error:', error);
   938→    return { success: false, error: error.message };
   939→  }
   940→}
   941→
   942→/**
   943→ * Calculate AI Conversion Probability
   944→ */
   945→async function calculateConversionProbability(lead, communications = [], factors = []) {
   946→  if (!openai) {
   947→    return { success: false, error: 'AI features not configured - OPENAI_API_KEY not set' };
   948→  }
   949→
   950→  const engagementScore = communications.filter(c => c.direction === 'inbound').length * 10;
   951→  const recencyBonus = lead.last_contact_at &&
   952→    (Date.now() - new Date(lead.last_contact_at).getTime()) < 86400000 ? 15 : 0;
   953→  const sourceBonus = lead.source === 'referral' ? 20 : lead.source === 'website' ? 10 : 0;
   954→
   955→  // Base probability from factors
   956→  let probability = 0.3 + (engagementScore / 100) + (recencyBonus / 100) + (sourceBonus / 100);
   957→  probability = Math.min(0.95, Math.max(0.05, probability));
   958→
   959→  const prompt = `Given this lead data, refine the conversion probability:
   960→
   961→LEAD: ${lead.first_name} ${lead.last_name || ''} - ${lead.company || 'Unknown'}
   962→Current Score: ${lead.score || 0}
   963→Status: ${lead.status}
   964→Source: ${lead.source}
   965→Communications: ${communications.length} total, ${communications.filter(c => c.direction === 'inbound').length} inbound
   966→Last Contact: ${lead.last_contact_at || 'Never'}
   967→Base Probability Estimate: ${(probability * 100).toFixed(0)}%
   968→
   969→Respond with JSON:
   970→{
   971→  "probability": 0.0 to 1.0,
   972→  "factors": [
   973→    {"factor": "Factor name", "impact": "positive|negative", "weight": 0.1}
   974→  ],
   975→  "reasoning": "Brief explanation",
   976→  "recommended_action": "Best next step",
   977→  "recommended_timing": "When to take action"
   978→}`;
   979→
   980→  try {
   981→    const response = await openai.chat.completions.create({
   982→      model: MODELS.REASONING,
   983→      messages: [{ role: 'user', content: prompt }],
   984→      response_format: { type: 'json_object' },
   985→      temperature: 0.4,
   986→    });
   987→
   988→    const result = JSON.parse(response.choices[0].message.content);
   989→    return { success: true, ...result };
   990→  } catch (error) {
   991→    console.error('Conversion probability error:', error);
   992→    return { success: false, probability, error: error.message };
   993→  }
   994→}
   995→
   996→/**
   997→ * Generate Weekly Pattern Insights
   998→ */
   999→async function generateWeeklyInsights(metrics, conversions, communications) {
  1000→  if (!openai) {
  1001→    return { success: false, error: 'AI features not configured - OPENAI_API_KEY not set' };
  1002→  }
  1003→
  1004→  const prompt = `Analyze this week's sales data for RateRight and generate actionable insights:
  1005→
  1006→METRICS:
  1007→- Calls Made: ${metrics.calls_made || 0}
  1008→- SMS Sent: ${metrics.sms_sent || 0}
  1009→- SMS Received: ${metrics.sms_received || 0}
  1010→- New Leads: ${metrics.new_leads || 0}
  1011→- Conversions: ${metrics.conversions || 0}
  1012→
  1013→CONVERSION DATA:
  1014→${JSON.stringify(conversions.slice(0, 10), null, 2)}
  1015→
  1016→COMMUNICATION PATTERNS:
  1017→${JSON.stringify(communications.slice(0, 20), null, 2)}
  1018→
  1019→Generate actionable insights. Respond with JSON:
  1020→{
  1021→  "summary": "One paragraph summary of the week",
  1022→  "top_insight": "The most important finding",
  1023→  "patterns": [
  1024→    {"pattern": "What's working", "evidence": "Data supporting it", "recommendation": "How to do more"}
  1025→  ],
  1026→  "concerns": [
  1027→    {"concern": "What needs attention", "data": "Evidence", "action": "What to do"}
  1028→  ],
  1029→  "recommendations": [
  1030→    "Specific action item 1",
  1031→    "Specific action item 2",
  1032→    "Specific action item 3"
  1033→  ],
  1034→  "best_performing": {
  1035→    "time": "Best time to call",
  1036→    "message_type": "Most effective message approach",
  1037→    "lead_type": "Highest converting lead type"
  1038→  }
  1039→}`;
  1040→
  1041→  try {
  1042→    const response = await openai.chat.completions.create({
  1043→      model: MODELS.REASONING,
  1044→      messages: [{ role: 'user', content: prompt }],
  1045→      response_format: { type: 'json_object' },
  1046→      temperature: 0.6,
  1047→    });
  1048→
  1049→    const insights = JSON.parse(response.choices[0].message.content);
  1050→    return { success: true, insights };
  1051→  } catch (error) {
  1052→    console.error('Weekly insights error:', error);
  1053→    return { success: false, error: error.message };
  1054→  }
  1055→}
  1056→
  1057→/**
  1058→ * Clean up rough notes into structured format
  1059→ */
  1060→async function cleanupNotes(roughNotes, lead) {
  1061→  if (!openai) {
  1062→    return { success: false, error: 'AI features not configured - OPENAI_API_KEY not set' };
  1063→  }
  1064→
  1065→  const prompt = `Clean up these rough sales notes into a structured format:
  1066→
  1067→LEAD: ${lead.first_name} ${lead.last_name || ''} - ${lead.company || 'Unknown'}
  1068→
  1069→ROUGH NOTES:
  1070→${roughNotes}
  1071→
  1072→Respond with JSON:
  1073→{
  1074→  "cleaned_notes": "Well-formatted, clear notes",
  1075→  "key_points": ["Point 1", "Point 2"],
  1076→  "action_items": ["Action 1", "Action 2"],
  1077→  "sentiment": "positive|neutral|negative",
  1078→  "follow_up_needed": true|false,
  1079→  "suggested_follow_up": "What to do next"
  1080→}`;
  1081→
  1082→  try {
  1083→    const response = await openai.chat.completions.create({
  1084→      model: MODELS.SIMPLE,
  1085→      messages: [{ role: 'user', content: prompt }],
  1086→      response_format: { type: 'json_object' },
  1087→      temperature: 0.3,
  1088→    });
  1089→
  1090→    const cleaned = JSON.parse(response.choices[0].message.content);
  1091→    return { success: true, cleaned };
  1092→  } catch (error) {
  1093→    console.error('Notes cleanup error:', error);
  1094→    return { success: false, error: error.message };
  1095→  }
  1096→}
  1097→
  1098→/**
  1099→ * Find similar leads who converted
  1100→ */
  1101→async function findSimilarWins(lead) {
  1102→  if (!supabase) return { success: false, error: 'Database not configured' };
  1103→
  1104→  try {
  1105→    // Find converted leads with similar characteristics
  1106→    const { data: wins, error } = await supabase
  1107→      .from('leads')
  1108→      .select('id, first_name, company, source, score, metadata')
  1109→      .eq('status', 'converted')
  1110→      .limit(10);
  1111→
  1112→    if (error) throw error;
  1113→
  1114→    // Score similarity (simple matching for now)
  1115→    const similarWins = wins.map(w => ({
  1116→      id: w.id,
  1117→      company: w.company,
  1118→      source: w.source,
  1119→      similarity: calculateSimilarity(lead, w),
  1120→      key_factor: w.metadata?.conversion_factor || 'engagement',
  1121→      touchpoints: w.metadata?.touchpoints || 5,
  1122→    }))
  1123→    .filter(w => w.similarity > 0.3)
  1124→    .sort((a, b) => b.similarity - a.similarity)
  1125→    .slice(0, 3);
  1126→
  1127→    return { success: true, similarWins };
  1128→  } catch (error) {
  1129→    console.error('Find similar wins error:', error);
  1130→    return { success: false, error: error.message };
  1131→  }
  1132→}
  1133→
  1134→function calculateSimilarity(lead1, lead2) {
  1135→  let score = 0;
  1136→  if (lead1.source === lead2.source) score += 0.3;
  1137→  if (lead1.company && lead2.company) {
  1138→    // Simple company name similarity
  1139→    const words1 = lead1.company.toLowerCase().split(' ');
  1140→    const words2 = lead2.company.toLowerCase().split(' ');
  1141→    const common = words1.filter(w => words2.includes(w)).length;
  1142→    score += (common / Math.max(words1.length, words2.length)) * 0.3;
  1143→  }
  1144→  // Score range similarity
  1145→  const scoreDiff = Math.abs((lead1.score || 0) - (lead2.score || 0));
  1146→  score += Math.max(0, 0.4 - (scoreDiff / 100));
  1147→  return score;
  1148→}
  1149→
  1150→/**
  1151→ * Extract intel from call transcript
  1152→ * Identifies companies, projects, people, and other valuable intel
  1153→ * Also extracts contractor intel when workers mention who they work for
  1154→ */
  1155→async function extractTranscriptIntel(transcript) {
  1156→  if (!openai || !transcript || transcript.length < 50) {
  1157→    return { success: false, error: 'Transcript too short or AI not configured' };
  1158→  }
  1159→
  1160→  const prompt = `Analyze this sales call transcript and extract any valuable intel mentioned.
  1161→
  1162→TRANSCRIPT:
  1163→"${transcript}"
  1164→
  1165→Extract and return JSON:
  1166→{
  1167→  "companies_mentioned": [
  1168→    {"name": "Company name", "context": "How it was mentioned"}
  1169→  ],
  1170→  "projects_mentioned": [
  1171→    {"name": "Project name", "type": "construction/renovation/etc", "context": "Details mentioned"}
  1172→  ],
  1173→  "people_mentioned": [
  1174→    {"name": "Person name", "role": "Their role if mentioned", "company": "Their company if mentioned"}
  1175→  ],
  1176→  "intel_nuggets": [
  1177→    {"type": "budget/timeline/pain_point/competitor/opportunity", "detail": "The specific intel"}
  1178→  ],
  1179→  "company_to_research": "Main company name to research (if any new company was mentioned)",
  1180→  "person_to_research": {"name": "Name", "company": "Company"} or null,
  1181→  "contractor_intel": {
  1182→    "company": "Company the worker works FOR (if mentioned, e.g., 'I work for ABC Construction')",
  1183→    "project": "Current project/site they're working on (if mentioned)",
  1184→    "hiring_manager_name": "Name of whoever does the hiring/their boss if mentioned",
  1185→    "should_research": true if they mentioned working for a company (good contractor lead opportunity)
  1186→  }
  1187→}
  1188→
  1189→IMPORTANT for contractor_intel:
  1190→- Listen for phrases like "I work for...", "my boss at...", "who does the hiring...", "we're working on..."
  1191→- We want to find the person who HIRES workers at their company
  1192→- Only set should_research=true if they clearly mentioned a company they work for
  1193→
  1194→Only include actual companies/people mentioned in the call, not generic references.
  1195→Return ONLY valid JSON.`;
  1196→
  1197→  try {
  1198→    const response = await openai.chat.completions.create({
  1199→      model: MODELS.SIMPLE,
  1200→      messages: [{ role: 'user', content: prompt }],
  1201→      response_format: { type: 'json_object' },
  1202→      temperature: 0.3,
  1203→    });
  1204→
  1205→    const intel = JSON.parse(response.choices[0].message.content);
  1206→    return { success: true, intel };
  1207→  } catch (error) {
  1208→    console.error('Extract transcript intel error:', error);
  1209→    return { success: false, error: error.message };
  1210→  }
  1211→}
  1212→
  1213→/**
  1214→ * Extract PERSONAL intel from conversations (SMS + call transcripts)
  1215→ * Finds family, hobbies, interests, weekend plans, etc.
  1216→ * @param {string} leadId - Lead ID
  1217→ * @returns {Promise<object>} Personal intel from conversations
  1218→ */
  1219→async function extractPersonalIntelFromConversations(leadId) {
  1220→  if (!openai || !supabase) {
  1221→    return { success: false, error: 'AI or database not configured' };
  1222→  }
  1223→
  1224→  try {
  1225→    // Get all communications for this lead
  1226→    const { data: comms } = await supabase
  1227→      .from('communications')
  1228→      .select('content, direction, created_at')
  1229→      .eq('lead_id', leadId)
  1230→      .order('created_at', { ascending: false })
  1231→      .limit(50);
  1232→
  1233→    // Get lead notes (may contain manually captured intel)
  1234→    const { data: notes } = await supabase
  1235→      .from('lead_notes')
  1236→      .select('content, created_at')
  1237→      .eq('lead_id', leadId)
  1238→      .order('created_at', { ascending: false })
  1239→      .limit(20);
  1240→
  1241→    // Get call transcripts from copilot sessions
  1242→    const { data: sessions } = await supabase
  1243→      .from('copilot_sessions')
  1244→      .select('transcript, created_at')
  1245→      .eq('lead_id', leadId)
  1246→      .not('transcript', 'is', null)
  1247→      .order('created_at', { ascending: false })
  1248→      .limit(10);
  1249→
  1250→    // Build conversation history
  1251→    const conversationText = [];
  1252→
  1253→    if (comms && comms.length > 0) {
  1254→      conversationText.push('=== SMS MESSAGES ===');
  1255→      comms.forEach(c => {
  1256→        conversationText.push(`[${c.direction}]: ${c.content}`);
  1257→      });
  1258→    }
  1259→
  1260→    if (sessions && sessions.length > 0) {
  1261→      conversationText.push('\n=== CALL TRANSCRIPTS ===');
  1262→      sessions.forEach(s => {
  1263→        if (s.transcript && s.transcript.length > 20) {
  1264→          conversationText.push(s.transcript.substring(0, 2000)); // Limit length
  1265→        }
  1266→      });
  1267→    }
  1268→
  1269→    if (notes && notes.length > 0) {
  1270→      conversationText.push('\n=== NOTES ===');
  1271→      notes.forEach(n => {
  1272→        conversationText.push(n.content);
  1273→      });
  1274→    }
  1275→
  1276→    const allText = conversationText.join('\n');
  1277→
  1278→    if (allText.length < 50) {
  1279→      return { success: true, intel: null, reason: 'Not enough conversation history' };
  1280→    }
  1281→
  1282→    // Ask AI to extract BOTH personal AND business intel
  1283→    const prompt = `Analyze these conversations with a lead and extract PERSONAL and BUSINESS information they've mentioned.
  1284→
  1285→CONVERSATIONS:
  1286→${allText.substring(0, 8000)}
  1287→
  1288→Extract details they've ACTUALLY mentioned (not assumed). Return JSON:
  1289→{
  1290→  "personal": {
  1291→    "family": {
  1292→      "status": "married/single/divorced or null if not mentioned",
  1293→      "kids": "2 kids, daughter plays netball" or null,
  1294→      "partner_name": "Wife's name is Sarah" or null
  1295→    },
  1296→    "hobbies_mentioned": [
  1297→      {"hobby": "fishing", "detail": "Goes to Lake Eucumbene on weekends", "mentioned_when": "SMS on Tuesday"}
  1298→    ],
  1299→    "interests_mentioned": [
  1300→      {"interest": "property investment", "detail": "Looking at a block in Penrith", "mentioned_when": "Call last week"}
  1301→    ],
  1302→    "weekend_plans": "Going camping at Jindabyne" or null,
  1303→    "sports_teams": ["Parramatta Eels", "NSW Blues"],
  1304→    "personality_traits": ["straight-shooter", "family-oriented", "busy"],
  1305→    "pet_peeves": ["unreliable workers", "no-shows"],
  1306→    "important_dates": [
  1307→      {"date": "Next weekend", "event": "Camping trip to Jindabyne"}
  1308→    ]
  1309→  },
  1310→  "business": {
  1311→    "current_employer": {
  1312→      "company_name": "ABC Construction" or null,
  1313→      "role": "Foreman / Labourer / Steelfixer" or null,
  1314→      "how_long": "Been there 3 years" or null
  1315→    },
  1316→    "previous_employers": [
  1317→      {"company": "XYZ Formwork", "role": "Labourer", "detail": "Left because pay was bad"}
  1318→    ],
  1319→    "location": {
  1320→      "lives_in": "Blacktown" or null,
  1321→      "works_in": "Western Sydney, sometimes CBD" or null,
  1322→      "willing_to_travel": "Up to 1 hour" or null
  1323→    },
  1324→    "company_size": "Small team of 5" or "Big company, 50+ workers" or null,
  1325→    "pain_points": [
  1326→      {"issue": "Hard to find reliable workers", "detail": "Had 3 no-shows last week"},
  1327→      {"issue": "Agency fees too high", "detail": "Paying 35% markup"}
  1328→    ],
  1329→    "current_projects": [
  1330→      {"project": "Westmead Hospital", "detail": "6 month job, needs more formworkers"}
  1331→    ],
  1332→    "hiring_needs": {
  1333→      "looking_for": ["formworkers", "steelfixers"] or null,
  1334→      "urgency": "Needs someone Monday" or null,
  1335→      "quantity": "2-3 guys" or null
  1336→    },
  1337→    "budget_signals": {
  1338→      "hourly_rate_mentioned": "$45/hr" or null,
  1339→      "willing_to_pay": "Happy to pay good money for reliable workers" or null
  1340→    },
  1341→    "decision_timeline": "Needs to decide by Friday" or null,
  1342→    "competitors_mentioned": ["Chandler Macleod", "Hays"] or null
  1343→  },
  1344→  "conversation_starters": [
  1345→    "Ask about the Westmead project - is it going well?",
  1346→    "Follow up on the formworkers they mentioned needing",
  1347→    "Ask if the reliability issue with agencies has improved"
  1348→  ],
  1349→  "location_details": "Lives in Blacktown, works sites in Western Sydney"
  1350→}
  1351→
  1352→IMPORTANT:
  1353→- Only include things they ACTUALLY said in conversations - don't assume or make up details
  1354→- Include the context of when/how they mentioned it
  1355→- Business intel is critical for sales - capture pain points, needs, timeline
  1356→- Conversation starters should focus on WORK/BUSINESS topics only (projects, hiring needs, pain points)
  1357→- AVOID personal conversation starters (family, hobbies, trips) - these feel intrusive unless they brought it up repeatedly
  1358→- If something wasn't mentioned, use null or empty array
  1359→
  1360→Return ONLY valid JSON.`;
  1361→
  1362→    const response = await openai.chat.completions.create({
  1363→      model: MODELS.SIMPLE,
  1364→      messages: [{ role: 'user', content: prompt }],
  1365→      response_format: { type: 'json_object' },
  1366→      temperature: 0.3,
  1367→    });
  1368→
  1369→    const intel = JSON.parse(response.choices[0].message.content);
  1370→    return { success: true, intel };
  1371→
  1372→  } catch (error) {
  1373→    console.error('Extract personal intel error:', error);
  1374→    return { success: false, error: error.message };
  1375→  }
  1376→}
  1377→
  1378→/**
  1379→ * Save extracted intel to lead_intel table
  1380→ * Called after calls to persist personal + business intel
  1381→ * @param {string} leadId - Lead ID
  1382→ * @param {object} intel - Extracted intel from extractPersonalIntelFromConversations
  1383→ * @returns {Promise<object>} Save result
  1384→ */
  1385→async function saveExtractedIntel(leadId, intel) {
  1386→  if (!supabase || !intel) {
  1387→    return { success: false, error: 'Database not configured or no intel to save' };
  1388→  }
  1389→
  1390→  try {
  1391→    const personal = intel.personal || {};
  1392→    const business = intel.business || {};
  1393→
  1394→    // Build arrays from extracted data
  1395→    const hobbies = (personal.hobbies_mentioned || []).map(h => h.hobby).filter(Boolean);
  1396→    const interests = (personal.interests_mentioned || []).map(i => i.interest).filter(Boolean);
  1397→    const sportsTeams = personal.sports_teams || [];
  1398→
  1399→    // Build conversation starters
  1400→    const conversationStarters = intel.conversation_starters || [];
  1401→
  1402→    // Build business intel as JSONB
  1403→    const businessIntel = {
  1404→      current_employer: business.current_employer || null,
  1405→      previous_employers: business.previous_employers || [],
  1406→      company_size: business.company_size || null,
  1407→      pain_points: business.pain_points || [],
  1408→      current_projects: business.current_projects || [],
  1409→      hiring_needs: business.hiring_needs || null,
  1410→      budget_signals: business.budget_signals || null,
  1411→      decision_timeline: business.decision_timeline || null,
  1412→      competitors_mentioned: business.competitors_mentioned || [],
  1413→    };
  1414→
  1415→    // Build personal news JSONB (includes family, dates, etc.)
  1416→    const personalNews = {
  1417→      family: personal.family || null,
  1418→      weekend_plans: personal.weekend_plans || null,
  1419→      important_dates: personal.important_dates || [],
  1420→      pet_peeves: personal.pet_peeves || [],
  1421→      personality_traits: personal.personality_traits || [],
  1422→      sports_teams: sportsTeams,
  1423→    };
  1424→
  1425→    // Location details
  1426→    const locationSuburb = business.location?.lives_in || intel.location_details || null;
  1427→
  1428→    // Check if lead_intel exists for this lead
  1429→    const { data: existing } = await supabase
  1430→      .from('lead_intel')
  1431→      .select('id, hobbies, interests, conversation_starters')
  1432→      .eq('lead_id', leadId)
  1433→      .single();
  1434→
  1435→    if (existing) {
  1436→      // Merge with existing data (don't overwrite, append new items)
  1437→      const mergedHobbies = [...new Set([...(existing.hobbies || []), ...hobbies])];
  1438→      const mergedInterests = [...new Set([...(existing.interests || []), ...interests])];
  1439→      const mergedStarters = [...new Set([...(existing.conversation_starters || []), ...conversationStarters])].slice(0, 10);
  1440→
  1441→      const { error: updateError } = await supabase
  1442→        .from('lead_intel')
  1443→        .update({
  1444→          hobbies: mergedHobbies.length > 0 ? mergedHobbies : existing.hobbies,
  1445→          interests: mergedInterests.length > 0 ? mergedInterests : existing.interests,
  1446→          conversation_starters: mergedStarters.length > 0 ? mergedStarters : existing.conversation_starters,
  1447→          family_status: personal.family?.status || existing.family_status,
  1448→          location_suburb: locationSuburb || existing.location_suburb,
  1449→          personal_news: personalNews,
  1450→          business_intel: businessIntel,
  1451→        })
  1452→        .eq('id', existing.id);
  1453→
  1454→      if (updateError) {
  1455→        console.error('[SaveIntel] Update error:', updateError);
  1456→        return { success: false, error: updateError.message };
  1457→      }
  1458→
  1459→      console.log(`[SaveIntel] Updated intel for lead ${leadId}: ${hobbies.length} hobbies, ${interests.length} interests, ${(business.pain_points || []).length} pain points`);
  1460→    } else {
  1461→      // Insert new record
  1462→      const { error: insertError } = await supabase
  1463→        .from('lead_intel')
  1464→        .insert({
  1465→          lead_id: leadId,
  1466→          hobbies: hobbies.length > 0 ? hobbies : null,
  1467→          interests: interests.length > 0 ? interests : null,
  1468→          conversation_starters: conversationStarters.length > 0 ? conversationStarters : null,
  1469→          family_status: personal.family?.status || null,
  1470→          location_suburb: locationSuburb,
  1471→          personal_news: personalNews,
  1472→          business_intel: businessIntel,
  1473→        });
  1474→
  1475→      if (insertError) {
  1476→        console.error('[SaveIntel] Insert error:', insertError);
  1477→        return { success: false, error: insertError.message };
  1478→      }
  1479→
  1480→      console.log(`[SaveIntel] Created intel for lead ${leadId}`);
  1481→    }
  1482→
  1483→    // Also update lead metadata with key business signals
  1484→    if (business.hiring_needs || business.pain_points?.length > 0) {
  1485→      const { data: lead } = await supabase
  1486→        .from('leads')
  1487→        .select('metadata')
  1488→        .eq('id', leadId)
  1489→        .single();
  1490→
  1491→      const metadata = lead?.metadata || {};
  1492→      metadata.business_intel = {
  1493→        has_pain_points: (business.pain_points || []).length > 0,
  1494→        is_hiring: !!business.hiring_needs?.looking_for,
  1495→        decision_timeline: business.decision_timeline,
  1496→        last_intel_update: new Date().toISOString(),
  1497→      };
  1498→
  1499→      await supabase
  1500→        .from('leads')
  1501→        .update({ metadata })
  1502→        .eq('id', leadId);
  1503→    }
  1504→
  1505→    return { success: true, saved: true };
  1506→
  1507→  } catch (error) {
  1508→    console.error('[SaveIntel] Error:', error);
  1509→    return { success: false, error: error.message };
  1510→  }
  1511→}
  1512→
  1513→/**
  1514→ * Extract and save intel from a call transcript
  1515→ * Called automatically after calls end
  1516→ * @param {string} leadId - Lead ID
  1517→ * @returns {Promise<object>} Extraction + save result
  1518→ */
  1519→async function extractAndSaveCallIntel(leadId) {
  1520→  console.log(`[Intel] Extracting and saving intel for lead ${leadId}`);
  1521→
  1522→  // Extract intel from all conversations
  1523→  const extractResult = await extractPersonalIntelFromConversations(leadId);
  1524→
  1525→  if (!extractResult.success || !extractResult.intel) {
  1526→    console.log(`[Intel] No intel extracted for lead ${leadId}:`, extractResult.reason || extractResult.error);
  1527→    return { success: false, reason: extractResult.reason || extractResult.error };
  1528→  }
  1529→
  1530→  // Save to database
  1531→  const saveResult = await saveExtractedIntel(leadId, extractResult.intel);
  1532→
  1533→  return {
  1534→    success: saveResult.success,
  1535→    extracted: extractResult.intel,
  1536→    saved: saveResult.saved,
  1537→  };
  1538→}
  1539→
  1540→/**
  1541→ * Parse contact info (name + phone) from SMS reply
  1542→ * Used for worker referral contact extraction
  1543→ */
  1544→async function parseContactFromSMS(message) {
  1545→  if (!openai) {
  1546→    return { success: false, error: 'AI features not configured' };
  1547→  }
  1548→
  1549→  // Quick regex check for phone number first
  1550→  const phonePatterns = [
  1551→    /\b0\d{3}[\s.-]?\d{3}[\s.-]?\d{3}\b/,  // 0412 345 678 or 0412-345-678
  1552→    /\b\+61\s?\d{3}[\s.-]?\d{3}[\s.-]?\d{3}\b/, // +61 412 345 678
  1553→    /\b\d{10}\b/, // 0412345678
  1554→  ];
  1555→
  1556→  let hasPhoneLike = phonePatterns.some(p => p.test(message));
  1557→
  1558→  // If no phone pattern found, likely not a contact reply
  1559→  if (!hasPhoneLike) {
  1560→    return { success: true, hasContact: false };
  1561→  }
  1562→
  1563→  const prompt = `Extract contact info from this SMS message. The user is providing their hiring manager/boss's contact details.
  1564→
  1565→MESSAGE:
  1566→"${message}"
  1567→
  1568→Return JSON:
  1569→{
  1570→  "hasContact": true/false,
  1571→  "name": "Full name (first and last if provided)",
  1572→  "first_name": "First name",
  1573→  "last_name": "Last name (or empty)",
  1574→  "phone": "Phone number in format 0412345678 (just digits)",
  1575→  "job_title": "Job title if mentioned (e.g., 'hiring manager', 'operations manager', 'boss')",
  1576→  "company": "Company if mentioned",
  1577→  "confidence": 0.0-1.0 confidence score
  1578→}
  1579→
  1580→If no valid contact info found, return: { "hasContact": false }
  1581→Normalize Australian phone numbers to 10 digits (0412345678 format).
  1582→Return ONLY valid JSON.`;
  1583→
  1584→  try {
  1585→    const response = await openai.chat.completions.create({
  1586→      model: MODELS.SIMPLE,
  1587→      messages: [{ role: 'user', content: prompt }],
  1588→      response_format: { type: 'json_object' },
  1589→      temperature: 0.1,
  1590→    });
  1591→
  1592→    const result = JSON.parse(response.choices[0].message.content);
  1593→    return { success: true, ...result };
  1594→  } catch (error) {
  1595→    console.error('Parse contact from SMS error:', error);
  1596→    return { success: false, error: error.message };
  1597→  }
  1598→}
  1599→
  1600→/**
  1601→ * Generate AI insight for re-engaged lead
  1602→ * Analyzes why a lead might be coming back and suggests talking points
  1603→ */
  1604→async function generateReEngagedInsight(lead, previousHistory = []) {
  1605→  if (!openai) {
  1606→    return null; // Return null if AI not configured
  1607→  }
  1608→
  1609→  // Build history summary
  1610→  let historySummary = 'No previous history';
  1611→  if (previousHistory && previousHistory.length > 0) {
  1612→    historySummary = previousHistory.slice(0, 5).map(h => {
  1613→      const date = new Date(h.created_at).toLocaleDateString('en-AU');
  1614→      const type = h.type?.replace('_', ' ') || 'unknown';
  1615→      const snippet = h.content?.substring(0, 80) || '';
  1616→      return `- ${date} [${type}]: ${snippet}${snippet.length >= 80 ? '...' : ''}`;
  1617→    }).join('\n');
  1618→  }
  1619→
  1620→  // Calculate time since last contact
  1621→  const lastContactDate = lead.last_contact_at ? new Date(lead.last_contact_at) : null;
  1622→  const daysSinceContact = lastContactDate
  1623→    ? Math.floor((Date.now() - lastContactDate.getTime()) / (1000 * 60 * 60 * 24))
  1624→    : null;
  1625→
  1626→  const prompt = `A lead just signed up on our website AGAIN. They were already in our CRM. Analyze why they might be coming back and suggest how to approach them.
  1627→
  1628→LEAD INFO:
  1629→- Name: ${lead.first_name || ''} ${lead.last_name || ''}
  1630→- Company: ${lead.company || 'Unknown'}
  1631→- Previous Status: ${lead.status || 'unknown'}
  1632→- Previous Score: ${lead.score || 0}
  1633→- Days Since Last Contact: ${daysSinceContact !== null ? daysSinceContact : 'Unknown'}
  1634→
  1635→PREVIOUS COMMUNICATIONS:
  1636→${historySummary}
  1637→
  1638→CONTEXT:
  1639→- RateRight is a worker marketplace (9.9% fee vs 30-40% agency margins)
  1640→- They're signing up again = high intent signal
  1641→- This is a hot opportunity
  1642→
  1643→Generate ONE short insight (1-2 sentences) about why they might be back and how to approach them.
  1644→Be specific and actionable. This will be shown to the sales rep as they call.
  1645→
  1646→Examples of good insights:
  1647→- "Back after 3 months - previously objected on price. Market rates have increased, perfect time to revisit the savings pitch."
  1648→- "Re-engaged after missing our calls last week. They're ready now - don't let them slip away!"
  1649→- "Signed up again 2 days after our follow-up SMS. The message landed - they're interested."
  1650→
  1651→Respond with just the insight text (no JSON, no quotes, just the insight).`;
  1652→
  1653→  try {
  1654→    const response = await openai.chat.completions.create({
  1655→      model: MODELS.SIMPLE,
  1656→      messages: [{ role: 'user', content: prompt }],
  1657→      temperature: 0.7,
  1658→      max_tokens: 100,
  1659→    });
  1660→
  1661→    const insight = response.choices[0].message.content.trim();
  1662→    return insight;
  1663→  } catch (error) {
  1664→    console.error('Re-engaged insight generation error:', error);
  1665→    return null;
  1666→  }
  1667→}
  1668→
  1669→/**
  1670→ * Extract lead corrections/updates from call transcript
  1671→ * Detects when the person corrects their name, gives a new number, etc.
  1672→ */
  1673→async function extractLeadCorrections(transcript, currentLead) {
  1674→  if (!openai || !transcript || transcript.length < 20) {
  1675→    return { success: true, corrections: [] };
  1676→  }
  1677→
  1678→  const prompt = `Analyze this call transcript and identify any CORRECTIONS or UPDATES to the person's contact information.
  1679→
  1680→CURRENT LEAD INFO:
  1681→- Name: ${currentLead.first_name || ''} ${currentLead.last_name || ''}
  1682→- Company: ${currentLead.company || 'Unknown'}
  1683→- Phone: ${currentLead.phone || 'Unknown'}
  1684→- Email: ${currentLead.email || 'Unknown'}
  1685→
  1686→TRANSCRIPT:
  1687→"${transcript}"
  1688→
  1689→Look for:
  1690→1. Name corrections: "Actually my name is...", "It's John, not James", "Call me..."
  1691→2. New phone numbers: "My new number is...", "You can reach me at...", "My mobile is..."
  1692→3. Email addresses: "My email is...", "Send it to..."
  1693→4. Company changes: "I work for ... now", "I'm at ... company", "I moved to..."
  1694→5. Job title mentions: "I'm the site manager", "I'm a foreman", "I'm the hiring manager"
  1695→
  1696→Return JSON:
  1697→{
  1698→  "corrections": [
  1699→    {
  1700→      "field": "first_name|last_name|phone|email|company|job_title",
  1701→      "old_value": "Current value (or null if not set)",
  1702→      "new_value": "What they said",
  1703→      "confidence": 0.0-1.0,
  1704→      "quote": "The exact phrase they said"
  1705→    }
  1706→  ]
  1707→}
  1708→
  1709→IMPORTANT:
  1710→- Only include CLEAR corrections/updates, not assumptions
  1711→- Phone numbers should be in Australian format (0412 xxx xxx or +614...)
  1712→- Be confident - only include if they clearly stated the info
  1713→- Return empty corrections array if nothing was mentioned
  1714→
  1715→Return ONLY valid JSON.`;
  1716→
  1717→  try {
  1718→    const response = await openai.chat.completions.create({
  1719→      model: MODELS.SIMPLE,
  1720→      messages: [{ role: 'user', content: prompt }],
  1721→      response_format: { type: 'json_object' },
  1722→      temperature: 0.2,
  1723→    });
  1724→
  1725→    const result = JSON.parse(response.choices[0].message.content);
  1726→    return { success: true, corrections: result.corrections || [] };
  1727→  } catch (error) {
  1728→    console.error('Lead correction extraction error:', error);
  1729→    return { success: false, error: error.message, corrections: [] };
  1730→  }
  1731→}
  1732→
  1733→/**
  1734→ * AI-Powered Script Generation
  1735→ * Analyzes lead context and GENERATES a personalized script (not just picks from library)
  1736→ */
  1737→async function recommendScriptForLead(leadId) {
  1738→  if (!openai) {
  1739→    return { success: false, error: 'AI features not configured' };
  1740→  }
  1741→
  1742→  if (!supabase) {
  1743→    return { success: false, error: 'Database not configured' };
  1744→  }
  1745→
  1746→  try {
  1747→    // 1. Get lead with full context
  1748→    const { data: lead, error: leadError } = await supabase
  1749→      .from('leads')
  1750→      .select('*')
  1751→      .eq('id', leadId)
  1752→      .single();
  1753→
  1754→    if (leadError || !lead) {
  1755→      return { success: false, error: 'Lead not found' };
  1756→    }
  1757→
  1758→    // 2. Get recent communications
  1759→    const { data: comms } = await supabase
  1760→      .from('communications')
  1761→      .select('direction, content, created_at, sentiment')
  1762→      .eq('lead_id', leadId)
  1763→      .order('created_at', { ascending: false })
  1764→      .limit(10);
  1765→
  1766→    // 3. Get previous objections from copilot sessions
  1767→    const { data: sessions } = await supabase
  1768→      .from('copilot_sessions')
  1769→      .select('objections_detected, outcome')
  1770→      .eq('lead_id', leadId)
  1771→      .order('created_at', { ascending: false })
  1772→      .limit(5);
  1773→
  1774→    const previousObjections = [];
  1775→    (sessions || []).forEach(s => {
  1776→      if (s.objections_detected && Array.isArray(s.objections_detected)) {
  1777→        s.objections_detected.forEach(obj => {
  1778→          previousObjections.push({
  1779→            objection: obj.objection || obj,
  1780→            wasOvercome: s.outcome === 'positive' || s.outcome === 'callback'
  1781→          });
  1782→        });
  1783→      }
  1784→    });
  1785→
  1786→    // 4. Get similar wins
  1787→    const similarWinsResult = await findSimilarWins(lead);
  1788→    const similarWins = similarWinsResult?.similarWins || [];
  1789→
  1790→    // 5. Get example scripts for style reference
  1791→    const leadType = lead.lead_type || (lead.metadata?.lead_type) || 'both';
  1792→    const { data: exampleScripts } = await supabase
  1793→      .from('scripts')
  1794→      .select('name, content, opening, category')
  1795→      .eq('is_active', true)
  1796→      .limit(5);
  1797→
  1798→    // 6. Get company/person intel if available
  1799→    const { data: companyIntel } = await supabase
  1800→      .from('company_intel')
  1801→      .select('*')
  1802→      .eq('company_name', lead.company)
  1803→      .order('created_at', { ascending: false })
  1804→      .limit(1)
  1805→      .single();
  1806→
  1807→    // Calculate context
  1808→    const daysSinceContact = lead.last_contact_at
  1809→      ? Math.floor((Date.now() - new Date(lead.last_contact_at).getTime()) / (1000 * 60 * 60 * 24))
  1810→      : 999;
  1811→
  1812→    // 7. AI GENERATES personalized script
  1813→    const prompt = `You are an elite sales coach for RateRight, Australia's #1 tradie marketplace.
  1814→
  1815→Your job: Write a PERSONALIZED call script for this specific lead. Not a template - a script tailored to THIS person based on everything we know about them.
  1816→
  1817→═══════════════════════════════════════════════════════════════
  1818→ABOUT RATERIGHT (use this in your pitch):
  1819→- Marketplace connecting contractors with workers DIRECTLY (not an agency)
  1820→- Contractors: FREE to use, post jobs and get matched
  1821→- Workers: 9.9% platform fee (vs 30-40% agency fees)
  1822→- Rating system shows worker reliability before booking
  1823→- Same-day matching for urgent jobs
  1824→═══════════════════════════════════════════════════════════════
  1825→
  1826→LEAD PROFILE:
  1827→- Name: ${lead.first_name} ${lead.last_name || ''}
  1828→- Company: ${lead.company || 'Unknown'}
  1829→- Type: ${leadType} (${leadType === 'worker' ? 'looking for work' : leadType === 'contractor' ? 'hiring workers' : 'unknown'})
  1830→- Trade: ${lead.metadata?.trade || lead.trade || 'Unknown trade'}
  1831→- Location: ${lead.metadata?.region || lead.region || 'Unknown'}
  1832→- Score: ${lead.score || 0}/100 (${lead.score >= 70 ? 'HOT' : lead.score >= 50 ? 'WARM' : 'COOL'})
  1833→- Status: ${lead.status || 'new'}
  1834→- Days since last contact: ${daysSinceContact}
  1835→- Source: ${lead.source || 'Unknown'}
  1836→
  1837→COMMUNICATION HISTORY:
  1838→${(comms || []).slice(0, 5).map(c => `[${c.direction}] "${(c.content || '').substring(0, 100)}"`).join('\n') || 'No previous communications - this is first contact'}
  1839→
  1840→${previousObjections.length > 0 ? `
  1841→OBJECTIONS THEY'VE RAISED BEFORE:
  1842→${previousObjections.map(o => `- "${o.objection}" ${o.wasOvercome ? '(we overcame this)' : '(still a concern)'}`).join('\n')}
  1843→` : ''}
  1844→
  1845→${(similarWins || []).length > 0 ? `
  1846→WHAT WORKED ON SIMILAR LEADS:
  1847→${similarWins.slice(0, 3).map(w => `- ${w.what_worked}`).join('\n')}
  1848→` : ''}
  1849→
  1850→${companyIntel ? `
  1851→COMPANY INTEL:
  1852→- Industry: ${companyIntel.industry || 'Unknown'}
  1853→- Size: ${companyIntel.size || 'Unknown'}
  1854→- Sales Angle: ${companyIntel.sales_angle || 'None identified'}
  1855→` : ''}
  1856→
  1857→EXAMPLE SCRIPTS FOR TONE/STYLE REFERENCE:
  1858→${(exampleScripts || []).slice(0, 2).map(s => `"${s.content?.substring(0, 150) || s.opening?.substring(0, 150) || ''}..."`).join('\n')}
  1859→
  1860→═══════════════════════════════════════════════════════════════
  1861→NOW GENERATE A PERSONALIZED SCRIPT FOR ${lead.first_name}:
  1862→═══════════════════════════════════════════════════════════════
  1863→
  1864→Respond in JSON:
  1865→{
  1866→  "script": {
  1867→    "name": "Personalized script for ${lead.first_name}",
  1868→    "opening": "The exact words to say when they pick up (personalized to them)",
  1869→    "discovery_questions": [
  1870→      {"question": "Question tailored to their situation", "why": "Why ask this"},
  1871→      {"question": "Another relevant question", "why": "Why ask this"}
  1872→    ],
  1873→    "value_props": [
  1874→      {"prop": "Value prop relevant to THEIR situation", "whenToUse": "When to mention this"},
  1875→      {"prop": "Another relevant value prop", "whenToUse": "When to mention this"}
  1876→    ],
  1877→    "objection_handlers": [
  1878→      {"objection": "Likely objection based on their history", "response": "How to handle it"}
  1879→    ],
  1880→    "closing_techniques": [
  1881→      {"name": "Primary close", "script": "Exact words to close the call"},
  1882→      {"name": "Fallback close", "script": "If primary doesn't work"}
  1883→    ]
  1884→  },
  1885→  "reason": "Why this script will work for this specific lead",
  1886→  "confidence": 0.0 to 1.0,
  1887→  "callStrategy": {
  1888→    "approach": "consultative/direct/relationship/urgent",
  1889→    "timeEstimate": "5-7 min",
  1890→    "primaryGoal": "Main objective for this call",
  1891→    "fallbackGoal": "If primary fails, aim for this",
  1892→    "keyInsight": "The one thing that will make or break this call"
  1893→  }
  1894→}`;
  1895→
  1896→    console.log('Generating personalized script for lead:', leadId);
  1897→
  1898→    const response = await openai.chat.completions.create({
  1899→      model: MODELS.REASONING, // Use GPT-4o for better quality
  1900→      messages: [{ role: 'user', content: prompt }],
  1901→      response_format: { type: 'json_object' },
  1902→      temperature: 0.7,
  1903→    });
  1904→
  1905→    const result = JSON.parse(response.choices[0].message.content);
  1906→
  1907→    console.log('Script generated successfully for:', lead.first_name);
  1908→
  1909→    return {
  1910→      success: true,
  1911→      recommendation: {
  1912→        script: result.script,
  1913→        reason: result.reason,
  1914→        confidence: result.confidence || 0.8,
  1915→        callStrategy: result.callStrategy || {},
  1916→        isPersonalized: true
  1917→      }
  1918→    };
  1919→
  1920→  } catch (error) {
  1921→    console.error('Script generation error:', error.message);
  1922→    console.error('Script generation stack:', error.stack);
  1923→    return { success: false, error: `Script generation failed: ${error.message}` };
  1924→  }
  1925→}
  1926→
  1927→/**
  1928→ * Quick intel extraction from a single message
  1929→ * Lightweight - runs on every inbound SMS/voicemail
  1930→ * @param {string} content - Message text or transcript
  1931→ * @param {string} source - 'sms', 'voicemail', 'inbound_call'
  1932→ * @returns {Promise<object>} Quick intel extraction result
  1933→ */
  1934→async function extractQuickIntel(content, source) {
  1935→  if (!openai) {
  1936→    return { success: false, error: 'AI not configured' };
  1937→  }
  1938→
  1939→  // Skip very short messages
  1940→  if (!content || content.length < 15) {
  1941→    return { success: true, hasSignificantIntel: false, reason: 'Message too short' };
  1942→  }
  1943→
  1944→  try {
  1945→    const response = await openai.chat.completions.create({
  1946→      model: MODELS.SIMPLE, // GPT-4o-mini for speed/cost
  1947→      messages: [{
  1948→        role: 'system',
  1949→        content: `Extract key intel from this ${source} message. Be concise and only include what's explicitly mentioned.
  1950→
  1951→Return JSON:
  1952→{
  1953→  "locations": ["suburb or area mentioned"],
  1954→  "headcount": "number of workers needed if mentioned, or null",
  1955→  "timeline": "when they need it, or null",
  1956→  "trade": "trade type if mentioned (formwork, electrical, etc), or null",
  1957→  "objection": "any concern or pushback mentioned, or null",
  1958→  "question": "any question they asked, or null",
  1959→  "decisionMaker": "other person mentioned who influences decision (partner, boss, accountant), or null",
  1960→  "companyMentioned": "company name if mentioned, or null",
  1961→  "projectMentioned": "project or site name if mentioned, or null",
  1962→  "callbackRequested": true/false,
  1963→  "urgency": "high/medium/low/none",
  1964→  "personalIntel": {
  1965→    "family": "kids mentioned, partner mentioned, etc. or null",
  1966→    "hobby": "any hobby or interest mentioned (fishing, footy, golf, etc.) or null",
  1967→    "event": "event they're at or going to (soccer game, camping, wedding, etc.) or null",
  1968→    "conversationHook": "brief follow-up question for next call (e.g. 'Ask about the soccer game') or null"
  1969→  },
  1970→  "hasSignificantIntel": true if any meaningful business OR personal intel found, false otherwise
  1971→}`
  1972→      }, {
  1973→        role: 'user',
  1974→        content: content
  1975→      }],
  1976→      response_format: { type: 'json_object' },
  1977→      max_tokens: 400,
  1978→      temperature: 0.1,
  1979→    });
  1980→
  1981→    const result = JSON.parse(response.choices[0].message.content);
  1982→    return { success: true, ...result };
  1983→
  1984→  } catch (error) {
  1985→    console.error('[QuickIntel] Extraction error:', error.message);
  1986→    return { success: false, error: error.message, hasSignificantIntel: false };
  1987→  }
  1988→}
  1989→
  1990→/**
  1991→ * Extract intel from any inbound communication
  1992→ * Runs async after webhook responds - doesn't slow down Twilio
  1993→ * @param {string} leadId - Lead UUID
  1994→ * @param {string} content - Message text or transcript
  1995→ * @param {string} source - 'sms', 'voicemail', 'inbound_call'
  1996→ * @returns {Promise<object>} Extraction result
  1997→ */
  1998→async function extractInboundIntel(leadId, content, source) {
  1999→  console.log(`[InboundIntel] Processing ${source} for lead ${leadId}`);
  2000→
  2001→  // Skip if content too short
  2002→  if (!content || content.length < 15) {
  2003→    console.log(`[InboundIntel] Skipping - content too short`);
  2004→    return { success: false, reason: 'Content too short' };
  2005→  }
  2006→
  2007→  try {
  2008→    // Step 1: Quick extraction from this specific message
  2009→    const quickIntel = await extractQuickIntel(content, source);
  2010→
  2011→    if (!quickIntel.success) {
  2012→      console.log(`[InboundIntel] Quick extraction failed:`, quickIntel.error);
  2013→      return { success: false, error: quickIntel.error };
  2014→    }
  2015→
  2016→    console.log(`[InboundIntel] Quick intel:`, JSON.stringify(quickIntel, null, 2));
  2017→
  2018→    // Step 2: Save quick intel to communication metadata
  2019→    if (supabase) {
  2020→      // Find the most recent communication for this lead to update
  2021→      const { data: recentComm } = await supabase
  2022→        .from('communications')
  2023→        .select('id, metadata')
  2024→        .eq('lead_id', leadId)
  2025→        .order('created_at', { ascending: false })
  2026→        .limit(1)
  2027→        .single();
  2028→
  2029→      if (recentComm) {
  2030→        const updatedMetadata = {
  2031→          ...(recentComm.metadata || {}),
  2032→          extracted_intel: quickIntel
  2033→        };
  2034→        await supabase
  2035→          .from('communications')
  2036→          .update({ metadata: updatedMetadata })
  2037→          .eq('id', recentComm.id);
  2038→      }
  2039→    }
  2040→
  2041→    // Step 3: If significant intel found, trigger full extraction
  2042→    if (quickIntel.hasSignificantIntel) {
  2043→      console.log(`[InboundIntel] Significant intel found - triggering full extraction`);
  2044→
  2045→      // Full extraction analyzes ALL communications for comprehensive intel
  2046→      const fullResult = await extractAndSaveCallIntel(leadId);
  2047→
  2048→      // Update lead metadata with key signals
  2049→      const hasUrgentNeed = quickIntel.urgency === 'high';
  2050→      const hasPersonalIntel = quickIntel.personalIntel?.family ||
  2051→                               quickIntel.personalIntel?.hobby ||
  2052→                               quickIntel.personalIntel?.event;
  2053→
  2054→      if (supabase && (hasUrgentNeed || hasPersonalIntel)) {
  2055→        const { data: lead } = await supabase
  2056→          .from('leads')
  2057→          .select('metadata')
  2058→          .eq('id', leadId)
  2059→          .single();
  2060→
  2061→        const existingPersonalIntel = lead?.metadata?.quick_personal_intel || [];
  2062→        const newPersonalIntel = hasPersonalIntel ? [{
  2063→          captured_at: new Date().toISOString(),
  2064→          source,
  2065→          ...quickIntel.personalIntel
  2066→        }] : [];
  2067→
  2068→        const updatedLeadMetadata = {
  2069→          ...(lead?.metadata || {}),
  2070→          last_intel_source: source,
  2071→          last_intel_date: new Date().toISOString(),
  2072→          has_urgent_need: hasUrgentNeed ? true : lead?.metadata?.has_urgent_need,
  2073→          // Store personal intel for surfacing on next call
  2074→          quick_personal_intel: [...newPersonalIntel, ...existingPersonalIntel].slice(0, 5)
  2075→        };
  2076→        await supabase
  2077→          .from('leads')
  2078→          .update({ metadata: updatedLeadMetadata })
  2079→          .eq('id', leadId);
  2080→
  2081→        if (hasPersonalIntel) {
  2082→          console.log(`[InboundIntel] Personal intel saved: ${JSON.stringify(quickIntel.personalIntel)}`);
  2083→        }
  2084→      }
  2085→
  2086→      return {
  2087→        success: true,
  2088→        quickIntel,
  2089→        fullExtraction: fullResult,
  2090→        triggeredFullExtraction: true
  2091→      };
  2092→    }
  2093→
  2094→    // No significant intel - just return quick results
  2095→    return {
  2096→      success: true,
  2097→      quickIntel,
  2098→      triggeredFullExtraction: false
  2099→    };
  2100→
  2101→  } catch (error) {
  2102→    console.error('[InboundIntel] Error:', error.message);
  2103→    return { success: false, error: error.message };
  2104→  }
  2105→}
  2106→
  2107→/**
  2108→ * Find similar leads for batch messaging
  2109→ * Matches by lead_type, trade, status, and waiting status
  2110→ */
  2111→async function findSimilarLeads(lead, limit = 5) {
  2112→  try {
  2113→    const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
  2114→
  2115→    // Build query for similar leads
  2116→    let query = supabase
  2117→      .from('leads')
  2118→      .select(`
  2119→        id,
  2120→        first_name,
  2121→        last_name,
  2122→        company,
  2123→        phone,
  2124→        score,
  2125→        status,
  2126→        lead_type,
  2127→        trade,
  2128→        last_contact_at,
  2129→        metadata
  2130→      `)
  2131→      .neq('id', lead.id) // Exclude the original lead
  2132→      .is('deleted_at', null) // Not deleted
  2133→      .in('status', ['active', 'contacted', 'new']) // Active leads
  2134→      .not('phone', 'is', null); // Must have phone
  2135→
  2136→    // Match lead type if available
  2137→    if (lead.lead_type) {
  2138→      query = query.eq('lead_type', lead.lead_type);
  2139→    }
  2140→
  2141→    // Match trade if available (partial match)
  2142→    if (lead.trade) {
  2143→      query = query.ilike('trade', `%${lead.trade}%`);
  2144→    }
  2145→
  2146→    // Get leads where last contact was > 24 hours ago (waiting on them)
  2147→    query = query.lt('last_contact_at', oneDayAgo);
  2148→
  2149→    // Order by score (highest first), then by last contact (oldest first)
  2150→    query = query
  2151→      .order('score', { ascending: false })
  2152→      .order('last_contact_at', { ascending: true })
  2153→      .limit(limit * 2); // Get extra to filter
  2154→
  2155→    const { data: leads, error } = await query;
  2156→
  2157→    if (error) {
  2158→      console.error('[FindSimilar] Query error:', error.message);
  2159→      return [];
  2160→    }
  2161→
  2162→    if (!leads || leads.length === 0) {
  2163→      return [];
  2164→    }
  2165→
  2166→    // Filter to leads where WE sent the last message (waiting on them to reply)
  2167→    const leadsWithLastMessage = await Promise.all(
  2168→      leads.map(async (l) => {
  2169→        const { data: lastComm } = await supabase
  2170→          .from('communications')
  2171→          .select('direction, created_at')
  2172→          .eq('lead_id', l.id)
  2173→          .order('created_at', { ascending: false })
  2174→          .limit(1)
  2175→          .single();
  2176→
  2177→        // Only include if last message was outbound (we're waiting on them)
  2178→        if (lastComm?.direction === 'outbound') {
  2179→          const daysSinceContact = l.last_contact_at
  2180→            ? Math.floor((Date.now() - new Date(l.last_contact_at)) / (1000 * 60 * 60 * 24))
  2181→            : 999;
  2182→
  2183→          return {
  2184→            ...l,
  2185→            name: `${l.first_name || ''} ${l.last_name || ''}`.trim() || 'Unknown',
  2186→            daysSinceContact,
  2187→            lastMessageDirection: 'outbound'
  2188→          };
  2189→        }
  2190→        return null;
  2191→      })
  2192→    );
  2193→
  2194→    // Filter out nulls and limit
  2195→    return leadsWithLastMessage.filter(Boolean).slice(0, limit);
  2196→
  2197→  } catch (error) {
  2198→    console.error('[FindSimilar] Error:', error.message);
  2199→    return [];
  2200→  }
  2201→}
  2202→
  2203→/**
  2204→ * Generate personalized messages for a batch of similar leads
  2205→ */
  2206→async function generatePersonalizedBatch(leads, originalPrompt, originalMessage, tone = 'friendly') {
  2207→  if (!openai) {
  2208→    // Fallback: simple personalization without AI
  2209→    return leads.map(lead => ({
  2210→      ...lead,
  2211→      personalizedMessage: originalMessage
  2212→        .replace(/Hey \w+/i, `Hey ${lead.first_name || 'there'}`)
  2213→        .replace(/Hi \w+/i, `Hi ${lead.first_name || 'there'}`)
  2214→    }));
  2215→  }
  2216→
  2217→  try {
  2218→    const prompt = `You are an SMS personalization expert for RateRight (Australia's worker marketplace).
  2219→
  2220→TASK: Create personalized variations of a message for different leads. Each message should feel natural and personal, not templated.
  2221→
  2222→ORIGINAL MESSAGE (sent to first lead):
  2223→"${originalMessage}"
  2224→
  2225→ORIGINAL INTENT: ${originalPrompt}
  2226→TONE: ${tone}
  2227→
  2228→CREATE PERSONALIZED VERSIONS FOR THESE LEADS:
  2229→${leads.map((l, i) => `
  2230→${i + 1}. ${l.name}
  2231→   - Company: ${l.company || 'Unknown'}
  2232→   - Trade: ${l.trade || 'Unknown'}
  2233→   - Score: ${l.score || 0}
  2234→   - Days waiting: ${l.daysSinceContact}
  2235→`).join('\n')}
  2236→
  2237→Return a JSON array with personalized messages:
  2238→{
  2239→  "messages": [
  2240→    {"leadId": "id1", "message": "Personalized message for lead 1"},
  2241→    {"leadId": "id2", "message": "Personalized message for lead 2"}
  2242→  ]
  2243→}
  2244→
  2245→RULES:
  2246→- Keep same length/style as original
  2247→- Use their first name naturally
  2248→- Reference their trade/company if relevant
  2249→- Don't be repetitive - vary the phrasing
  2250→- Australian casual tone (no "Hey y'all")
  2251→- Max 160 characters each (SMS limit)`;
  2252→
  2253→    const response = await openai.chat.completions.create({
  2254→      model: MODELS.SIMPLE,
  2255→      messages: [{ role: 'user', content: prompt }],
  2256→      response_format: { type: 'json_object' },
  2257→      temperature: 0.8, // Higher for more variation
  2258→    });
  2259→
  2260→    const result = JSON.parse(response.choices[0].message.content);
  2261→
  2262→    // Merge personalized messages with lead data
  2263→    return leads.map(lead => {
  2264→      const msgData = result.messages?.find(m => m.leadId === lead.id);
  2265→      return {
  2266→        ...lead,
  2267→        personalizedMessage: msgData?.message || originalMessage.replace(/Hey \w+/i, `Hey ${lead.first_name || 'there'}`)
  2268→      };
  2269→    });
  2270→
  2271→  } catch (error) {
  2272→    console.error('[PersonalizedBatch] Error:', error.message);
  2273→    // Fallback: simple name replacement
  2274→    return leads.map(lead => ({
  2275→      ...lead,
  2276→      personalizedMessage: originalMessage
  2277→        .replace(/Hey \w+/i, `Hey ${lead.first_name || 'there'}`)
  2278→        .replace(/Hi \w+/i, `Hi ${lead.first_name || 'there'}`)
  2279→    }));
  2280→  }
  2281→}
  2282→
  2283→module.exports = {
  2284→  generateIntelBrief,
  2285→  generatePostCallSummary,
  2286→  draftMessage,
  2287→  transcribeAudio,
  2288→  parseVoiceCommand,
  2289→  getObjectionResponse,
  2290→  calculateConversionProbability,
  2291→  generateWeeklyInsights,
  2292→  cleanupNotes,
  2293→  findSimilarWins,
  2294→  extractTranscriptIntel,
  2295→  extractPersonalIntelFromConversations,
  2296→  saveExtractedIntel,
  2297→  extractAndSaveCallIntel,
  2298→  parseContactFromSMS,
  2299→  generateReEngagedInsight,
  2300→  extractLeadCorrections,
  2301→  recommendScriptForLead,
  2302→  extractQuickIntel,
  2303→  extractInboundIntel,
  2304→  findSimilarLeads,
  2305→  generatePersonalizedBatch,
  2306→};
  2307→

<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>