# Messages System Fix Plan - 0.1% UPGRADE

**Created:** Jan 18, 2026
**Status:** ✅ COMPLETE (Session 15)
**Actual Effort:** ~2 hours

---

## Overview

Complete overhaul of SMS inbound handling to 0.1% (best-in-class):

1. **Kill generic auto-reply** - Context-aware responses only
2. **Inbox badges** - Visual priority with smart sorting
3. **Intel extraction FIRST** - Extract before classify
4. **Lead Profile intel** - Show what was learned from messages

---

## NEW AUTO-REPLY LOGIC

```
Inbound SMS
    │
    ▼
extractInboundIntel() ◄── RUNS FIRST, extracts data
    │
    ├── locations, headcount, timelines
    ├── objections, questions, decision makers
    └── writes to lead record
    │
    ▼
Determine intent FROM extracted intel
    │
    ├─► HIGH INTENT (wants to buy/start)
    │   ├── NO auto-reply
    │   ├── Urgent Slack alert
    │   ├── In-app notification
    │   └── Badge: "🔥 HIGH INTENT" in inbox
    │
    ├─► MEDIUM INTENT (interested, has questions)
    │   ├── AI contextual reply (auto-send or queue)
    │   ├── Uses AIMessageWriter logic
    │   └── Badge: "⚡ NEEDS REPLY" in inbox
    │
    └─► LOW/UNCLEAR
        ├── NO auto-reply
        ├── Just log to communications
        └── No badge (low priority)
```

---

## Phase 1: Intel Extraction First (1.5 hours)

### 1.1 Move extractInboundIntel() to Run FIRST

**File:** `src/routes/webhooks.js`

**Current (WRONG):**
```javascript
// Line 321-326 - runs AFTER response, fire-and-forget
if (lead?.id && Body) {
  extractInboundIntel(lead.id, Body, 'sms').catch(err => {...});
}
```

**New (CORRECT):**
```javascript
// Run FIRST, before intent classification
let extractedIntel = null;
if (lead?.id && Body) {
  try {
    extractedIntel = await extractInboundIntel(lead.id, Body, 'sms');
    console.log('[InboundIntel] Extracted:', extractedIntel?.summary || 'none');
  } catch (err) {
    console.error('[InboundIntel] Extraction failed:', err.message);
  }
}

// NOW classify intent using extracted intel
const intentResult = classifyIntentFromIntel(Body, extractedIntel);
```

### 1.2 Create classifyIntentFromIntel()

**File:** `src/utils/intent.js`

**Add new function:**
```javascript
/**
 * Classify intent using both patterns AND extracted intel
 * Returns: { level: 'high'|'medium'|'low', reason: string, buyingSignal: object }
 */
function classifyIntentFromIntel(message, extractedIntel) {
  const text = message.toLowerCase();

  // Check extracted intel first (more accurate)
  if (extractedIntel) {
    // High intent signals from intel
    if (extractedIntel.timeline && /asap|urgent|immediately|this week|tomorrow/i.test(extractedIntel.timeline)) {
      return { level: 'high', reason: 'urgent_timeline', buyingSignal: { level: 'high', score: 30 } };
    }
    if (extractedIntel.headcount && parseInt(extractedIntel.headcount) > 0) {
      return { level: 'high', reason: 'specific_headcount', buyingSignal: { level: 'high', score: 30 } };
    }
    if (extractedIntel.decision_maker) {
      return { level: 'medium', reason: 'decision_maker_mentioned', buyingSignal: { level: 'medium', score: 15 } };
    }
    if (extractedIntel.questions?.length > 0) {
      return { level: 'medium', reason: 'has_questions', buyingSignal: { level: 'medium', score: 15 } };
    }
  }

  // Fall back to pattern matching
  const patternResult = detectBuyingSignal(text);
  if (patternResult.level === 'high') {
    return { level: 'high', reason: 'pattern_match', buyingSignal: patternResult };
  }
  if (patternResult.level === 'medium') {
    return { level: 'medium', reason: 'pattern_match', buyingSignal: patternResult };
  }

  // Check for explicit positive responses
  if (/\b(yes|yep|yeah|sure|keen|interested|please|send)\b/i.test(text)) {
    return { level: 'medium', reason: 'positive_response', buyingSignal: { level: 'medium', score: 10 } };
  }

  // Default: low intent
  return { level: 'low', reason: 'no_signal', buyingSignal: { level: null, score: 0 } };
}
```

### 1.3 Update extractInboundIntel() to Return Data

**File:** `src/services/ai.js`

**Check/modify:** Ensure function returns extracted data, not just saves it.

