Spaces:
Sleeping
Sleeping
| import os | |
| import json | |
| import random | |
| import pandas as pd | |
| from flask import Flask, render_template_string, request, jsonify | |
| from faker import Faker | |
| import logging | |
| # Configure logging | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| app = Flask(__name__) | |
| fake = Faker(['zh_CN']) | |
| # Default Scoring Model | |
| DEFAULT_MODEL = { | |
| "demographic": [ | |
| {"field": "role", "operator": "contains", "value": "CEO", "score": 20, "desc": "职位包含 CEO"}, | |
| {"field": "role", "operator": "contains", "value": "总监", "score": 15, "desc": "职位包含 总监"}, | |
| {"field": "role", "operator": "contains", "value": "经理", "score": 10, "desc": "职位包含 经理"}, | |
| {"field": "industry", "operator": "equals", "value": "互联网", "score": 10, "desc": "行业为 互联网"}, | |
| {"field": "company_size", "operator": "gt", "value": 100, "score": 15, "desc": "公司规模 > 100人"} | |
| ], | |
| "behavioral": [ | |
| {"field": "website_visits", "operator": "gt", "value": 5, "score": 10, "desc": "访问官网 > 5次"}, | |
| {"field": "email_opens", "operator": "gt", "value": 0, "score": 5, "desc": "打开过邮件"}, | |
| {"field": "downloaded_whitepaper", "operator": "equals", "value": True, "score": 20, "desc": "下载过白皮书"}, | |
| {"field": "webinar_attended", "operator": "equals", "value": True, "score": 15, "desc": "参加过研讨会"} | |
| ] | |
| } | |
| def evaluate_rule(lead, rule): | |
| field = rule.get('field') | |
| operator = rule.get('operator') | |
| target = rule.get('value') | |
| score = rule.get('score', 0) | |
| val = lead.get(field) | |
| if val is None: | |
| return 0 | |
| matched = False | |
| try: | |
| if operator == 'equals': | |
| # Handle boolean/string comparison carefully | |
| if isinstance(target, bool): | |
| matched = bool(val) == target | |
| elif isinstance(val, str) and isinstance(target, str): | |
| matched = val.lower() == target.lower() | |
| else: | |
| matched = val == target | |
| elif operator == 'contains': | |
| matched = str(target).lower() in str(val).lower() | |
| elif operator == 'gt': | |
| matched = float(val) > float(target) | |
| elif operator == 'lt': | |
| matched = float(val) < float(target) | |
| elif operator == 'gte': | |
| matched = float(val) >= float(target) | |
| elif operator == 'lte': | |
| matched = float(val) <= float(target) | |
| except Exception as e: | |
| logger.warning(f"Error evaluating rule {rule} for value {val}: {e}") | |
| matched = False | |
| return score if matched else 0 | |
| def index(): | |
| return render_template_string(HTML_TEMPLATE) | |
| def generate_leads(): | |
| try: | |
| count = request.json.get('count', 10) | |
| leads = [] | |
| industries = ['互联网', '金融', '制造业', '教育', '医疗', '零售'] | |
| roles = ['CEO', 'CTO', '市场总监', '销售经理', '研发工程师', '运营专员', '采购经理'] | |
| for _ in range(count): | |
| leads.append({ | |
| "id": fake.uuid4(), | |
| "name": fake.name(), | |
| "company": fake.company(), | |
| "role": random.choice(roles), | |
| "industry": random.choice(industries), | |
| "company_size": random.randint(10, 5000), | |
| "email": fake.email(), | |
| "website_visits": random.randint(0, 50), | |
| "email_opens": random.randint(0, 20), | |
| "downloaded_whitepaper": random.choice([True, False]), | |
| "webinar_attended": random.choice([True, False]), | |
| "last_contact_days": random.randint(1, 100) | |
| }) | |
| return jsonify(leads) | |
| except Exception as e: | |
| logger.error(f"Error generating leads: {e}") | |
| return jsonify({"error": str(e)}), 500 | |
| def score_leads(): | |
| try: | |
| data = request.json | |
| leads = data.get('leads', []) | |
| model = data.get('model', DEFAULT_MODEL) | |
| results = [] | |
| for lead in leads: | |
| total_score = 0 | |
| breakdown = [] | |
| # Demographic | |
| for rule in model.get('demographic', []): | |
| points = evaluate_rule(lead, rule) | |
| if points > 0: | |
| total_score += points | |
| breakdown.append({"desc": rule['desc'], "score": points, "type": "基本属性"}) | |
| # Behavioral | |
| for rule in model.get('behavioral', []): | |
| points = evaluate_rule(lead, rule) | |
| if points > 0: | |
| total_score += points | |
| breakdown.append({"desc": rule['desc'], "score": points, "type": "行为数据"}) | |
| # Determine Grade | |
| grade = 'D' | |
| if total_score >= 80: grade = 'A' | |
| elif total_score >= 60: grade = 'B' | |
| elif total_score >= 40: grade = 'C' | |
| results.append({ | |
| **lead, | |
| "score": total_score, | |
| "grade": grade, | |
| "breakdown": breakdown | |
| }) | |
| # Sort by score desc | |
| results.sort(key=lambda x: x['score'], reverse=True) | |
| return jsonify(results) | |
| except Exception as e: | |
| logger.error(f"Error scoring leads: {e}") | |
| return jsonify({"error": str(e)}), 500 | |
| def upload_file(): | |
| if 'file' not in request.files: | |
| return jsonify({"error": "No file part"}), 400 | |
| file = request.files['file'] | |
| if file.filename == '': | |
| return jsonify({"error": "No selected file"}), 400 | |
| try: | |
| if file.filename.endswith('.csv'): | |
| df = pd.read_csv(file) | |
| elif file.filename.endswith(('.xls', '.xlsx')): | |
| df = pd.read_excel(file) | |
| else: | |
| return jsonify({"error": "Unsupported file format. Please use CSV or Excel."}), 400 | |
| # Ensure required columns exist or fill with defaults | |
| required_fields = ['name', 'company', 'role', 'industry', 'company_size', 'website_visits', 'email_opens'] | |
| for field in required_fields: | |
| if field not in df.columns: | |
| if field == 'name': df['name'] = 'Unknown' | |
| elif field == 'company': df['company'] = 'Unknown' | |
| else: df[field] = 0 # Default numeric | |
| leads = df.to_dict('records') | |
| # Add ID if missing | |
| for lead in leads: | |
| if 'id' not in lead: | |
| lead['id'] = fake.uuid4() | |
| # Normalize boolean fields | |
| if 'downloaded_whitepaper' in lead: | |
| lead['downloaded_whitepaper'] = bool(lead['downloaded_whitepaper']) | |
| else: | |
| lead['downloaded_whitepaper'] = False | |
| if 'webinar_attended' in lead: | |
| lead['webinar_attended'] = bool(lead['webinar_attended']) | |
| else: | |
| lead['webinar_attended'] = False | |
| return jsonify(leads) | |
| except Exception as e: | |
| logger.error(f"Error processing file: {e}") | |
| return jsonify({"error": f"File processing failed: {str(e)}"}), 500 | |
| HTML_TEMPLATE = """ | |
| <!DOCTYPE html> | |
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>销售线索智能评分引擎 | Lead Scoring Engine</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script> | |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet"> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap'); | |
| body { font-family: 'Inter', sans-serif; background-color: #f3f4f6; } | |
| .card { background: white; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); } | |
| .grade-A { color: #16a34a; font-weight: bold; } | |
| .grade-B { color: #2563eb; font-weight: bold; } | |
| .grade-C { color: #d97706; font-weight: bold; } | |
| .grade-D { color: #dc2626; font-weight: bold; } | |
| /* Loading Overlay */ | |
| .loading-overlay { | |
| position: fixed; top: 0; left: 0; width: 100%; height: 100%; | |
| background: rgba(255, 255, 255, 0.8); | |
| display: flex; justify-content: center; align-items: center; | |
| z-index: 9999; | |
| } | |
| /* Toast */ | |
| .toast { | |
| position: fixed; top: 20px; right: 20px; | |
| padding: 1rem; border-radius: 8px; color: white; | |
| z-index: 10000; transition: all 0.3s; | |
| box-shadow: 0 4px 12px rgba(0,0,0,0.15); | |
| } | |
| .toast-error { background-color: #ef4444; } | |
| .toast-success { background-color: #10b981; } | |
| /* Fade transition */ | |
| .fade-enter-active, .fade-leave-active { transition: opacity 0.5s; } | |
| .fade-enter-from, .fade-leave-to { opacity: 0; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app" class="min-h-screen p-6"> | |
| <!-- Toast Notification --> | |
| <transition name="fade"> | |
| <div v-if="toast.show" :class="['toast', 'toast-' + toast.type]"> | |
| <i :class="['fas', toast.type === 'success' ? 'fa-check-circle' : 'fa-exclamation-circle', 'mr-2']"></i> | |
| ${ toast.message } | |
| </div> | |
| </transition> | |
| <!-- Loading --> | |
| <div v-if="loading" class="loading-overlay"> | |
| <div class="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div> | |
| </div> | |
| <!-- Header --> | |
| <header class="mb-8 flex flex-col md:flex-row justify-between items-center gap-4"> | |
| <div> | |
| <h1 class="text-3xl font-bold text-gray-800"><i class="fas fa-bullseye text-indigo-600 mr-2"></i>销售线索智能评分引擎</h1> | |
| <p class="text-gray-500 mt-1">基于多维数据的智能化潜客分级系统</p> | |
| </div> | |
| <div class="flex gap-3"> | |
| <input type="file" ref="fileInput" @change="handleFileUpload" style="display:none" accept=".csv,.xlsx,.xls"> | |
| <button @click="triggerUpload" class="bg-white text-gray-700 px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition"> | |
| <i class="fas fa-file-upload mr-2"></i>导入数据 | |
| </button> | |
| <button @click="generateDemoData" class="bg-white text-indigo-600 px-4 py-2 rounded-lg border border-indigo-200 hover:bg-indigo-50 transition"> | |
| <i class="fas fa-random mr-2"></i>生成模拟数据 | |
| </button> | |
| <button @click="runScoring" class="bg-indigo-600 text-white px-6 py-2 rounded-lg hover:bg-indigo-700 transition shadow-lg"> | |
| <i class="fas fa-play mr-2"></i>执行评分 | |
| </button> | |
| </div> | |
| </header> | |
| <div class="grid grid-cols-1 lg:grid-cols-12 gap-6"> | |
| <!-- Sidebar: Scoring Model --> | |
| <div class="lg:col-span-4 space-y-6"> | |
| <div class="card p-6"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h2 class="text-xl font-semibold text-gray-800">评分模型配置</h2> | |
| <span class="text-xs bg-indigo-100 text-indigo-800 px-2 py-1 rounded">当前版本: v1.0</span> | |
| </div> | |
| <!-- Demographic Rules --> | |
| <div class="mb-6"> | |
| <h3 class="text-sm font-bold text-gray-500 uppercase tracking-wider mb-3">基本属性规则 (Demographic)</h3> | |
| <div class="space-y-3"> | |
| <div v-for="(rule, index) in model.demographic" :key="'demo-'+index" class="flex items-center justify-between bg-gray-50 p-3 rounded border border-gray-100"> | |
| <div> | |
| <div class="text-sm font-medium text-gray-700">${ rule.desc }</div> | |
| <div class="text-xs text-gray-400">${ rule.field } ${ rule.operator } ${ rule.value }</div> | |
| </div> | |
| <div class="font-bold text-indigo-600">+${ rule.score }</div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Behavioral Rules --> | |
| <div> | |
| <h3 class="text-sm font-bold text-gray-500 uppercase tracking-wider mb-3">行为数据规则 (Behavioral)</h3> | |
| <div class="space-y-3"> | |
| <div v-for="(rule, index) in model.behavioral" :key="'beh-'+index" class="flex items-center justify-between bg-gray-50 p-3 rounded border border-gray-100"> | |
| <div> | |
| <div class="text-sm font-medium text-gray-700">${ rule.desc }</div> | |
| <div class="text-xs text-gray-400">${ rule.field } ${ rule.operator } ${ rule.value }</div> | |
| </div> | |
| <div class="font-bold text-emerald-600">+${ rule.score }</div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Stats Chart --> | |
| <div class="card p-6 h-80"> | |
| <h3 class="text-lg font-semibold mb-4">线索质量分布</h3> | |
| <div id="chart-container" class="w-full h-full"></div> | |
| </div> | |
| </div> | |
| <!-- Main: Lead List --> | |
| <div class="lg:col-span-8"> | |
| <div class="card p-6"> | |
| <div class="flex justify-between items-center mb-6"> | |
| <h2 class="text-xl font-semibold text-gray-800">线索列表 (${ leads.length })</h2> | |
| <div class="flex gap-2"> | |
| <div class="flex items-center gap-2 text-sm text-gray-500"> | |
| <span class="w-3 h-3 rounded-full bg-green-500"></span> A级(High) | |
| <span class="w-3 h-3 rounded-full bg-blue-500"></span> B级(Med) | |
| <span class="w-3 h-3 rounded-full bg-yellow-500"></span> C级(Low) | |
| </div> | |
| </div> | |
| </div> | |
| <div class="overflow-x-auto"> | |
| <table class="w-full text-left border-collapse"> | |
| <thead> | |
| <tr class="text-gray-400 text-sm border-b border-gray-100"> | |
| <th class="py-3 px-2">姓名/公司</th> | |
| <th class="py-3 px-2">职位</th> | |
| <th class="py-3 px-2">行为指标</th> | |
| <th class="py-3 px-2 text-center">总分</th> | |
| <th class="py-3 px-2 text-center">等级</th> | |
| <th class="py-3 px-2">得分详情</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr v-for="lead in leads" :key="lead.id" class="border-b border-gray-50 hover:bg-gray-50 transition"> | |
| <td class="py-4 px-2"> | |
| <div class="font-semibold text-gray-800">${ lead.name }</div> | |
| <div class="text-xs text-gray-500">${ lead.company } (${ lead.industry })</div> | |
| </td> | |
| <td class="py-4 px-2 text-sm text-gray-600">${ lead.role }<br><span class="text-xs text-gray-400">${ lead.company_size }人</span></td> | |
| <td class="py-4 px-2"> | |
| <div class="flex gap-2 text-xs"> | |
| <span v-if="lead.website_visits > 0" class="px-2 py-1 bg-blue-50 text-blue-600 rounded">访客:${lead.website_visits}</span> | |
| <span v-if="lead.downloaded_whitepaper" class="px-2 py-1 bg-purple-50 text-purple-600 rounded">白皮书</span> | |
| </div> | |
| </td> | |
| <td class="py-4 px-2 text-center font-bold text-lg text-gray-800">${ lead.score || '-' }</td> | |
| <td class="py-4 px-2 text-center"> | |
| <span v-if="lead.grade" :class="'px-3 py-1 rounded-full text-sm bg-opacity-10 grade-' + lead.grade" | |
| :style="{ backgroundColor: getGradeColor(lead.grade) }"> | |
| ${ lead.grade } | |
| </span> | |
| <span v-else class="text-gray-300">-</span> | |
| </td> | |
| <td class="py-4 px-2"> | |
| <div v-if="lead.breakdown && lead.breakdown.length" class="text-xs space-y-1"> | |
| <div v-for="item in lead.breakdown.slice(0, 2)" class="flex justify-between w-32"> | |
| <span class="text-gray-500 truncate w-24">${ item.desc }</span> | |
| <span class="text-green-600">+${ item.score }</span> | |
| </div> | |
| <div v-if="lead.breakdown.length > 2" class="text-gray-400 italic">+${ lead.breakdown.length - 2 } 更多...</div> | |
| </div> | |
| </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| <div v-if="leads.length === 0" class="text-center py-12 text-gray-400"> | |
| <i class="fas fa-inbox text-4xl mb-3"></i> | |
| <p>暂无数据,请点击右上角生成数据或导入文件</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const { createApp, ref, reactive, onMounted, nextTick } = Vue; | |
| createApp({ | |
| setup() { | |
| const leads = ref([]); | |
| const loading = ref(false); | |
| const fileInput = ref(null); | |
| const toast = reactive({ show: false, message: '', type: 'success' }); | |
| const model = ref({ | |
| demographic: [ | |
| {field: "role", operator: "contains", value: "CEO", score: 20, desc: "职位包含 CEO"}, | |
| {field: "role", operator: "contains", value: "总监", score: 15, desc: "职位包含 总监"}, | |
| {field: "industry", operator: "equals", value: "互联网", score: 10, desc: "行业为 互联网"}, | |
| {field: "company_size", operator: "gt", value: 100, score: 15, desc: "公司规模 > 100人"} | |
| ], | |
| behavioral: [ | |
| {field: "website_visits", operator: "gt", value: 5, score: 10, desc: "访问官网 > 5次"}, | |
| {field: "downloaded_whitepaper", operator: "equals", value: true, score: 20, desc: "下载过白皮书"}, | |
| {field: "webinar_attended", operator: "equals", value: true, score: 15, desc: "参加过研讨会"} | |
| ] | |
| }); | |
| let chartInstance = null; | |
| const showToast = (msg, type='success') => { | |
| toast.message = msg; | |
| toast.type = type; | |
| toast.show = true; | |
| setTimeout(() => toast.show = false, 3000); | |
| }; | |
| const getGradeColor = (grade) => { | |
| const map = { 'A': '#dcfce7', 'B': '#dbeafe', 'C': '#fef3c7', 'D': '#fee2e2' }; | |
| return map[grade] || '#f3f4f6'; | |
| }; | |
| const generateDemoData = async () => { | |
| loading.value = true; | |
| try { | |
| const res = await fetch('/api/generate-leads', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ count: 15 }) | |
| }); | |
| if (!res.ok) throw new Error('Failed to generate data'); | |
| const data = await res.json(); | |
| leads.value = data; | |
| showToast('模拟数据生成成功'); | |
| // Auto score after generation | |
| await runScoring(); | |
| } catch (e) { | |
| console.error(e); | |
| showToast(e.message, 'error'); | |
| } finally { | |
| loading.value = false; | |
| } | |
| }; | |
| const runScoring = async () => { | |
| if (leads.value.length === 0) return; | |
| loading.value = true; | |
| try { | |
| const res = await fetch('/api/score', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({ | |
| leads: leads.value, | |
| model: model.value | |
| }) | |
| }); | |
| if (!res.ok) throw new Error('Scoring failed'); | |
| const data = await res.json(); | |
| leads.value = data; | |
| updateChart(); | |
| showToast('评分完成'); | |
| } catch (e) { | |
| console.error(e); | |
| showToast(e.message, 'error'); | |
| } finally { | |
| loading.value = false; | |
| } | |
| }; | |
| const triggerUpload = () => { | |
| if(fileInput.value) fileInput.value.click(); | |
| }; | |
| const handleFileUpload = async (event) => { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| const formData = new FormData(); | |
| formData.append('file', file); | |
| loading.value = true; | |
| try { | |
| const res = await fetch('/api/upload', { | |
| method: 'POST', | |
| body: formData | |
| }); | |
| if (!res.ok) { | |
| const err = await res.json(); | |
| throw new Error(err.error || 'Upload failed'); | |
| } | |
| const data = await res.json(); | |
| leads.value = data; | |
| showToast(`成功导入 ${data.length} 条线索`); | |
| // Auto score | |
| await runScoring(); | |
| } catch (e) { | |
| console.error(e); | |
| showToast(e.message, 'error'); | |
| } finally { | |
| loading.value = false; | |
| event.target.value = ''; // Reset input | |
| } | |
| }; | |
| const updateChart = () => { | |
| nextTick(() => { | |
| if (!chartInstance) { | |
| const el = document.getElementById('chart-container'); | |
| if (el) chartInstance = echarts.init(el); | |
| } | |
| if (!chartInstance) return; | |
| const grades = { 'A': 0, 'B': 0, 'C': 0, 'D': 0 }; | |
| leads.value.forEach(l => { | |
| if (l.grade) grades[l.grade]++; | |
| }); | |
| const option = { | |
| tooltip: { trigger: 'item' }, | |
| legend: { bottom: '0%' }, | |
| color: ['#16a34a', '#2563eb', '#d97706', '#dc2626'], | |
| series: [ | |
| { | |
| name: '线索等级', | |
| type: 'pie', | |
| radius: ['40%', '70%'], | |
| avoidLabelOverlap: false, | |
| itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 }, | |
| label: { show: false, position: 'center' }, | |
| emphasis: { label: { show: true, fontSize: 20, fontWeight: 'bold' } }, | |
| data: [ | |
| { value: grades.A, name: 'A级 (高价值)' }, | |
| { value: grades.B, name: 'B级 (潜力)' }, | |
| { value: grades.C, name: 'C级 (一般)' }, | |
| { value: grades.D, name: 'D级 (低质)' } | |
| ] | |
| } | |
| ] | |
| }; | |
| chartInstance.setOption(option); | |
| }); | |
| }; | |
| onMounted(() => { | |
| generateDemoData(); | |
| window.addEventListener('resize', () => chartInstance && chartInstance.resize()); | |
| }); | |
| return { | |
| leads, | |
| model, | |
| loading, | |
| toast, | |
| fileInput, | |
| generateDemoData, | |
| runScoring, | |
| getGradeColor, | |
| triggerUpload, | |
| handleFileUpload | |
| }; | |
| }, | |
| delimiters: ['${', '}'] | |
| }).mount('#app'); | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| if __name__ == '__main__': | |
| app.run(host='0.0.0.0', port=7860, debug=True) | |