- Created chatwoot-agent-bot/ with Node.js webhook server - Bot detects intent (greeting, billing, technical, features, account) - Auto-responds from FAQ knowledge base or escalates to human - FAQ-KB.md: Living knowledge base that grows with customer questions - CHATWOOT-SETUP.md: Complete deployment and configuration guide - Supports Telegram notifications on escalation - Bot runs on port 3001, ready for Chatwoot webhook integration
305 lines
9.9 KiB
Python
305 lines
9.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Tier 1 Lead Scorer - Progressive Filtering
|
|
- Runs parallel to JAE v5
|
|
- Scores leads as they're processed
|
|
- Maintains dynamic top 50 list
|
|
- Updates in real-time
|
|
"""
|
|
import json, re, time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
SCRIPT_DIR = Path(__file__).parent
|
|
STATE_FILE = SCRIPT_DIR / "state" / "tier1-state.json"
|
|
SCORED_FILE = SCRIPT_DIR / "state" / "tier1-scored-leads.json"
|
|
CRM_URL = "https://salesforce.hoaledgeriq.com/rest"
|
|
CRM_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI5M2FmNGFmNS0zZWQ0LTQ1ZDMtOWE5Zi01MDMzZjc3YTY3MjMiLCJ0eXBlIjoiQVBJX0tFWSIsIndvcmtzcGFjZUlkIjoiOTNhZjRhZjUtM2VkNC00NWQzLTlhOWYtNTAzM2Y3N2E2NzIzIiwiaWF0IjoxNzczMzI4NDQzLCJleHAiOjE4MDQ3ODE2NDIsImp0aSI6IjIwZjEyYzkwLTRkMDctNGJmNi1iMzk3LTZjNmU3MzlmMThjOCJ9.zeM5NvwCSGEcz99m2LYtgb0sVD6WUXcCF7SwonFg930"
|
|
|
|
def log(msg):
|
|
ts = datetime.now().strftime('%H:%M:%S')
|
|
print(f"[{ts}] {msg}")
|
|
|
|
def load_state():
|
|
if STATE_FILE.exists():
|
|
return json.loads(STATE_FILE.read_text())
|
|
return {"last_processed": 0, "tier1_leads": [], "scoring_rules": {
|
|
"unit_range_ideal": [150, 400],
|
|
"unit_range_acceptable": [100, 500],
|
|
"require_budget_pdf": False,
|
|
"min_score": 6
|
|
}}
|
|
|
|
def save_state(s):
|
|
STATE_FILE.write_text(json.dumps(s, indent=2))
|
|
|
|
def extract_units_from_note(note):
|
|
"""Extract unit count from note body or title"""
|
|
body = note.get('bodyV2', {}).get('markdown', '') if isinstance(note.get('bodyV2'), dict) else ''
|
|
title = note.get('title', '')
|
|
text = f"{title} {body}".lower()
|
|
|
|
# Look for unit patterns
|
|
patterns = [
|
|
r'(\d{1,4})\s*(?:homes|units|lots|properties|residences)',
|
|
r'(\d{1,4})\s*-?\s*(?:home|unit|lot|property|residence)\s*(?:community|association|complex)',
|
|
r'community\s*of\s*(\d{1,4})',
|
|
r'units:\s*(\d{1,4})',
|
|
]
|
|
|
|
for pattern in patterns:
|
|
match = re.search(pattern, text, re.IGNORECASE)
|
|
if match:
|
|
try:
|
|
units = int(match.group(1))
|
|
if 10 <= units <= 5000: # Reasonable range
|
|
return units
|
|
except:
|
|
pass
|
|
return None
|
|
|
|
def has_budget_pdf(note):
|
|
"""Check if note has budget PDF"""
|
|
body = note.get('bodyV2', {}).get('markdown', '') if isinstance(note.get('bodyV2'), dict) else ''
|
|
title = note.get('title', '')
|
|
text = f"{title} {body}".lower()
|
|
|
|
# Check for budget PDF mentions
|
|
if 'budget pdf' in text or 'budget.pdf' in text or 'found budget pdf' in text:
|
|
return True
|
|
if 'budget found' in text and 'pdf' in text:
|
|
return True
|
|
return False
|
|
|
|
def has_website(note):
|
|
"""Check if note has website"""
|
|
body = note.get('bodyV2', {}).get('markdown', '') if isinstance(note.get('bodyV2'), dict) else ''
|
|
title = note.get('title', '')
|
|
text = f"{title} {body}"
|
|
|
|
return 'https://' in text or 'http://' in text
|
|
|
|
def get_temp(note):
|
|
"""Get temperature from note"""
|
|
temp = note.get('temp', 'COLD')
|
|
if temp and temp.upper() in ['HOT', 'WARM', 'COLD']:
|
|
return temp.upper()
|
|
|
|
title = note.get('title', '').upper()
|
|
if title.startswith('HOT:'):
|
|
return 'HOT'
|
|
if title.startswith('WARM:'):
|
|
return 'WARM'
|
|
return 'COLD'
|
|
|
|
def score_lead(note):
|
|
"""
|
|
Score a lead 1-10 based on Tier 1 criteria
|
|
Returns: (score, breakdown)
|
|
"""
|
|
temp = get_temp(note)
|
|
units = extract_units_from_note(note)
|
|
budget_pdf = has_budget_pdf(note)
|
|
has_site = has_website(note)
|
|
|
|
score = 0
|
|
breakdown = []
|
|
|
|
# 1. Temperature (max 3 points)
|
|
if temp == 'HOT':
|
|
score += 3
|
|
breakdown.append("Temp: HOT (+3)")
|
|
elif temp == 'WARM':
|
|
score += 2
|
|
breakdown.append("Temp: WARM (+2)")
|
|
else:
|
|
breakdown.append("Temp: COLD (+0)")
|
|
|
|
# 2. Unit Count (max 4 points)
|
|
if units:
|
|
if 150 <= units <= 400:
|
|
score += 4
|
|
breakdown.append(f"Units: {units} (ideal range +4)")
|
|
elif 100 <= units < 150 or 400 < units <= 500:
|
|
score += 3
|
|
breakdown.append(f"Units: {units} (good range +3)")
|
|
elif 50 <= units < 100 or 500 < units <= 1000:
|
|
score += 2
|
|
breakdown.append(f"Units: {units} (acceptable +2)")
|
|
else:
|
|
score += 1
|
|
breakdown.append(f"Units: {units} (outside ideal +1)")
|
|
else:
|
|
breakdown.append("Units: Unknown (0)")
|
|
|
|
# 3. Budget PDF (max 2 points)
|
|
if budget_pdf:
|
|
score += 2
|
|
breakdown.append("Budget PDF: Found (+2)")
|
|
elif has_site:
|
|
score += 1
|
|
breakdown.append("Budget: Mentioned (+1)")
|
|
else:
|
|
breakdown.append("Budget: Not found (0)")
|
|
|
|
# 4. Website Quality (max 1 point)
|
|
if has_site:
|
|
score += 1
|
|
breakdown.append("Website: Yes (+1)")
|
|
|
|
return score, breakdown
|
|
|
|
def fetch_recent_notes(limit=200):
|
|
"""Fetch recent notes from CRM"""
|
|
import urllib.request, ssl, json
|
|
ssl_context = ssl.create_default_context()
|
|
ssl_context.check_hostname = False
|
|
ssl_context.verify_mode = ssl.CERT_NONE
|
|
|
|
try:
|
|
req = urllib.request.Request(
|
|
f"{CRM_URL}/notes?limit={limit}&order[createdAt]=desc",
|
|
headers={"Authorization": f"Bearer {CRM_TOKEN}", "Accept": "application/json"}
|
|
)
|
|
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ssl_context))
|
|
with opener.open(req, timeout=30) as r:
|
|
data = json.loads(r.read().decode())
|
|
return data.get('data', {}).get('notes', [])
|
|
except Exception as e:
|
|
log(f"Fetch error: {e}")
|
|
return []
|
|
|
|
def update_crm_note(note_id, score, tier1_label):
|
|
"""Update CRM note with tier1_score field"""
|
|
import urllib.request, ssl, json
|
|
|
|
ssl_context = ssl.create_default_context()
|
|
ssl_context.check_hostname = False
|
|
ssl_context.verify_mode = ssl.CERT_NONE
|
|
|
|
try:
|
|
# Patch the note to add tier1_score
|
|
patch_data = json.dumps({
|
|
"tier1Score": score,
|
|
"tier1Label": tier1_label
|
|
}).encode()
|
|
|
|
req = urllib.request.Request(
|
|
f"{CRM_URL}/notes/{note_id}",
|
|
data=patch_data,
|
|
headers={
|
|
"Authorization": f"Bearer {CRM_TOKEN}",
|
|
"Content-Type": "application/json"
|
|
},
|
|
method='PATCH'
|
|
)
|
|
|
|
opener = urllib.request.build_opener(urllib.request.HTTPSHandler(context=ssl_context))
|
|
with opener.open(req, timeout=20) as r:
|
|
return True
|
|
except Exception as e:
|
|
log(f" ⚠️ CRM update failed: {e}")
|
|
return False
|
|
|
|
def main():
|
|
log("=" * 60)
|
|
log("Tier 1 Scorer - Starting")
|
|
log("=" * 60)
|
|
|
|
state = load_state()
|
|
scored_leads = state.get('tier1_leads', [])
|
|
last_processed = state.get('last_processed', 0)
|
|
|
|
log(f"Loading {len(scored_leads)} previously scored leads")
|
|
|
|
# Fetch recent notes
|
|
notes = fetch_recent_notes(500)
|
|
log(f"Fetched {len(notes)} recent notes")
|
|
|
|
new_additions = 0
|
|
updated_additions = 0
|
|
crm_updates = 0
|
|
|
|
for i, note in enumerate(notes):
|
|
note_id = note.get('id')
|
|
title = note.get('title', '')[:50]
|
|
|
|
# Score the lead
|
|
score, breakdown = score_lead(note)
|
|
|
|
# Determine Tier 1 label
|
|
if score >= 8:
|
|
tier1_label = "Tier 1 - Priority"
|
|
elif score >= 6:
|
|
tier1_label = "Tier 1"
|
|
else:
|
|
tier1_label = ""
|
|
|
|
# Check if already in scored list
|
|
existing = next((x for x in scored_leads if x['id'] == note_id), None)
|
|
|
|
if score >= 6: # Minimum threshold for Tier 1 consideration
|
|
lead_data = {
|
|
'id': note_id,
|
|
'title': title,
|
|
'score': score,
|
|
'breakdown': breakdown,
|
|
'temp': get_temp(note),
|
|
'units': extract_units_from_note(note),
|
|
'budget_pdf': has_budget_pdf(note),
|
|
'updated': datetime.now().isoformat()
|
|
}
|
|
|
|
if existing:
|
|
# Update existing
|
|
if existing['score'] != score:
|
|
existing.update(lead_data)
|
|
updated_additions += 1
|
|
# Update CRM
|
|
if update_crm_note(note_id, score, tier1_label):
|
|
crm_updates += 1
|
|
log(f" ✓ Updated CRM: {title[:40]} (Score: {score}/10)")
|
|
else:
|
|
# Add new
|
|
scored_leads.append(lead_data)
|
|
new_additions += 1
|
|
# Update CRM
|
|
if update_crm_note(note_id, score, tier1_label):
|
|
crm_updates += 1
|
|
log(f" ✓ Updated CRM: {title[:40]} (Score: {score}/10)")
|
|
|
|
# Sort by score (descending)
|
|
scored_leads.sort(key=lambda x: x['score'], reverse=True)
|
|
|
|
# Keep only top 100 for now
|
|
scored_leads = scored_leads[:100]
|
|
|
|
# Save state
|
|
state['tier1_leads'] = scored_leads
|
|
state['last_processed'] = len(notes)
|
|
save_state(state)
|
|
|
|
log(f"\n=== Tier 1 Results ===")
|
|
log(f"Total scored: {len(scored_leads)}")
|
|
log(f"New additions: {new_additions}")
|
|
log(f"Updates: {updated_additions}")
|
|
log(f"CRM updates: {crm_updates}")
|
|
|
|
if scored_leads:
|
|
log(f"\nTop 10 Tier 1 Leads:")
|
|
for i, lead in enumerate(scored_leads[:10], 1):
|
|
units_str = f"{lead['units']} units" if lead['units'] else "units: ?"
|
|
log(f" {i}. [{lead['score']}/10] {lead['title'][:40]} ({lead['temp']}, {units_str})")
|
|
|
|
log("\n" + "=" * 60)
|
|
log(f"Tier 1 scoring complete. Top lead score: {scored_leads[0]['score'] if scored_leads else 0}/10")
|
|
log("=" * 60)
|
|
log(f"\n📊 CRM Integration:")
|
|
log(f" • Field: tier1_score (numeric)")
|
|
log(f" • Label: tier1_label (text)")
|
|
log(f" • Filter view: tier1_score >= 6")
|
|
log("=" * 60)
|
|
|
|
if __name__ == "__main__":
|
|
main()
|