```javascript
async function extractInboundIntel(leadId, message, source) {
  // ... existing extraction logic ...

  const extracted = {
    locations: [...],
    headcount: '...',
    timeline: '...',
    objections: [...],
    questions: [...],
    decision_maker: '...',
    summary: '...',
  };

  // Save to lead record
  await updateLeadWithIntel(leadId, extracted);

  // RETURN the data for intent classification
  return extracted;
}
```

---

## Phase 2: Kill Generic Auto-Reply (1 hour)

### 2.1 Remove Old Auto-Reply Logic

**File:** `src/routes/webhooks.js`

**Delete lines 290-312** (the entire auto-reply section)

### 2.2 Add New Intent-Based Response Logic

**File:** `src/routes/webhooks.js`

**Replace with:**
```javascript
// ========== INTENT-BASED RESPONSE ==========
const { level: intentLevel, reason: intentReason, buyingSignal } = intentResult;

console.log(`[Intent] Level: ${intentLevel}, Reason: ${intentReason}`);

// Store intent in communication metadata
if (communicationId && supabase) {
  await supabase
    .from('communications')
    .update({
      metadata: {
        ...existingMetadata,
        intent_level: intentLevel,
        intent_reason: intentReason,
        buying_signal: buyingSignal,
        extracted_intel: extractedIntel,
      }
    })
    .eq('id', communicationId);
}

// HIGH INTENT: Escalate immediately, no auto-reply
if (intentLevel === 'high') {
  console.log('[Response] HIGH INTENT - Escalating, no auto-reply');

  // Urgent Slack alert
  await sendUrgentBuyingSignalAlert(lead, Body, buyingSignal, normalizedPhone, extractedIntel);

  // In-app notification
  if (supabase) {
    await supabase.from('notifications').insert({
      type: 'buying_signal_high',
      title: '🔥 HIGH INTENT - CALL NOW',
      message: `${lead?.first_name || 'Lead'}: "${Body.substring(0, 60)}..."`,
      priority: 'urgent',
      metadata: {
        lead_id: lead?.id,
        phone: normalizedPhone,
        intent_level: intentLevel,
        intent_reason: intentReason,
        extracted_intel: extractedIntel,
      }
    });
  }

  // Update lead with high_intent flag
  if (lead?.id && supabase) {
    await supabase
      .from('leads')
      .update({
        metadata: {
          ...lead.metadata,
          last_high_intent_at: new Date().toISOString(),
          last_high_intent_message: Body.substring(0, 200),
        }
      })
      .eq('id', lead.id);
  }
}

// MEDIUM INTENT: AI contextual reply
else if (intentLevel === 'medium') {
  console.log('[Response] MEDIUM INTENT - Generating AI reply');

  try {
    const aiReply = await generateContextualReply(lead, Body, extractedIntel);

    if (aiReply) {
      // Auto-send if confidence is high, otherwise queue for approval
      if (aiReply.confidence >= 0.8) {
        const sendResult = await sendSMS(normalizedPhone || From, aiReply.message);

        if (sendResult.success && supabase) {
          await supabase.from('communications').insert({
            lead_id: lead?.id || null,
            type: 'sms_outbound',
            direction: 'outbound',
            phone: normalizedPhone || From,
            content: aiReply.message,
            external_id: sendResult.messageSid,
            metadata: {
              ai_generated: true,
              ai_confidence: aiReply.confidence,
              in_response_to: MessageSid,
              intent_level: intentLevel,
            }
          });
        }
        console.log('[Response] AI reply sent:', aiReply.message.substring(0, 50));
      } else {
        // Queue for human approval
        console.log('[Response] AI reply queued for approval (low confidence)');
        // Could add to a draft_messages table here
      }
    }
  } catch (err) {
    console.error('[Response] AI reply generation failed:', err.message);
  }

  // Still send Slack notification (not urgent)
  await sendInboundAlert(lead, Body, 'medium_intent', formatPhoneDisplay(normalizedPhone || From));
}

// LOW/UNCLEAR INTENT: Just log, no reply
else {
  console.log('[Response] LOW INTENT - No auto-reply, logging only');
  // Already logged to communications table above
}
```

### 2.3 Create generateContextualReply()

**File:** `src/services/ai.js`

