Spaces:
Sleeping
Sleeping
Trae Assistant
commited on
Commit
·
bb3c41b
0
Parent(s):
feat: upgrade UI, fix delimiters, add file upload, localization
Browse files- .gitignore +5 -0
- Dockerfile +23 -0
- README.md +70 -0
- app.py +274 -0
- requirements.txt +2 -0
- templates/index.html +353 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.DS_Store
|
| 4 |
+
.env
|
| 5 |
+
venv/
|
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
# Install system dependencies
|
| 6 |
+
RUN apt-get update && apt-get install -y \
|
| 7 |
+
gcc \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
# Install Python dependencies
|
| 11 |
+
COPY requirements.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 13 |
+
|
| 14 |
+
COPY . .
|
| 15 |
+
|
| 16 |
+
# Create a non-root user
|
| 17 |
+
RUN useradd -m -u 1000 user
|
| 18 |
+
USER user
|
| 19 |
+
ENV PATH="/home/user/.local/bin:$PATH"
|
| 20 |
+
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
|
| 23 |
+
CMD ["python", "app.py"]
|
README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Attribution Logic Engine
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
short_description: 商业级多渠道营销归因分析与模型对比系统
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# 归因逻辑引擎 (Attribution Logic Engine)
|
| 12 |
+
|
| 13 |
+
## 项目简介
|
| 14 |
+
这是一个商业级的多渠道营销归因分析系统,旨在帮助市场营销人员理解不同营销渠道(如搜索广告、社交媒体、邮件等)对最终转化的贡献价值。
|
| 15 |
+
|
| 16 |
+
传统的“最后点击” (Last Click) 归因模型往往掩盖了用户决策路径中早期接触点的重要性。本系统通过模拟真实用户路径,并应用多种归因算法(线性、时间衰减、位置优先等),直观展示不同模型下的 ROI 差异。
|
| 17 |
+
|
| 18 |
+
## 核心功能
|
| 19 |
+
1. **多模型对比 (Model Comparison)**:
|
| 20 |
+
- **Last Click**: 100% 归因于最后一次交互。
|
| 21 |
+
- **First Click**: 100% 归因于第一次交互。
|
| 22 |
+
- **Linear**: 所有交互点平分功劳。
|
| 23 |
+
- **Time Decay**: 距离转化越近的交互点权重越高(指数衰减)。
|
| 24 |
+
- **Position Based (U-Shaped)**: 首尾各 40%,中间平分 20%。
|
| 25 |
+
|
| 26 |
+
2. **用户路径可视化 (Journey Visualization)**:
|
| 27 |
+
- 使用 Sankey 图(桑基图)展示用户从首次接触到最终转化(或流失)的常见流动路径。
|
| 28 |
+
|
| 29 |
+
3. **数据导入与模拟**:
|
| 30 |
+
- **模拟引擎**: 内置蒙特卡洛模拟器,可生成数千条复杂的用户行为路径。
|
| 31 |
+
- **自定义数据上传**: 支持上传 `.csv` 或 `.json` 格式的归因数据进行分析。
|
| 32 |
+
|
| 33 |
+
## 数据上传格式说明
|
| 34 |
+
|
| 35 |
+
### CSV 格式
|
| 36 |
+
需要包含 `path` (或 `touchpoints`), `converted`, `value` 字段。
|
| 37 |
+
- `path`: 渠道路径,可用 `>` 或 `,` 分隔。例如 `Email > Social > Direct`。
|
| 38 |
+
- `converted`: 是否转化 (1/0, true/false)。
|
| 39 |
+
- `value`: 转化价值 (数字)。
|
| 40 |
+
|
| 41 |
+
### JSON 格式
|
| 42 |
+
一个包含对象列表的文件:
|
| 43 |
+
```json
|
| 44 |
+
[
|
| 45 |
+
{
|
| 46 |
+
"path": ["Email", "Social Ads", "Direct"],
|
| 47 |
+
"converted": true,
|
| 48 |
+
"value": 150.0
|
| 49 |
+
},
|
| 50 |
+
...
|
| 51 |
+
]
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
## 技术栈
|
| 55 |
+
- **Backend**: Python 3.11, Flask, Pandas
|
| 56 |
+
- **Frontend**: Vue 3, Tailwind CSS, ECharts 5
|
| 57 |
+
- **Deployment**: Docker (Hugging Face Spaces Compatible)
|
| 58 |
+
|
| 59 |
+
## 商业价值
|
| 60 |
+
对于广告投放预算超过 $10k/月的企业,错误的归因模型可能导致 20-30% 的预算浪费。本工具帮助识别那些“助攻”型渠道(如社交媒体种草),避免因 ROI 计算错误而过早关闭有效渠道。
|
| 61 |
+
|
| 62 |
+
## 运行方式
|
| 63 |
+
```bash
|
| 64 |
+
# 构建镜像
|
| 65 |
+
docker build -t attribution-engine .
|
| 66 |
+
|
| 67 |
+
# 运行容器
|
| 68 |
+
docker run -p 7860:7860 attribution-engine
|
| 69 |
+
```
|
| 70 |
+
访问 http://localhost:7860 即可使用。
|
app.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import random
|
| 3 |
+
import json
|
| 4 |
+
import csv
|
| 5 |
+
import io
|
| 6 |
+
from flask import Flask, render_template, jsonify, request
|
| 7 |
+
from collections import defaultdict
|
| 8 |
+
|
| 9 |
+
app = Flask(__name__)
|
| 10 |
+
app.secret_key = os.urandom(24)
|
| 11 |
+
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max upload
|
| 12 |
+
|
| 13 |
+
# Configuration
|
| 14 |
+
CHANNELS = ['Paid Search', 'Social Ads', 'Email', 'Direct', 'Referral', 'Display']
|
| 15 |
+
MAX_JOURNEY_LENGTH = 5
|
| 16 |
+
|
| 17 |
+
def generate_mock_data(count=1000):
|
| 18 |
+
"""Generate synthetic user journeys."""
|
| 19 |
+
journeys = []
|
| 20 |
+
for _ in range(count):
|
| 21 |
+
# Random journey length 1-5
|
| 22 |
+
length = random.randint(1, MAX_JOURNEY_LENGTH)
|
| 23 |
+
# Random path
|
| 24 |
+
path = [random.choice(CHANNELS) for _ in range(length)]
|
| 25 |
+
# Random conversion (20% chance)
|
| 26 |
+
converted = random.random() < 0.2
|
| 27 |
+
value = 100 if converted else 0
|
| 28 |
+
|
| 29 |
+
journeys.append({
|
| 30 |
+
'path': path,
|
| 31 |
+
'converted': converted,
|
| 32 |
+
'value': value
|
| 33 |
+
})
|
| 34 |
+
return journeys
|
| 35 |
+
|
| 36 |
+
def calculate_attribution(journeys, model):
|
| 37 |
+
"""
|
| 38 |
+
Calculate attribution value for each channel based on the selected model.
|
| 39 |
+
Models: 'last_click', 'first_click', 'linear', 'time_decay', 'position_based'
|
| 40 |
+
"""
|
| 41 |
+
channel_values = defaultdict(float)
|
| 42 |
+
total_conversions = 0
|
| 43 |
+
total_revenue = 0
|
| 44 |
+
|
| 45 |
+
for journey in journeys:
|
| 46 |
+
# Ensure robust data types
|
| 47 |
+
converted = bool(journey.get('converted', False))
|
| 48 |
+
if not converted:
|
| 49 |
+
continue
|
| 50 |
+
|
| 51 |
+
path = journey.get('path', [])
|
| 52 |
+
if not path:
|
| 53 |
+
continue
|
| 54 |
+
|
| 55 |
+
value = float(journey.get('value', 0))
|
| 56 |
+
|
| 57 |
+
total_conversions += 1
|
| 58 |
+
total_revenue += value
|
| 59 |
+
|
| 60 |
+
if model == 'last_click':
|
| 61 |
+
if path:
|
| 62 |
+
channel_values[path[-1]] += value
|
| 63 |
+
|
| 64 |
+
elif model == 'first_click':
|
| 65 |
+
if path:
|
| 66 |
+
channel_values[path[0]] += value
|
| 67 |
+
|
| 68 |
+
elif model == 'linear':
|
| 69 |
+
weight = value / len(path)
|
| 70 |
+
for touch in path:
|
| 71 |
+
channel_values[touch] += weight
|
| 72 |
+
|
| 73 |
+
elif model == 'time_decay':
|
| 74 |
+
# Exponential decay: 2^(-x) where x is distance from conversion
|
| 75 |
+
weights = [2 ** -(len(path) - 1 - i) for i in range(len(path))]
|
| 76 |
+
total_weight = sum(weights)
|
| 77 |
+
if total_weight > 0:
|
| 78 |
+
normalized_weights = [w / total_weight * value for w in weights]
|
| 79 |
+
for i, touch in enumerate(path):
|
| 80 |
+
channel_values[touch] += normalized_weights[i]
|
| 81 |
+
|
| 82 |
+
elif model == 'position_based':
|
| 83 |
+
# 40% first, 40% last, 20% middle distributed
|
| 84 |
+
if len(path) == 1:
|
| 85 |
+
channel_values[path[0]] += value
|
| 86 |
+
elif len(path) == 2:
|
| 87 |
+
channel_values[path[0]] += value * 0.5
|
| 88 |
+
channel_values[path[1]] += value * 0.5
|
| 89 |
+
else:
|
| 90 |
+
channel_values[path[0]] += value * 0.4
|
| 91 |
+
channel_values[path[-1]] += value * 0.4
|
| 92 |
+
middle_weight = (value * 0.2) / (len(path) - 2)
|
| 93 |
+
for touch in path[1:-1]:
|
| 94 |
+
channel_values[touch] += middle_weight
|
| 95 |
+
|
| 96 |
+
return {
|
| 97 |
+
'breakdown': dict(channel_values),
|
| 98 |
+
'total_conversions': total_conversions,
|
| 99 |
+
'total_revenue': total_revenue
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
def get_top_paths(journeys, limit=10):
|
| 103 |
+
"""Aggregate common paths for Sankey diagram."""
|
| 104 |
+
path_counts = defaultdict(int)
|
| 105 |
+
for journey in journeys:
|
| 106 |
+
path = journey.get('path', [])
|
| 107 |
+
converted = journey.get('converted', False)
|
| 108 |
+
if not path:
|
| 109 |
+
continue
|
| 110 |
+
|
| 111 |
+
# Convert list to tuple for hashing
|
| 112 |
+
path_tuple = tuple(path + ['Conversion' if converted else 'Dropoff'])
|
| 113 |
+
path_counts[path_tuple] += 1
|
| 114 |
+
|
| 115 |
+
sorted_paths = sorted(path_counts.items(), key=lambda x: x[1], reverse=True)[:limit]
|
| 116 |
+
|
| 117 |
+
# Format for ECharts Sankey
|
| 118 |
+
nodes = set()
|
| 119 |
+
links = []
|
| 120 |
+
|
| 121 |
+
for path, count in sorted_paths:
|
| 122 |
+
for i in range(len(path) - 1):
|
| 123 |
+
src_node = f"{path[i]} (Step {i+1})"
|
| 124 |
+
tgt_node = f"{path[i+1]} (Step {i+2})"
|
| 125 |
+
|
| 126 |
+
if path[i+1] in ['Conversion', 'Dropoff']:
|
| 127 |
+
tgt_node = path[i+1]
|
| 128 |
+
|
| 129 |
+
nodes.add(src_node)
|
| 130 |
+
nodes.add(tgt_node)
|
| 131 |
+
|
| 132 |
+
# Check if link exists
|
| 133 |
+
found = False
|
| 134 |
+
for link in links:
|
| 135 |
+
if link['source'] == src_node and link['target'] == tgt_node:
|
| 136 |
+
link['value'] += count
|
| 137 |
+
found = True
|
| 138 |
+
break
|
| 139 |
+
if not found:
|
| 140 |
+
links.append({'source': src_node, 'target': tgt_node, 'value': count})
|
| 141 |
+
|
| 142 |
+
return {
|
| 143 |
+
'nodes': [{'name': n} for n in list(nodes)],
|
| 144 |
+
'links': links
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
def parse_uploaded_file(file):
|
| 148 |
+
"""Parse CSV or JSON file into standard journey format."""
|
| 149 |
+
filename = file.filename.lower()
|
| 150 |
+
journeys = []
|
| 151 |
+
|
| 152 |
+
try:
|
| 153 |
+
if filename.endswith('.json'):
|
| 154 |
+
content = json.load(file)
|
| 155 |
+
# Expect list of dicts
|
| 156 |
+
if isinstance(content, list):
|
| 157 |
+
journeys = content
|
| 158 |
+
else:
|
| 159 |
+
raise ValueError("JSON must be a list of journey objects")
|
| 160 |
+
|
| 161 |
+
elif filename.endswith('.csv'):
|
| 162 |
+
# Read CSV
|
| 163 |
+
stream = io.StringIO(file.stream.read().decode("UTF8"), newline=None)
|
| 164 |
+
reader = csv.DictReader(stream)
|
| 165 |
+
|
| 166 |
+
for row in reader:
|
| 167 |
+
# Heuristic to find path column
|
| 168 |
+
path_str = row.get('path') or row.get('touchpoints') or row.get('channels')
|
| 169 |
+
if not path_str:
|
| 170 |
+
continue
|
| 171 |
+
|
| 172 |
+
# Try to parse path string (e.g. "A > B > C" or "A,B,C")
|
| 173 |
+
if '>' in path_str:
|
| 174 |
+
path = [p.strip() for p in path_str.split('>')]
|
| 175 |
+
else:
|
| 176 |
+
path = [p.strip() for p in path_str.split(',')]
|
| 177 |
+
|
| 178 |
+
# Conversion
|
| 179 |
+
conv_str = str(row.get('converted', '0')).lower()
|
| 180 |
+
converted = conv_str in ['true', '1', 'yes', 'on']
|
| 181 |
+
|
| 182 |
+
# Value
|
| 183 |
+
try:
|
| 184 |
+
value = float(row.get('value', 0))
|
| 185 |
+
except:
|
| 186 |
+
value = 0
|
| 187 |
+
|
| 188 |
+
journeys.append({
|
| 189 |
+
'path': path,
|
| 190 |
+
'converted': converted,
|
| 191 |
+
'value': value
|
| 192 |
+
})
|
| 193 |
+
else:
|
| 194 |
+
raise ValueError("Unsupported file type. Please upload .csv or .json")
|
| 195 |
+
|
| 196 |
+
except Exception as e:
|
| 197 |
+
raise ValueError(f"Error parsing file: {str(e)}")
|
| 198 |
+
|
| 199 |
+
if not journeys:
|
| 200 |
+
raise ValueError("No valid journey data found in file")
|
| 201 |
+
|
| 202 |
+
return journeys
|
| 203 |
+
|
| 204 |
+
@app.route('/')
|
| 205 |
+
def index():
|
| 206 |
+
return render_template('index.html')
|
| 207 |
+
|
| 208 |
+
@app.route('/api/analyze', methods=['POST'])
|
| 209 |
+
def analyze():
|
| 210 |
+
try:
|
| 211 |
+
data = request.json
|
| 212 |
+
sample_size = int(data.get('sample_size', 1000))
|
| 213 |
+
|
| 214 |
+
# Generate data
|
| 215 |
+
journeys = generate_mock_data(sample_size)
|
| 216 |
+
|
| 217 |
+
# Calculate for all models
|
| 218 |
+
results = {}
|
| 219 |
+
models = ['last_click', 'first_click', 'linear', 'time_decay', 'position_based']
|
| 220 |
+
|
| 221 |
+
for m in models:
|
| 222 |
+
results[m] = calculate_attribution(journeys, m)
|
| 223 |
+
|
| 224 |
+
# Get Sankey data
|
| 225 |
+
sankey_data = get_top_paths(journeys, limit=20)
|
| 226 |
+
|
| 227 |
+
return jsonify({
|
| 228 |
+
'attribution_results': results,
|
| 229 |
+
'sankey_data': sankey_data,
|
| 230 |
+
'journey_count': len(journeys)
|
| 231 |
+
})
|
| 232 |
+
|
| 233 |
+
except Exception as e:
|
| 234 |
+
return jsonify({'error': str(e)}), 500
|
| 235 |
+
|
| 236 |
+
@app.route('/api/upload', methods=['POST'])
|
| 237 |
+
def upload_file():
|
| 238 |
+
try:
|
| 239 |
+
if 'file' not in request.files:
|
| 240 |
+
return jsonify({'error': 'No file part'}), 400
|
| 241 |
+
|
| 242 |
+
file = request.files['file']
|
| 243 |
+
if file.filename == '':
|
| 244 |
+
return jsonify({'error': 'No selected file'}), 400
|
| 245 |
+
|
| 246 |
+
journeys = parse_uploaded_file(file)
|
| 247 |
+
|
| 248 |
+
# Limit processing for performance if too large
|
| 249 |
+
if len(journeys) > 50000:
|
| 250 |
+
journeys = journeys[:50000]
|
| 251 |
+
|
| 252 |
+
# Calculate for all models
|
| 253 |
+
results = {}
|
| 254 |
+
models = ['last_click', 'first_click', 'linear', 'time_decay', 'position_based']
|
| 255 |
+
|
| 256 |
+
for m in models:
|
| 257 |
+
results[m] = calculate_attribution(journeys, m)
|
| 258 |
+
|
| 259 |
+
# Get Sankey data
|
| 260 |
+
sankey_data = get_top_paths(journeys, limit=30)
|
| 261 |
+
|
| 262 |
+
return jsonify({
|
| 263 |
+
'attribution_results': results,
|
| 264 |
+
'sankey_data': sankey_data,
|
| 265 |
+
'journey_count': len(journeys)
|
| 266 |
+
})
|
| 267 |
+
|
| 268 |
+
except ValueError as e:
|
| 269 |
+
return jsonify({'error': str(e)}), 400
|
| 270 |
+
except Exception as e:
|
| 271 |
+
return jsonify({'error': f"Internal error: {str(e)}"}), 500
|
| 272 |
+
|
| 273 |
+
if __name__ == '__main__':
|
| 274 |
+
app.run(host='0.0.0.0', port=7860)
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask
|
| 2 |
+
pandas
|
templates/index.html
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="zh-CN" class="dark">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>归因逻辑引擎 | Attribution Logic Engine</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
| 9 |
+
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
| 10 |
+
<script>
|
| 11 |
+
tailwind.config = {
|
| 12 |
+
darkMode: 'class',
|
| 13 |
+
theme: {
|
| 14 |
+
extend: {
|
| 15 |
+
colors: {
|
| 16 |
+
gray: {
|
| 17 |
+
800: '#1f2937',
|
| 18 |
+
900: '#111827',
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
</script>
|
| 25 |
+
<style>
|
| 26 |
+
body { background-color: #0f172a; color: #e2e8f0; }
|
| 27 |
+
.glass-panel {
|
| 28 |
+
background: rgba(30, 41, 59, 0.7);
|
| 29 |
+
backdrop-filter: blur(10px);
|
| 30 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 31 |
+
}
|
| 32 |
+
.chart-container {
|
| 33 |
+
height: 400px;
|
| 34 |
+
width: 100%;
|
| 35 |
+
}
|
| 36 |
+
/* Custom scrollbar */
|
| 37 |
+
::-webkit-scrollbar {
|
| 38 |
+
width: 8px;
|
| 39 |
+
height: 8px;
|
| 40 |
+
}
|
| 41 |
+
::-webkit-scrollbar-track {
|
| 42 |
+
background: #1e293b;
|
| 43 |
+
}
|
| 44 |
+
::-webkit-scrollbar-thumb {
|
| 45 |
+
background: #475569;
|
| 46 |
+
border-radius: 4px;
|
| 47 |
+
}
|
| 48 |
+
::-webkit-scrollbar-thumb:hover {
|
| 49 |
+
background: #64748b;
|
| 50 |
+
}
|
| 51 |
+
</style>
|
| 52 |
+
</head>
|
| 53 |
+
<body class="min-h-screen p-6 font-sans">
|
| 54 |
+
<div id="app" class="max-w-7xl mx-auto space-y-6">
|
| 55 |
+
<!-- Header -->
|
| 56 |
+
<header class="flex justify-between items-center mb-8">
|
| 57 |
+
<div>
|
| 58 |
+
<h1 class="text-3xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-purple-500">
|
| 59 |
+
归因逻辑引擎
|
| 60 |
+
</h1>
|
| 61 |
+
<p class="text-slate-400 mt-2">商业级多渠道营销归因分析与模型对比系统</p>
|
| 62 |
+
</div>
|
| 63 |
+
<div class="flex items-center space-x-4">
|
| 64 |
+
<span class="px-3 py-1 rounded-full bg-blue-500/20 text-blue-300 text-sm border border-blue-500/30">
|
| 65 |
+
v1.1.0
|
| 66 |
+
</span>
|
| 67 |
+
</div>
|
| 68 |
+
</header>
|
| 69 |
+
|
| 70 |
+
<!-- Controls -->
|
| 71 |
+
<div class="glass-panel p-6 rounded-xl grid grid-cols-1 md:grid-cols-3 gap-6 items-end">
|
| 72 |
+
<div>
|
| 73 |
+
<label class="block text-sm font-medium text-slate-300 mb-2">
|
| 74 |
+
模拟样本量 (Sample Size)
|
| 75 |
+
</label>
|
| 76 |
+
<input type="range" v-model.number="sampleSize" min="100" max="5000" step="100"
|
| 77 |
+
class="w-full h-2 bg-slate-700 rounded-lg appearance-none cursor-pointer">
|
| 78 |
+
<div class="text-right text-xs text-slate-400 mt-1">${ sampleSize } 条数据</div>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
<div class="flex justify-end md:col-span-2 space-x-4">
|
| 82 |
+
<!-- File Upload -->
|
| 83 |
+
<input type="file" ref="fileInput" @change="handleFileUpload" class="hidden" accept=".csv,.json">
|
| 84 |
+
<button @click="triggerUpload" :disabled="loading"
|
| 85 |
+
class="px-6 py-2.5 bg-slate-700 hover:bg-slate-600 text-white rounded-lg font-medium transition-all border border-slate-600 flex items-center space-x-2">
|
| 86 |
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
| 87 |
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
| 88 |
+
</svg>
|
| 89 |
+
<span>上传数据</span>
|
| 90 |
+
</button>
|
| 91 |
+
|
| 92 |
+
<button @click="analyze" :disabled="loading"
|
| 93 |
+
class="px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-medium transition-all shadow-lg shadow-blue-900/50 flex items-center space-x-2">
|
| 94 |
+
<span v-if="loading" class="animate-spin">⟳</span>
|
| 95 |
+
<span>${ loading ? '计算中...' : '生成模拟分析' }</span>
|
| 96 |
+
</button>
|
| 97 |
+
</div>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<!-- Error Message -->
|
| 101 |
+
<div v-if="error" class="p-4 rounded-lg bg-red-500/20 border border-red-500/50 text-red-200">
|
| 102 |
+
<strong>错误:</strong> ${ error }
|
| 103 |
+
</div>
|
| 104 |
+
|
| 105 |
+
<!-- Metrics Cards -->
|
| 106 |
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-6" v-if="results">
|
| 107 |
+
<div class="glass-panel p-6 rounded-xl border-l-4 border-emerald-500">
|
| 108 |
+
<h3 class="text-slate-400 text-sm uppercase">总转化数 (Conversions)</h3>
|
| 109 |
+
<p class="text-3xl font-bold text-emerald-400 mt-2">${ results.attribution_results.last_click.total_conversions }</p>
|
| 110 |
+
</div>
|
| 111 |
+
<div class="glass-panel p-6 rounded-xl border-l-4 border-indigo-500">
|
| 112 |
+
<h3 class="text-slate-400 text-sm uppercase">总营收 (Revenue)</h3>
|
| 113 |
+
<p class="text-3xl font-bold text-indigo-400 mt-2">
|
| 114 |
+
¥${ formatCurrency(results.attribution_results.last_click.total_revenue) }
|
| 115 |
+
</p>
|
| 116 |
+
</div>
|
| 117 |
+
<div class="glass-panel p-6 rounded-xl border-l-4 border-purple-500">
|
| 118 |
+
<h3 class="text-slate-400 text-sm uppercase">平均转化率 (CVR)</h3>
|
| 119 |
+
<p class="text-3xl font-bold text-purple-400 mt-2">
|
| 120 |
+
${ ((results.attribution_results.last_click.total_conversions / results.journey_count) * 100).toFixed(1) }%
|
| 121 |
+
</p>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<!-- Charts Grid -->
|
| 126 |
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6" v-show="results">
|
| 127 |
+
<!-- Model Comparison -->
|
| 128 |
+
<div class="glass-panel p-6 rounded-xl">
|
| 129 |
+
<h3 class="text-lg font-semibold text-white mb-4 flex items-center">
|
| 130 |
+
<span class="w-2 h-6 bg-blue-500 rounded mr-3"></span>
|
| 131 |
+
归因模型对比 (Model Comparison)
|
| 132 |
+
</h3>
|
| 133 |
+
<div id="barChart" class="chart-container"></div>
|
| 134 |
+
</div>
|
| 135 |
+
|
| 136 |
+
<!-- Sankey Flow -->
|
| 137 |
+
<div class="glass-panel p-6 rounded-xl">
|
| 138 |
+
<h3 class="text-lg font-semibold text-white mb-4 flex items-center">
|
| 139 |
+
<span class="w-2 h-6 bg-pink-500 rounded mr-3"></span>
|
| 140 |
+
用户路径流向 (Journey Flow)
|
| 141 |
+
</h3>
|
| 142 |
+
<div id="sankeyChart" class="chart-container"></div>
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<!-- Insights Table -->
|
| 147 |
+
<div class="glass-panel p-6 rounded-xl" v-if="results">
|
| 148 |
+
<h3 class="text-lg font-semibold text-white mb-4">渠道价值详情 (Channel Breakdown)</h3>
|
| 149 |
+
<div class="overflow-x-auto">
|
| 150 |
+
<table class="w-full text-left border-collapse">
|
| 151 |
+
<thead>
|
| 152 |
+
<tr class="text-slate-400 border-b border-slate-700">
|
| 153 |
+
<th class="p-3">渠道 (Channel)</th>
|
| 154 |
+
<th class="p-3">Last Click</th>
|
| 155 |
+
<th class="p-3">First Click</th>
|
| 156 |
+
<th class="p-3">Linear</th>
|
| 157 |
+
<th class="p-3">Time Decay</th>
|
| 158 |
+
<th class="p-3">Position Based</th>
|
| 159 |
+
</tr>
|
| 160 |
+
</thead>
|
| 161 |
+
<tbody class="text-slate-300">
|
| 162 |
+
<tr v-for="channel in displayedChannels" :key="channel" class="border-b border-slate-700/50 hover:bg-slate-800/50">
|
| 163 |
+
<td class="p-3 font-medium text-white">${ channel }</td>
|
| 164 |
+
<td class="p-3">¥${ formatCurrency(results.attribution_results.last_click.breakdown[channel] || 0) }</td>
|
| 165 |
+
<td class="p-3">¥${ formatCurrency(results.attribution_results.first_click.breakdown[channel] || 0) }</td>
|
| 166 |
+
<td class="p-3">¥${ formatCurrency(results.attribution_results.linear.breakdown[channel] || 0) }</td>
|
| 167 |
+
<td class="p-3">¥${ formatCurrency(results.attribution_results.time_decay.breakdown[channel] || 0) }</td>
|
| 168 |
+
<td class="p-3 text-yellow-400 font-bold">¥${ formatCurrency(results.attribution_results.position_based.breakdown[channel] || 0) }</td>
|
| 169 |
+
</tr>
|
| 170 |
+
</tbody>
|
| 171 |
+
</table>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<script>
|
| 178 |
+
const { createApp, ref, onMounted, nextTick, computed } = Vue;
|
| 179 |
+
|
| 180 |
+
createApp({
|
| 181 |
+
delimiters: ['${', '}'],
|
| 182 |
+
setup() {
|
| 183 |
+
const sampleSize = ref(1000);
|
| 184 |
+
const loading = ref(false);
|
| 185 |
+
const results = ref(null);
|
| 186 |
+
const error = ref(null);
|
| 187 |
+
const fileInput = ref(null);
|
| 188 |
+
|
| 189 |
+
let barChart = null;
|
| 190 |
+
let sankeyChart = null;
|
| 191 |
+
|
| 192 |
+
const displayedChannels = computed(() => {
|
| 193 |
+
if (!results.value) return [];
|
| 194 |
+
// Extract all unique channels from the results
|
| 195 |
+
const channels = new Set();
|
| 196 |
+
const breakdown = results.value.attribution_results.last_click.breakdown;
|
| 197 |
+
for (const ch in breakdown) {
|
| 198 |
+
channels.add(ch);
|
| 199 |
+
}
|
| 200 |
+
return Array.from(channels).sort();
|
| 201 |
+
});
|
| 202 |
+
|
| 203 |
+
const formatCurrency = (val) => {
|
| 204 |
+
return Math.round(val).toLocaleString();
|
| 205 |
+
};
|
| 206 |
+
|
| 207 |
+
const analyze = async () => {
|
| 208 |
+
loading.value = true;
|
| 209 |
+
error.value = null;
|
| 210 |
+
try {
|
| 211 |
+
const res = await fetch('/api/analyze', {
|
| 212 |
+
method: 'POST',
|
| 213 |
+
headers: {'Content-Type': 'application/json'},
|
| 214 |
+
body: JSON.stringify({ sample_size: sampleSize.value })
|
| 215 |
+
});
|
| 216 |
+
if (!res.ok) throw new Error(await res.text());
|
| 217 |
+
|
| 218 |
+
results.value = await res.json();
|
| 219 |
+
|
| 220 |
+
await nextTick();
|
| 221 |
+
renderCharts();
|
| 222 |
+
} catch (e) {
|
| 223 |
+
console.error(e);
|
| 224 |
+
error.value = '分析失败: ' + e.message;
|
| 225 |
+
} finally {
|
| 226 |
+
loading.value = false;
|
| 227 |
+
}
|
| 228 |
+
};
|
| 229 |
+
|
| 230 |
+
const triggerUpload = () => {
|
| 231 |
+
fileInput.value.click();
|
| 232 |
+
};
|
| 233 |
+
|
| 234 |
+
const handleFileUpload = async (event) => {
|
| 235 |
+
const file = event.target.files[0];
|
| 236 |
+
if (!file) return;
|
| 237 |
+
|
| 238 |
+
loading.value = true;
|
| 239 |
+
error.value = null;
|
| 240 |
+
|
| 241 |
+
const formData = new FormData();
|
| 242 |
+
formData.append('file', file);
|
| 243 |
+
|
| 244 |
+
try {
|
| 245 |
+
const res = await fetch('/api/upload', {
|
| 246 |
+
method: 'POST',
|
| 247 |
+
body: formData
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
if (!res.ok) {
|
| 251 |
+
const errText = await res.text();
|
| 252 |
+
throw new Error(errText || '上传失败');
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
results.value = await res.json();
|
| 256 |
+
await nextTick();
|
| 257 |
+
renderCharts();
|
| 258 |
+
|
| 259 |
+
// Reset input
|
| 260 |
+
event.target.value = '';
|
| 261 |
+
|
| 262 |
+
} catch (e) {
|
| 263 |
+
console.error(e);
|
| 264 |
+
error.value = '文件处理失败: ' + e.message;
|
| 265 |
+
} finally {
|
| 266 |
+
loading.value = false;
|
| 267 |
+
}
|
| 268 |
+
};
|
| 269 |
+
|
| 270 |
+
const renderCharts = () => {
|
| 271 |
+
if (!results.value) return;
|
| 272 |
+
|
| 273 |
+
// Bar Chart
|
| 274 |
+
if (barChart) barChart.dispose();
|
| 275 |
+
barChart = echarts.init(document.getElementById('barChart'), 'dark');
|
| 276 |
+
|
| 277 |
+
const channels = displayedChannels.value;
|
| 278 |
+
const models = ['last_click', 'first_click', 'linear', 'time_decay', 'position_based'];
|
| 279 |
+
const modelNames = ['Last Click', 'First Click', 'Linear', 'Time Decay', 'Position'];
|
| 280 |
+
|
| 281 |
+
const series = channels.map(channel => {
|
| 282 |
+
return {
|
| 283 |
+
name: channel,
|
| 284 |
+
type: 'bar',
|
| 285 |
+
stack: 'total',
|
| 286 |
+
emphasis: { focus: 'series' },
|
| 287 |
+
data: models.map(m => Math.round(results.value.attribution_results[m].breakdown[channel] || 0))
|
| 288 |
+
};
|
| 289 |
+
});
|
| 290 |
+
|
| 291 |
+
barChart.setOption({
|
| 292 |
+
backgroundColor: 'transparent',
|
| 293 |
+
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
| 294 |
+
legend: { data: channels, bottom: 0, textStyle: { color: '#94a3b8' } },
|
| 295 |
+
grid: { left: '3%', right: '4%', bottom: '15%', containLabel: true },
|
| 296 |
+
xAxis: {
|
| 297 |
+
type: 'category',
|
| 298 |
+
data: modelNames,
|
| 299 |
+
axisLine: { lineStyle: { color: '#475569' } }
|
| 300 |
+
},
|
| 301 |
+
yAxis: {
|
| 302 |
+
type: 'value',
|
| 303 |
+
axisLine: { lineStyle: { color: '#475569' } },
|
| 304 |
+
splitLine: { lineStyle: { color: '#334155', type: 'dashed' } }
|
| 305 |
+
},
|
| 306 |
+
series: series
|
| 307 |
+
});
|
| 308 |
+
|
| 309 |
+
// Sankey Chart
|
| 310 |
+
if (sankeyChart) sankeyChart.dispose();
|
| 311 |
+
sankeyChart = echarts.init(document.getElementById('sankeyChart'), 'dark');
|
| 312 |
+
|
| 313 |
+
sankeyChart.setOption({
|
| 314 |
+
backgroundColor: 'transparent',
|
| 315 |
+
tooltip: { trigger: 'item', triggerOn: 'mousemove' },
|
| 316 |
+
series: [{
|
| 317 |
+
type: 'sankey',
|
| 318 |
+
data: results.value.sankey_data.nodes,
|
| 319 |
+
links: results.value.sankey_data.links,
|
| 320 |
+
emphasis: { focus: 'adjacency' },
|
| 321 |
+
lineStyle: { color: 'gradient', curveness: 0.5 },
|
| 322 |
+
label: { color: '#e2e8f0' },
|
| 323 |
+
layoutIterations: 32 // Improve layout
|
| 324 |
+
}]
|
| 325 |
+
});
|
| 326 |
+
|
| 327 |
+
window.addEventListener('resize', () => {
|
| 328 |
+
barChart && barChart.resize();
|
| 329 |
+
sankeyChart && sankeyChart.resize();
|
| 330 |
+
});
|
| 331 |
+
};
|
| 332 |
+
|
| 333 |
+
onMounted(() => {
|
| 334 |
+
analyze();
|
| 335 |
+
});
|
| 336 |
+
|
| 337 |
+
return {
|
| 338 |
+
sampleSize,
|
| 339 |
+
loading,
|
| 340 |
+
results,
|
| 341 |
+
error,
|
| 342 |
+
fileInput,
|
| 343 |
+
analyze,
|
| 344 |
+
triggerUpload,
|
| 345 |
+
handleFileUpload,
|
| 346 |
+
displayedChannels,
|
| 347 |
+
formatCurrency
|
| 348 |
+
};
|
| 349 |
+
}
|
| 350 |
+
}).mount('#app');
|
| 351 |
+
</script>
|
| 352 |
+
</body>
|
| 353 |
+
</html>
|