**Add function:**
```javascript
/**
 * Generate contextual AI reply for medium-intent messages
 */
async function generateContextualReply(lead, inboundMessage, extractedIntel) {
  const leadName = lead?.first_name || 'there';
  const company = lead?.company || '';

  // Build context
  const context = [];
  if (extractedIntel?.questions?.length > 0) {
    context.push(`They asked: ${extractedIntel.questions.join(', ')}`);
  }
  if (extractedIntel?.timeline) {
    context.push(`Timeline mentioned: ${extractedIntel.timeline}`);
  }
  if (extractedIntel?.headcount) {
    context.push(`Headcount needed: ${extractedIntel.headcount}`);
  }

  const prompt = `Write a SHORT (under 160 chars) friendly SMS reply to this message from ${leadName}${company ? ` at ${company}` : ''}.

Their message: "${inboundMessage}"
${context.length > 0 ? `\nContext:\n${context.join('\n')}` : ''}

Rules:
- Be warm and helpful, not salesy
- If they asked a question, answer it briefly
- If they mentioned urgency, acknowledge it
- End with a clear next step (call offer)
- NO emojis, NO "Dear", NO formal greetings
- Sign off as "- RateRight Team"`;

  try {
    const response = await openai.chat.completions.create({
      model: 'gpt-4o-mini',
      messages: [{ role: 'user', content: prompt }],
      max_tokens: 100,
      temperature: 0.7,
    });

    const message = response.choices[0]?.message?.content?.trim();

    // Simple confidence heuristic
    const confidence = message && message.length < 200 && message.length > 20 ? 0.85 : 0.6;

    return { message, confidence };
  } catch (err) {
    console.error('[AI Reply] Generation failed:', err.message);
    return null;
  }
}
```

---

## Phase 3: Inbox Badges & Priority Sort (2 hours)

### 3.1 Add Intent Level to Conversations Query

**File:** `src/routes/sms.js`

**Modify GET /conversations:**
```javascript
// When fetching conversations, include latest message intent
const { data: conversations } = await supabase
  .from('communications')
  .select(`
    lead_id,
    leads!inner(id, first_name, last_name, company, score, metadata),
    content,
    direction,
    created_at,
    metadata
  `)
  .in('type', ['sms_inbound', 'sms_outbound'])
  .order('created_at', { ascending: false });

// Group by lead and add intent info
const grouped = conversations.reduce((acc, msg) => {
  const leadId = msg.lead_id;
  if (!acc[leadId]) {
    acc[leadId] = {
      lead: msg.leads,
      last_message: msg,
      intent_level: msg.metadata?.intent_level || null,
      needs_reply: msg.direction === 'inbound',
      last_inbound_at: msg.direction === 'inbound' ? msg.created_at : null,
    };
  }
  // Track if needs reply (last message was inbound)
  if (msg.direction === 'inbound' && !acc[leadId].last_inbound_at) {
    acc[leadId].needs_reply = true;
    acc[leadId].last_inbound_at = msg.created_at;
  }
  return acc;
}, {});
```

### 3.2 Add Priority Sorting

**File:** `src/routes/sms.js`

**Add sort logic:**
```javascript
// Sort by priority: high_intent > needs_reply > recency
const sorted = Object.values(grouped).sort((a, b) => {
  // High intent always first
  if (a.intent_level === 'high' && b.intent_level !== 'high') return -1;
  if (b.intent_level === 'high' && a.intent_level !== 'high') return 1;

  // Then medium intent
  if (a.intent_level === 'medium' && b.intent_level !== 'medium') return -1;
  if (b.intent_level === 'medium' && a.intent_level !== 'medium') return 1;

  // Then needs reply
  if (a.needs_reply && !b.needs_reply) return -1;
  if (b.needs_reply && !a.needs_reply) return 1;

  // Then recency
  return new Date(b.last_message.created_at) - new Date(a.last_message.created_at);
});
```

### 3.3 Frontend: Display Badges

**File:** `admin/src/pages/Messages.jsx`

**Modify ConversationCard or create new component:**
```jsx
function IntentBadge({ intentLevel, needsReply }) {
  if (intentLevel === 'high') {
    return (
      <span className="inline-flex items-center gap-1 px-2 py-0.5 bg-red-100 text-red-700 text-xs font-bold rounded-full animate-pulse">
        🔥 HIGH INTENT
      </span>
    );
  }
  if (intentLevel === 'medium' || needsReply) {
    return (
      <span className="inline-flex items-center gap-1 px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-bold rounded-full">
        ⚡ NEEDS REPLY
      </span>
    );
  }
  return null;
}
```

**Use in conversation list:**
```jsx
{conversations.map(convo => (
  <div key={convo.lead?.id} className="...">
    <div className="flex items-center gap-2">
      <span className="font-semibold">{convo.lead?.first_name}</span>
      <IntentBadge intentLevel={convo.intent_level} needsReply={convo.needs_reply} />
    </div>
    {/* ... rest of card */}
  </div>
))}
```

### 3.4 Add Filter for High Intent

**File:** `admin/src/pages/Messages.jsx`

**Add to FILTERS array:**
```javascript
const FILTERS = [
  { id: 'all', label: 'All', icon: MessageSquare },
  { id: 'high_intent', label: 'High Intent', icon: Flame, color: 'text-red-500' },  // NEW
  { id: 'needs_reply', label: 'Needs Reply', icon: Clock, color: 'text-amber-500' }, // NEW
  { id: 'hot', label: 'Hot Leads', icon: Flame },
  // ... existing filters
];
```

**Add filter logic:**
```javascript
const filteredConversations = conversations.filter(convo => {
  if (filter === 'high_intent') return convo.intent_level === 'high';
  if (filter === 'needs_reply') return convo.needs_reply;
  // ... existing logic
});
```

---

## Phase 4: Lead Profile Intel Section (1.5 hours)

### 4.1 Create RecentIntel Component

**File:** `admin/src/components/RecentIntel.jsx`

```jsx
import { useState, useEffect } from 'react';
import { Brain, MapPin, Users, Clock, HelpCircle, AlertTriangle, User, ChevronDown, ChevronUp, Sparkles } from 'lucide-react';
import { aiApi } from '../api/client';

const INTEL_ICONS = {
  locations: MapPin,
  headcount: Users,
  timeline: Clock,
  questions: HelpCircle,
  objections: AlertTriangle,
  decision_maker: User,
};

export default function RecentIntel({ leadId }) {
  const [intel, setIntel] = useState([]);
  const [loading, setLoading] = useState(true);
  const [expanded, setExpanded] = useState(false);

  useEffect(() => {
    if (leadId) loadIntel();
  }, [leadId]);

  const loadIntel = async () => {
    try {
      const data = await aiApi.getLeadIntel(leadId);
      setIntel(data.extracted_intel || []);
    } catch (err) {
      console.error('Failed to load intel:', err);
    } finally {
      setLoading(false);
    }
  };

  if (loading) {
    return (
      <div className="bg-slate-50 rounded-xl p-4 animate-pulse">
        <div className="h-4 bg-slate-200 rounded w-1/3 mb-2"></div>
        <div className="h-3 bg-slate-200 rounded w-2/3"></div>
      </div>
    );
  }

  if (intel.length === 0) {
    return null; // Don't show section if no intel
  }

  const displayIntel = expanded ? intel : intel.slice(0, 3);

  return (
    <div className="bg-gradient-to-br from-violet-50 to-purple-50 rounded-xl border border-violet-200 overflow-hidden">
      <div className="px-4 py-3 border-b border-violet-200 flex items-center justify-between">
        <div className="flex items-center gap-2">
          <Brain className="w-5 h-5 text-violet-600" />
          <h3 className="font-semibold text-violet-900">Recent Intel</h3>
          <span className="text-xs bg-violet-200 text-violet-700 px-2 py-0.5 rounded-full">
            {intel.length} extracted
          </span>
        </div>
        <Sparkles className="w-4 h-4 text-violet-400" />
      </div>

      <div className="p-4 space-y-3">
        {displayIntel.map((item, i) => {
          const Icon = INTEL_ICONS[item.type] || Brain;
          return (
            <div key={i} className="flex items-start gap-3">
              <div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center shadow-sm">
                <Icon className="w-4 h-4 text-violet-600" />
              </div>
              <div className="flex-1 min-w-0">
                <p className="text-sm font-medium text-slate-700 capitalize">{item.type.replace('_', ' ')}</p>
                <p className="text-sm text-slate-600">{item.value}</p>
                <p className="text-xs text-slate-400 mt-0.5">
                  From SMS • {new Date(item.extracted_at).toLocaleDateString()}
                </p>
              </div>
            </div>
          );
        })}

        {intel.length > 3 && (
          <button
            onClick={() => setExpanded(!expanded)}
            className="w-full flex items-center justify-center gap-1 text-sm text-violet-600 hover:text-violet-800 py-2"
          >
            {expanded ? (
              <>Show less <ChevronUp className="w-4 h-4" /></>
            ) : (
              <>Show {intel.length - 3} more <ChevronDown className="w-4 h-4" /></>
            )}
          </button>
        )}
      </div>
    </div>
  );
}
```

### 4.2 Add API Endpoint for Lead Intel

**File:** `src/routes/ai.js`

**Add endpoint:**
```javascript
/**
 * GET /api/ai/lead-intel/:leadId
 * Get extracted intel for a lead
 */
router.get('/lead-intel/:leadId', async (req, res) => {
  try {
    const { leadId } = req.params;

    // Get intel from communications metadata
    const { data: comms } = await supabase
      .from('communications')
      .select('metadata, created_at')
      .eq('lead_id', leadId)
      .not('metadata->extracted_intel', 'is', null)
      .order('created_at', { ascending: false })
      .limit(20);

    // Flatten extracted intel from all messages
    const allIntel = [];
    for (const comm of comms || []) {
      const intel = comm.metadata?.extracted_intel;
      if (intel) {
        for (const [type, value] of Object.entries(intel)) {
          if (value && type !== 'summary') {
            allIntel.push({
              type,
              value: Array.isArray(value) ? value.join(', ') : value,
              extracted_at: comm.created_at,
            });
          }
        }
      }
    }

    // Dedupe by type+value
    const unique = allIntel.filter((item, i, arr) =>
      arr.findIndex(x => x.type === item.type && x.value === item.value) === i
    );

    res.json({ extracted_intel: unique });
  } catch (error) {
    console.error('Failed to get lead intel:', error);
    res.status(500).json({ error: error.message });
  }
});
```

### 4.3 Add to Lead Profile Page

**File:** `admin/src/pages/LeadProfile.jsx`

**Add import:**
```javascript
import RecentIntel from '../components/RecentIntel';
```

**Add to layout (after IntelSummaryCard or similar):**
```jsx
{/* Recent Intel from Messages */}
<RecentIntel leadId={lead.id} />
```

---

## Phase 5: Build & Deploy (15 min)

### 5.1 Build Frontend
```bash
cd admin && npm run build
```

### 5.2 Copy to Public
```bash
cp -r admin/dist/* public/
```

### 5.3 Commit & Push
```bash
git add -A
git commit -m "0.1% Messages: Kill auto-reply, intel-first, smart badges, AI responses"
git push
```

---

## Testing Checklist

### Intel Extraction First
- [ ] Send SMS with headcount ("need 5 workers") → intel extracted BEFORE response
- [ ] Lead record updated with extracted headcount
- [ ] Communication metadata has extracted_intel

### High Intent Escalation
- [ ] Send "We need workers ASAP" → NO auto-reply
- [ ] Slack shows urgent alert with extracted intel
- [ ] In-app notification appears
- [ ] Inbox shows "🔥 HIGH INTENT" badge
- [ ] Conversation sorted to top

### Medium Intent AI Reply
- [ ] Send "How much does it cost?" → AI reply generated
- [ ] Reply is contextual (mentions pricing)
- [ ] Marked as ai_generated in metadata
- [ ] Inbox shows "⚡ NEEDS REPLY" if AI didn't send

### Low Intent No Reply
- [ ] Send "ok" → NO auto-reply
- [ ] Just logged to communications
- [ ] No badge in inbox

### Inbox Badges & Sorting
- [ ] High intent conversations at top
- [ ] Badge visible on conversation card
- [ ] Filter by "High Intent" works
- [ ] Filter by "Needs Reply" works

### Lead Profile Intel
- [ ] RecentIntel section visible on lead profile
- [ ] Shows extracted data (headcount, timeline, etc.)
- [ ] Expandable if more than 3 items

---

## Files Changed Summary

| File | Changes |
|------|---------|
| `src/routes/webhooks.js` | Intel first, new response logic, no generic auto-reply |
| `src/utils/intent.js` | Add classifyIntentFromIntel() |
| `src/services/ai.js` | Update extractInboundIntel(), add generateContextualReply() |
| `src/services/slack.js` | Update sendUrgentBuyingSignalAlert() with intel |
| `src/routes/sms.js` | Add intent to conversations, priority sorting |
| `src/routes/ai.js` | Add GET /lead-intel/:leadId endpoint |
| `admin/src/pages/Messages.jsx` | Add badges, filters, update conversation list |
| `admin/src/components/RecentIntel.jsx` | New component |
| `admin/src/pages/LeadProfile.jsx` | Add RecentIntel section |
| `admin/src/api/client.js` | Add getLeadIntel() |

---

## Success Criteria (0.1%)

1. ✅ ZERO generic auto-replies sent
2. ✅ High intent → Immediate escalation with extracted intel
3. ✅ Medium intent → AI contextual reply (not generic)
4. ✅ Low intent → Silent logging only
5. ✅ Inbox badges visible for high intent / needs reply
6. ✅ Conversations sorted by priority
7. ✅ Lead Profile shows extracted intel from messages
8. ✅ Intel extraction runs BEFORE response decision
