cdp-edge 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +324 -0
- package/bin/cdp-edge.js +71 -0
- package/contracts/agent-versions.json +679 -0
- package/contracts/api-versions.json +372 -0
- package/contracts/types.ts +81 -0
- package/dist/commands/analyze.js +52 -0
- package/dist/commands/infra.js +54 -0
- package/dist/commands/install.js +191 -0
- package/dist/commands/server.js +174 -0
- package/dist/commands/setup.js +355 -0
- package/dist/commands/validate.js +248 -0
- package/dist/index.js +12 -0
- package/dist/sdk/cdpTrack.js +2095 -0
- package/dist/sdk/cdpTrack.min.js +64 -0
- package/dist/sdk/install-snippet.html +10 -0
- package/docs/CI-CD-SETUP.md +217 -0
- package/docs/events-reference.md +359 -0
- package/docs/installation.md +155 -0
- package/docs/quick-start.md +185 -0
- package/docs/sdk-reference.md +371 -0
- package/docs/whatsapp-ctwa.md +210 -0
- package/extracted-skill/tracking-events-generator/INDEX.md +94 -0
- package/extracted-skill/tracking-events-generator/INSTALACAO-CDPEDGE.md +58 -0
- package/extracted-skill/tracking-events-generator/INTEGRACAO-COMPLETA.md +683 -0
- package/extracted-skill/tracking-events-generator/MELHORIAS-IMPLEMENTADAS.md +513 -0
- package/extracted-skill/tracking-events-generator/Premium-Tracking-Intelligence-Resumo.md +333 -0
- package/extracted-skill/tracking-events-generator/SKILL.md +257 -0
- package/extracted-skill/tracking-events-generator/advanced-matching.js +364 -0
- package/extracted-skill/tracking-events-generator/agents/ab-ltv-agent.md +196 -0
- package/extracted-skill/tracking-events-generator/agents/ab-testing-agent.md +54 -0
- package/extracted-skill/tracking-events-generator/agents/attribution-agent.md +1304 -0
- package/extracted-skill/tracking-events-generator/agents/bidding-agent.md +347 -0
- package/extracted-skill/tracking-events-generator/agents/bing-agent.md +66 -0
- package/extracted-skill/tracking-events-generator/agents/browser-tracking.md +364 -0
- package/extracted-skill/tracking-events-generator/agents/code-guardian-agent.md +149 -0
- package/extracted-skill/tracking-events-generator/agents/compliance-agent.md +2097 -0
- package/extracted-skill/tracking-events-generator/agents/crm-integration-agent.md +1459 -0
- package/extracted-skill/tracking-events-generator/agents/dashboard-agent.md +456 -0
- package/extracted-skill/tracking-events-generator/agents/database-agent.md +668 -0
- package/extracted-skill/tracking-events-generator/agents/debug-agent.md +1455 -0
- package/extracted-skill/tracking-events-generator/agents/devops-agent.md +232 -0
- package/extracted-skill/tracking-events-generator/agents/domain-setup-agent.md +238 -0
- package/extracted-skill/tracking-events-generator/agents/email-agent.md +88 -0
- package/extracted-skill/tracking-events-generator/agents/fingerprint-agent.md +257 -0
- package/extracted-skill/tracking-events-generator/agents/fraud-detection-agent.md +143 -0
- package/extracted-skill/tracking-events-generator/agents/google-agent.md +235 -0
- package/extracted-skill/tracking-events-generator/agents/intelligence-agent.md +525 -0
- package/extracted-skill/tracking-events-generator/agents/lead-scoring-agent.md +282 -0
- package/extracted-skill/tracking-events-generator/agents/linkedin-agent.md +173 -0
- package/extracted-skill/tracking-events-generator/agents/localization-agent.md +55 -0
- package/extracted-skill/tracking-events-generator/agents/ltv-predictor-agent.md +59 -0
- package/extracted-skill/tracking-events-generator/agents/master-feedback-loop.md +960 -0
- package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +2154 -0
- package/extracted-skill/tracking-events-generator/agents/match-quality-agent.md +304 -0
- package/extracted-skill/tracking-events-generator/agents/memory-agent.json +25 -0
- package/extracted-skill/tracking-events-generator/agents/memory-agent.md +878 -0
- package/extracted-skill/tracking-events-generator/agents/meta-agent.md +118 -0
- package/extracted-skill/tracking-events-generator/agents/ml-clustering-agent.md +749 -0
- package/extracted-skill/tracking-events-generator/agents/page-analyzer.md +272 -0
- package/extracted-skill/tracking-events-generator/agents/performance-agent.md +1167 -0
- package/extracted-skill/tracking-events-generator/agents/performance-optimization-agent.md +1442 -0
- package/extracted-skill/tracking-events-generator/agents/pinterest-agent.md +318 -0
- package/extracted-skill/tracking-events-generator/agents/premium-tracking-intelligence-agent.md +849 -0
- package/extracted-skill/tracking-events-generator/agents/r2-setup-agent.md +258 -0
- package/extracted-skill/tracking-events-generator/agents/reddit-agent.md +321 -0
- package/extracted-skill/tracking-events-generator/agents/security-enterprise-agent.md +1861 -0
- package/extracted-skill/tracking-events-generator/agents/server-tracking.md +1188 -0
- package/extracted-skill/tracking-events-generator/agents/spotify-agent.md +391 -0
- package/extracted-skill/tracking-events-generator/agents/tiktok-agent.md +182 -0
- package/extracted-skill/tracking-events-generator/agents/tracking-plan-agent.md +459 -0
- package/extracted-skill/tracking-events-generator/agents/utm-agent.md +322 -0
- package/extracted-skill/tracking-events-generator/agents/validator-agent.md +271 -0
- package/extracted-skill/tracking-events-generator/agents/webhook-agent.md +177 -0
- package/extracted-skill/tracking-events-generator/agents/whatsapp-agent.md +129 -0
- package/extracted-skill/tracking-events-generator/agents/whatsapp-ctwa-setup-agent.md +707 -0
- package/extracted-skill/tracking-events-generator/agents/youtube-agent.md +537 -0
- package/extracted-skill/tracking-events-generator/anti-blocking.js +285 -0
- package/extracted-skill/tracking-events-generator/cdpTrack.js +640 -0
- package/extracted-skill/tracking-events-generator/contracts/api-versions.json +372 -0
- package/extracted-skill/tracking-events-generator/docs/guia-cloudflare-iniciante.md +107 -0
- package/extracted-skill/tracking-events-generator/engagement-scoring.js +226 -0
- package/extracted-skill/tracking-events-generator/evals/evals.json +235 -0
- package/extracted-skill/tracking-events-generator/integration-test.js +497 -0
- package/extracted-skill/tracking-events-generator/knowledge-base.md +3066 -0
- package/extracted-skill/tracking-events-generator/micro-events.js +992 -0
- package/extracted-skill/tracking-events-generator/models/captura-de-lead.md +78 -0
- package/extracted-skill/tracking-events-generator/models/captura-lead-evento-externo.md +99 -0
- package/extracted-skill/tracking-events-generator/models/checkout-proprio.md +111 -0
- package/extracted-skill/tracking-events-generator/models/lancamento-imobiliario.md +344 -0
- package/extracted-skill/tracking-events-generator/models/multi-step-checkout.md +672 -0
- package/extracted-skill/tracking-events-generator/models/pagina-obrigado.md +55 -0
- package/extracted-skill/tracking-events-generator/models/pinterest/conversions-api-template.js +144 -0
- package/extracted-skill/tracking-events-generator/models/pinterest/event-mappings.json +48 -0
- package/extracted-skill/tracking-events-generator/models/pinterest/tag-template.js +28 -0
- package/extracted-skill/tracking-events-generator/models/quiz-funnel.md +132 -0
- package/extracted-skill/tracking-events-generator/models/reddit/conversions-api-template.js +205 -0
- package/extracted-skill/tracking-events-generator/models/reddit/event-mappings.json +56 -0
- package/extracted-skill/tracking-events-generator/models/reddit/pixel-template.js +19 -0
- package/extracted-skill/tracking-events-generator/models/scenarios/behavior-engine.js +425 -0
- package/extracted-skill/tracking-events-generator/models/scenarios/real-estate-logic.md +50 -0
- package/extracted-skill/tracking-events-generator/models/scenarios/sales-page-logic.md +50 -0
- package/extracted-skill/tracking-events-generator/models/trafego-direto.md +582 -0
- package/extracted-skill/tracking-events-generator/models/webinar-registration.md +63 -0
- package/extracted-skill/tracking-events-generator/route-intent-capture.js +222 -0
- package/extracted-skill/tracking-events-generator/tracking.config.js +46 -0
- package/extracted-skill/tracking-events-generator/walkthrough.md +26 -0
- package/package.json +89 -0
- package/scripts/build-sdk.js +106 -0
- package/server-edge-tracker/.client.env.example +14 -0
- package/server-edge-tracker/INSTALAR.md +527 -0
- package/server-edge-tracker/SEGMENTATION-DOCS.md +513 -0
- package/server-edge-tracker/config/utm-mapping.json +64 -0
- package/server-edge-tracker/deploy-client.cjs +76 -0
- package/server-edge-tracker/index.ts +1164 -0
- package/server-edge-tracker/migrate-new-db.sql +137 -0
- package/server-edge-tracker/migrate-v2.sql +16 -0
- package/server-edge-tracker/migrate-v3.sql +6 -0
- package/server-edge-tracker/migrate-v4.sql +18 -0
- package/server-edge-tracker/migrate-v5.sql +17 -0
- package/server-edge-tracker/migrate-v6.sql +24 -0
- package/server-edge-tracker/migrate-v7.sql +64 -0
- package/server-edge-tracker/migrate.sql +111 -0
- package/server-edge-tracker/modules/db.ts +702 -0
- package/server-edge-tracker/modules/dispatch/ga4.ts +72 -0
- package/server-edge-tracker/modules/dispatch/meta.ts +143 -0
- package/server-edge-tracker/modules/dispatch/platforms.ts +255 -0
- package/server-edge-tracker/modules/dispatch/tiktok.ts +107 -0
- package/server-edge-tracker/modules/dispatch/whatsapp.ts +279 -0
- package/server-edge-tracker/modules/intelligence.ts +589 -0
- package/server-edge-tracker/modules/ml/bidding.ts +247 -0
- package/server-edge-tracker/modules/ml/fraud.ts +302 -0
- package/server-edge-tracker/modules/ml/logistic.ts +226 -0
- package/server-edge-tracker/modules/ml/ltv.ts +531 -0
- package/server-edge-tracker/modules/ml/matchquality.ts +232 -0
- package/server-edge-tracker/modules/ml/quiz.ts +343 -0
- package/server-edge-tracker/modules/ml/roas.ts +255 -0
- package/server-edge-tracker/modules/ml/segmentation.ts +407 -0
- package/server-edge-tracker/modules/nurture.ts +257 -0
- package/server-edge-tracker/modules/utils.ts +311 -0
- package/server-edge-tracker/modules/utm/utm-enricher.ts +231 -0
- package/server-edge-tracker/schema-ab-ltv.sql +97 -0
- package/server-edge-tracker/schema-bidding.sql +86 -0
- package/server-edge-tracker/schema-fraud.sql +90 -0
- package/server-edge-tracker/schema-indexes.sql +67 -0
- package/server-edge-tracker/schema-ltv-feedback.sql +11 -0
- package/server-edge-tracker/schema-quiz.sql +52 -0
- package/server-edge-tracker/schema-sales-engine.sql +113 -0
- package/server-edge-tracker/schema-segmentation.sql +219 -0
- package/server-edge-tracker/schema-utm.sql +82 -0
- package/server-edge-tracker/schema.sql +265 -0
- package/server-edge-tracker/types.ts +258 -0
- package/server-edge-tracker/wrangler.toml +136 -0
- package/templates/afiliado-sem-landing.md +312 -0
- package/templates/captura-de-lead.md +78 -0
- package/templates/captura-lead-evento-externo.md +99 -0
- package/templates/checkout-proprio.md +111 -0
- package/templates/install/.claude/commands/cdp.md +1 -0
- package/templates/install/CLAUDE.md +65 -0
- package/templates/lancamento-imobiliario.md +344 -0
- package/templates/linkedin/tag-template.js +46 -0
- package/templates/multi-step-checkout.md +672 -0
- package/templates/pagina-obrigado.md +55 -0
- package/templates/pinterest/conversions-api-template.js +144 -0
- package/templates/pinterest/event-mappings.json +48 -0
- package/templates/pinterest/tag-template.js +28 -0
- package/templates/quiz-funnel.md +132 -0
- package/templates/reddit/conversions-api-template.js +205 -0
- package/templates/reddit/event-mappings.json +56 -0
- package/templates/reddit/pixel-template.js +19 -0
- package/templates/scenarios/behavior-engine.js +425 -0
- package/templates/scenarios/real-estate-logic.md +50 -0
- package/templates/scenarios/sales-page-logic.md +50 -0
- package/templates/spotify/pixel-template.js +46 -0
- package/templates/trafego-direto.md +582 -0
- package/templates/vsl-page.md +292 -0
- package/templates/webinar-registration.md +63 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — ROAS Feedback Loop (Fase 7)
|
|
3
|
+
*
|
|
4
|
+
* Cruza dados de leads (UTM) com compras confirmadas (Purchase events) no D1
|
|
5
|
+
* para calcular qualidade real de cada campanha:
|
|
6
|
+
* → revenue_per_lead, conversion_rate, ltv_accuracy
|
|
7
|
+
* → alimenta bidding.ts com dados reais de ROAS
|
|
8
|
+
* → relatório semanal via CallMeBot
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Env } from '../../types.js';
|
|
12
|
+
import { sendIntelligenceAlert } from '../intelligence.js';
|
|
13
|
+
|
|
14
|
+
// ── Tipos ─────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export interface CampaignRoas {
|
|
17
|
+
utm_source: string;
|
|
18
|
+
utm_campaign: string;
|
|
19
|
+
utm_content: string; // origem do lead: quiz_*, video_*, landing_*, ctwa_*
|
|
20
|
+
total_leads: number;
|
|
21
|
+
confirmed_buyers: number;
|
|
22
|
+
conversion_rate: number; // 0.0–1.0
|
|
23
|
+
total_revenue: number; // soma de value dos Purchase events
|
|
24
|
+
revenue_per_lead: number; // total_revenue / total_leads
|
|
25
|
+
avg_ltv_score: number; // média do ltvScore predito (valida accuracy do modelo)
|
|
26
|
+
ltv_accuracy: number; // % de leads com ltvClass=High que realmente compraram
|
|
27
|
+
top_qualification: string; // qualificação quiz mais frequente nessa campanha
|
|
28
|
+
bid_recommendation: 'increase' | 'maintain' | 'decrease' | 'pause';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface RoasReport {
|
|
32
|
+
generated_at: string;
|
|
33
|
+
period_days: number;
|
|
34
|
+
campaigns: CampaignRoas[];
|
|
35
|
+
total_revenue: number;
|
|
36
|
+
total_leads: number;
|
|
37
|
+
best_campaign: string | null;
|
|
38
|
+
worst_campaign: string | null;
|
|
39
|
+
model_accuracy: number; // % geral do modelo LTV
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── computeRoasFeedback — calcula ROAS por campanha ───────────────────────────
|
|
43
|
+
|
|
44
|
+
export async function computeRoasFeedback(
|
|
45
|
+
env: Env,
|
|
46
|
+
periodDays = 30,
|
|
47
|
+
): Promise<RoasReport | null> {
|
|
48
|
+
if (!env.DB) return null;
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
// Leads com UTM no período + seu LTV predito
|
|
52
|
+
const leadsRows = await env.DB.prepare(`
|
|
53
|
+
SELECT
|
|
54
|
+
COALESCE(l.utm_source, 'direct') AS utm_source,
|
|
55
|
+
COALESCE(l.utm_campaign, 'unknown') AS utm_campaign,
|
|
56
|
+
COALESCE(l.utm_content, 'unknown') AS utm_content,
|
|
57
|
+
l.user_id,
|
|
58
|
+
l.value AS predicted_value,
|
|
59
|
+
l.intention_level,
|
|
60
|
+
up.predicted_ltv_class,
|
|
61
|
+
qs.qualification AS quiz_qualification
|
|
62
|
+
FROM leads l
|
|
63
|
+
LEFT JOIN user_profiles up ON up.user_id = l.user_id
|
|
64
|
+
LEFT JOIN quiz_sessions qs ON qs.user_id = l.user_id
|
|
65
|
+
WHERE l.created_at >= datetime('now', '-' || ? || ' days')
|
|
66
|
+
AND l.event_name IN ('Lead','Contact','QuizComplete','CompleteRegistration')
|
|
67
|
+
ORDER BY l.created_at DESC
|
|
68
|
+
`).bind(periodDays).all();
|
|
69
|
+
|
|
70
|
+
// Compras confirmadas no mesmo período — JOIN por user_id
|
|
71
|
+
const purchaseRows = await env.DB.prepare(`
|
|
72
|
+
SELECT
|
|
73
|
+
l.utm_source,
|
|
74
|
+
l.utm_campaign,
|
|
75
|
+
l.utm_content,
|
|
76
|
+
l.user_id,
|
|
77
|
+
e.value AS purchase_value
|
|
78
|
+
FROM events e
|
|
79
|
+
JOIN leads l ON l.user_id = e.user_id
|
|
80
|
+
WHERE e.event_name IN ('Purchase','purchase')
|
|
81
|
+
AND e.created_at >= datetime('now', '-' || ? || ' days')
|
|
82
|
+
AND l.event_name IN ('Lead','Contact','QuizComplete','CompleteRegistration')
|
|
83
|
+
`).bind(periodDays).all();
|
|
84
|
+
|
|
85
|
+
const leads = (leadsRows.results || []) as any[];
|
|
86
|
+
const purchases = (purchaseRows.results || []) as any[];
|
|
87
|
+
|
|
88
|
+
if (leads.length === 0) return null;
|
|
89
|
+
|
|
90
|
+
// Indexa compras por user_id para lookup O(1)
|
|
91
|
+
const buyerMap = new Map<string, number>(); // user_id → purchase_value
|
|
92
|
+
for (const p of purchases) {
|
|
93
|
+
const existing = buyerMap.get(p.user_id) || 0;
|
|
94
|
+
buyerMap.set(p.user_id, existing + (parseFloat(String(p.purchase_value || 0))));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Agrupa por campanha
|
|
98
|
+
const campaignMap = new Map<string, {
|
|
99
|
+
leads: any[];
|
|
100
|
+
buyers: Set<string>;
|
|
101
|
+
revenue: number;
|
|
102
|
+
highLtvLeads: number;
|
|
103
|
+
highLtvBuyers: number;
|
|
104
|
+
qualifications: Record<string, number>;
|
|
105
|
+
}>();
|
|
106
|
+
|
|
107
|
+
for (const lead of leads) {
|
|
108
|
+
const key = `${lead.utm_source}|||${lead.utm_campaign}|||${lead.utm_content || 'unknown'}`;
|
|
109
|
+
if (!campaignMap.has(key)) {
|
|
110
|
+
campaignMap.set(key, {
|
|
111
|
+
leads: [], buyers: new Set(), revenue: 0,
|
|
112
|
+
highLtvLeads: 0, highLtvBuyers: 0,
|
|
113
|
+
qualifications: {},
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const c = campaignMap.get(key)!;
|
|
117
|
+
c.leads.push(lead);
|
|
118
|
+
|
|
119
|
+
if (lead.predicted_ltv_class === 'High') c.highLtvLeads++;
|
|
120
|
+
|
|
121
|
+
const purchaseValue = buyerMap.get(lead.user_id);
|
|
122
|
+
if (purchaseValue !== undefined) {
|
|
123
|
+
c.buyers.add(lead.user_id);
|
|
124
|
+
c.revenue += purchaseValue;
|
|
125
|
+
if (lead.predicted_ltv_class === 'High') c.highLtvBuyers++;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const qual = lead.quiz_qualification || lead.intention_level || 'unknown';
|
|
129
|
+
c.qualifications[qual] = (c.qualifications[qual] || 0) + 1;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Monta relatório por campanha
|
|
133
|
+
const campaigns: CampaignRoas[] = [];
|
|
134
|
+
for (const [key, c] of campaignMap.entries()) {
|
|
135
|
+
const [utm_source, utm_campaign, utm_content] = key.split('|||');
|
|
136
|
+
const total_leads = c.leads.length;
|
|
137
|
+
const confirmed_buyers = c.buyers.size;
|
|
138
|
+
const conversion_rate = total_leads > 0 ? confirmed_buyers / total_leads : 0;
|
|
139
|
+
const revenue_per_lead = total_leads > 0 ? c.revenue / total_leads : 0;
|
|
140
|
+
const avg_ltv_score = c.leads.reduce((s: number, l: any) => s + parseFloat(String(l.predicted_value || 0)), 0) / total_leads;
|
|
141
|
+
const ltv_accuracy = c.highLtvLeads > 0 ? c.highLtvBuyers / c.highLtvLeads : 0;
|
|
142
|
+
|
|
143
|
+
const top_qualification = Object.entries(c.qualifications)
|
|
144
|
+
.sort((a, b) => b[1] - a[1])[0]?.[0] || 'unknown';
|
|
145
|
+
|
|
146
|
+
// Recomendação de bid baseada em conversão + accuracy
|
|
147
|
+
let bid_recommendation: CampaignRoas['bid_recommendation'];
|
|
148
|
+
if (conversion_rate >= 0.15 && ltv_accuracy >= 0.6) bid_recommendation = 'increase';
|
|
149
|
+
else if (conversion_rate >= 0.05 && ltv_accuracy >= 0.3) bid_recommendation = 'maintain';
|
|
150
|
+
else if (conversion_rate > 0 && confirmed_buyers > 0) bid_recommendation = 'decrease';
|
|
151
|
+
else bid_recommendation = 'pause';
|
|
152
|
+
|
|
153
|
+
campaigns.push({
|
|
154
|
+
utm_source, utm_campaign, utm_content,
|
|
155
|
+
total_leads, confirmed_buyers,
|
|
156
|
+
conversion_rate: Math.round(conversion_rate * 10000) / 10000,
|
|
157
|
+
total_revenue: Math.round(c.revenue * 100) / 100,
|
|
158
|
+
revenue_per_lead: Math.round(revenue_per_lead * 100) / 100,
|
|
159
|
+
avg_ltv_score: Math.round(avg_ltv_score * 100) / 100,
|
|
160
|
+
ltv_accuracy: Math.round(ltv_accuracy * 10000) / 10000,
|
|
161
|
+
top_qualification,
|
|
162
|
+
bid_recommendation,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Ordena por revenue total desc
|
|
167
|
+
campaigns.sort((a, b) => b.total_revenue - a.total_revenue);
|
|
168
|
+
|
|
169
|
+
const total_revenue = campaigns.reduce((s, c) => s + c.total_revenue, 0);
|
|
170
|
+
const total_leads = campaigns.reduce((s, c) => s + c.total_leads, 0);
|
|
171
|
+
// model_accuracy: média ponderada de ltv_accuracy por campanha (High LTV que realmente compraram)
|
|
172
|
+
const model_accuracy = campaigns.length > 0
|
|
173
|
+
? campaigns.reduce((s, c) => s + c.ltv_accuracy * c.total_leads, 0) / Math.max(total_leads, 1)
|
|
174
|
+
: 0;
|
|
175
|
+
|
|
176
|
+
const best_campaign = campaigns.find(c => c.bid_recommendation === 'increase')
|
|
177
|
+
? `${campaigns[0].utm_source} / ${campaigns[0].utm_campaign}`
|
|
178
|
+
: null;
|
|
179
|
+
const worst_campaign = campaigns.find(c => c.bid_recommendation === 'pause')
|
|
180
|
+
? (() => { const w = campaigns.find(c => c.bid_recommendation === 'pause')!; return `${w.utm_source} / ${w.utm_campaign}`; })()
|
|
181
|
+
: null;
|
|
182
|
+
|
|
183
|
+
// Persiste no D1 para histórico
|
|
184
|
+
await _persistRoasReport(env, campaigns, periodDays);
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
generated_at: new Date().toISOString(),
|
|
188
|
+
period_days: periodDays,
|
|
189
|
+
campaigns,
|
|
190
|
+
total_revenue: Math.round(total_revenue * 100) / 100,
|
|
191
|
+
total_leads,
|
|
192
|
+
best_campaign,
|
|
193
|
+
worst_campaign,
|
|
194
|
+
model_accuracy: Math.round(model_accuracy * 10000) / 10000,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
} catch (err: any) {
|
|
198
|
+
console.error('[ROAS] computeRoasFeedback error:', err?.message || String(err));
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── sendRoasAlert — relatório via CallMeBot ───────────────────────────────────
|
|
204
|
+
|
|
205
|
+
export async function sendRoasAlert(env: Env, report: RoasReport): Promise<void> {
|
|
206
|
+
const top3 = report.campaigns.slice(0, 3);
|
|
207
|
+
const lines = top3.map(c =>
|
|
208
|
+
`• ${c.utm_source}/${c.utm_campaign}/${c.utm_content}: ${c.confirmed_buyers} compradores, R$${c.total_revenue.toLocaleString('pt-BR')} (${(c.conversion_rate * 100).toFixed(1)}% conv) → ${c.bid_recommendation.toUpperCase()}`
|
|
209
|
+
).join('\n');
|
|
210
|
+
|
|
211
|
+
const pauseCount = report.campaigns.filter(c => c.bid_recommendation === 'pause').length;
|
|
212
|
+
const increaseCount = report.campaigns.filter(c => c.bid_recommendation === 'increase').length;
|
|
213
|
+
|
|
214
|
+
const details = [
|
|
215
|
+
`📅 Período: últimos ${report.period_days} dias`,
|
|
216
|
+
`💰 Receita total: R$${report.total_revenue.toLocaleString('pt-BR')}`,
|
|
217
|
+
`👥 Total leads: ${report.total_leads}`,
|
|
218
|
+
`📈 Top campanhas por receita:\n${lines}`,
|
|
219
|
+
increaseCount > 0 ? `✅ ${increaseCount} campanha(s) recomendada(s) para AUMENTAR bid` : '',
|
|
220
|
+
pauseCount > 0 ? `⛔ ${pauseCount} campanha(s) recomendada(s) para PAUSAR` : '',
|
|
221
|
+
report.best_campaign ? `🏆 Melhor: ${report.best_campaign}` : '',
|
|
222
|
+
report.worst_campaign ? `🔻 Pior: ${report.worst_campaign}` : '',
|
|
223
|
+
].filter(Boolean).join('\n');
|
|
224
|
+
|
|
225
|
+
await sendIntelligenceAlert(env, 'info', 'ROAS Feedback Semanal', details);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── _persistRoasReport — salva snapshot no D1 ────────────────────────────────
|
|
229
|
+
|
|
230
|
+
async function _persistRoasReport(
|
|
231
|
+
env: Env,
|
|
232
|
+
campaigns: CampaignRoas[],
|
|
233
|
+
periodDays: number,
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
if (!env.DB) return;
|
|
236
|
+
try {
|
|
237
|
+
for (const c of campaigns) {
|
|
238
|
+
await env.DB.prepare(`
|
|
239
|
+
INSERT INTO roas_reports (
|
|
240
|
+
utm_source, utm_campaign, utm_content, period_days,
|
|
241
|
+
total_leads, confirmed_buyers, conversion_rate,
|
|
242
|
+
total_revenue, revenue_per_lead, ltv_accuracy,
|
|
243
|
+
top_qualification, bid_recommendation, created_at
|
|
244
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,datetime('now'))
|
|
245
|
+
`).bind(
|
|
246
|
+
c.utm_source, c.utm_campaign, c.utm_content, periodDays,
|
|
247
|
+
c.total_leads, c.confirmed_buyers, c.conversion_rate,
|
|
248
|
+
c.total_revenue, c.revenue_per_lead, c.ltv_accuracy,
|
|
249
|
+
c.top_qualification, c.bid_recommendation,
|
|
250
|
+
).run();
|
|
251
|
+
}
|
|
252
|
+
} catch (err: any) {
|
|
253
|
+
console.error('[ROAS] persist error:', err?.message || String(err));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CDP Edge — ML Clustering (Fase 1)
|
|
3
|
+
* Handlers das rotas /api/segmentation/*
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { tryParseJson } from '../utils.js';
|
|
7
|
+
import { Env } from '../../types.js';
|
|
8
|
+
|
|
9
|
+
// ── Tipos ───────────────────────────────────────────────────────────────────────
|
|
10
|
+
interface KmeansResult {
|
|
11
|
+
assignments: number[];
|
|
12
|
+
centroids: number[][];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ClusterStats {
|
|
16
|
+
c: number;
|
|
17
|
+
size: number;
|
|
18
|
+
pct: number;
|
|
19
|
+
avgLtv: number;
|
|
20
|
+
avgEng: number;
|
|
21
|
+
avgDays: number;
|
|
22
|
+
topSource: string;
|
|
23
|
+
topState: string;
|
|
24
|
+
topIntent: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Cluster {
|
|
28
|
+
cluster_id: number;
|
|
29
|
+
name: string;
|
|
30
|
+
size: number;
|
|
31
|
+
percentage: number;
|
|
32
|
+
action_recommendation: string;
|
|
33
|
+
characteristics: {
|
|
34
|
+
avg_ltv_class: number;
|
|
35
|
+
avg_engagement_score: number;
|
|
36
|
+
avg_intention_level: number;
|
|
37
|
+
avg_days_since_lead: number;
|
|
38
|
+
dominant_countries: string[];
|
|
39
|
+
dominant_states: string[];
|
|
40
|
+
dominant_utm_sources: string[];
|
|
41
|
+
top_features: string[];
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ClusterInfo {
|
|
46
|
+
cluster_id: number;
|
|
47
|
+
name: string;
|
|
48
|
+
action: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Helpers K-means vetorial ──────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
function _cosDist(a: number[], b: number[]): number {
|
|
54
|
+
let dot = 0, na = 0, nb = 0;
|
|
55
|
+
for (let i = 0; i < a.length; i++) { dot += a[i]*b[i]; na += a[i]*a[i]; nb += b[i]*b[i]; }
|
|
56
|
+
return 1 - dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-10);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function _kmeansRun(vectors: number[][], k: number, maxIter = 25): KmeansResult {
|
|
60
|
+
const n = vectors.length, dim = vectors[0].length;
|
|
61
|
+
const centroids = [vectors[Math.floor(Math.random() * n)]];
|
|
62
|
+
while (centroids.length < k) {
|
|
63
|
+
const dists = vectors.map(v => Math.min(...centroids.map(c => _cosDist(v, c))));
|
|
64
|
+
const sum = dists.reduce((a, b) => a + b, 0);
|
|
65
|
+
let r = Math.random() * sum, cumul = 0;
|
|
66
|
+
for (let i = 0; i < n; i++) { cumul += dists[i]; if (cumul >= r) { centroids.push(vectors[i]); break; } }
|
|
67
|
+
if (centroids.length < k) centroids.push(vectors[Math.floor(Math.random() * n)]);
|
|
68
|
+
}
|
|
69
|
+
let assignments = new Array(n).fill(0);
|
|
70
|
+
for (let iter = 0; iter < maxIter; iter++) {
|
|
71
|
+
let changed = false;
|
|
72
|
+
for (let i = 0; i < n; i++) {
|
|
73
|
+
let best = 0, bestD = Infinity;
|
|
74
|
+
for (let c = 0; c < k; c++) { const d = _cosDist(vectors[i], centroids[c]); if (d < bestD) { bestD = d; best = c; } }
|
|
75
|
+
if (assignments[i] !== best) { assignments[i] = best; changed = true; }
|
|
76
|
+
}
|
|
77
|
+
if (!changed) break;
|
|
78
|
+
for (let c = 0; c < k; c++) {
|
|
79
|
+
const members = vectors.filter((_, i) => assignments[i] === c);
|
|
80
|
+
if (!members.length) continue;
|
|
81
|
+
for (let d = 0; d < dim; d++) centroids[c][d] = members.reduce((s, v) => s + v[d], 0) / members.length;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { assignments, centroids };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function _silhouette(vectors: number[][], assignments: number[], k: number): number {
|
|
88
|
+
const n = vectors.length;
|
|
89
|
+
let total = 0;
|
|
90
|
+
for (let i = 0; i < n; i++) {
|
|
91
|
+
const ci = assignments[i];
|
|
92
|
+
const same = vectors.filter((_, j) => j !== i && assignments[j] === ci);
|
|
93
|
+
const a = same.length ? same.reduce((s, v) => s + _cosDist(vectors[i], v), 0) / same.length : 0;
|
|
94
|
+
let b = Infinity;
|
|
95
|
+
for (let c = 0; c < k; c++) {
|
|
96
|
+
if (c === ci) continue;
|
|
97
|
+
const other = vectors.filter((_, j) => assignments[j] === c);
|
|
98
|
+
if (other.length) b = Math.min(b, other.reduce((s, v) => s + _cosDist(vectors[i], v), 0) / other.length);
|
|
99
|
+
}
|
|
100
|
+
total += b === Infinity ? 0 : (b - a) / Math.max(a, b);
|
|
101
|
+
}
|
|
102
|
+
return Math.round((total / n) * 1000) / 1000;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function _buildLeadProfile(l: any): string {
|
|
106
|
+
return [
|
|
107
|
+
`LTV: ${l.predicted_ltv_class || 'desconhecido'}`,
|
|
108
|
+
`engajamento: ${Math.round(l.engagement_score || 0)}`,
|
|
109
|
+
`intenção: ${l.intention_level || 'desconhecida'}`,
|
|
110
|
+
`origem: ${l.utm_source || 'direto'}`,
|
|
111
|
+
`canal: ${l.utm_medium || 'desconhecido'}`,
|
|
112
|
+
`país: ${l.country || 'BR'}`,
|
|
113
|
+
`estado: ${l.state || ''}`,
|
|
114
|
+
`hora: ${l.hour_of_day || 12}h`,
|
|
115
|
+
(l.is_weekend ? 'fim-de-semana' : 'dia-útil'),
|
|
116
|
+
`recência: ${l.days_since_lead || 0} dias`,
|
|
117
|
+
].filter(Boolean).join(', ');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── POST /api/segmentation/cluster ────────────────────────────────────────────
|
|
121
|
+
// Clustering real: embeddinggemma-300m → K-means vetorial → Granite para nomear
|
|
122
|
+
export async function handleSegmentationCluster(env: Env, request: Request, headers: Headers): Promise<Response> {
|
|
123
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
124
|
+
if (!env.AI) return new Response(JSON.stringify({ error: 'Workers AI não configurado' }), { status: 503, headers });
|
|
125
|
+
|
|
126
|
+
const url = new URL(request.url);
|
|
127
|
+
const algorithm = url.searchParams.get('algorithm') || 'kmeans';
|
|
128
|
+
const nClusters = Math.min(10, Math.max(2, parseInt(url.searchParams.get('n_clusters') || '5')));
|
|
129
|
+
const clientVertical = url.searchParams.get('vertical') || 'general';
|
|
130
|
+
const forceRecluster = url.searchParams.get('force') === 'true';
|
|
131
|
+
|
|
132
|
+
if (!['kmeans', 'dbscan', 'hierarchical'].includes(algorithm)) {
|
|
133
|
+
return new Response(JSON.stringify({ error: 'algorithm deve ser: kmeans, dbscan ou hierarchical', received: algorithm }), { status: 400, headers });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
if (!forceRecluster) {
|
|
138
|
+
const existing = await env.DB.prepare(`
|
|
139
|
+
SELECT id, created_at, cluster_name FROM ml_segments
|
|
140
|
+
WHERE clustering_algorithm = ? AND is_active = 1 AND client_vertical = ?
|
|
141
|
+
ORDER BY created_at DESC LIMIT 1
|
|
142
|
+
`).bind(algorithm, clientVertical).first();
|
|
143
|
+
if (existing) {
|
|
144
|
+
const ageDays = (Date.now() - new Date((existing as any).created_at).getTime()) / 864e5;
|
|
145
|
+
if (ageDays < 7) {
|
|
146
|
+
return new Response(JSON.stringify({
|
|
147
|
+
success: true, message: 'Cluster existente ainda válido (< 7 dias). Use ?force=true para re-clustering.',
|
|
148
|
+
cluster_id: (existing as any).id, cluster_name: (existing as any).cluster_name,
|
|
149
|
+
age_days: Math.round(ageDays * 10) / 10, use_existing: true,
|
|
150
|
+
}), { status: 200, headers });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const leadsRes = await env.DB.prepare(`
|
|
156
|
+
SELECT id, predicted_ltv_class, engagement_score, intention_level,
|
|
157
|
+
country, state, utm_source, utm_medium, bot_score,
|
|
158
|
+
CAST(strftime('%H', created_at) AS INTEGER) AS hour_of_day,
|
|
159
|
+
CAST(julianday('now') - julianday(created_at) AS INTEGER) AS days_since_lead,
|
|
160
|
+
CASE WHEN strftime('%w', created_at) IN ('0','6') THEN 1 ELSE 0 END AS is_weekend
|
|
161
|
+
FROM leads
|
|
162
|
+
WHERE created_at >= datetime('now', '-6 months') AND (bot_score IS NULL OR bot_score < 2)
|
|
163
|
+
ORDER BY RANDOM() LIMIT 2000
|
|
164
|
+
`).all();
|
|
165
|
+
|
|
166
|
+
const leads = leadsRes.results || [];
|
|
167
|
+
if (leads.length < 50) {
|
|
168
|
+
return new Response(JSON.stringify({ error: 'Dados insuficientes para clustering. Mínimo: 50 leads.', leads_found: leads.length, required: 50 }), { status: 400, headers });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const startTime = Date.now();
|
|
172
|
+
const sample = leads.slice(0, 100);
|
|
173
|
+
const profiles = sample.map(_buildLeadProfile);
|
|
174
|
+
|
|
175
|
+
// Embeddings reais via embeddinggemma-300m
|
|
176
|
+
const embRes = await env.AI.run('@cf/baai/bge-m3', { text: profiles });
|
|
177
|
+
const vectors = (embRes as any).data as number[][];
|
|
178
|
+
if (!vectors || vectors.length < nClusters) throw new Error(`embeddinggemma retornou ${vectors?.length ?? 0} vetores`);
|
|
179
|
+
|
|
180
|
+
// K-means vetorial real
|
|
181
|
+
const { assignments } = _kmeansRun(vectors, nClusters);
|
|
182
|
+
const silhouetteScore = _silhouette(vectors, assignments, nClusters);
|
|
183
|
+
|
|
184
|
+
// Agregação por cluster para nomear com Granite
|
|
185
|
+
const clusterStats: (ClusterStats | null)[] = Array.from({ length: nClusters }, (_, c) => {
|
|
186
|
+
const members = sample.filter((_, i) => assignments[i] === c);
|
|
187
|
+
if (!members.length) return null;
|
|
188
|
+
const ltvMap: Record<string, number> = { High: 1, Medium: 0.5, Low: 0 };
|
|
189
|
+
const avgLtv = members.reduce((s: number, l: any) => s + (ltvMap[l.predicted_ltv_class] ?? 0), 0) / members.length;
|
|
190
|
+
const avgEng = members.reduce((s: number, l: any) => s + (l.engagement_score || 0), 0) / members.length;
|
|
191
|
+
const avgDays = members.reduce((s: number, l: any) => s + (l.days_since_lead || 0), 0) / members.length;
|
|
192
|
+
const freq = (arr: string[]) => arr.length ? [...arr.reduce((m,s) => m.set(s,(m.get(s)||0)+1), new Map())].sort((a,b)=>b[1]-a[1])[0]?.[0] : null;
|
|
193
|
+
return {
|
|
194
|
+
c, size: members.length, pct: Math.round(members.length / sample.length * 100),
|
|
195
|
+
avgLtv, avgEng, avgDays,
|
|
196
|
+
topSource: freq(members.map((l: any) => l.utm_source).filter(Boolean)) || 'direto',
|
|
197
|
+
topState: freq(members.map((l: any) => l.state).filter(Boolean)) || 'BR',
|
|
198
|
+
topIntent: freq(members.map((l: any) => l.intention_level).filter(Boolean)) || 'desconhecida',
|
|
199
|
+
};
|
|
200
|
+
}).filter(Boolean) as ClusterStats[];
|
|
201
|
+
|
|
202
|
+
// Type guard function to filter null values
|
|
203
|
+
function isNotNull<T>(value: T | null): value is T {
|
|
204
|
+
return value !== null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const validClusterStats = clusterStats.filter(isNotNull);
|
|
208
|
+
|
|
209
|
+
// Granite apenas para nomear segmentos
|
|
210
|
+
const namingPrompt =
|
|
211
|
+
`Você é especialista em segmentação de clientes. Dê um nome descritivo em português e uma recomendação de campanha para cada segmento. Retorne SOMENTE JSON válido:
|
|
212
|
+
{"segments":[{"cluster_id":0,"name":"...","action":"..."},...]}
|
|
213
|
+
|
|
214
|
+
${validClusterStats.map(s => `Cluster ${s.c}: LTV=${s.avgLtv.toFixed(2)}, engajamento=${s.avgEng.toFixed(0)}, intenção="${s.topIntent}", origem="${s.topSource}", estado="${s.topState}", recência=${s.avgDays.toFixed(0)} dias, tamanho=${s.size}`).join('\n')}`;
|
|
215
|
+
|
|
216
|
+
const nameRes = await env.AI.run('@cf/ibm-granite/granite-4.0-h-micro', { messages: [{ role: 'user', content: namingPrompt }], max_tokens: 800 });
|
|
217
|
+
let clusterNames: Record<number, ClusterInfo> = {};
|
|
218
|
+
try {
|
|
219
|
+
const m = ((nameRes as any)?.response || '').match(/\{[\s\S]*\}/);
|
|
220
|
+
if (m) {
|
|
221
|
+
const parsed = JSON.parse(m[0]);
|
|
222
|
+
(parsed.segments || []).forEach((s: any) => {
|
|
223
|
+
if (typeof s.cluster_id === 'number') {
|
|
224
|
+
clusterNames[s.cluster_id] = {
|
|
225
|
+
cluster_id: s.cluster_id,
|
|
226
|
+
name: s.name || `Segmento ${s.cluster_id + 1}`,
|
|
227
|
+
action: s.action || '',
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
} catch { /* usa nomes fallback */ }
|
|
233
|
+
|
|
234
|
+
const duration = Date.now() - startTime;
|
|
235
|
+
|
|
236
|
+
const clusters: Cluster[] = validClusterStats.map(s => ({
|
|
237
|
+
cluster_id: s.c,
|
|
238
|
+
name: clusterNames[s.c]?.name || `Segmento ${s.c + 1}`,
|
|
239
|
+
size: s.size, percentage: s.pct,
|
|
240
|
+
action_recommendation: clusterNames[s.c]?.action || '',
|
|
241
|
+
characteristics: {
|
|
242
|
+
avg_ltv_class: s.avgLtv, avg_engagement_score: s.avgEng,
|
|
243
|
+
avg_intention_level: s.avgLtv, avg_days_since_lead: s.avgDays,
|
|
244
|
+
dominant_countries: ['BR'], dominant_states: [s.topState || 'BR'],
|
|
245
|
+
dominant_utm_sources: [s.topSource || 'direto'], top_features: ['ltv', 'engagement', 'intention'],
|
|
246
|
+
},
|
|
247
|
+
}));
|
|
248
|
+
|
|
249
|
+
await env.DB.prepare(`UPDATE ml_segments SET is_active = 0 WHERE clustering_algorithm = ? AND client_vertical = ? AND is_active = 1`).bind(algorithm, clientVertical).run();
|
|
250
|
+
|
|
251
|
+
const now = new Date().toISOString();
|
|
252
|
+
for (const cluster of clusters) {
|
|
253
|
+
const ch = cluster.characteristics;
|
|
254
|
+
await env.DB.prepare(`
|
|
255
|
+
INSERT INTO ml_segments (
|
|
256
|
+
cluster_id, cluster_name, clustering_algorithm, client_vertical, size, percentage,
|
|
257
|
+
avg_ltv_class, avg_behavior_score, avg_engagement_score, avg_intention_level, avg_days_since_lead,
|
|
258
|
+
dominant_countries, dominant_states, dominant_utm_sources, dominant_features,
|
|
259
|
+
silhouette_score, action_recommendations, bid_recommendations, campaign_recommendations,
|
|
260
|
+
is_active, created_at, updated_at
|
|
261
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,1,?,?)
|
|
262
|
+
`).bind(
|
|
263
|
+
cluster.cluster_id, cluster.name, algorithm, clientVertical, cluster.size, cluster.percentage,
|
|
264
|
+
ch.avg_ltv_class, ch.avg_engagement_score, ch.avg_engagement_score, ch.avg_intention_level, ch.avg_days_since_lead,
|
|
265
|
+
JSON.stringify(ch.dominant_countries), JSON.stringify(ch.dominant_states),
|
|
266
|
+
JSON.stringify(ch.dominant_utm_sources), JSON.stringify(ch.top_features),
|
|
267
|
+
silhouetteScore,
|
|
268
|
+
JSON.stringify([cluster.action_recommendation]), JSON.stringify([]), JSON.stringify([]),
|
|
269
|
+
now, now,
|
|
270
|
+
).run();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
await env.DB.prepare(`
|
|
275
|
+
INSERT INTO ml_clustering_history (clustering_id, started_at, completed_at, algorithm, n_leads_processed, n_clusters_created, total_duration_ms, workers_ai_neurons_used, status, parameters, results_summary)
|
|
276
|
+
VALUES (0, ?, datetime('now'), ?, ?, ?, ?, ?, 'completed', ?, ?)
|
|
277
|
+
`).bind(new Date(startTime).toISOString(), algorithm, leads.length, clusters.length, duration, Math.ceil(duration * 0.01),
|
|
278
|
+
JSON.stringify({ algorithm, n_clusters: nClusters, vertical: clientVertical, engine: 'embeddinggemma-300m+kmeans' }),
|
|
279
|
+
JSON.stringify({ clusters: clusters.length, silhouette: silhouetteScore }),
|
|
280
|
+
).run();
|
|
281
|
+
} catch (e: any) { console.error('[Segmentation] history log error:', e?.message || String(e)); }
|
|
282
|
+
|
|
283
|
+
return new Response(JSON.stringify({
|
|
284
|
+
success: true, algorithm, engine: 'embeddinggemma-300m + kmeans vetorial',
|
|
285
|
+
n_clusters: clusters.length, client_vertical: clientVertical,
|
|
286
|
+
leads_analyzed: leads.length, sample_embedded: sample.length,
|
|
287
|
+
duration_ms: duration, silhouette_score: silhouetteScore,
|
|
288
|
+
clusters, generated_at: now,
|
|
289
|
+
}), { status: 200, headers });
|
|
290
|
+
|
|
291
|
+
} catch (err: any) {
|
|
292
|
+
console.error('[Segmentation] cluster error:', err?.message || String(err));
|
|
293
|
+
try {
|
|
294
|
+
if (env.DB) await env.DB.prepare(`
|
|
295
|
+
INSERT INTO ml_clustering_history (clustering_id, started_at, algorithm, n_leads_processed, n_clusters_created, total_duration_ms, workers_ai_neurons_used, status, error_message, parameters, results_summary)
|
|
296
|
+
VALUES (0, datetime('now'), ?, 0, 0, 0, 0, 'failed', ?, ?, '{}')
|
|
297
|
+
`).bind(algorithm, err?.message || String(err), JSON.stringify({ algorithm, n_clusters: nClusters })).run();
|
|
298
|
+
} catch { /* não bloquear */ }
|
|
299
|
+
return new Response(JSON.stringify({ error: 'Erro ao executar clustering', message: err?.message || String(err) }), { status: 500, headers });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── GET /api/segmentation/list ────────────────────────────────────────────────
|
|
304
|
+
export async function handleSegmentationList(env: Env, request: Request, headers: Headers): Promise<Response> {
|
|
305
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
306
|
+
|
|
307
|
+
const url = new URL(request.url);
|
|
308
|
+
const algorithm = url.searchParams.get('algorithm') || null;
|
|
309
|
+
const vertical = url.searchParams.get('vertical') || null;
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const conditions: string[] = ['is_active = 1'];
|
|
313
|
+
const bindings: (string | number)[] = [];
|
|
314
|
+
if (algorithm) { conditions.push('clustering_algorithm = ?'); bindings.push(algorithm); }
|
|
315
|
+
if (vertical) { conditions.push('client_vertical = ?'); bindings.push(vertical); }
|
|
316
|
+
|
|
317
|
+
const result = await env.DB.prepare(`
|
|
318
|
+
SELECT id, cluster_id, cluster_name, clustering_algorithm, client_vertical,
|
|
319
|
+
size, percentage, avg_ltv_class, avg_behavior_score, avg_engagement_score,
|
|
320
|
+
avg_intention_level, avg_days_since_lead, silhouette_score,
|
|
321
|
+
dominant_countries, dominant_states, dominant_utm_sources, dominant_features,
|
|
322
|
+
action_recommendations, bid_recommendations, campaign_recommendations,
|
|
323
|
+
is_active, created_at, updated_at
|
|
324
|
+
FROM ml_segments
|
|
325
|
+
WHERE ${conditions.join(' AND ')}
|
|
326
|
+
ORDER BY created_at DESC
|
|
327
|
+
LIMIT 50
|
|
328
|
+
`).bind(...bindings).all();
|
|
329
|
+
|
|
330
|
+
const segments = (result.results || []).map((s: any) => ({
|
|
331
|
+
...s,
|
|
332
|
+
dominant_countries: tryParseJson(s.dominant_countries, []),
|
|
333
|
+
dominant_states: tryParseJson(s.dominant_states, []),
|
|
334
|
+
dominant_utm_sources: tryParseJson(s.dominant_utm_sources, []),
|
|
335
|
+
dominant_features: tryParseJson(s.dominant_features, []),
|
|
336
|
+
action_recommendations: tryParseJson(s.action_recommendations, []),
|
|
337
|
+
bid_recommendations: tryParseJson(s.bid_recommendations, []),
|
|
338
|
+
campaign_recommendations: tryParseJson(s.campaign_recommendations, []),
|
|
339
|
+
}));
|
|
340
|
+
|
|
341
|
+
return new Response(JSON.stringify({ success: true, total: segments.length, segments }), { status: 200, headers });
|
|
342
|
+
} catch (err: any) {
|
|
343
|
+
console.error('[Segmentation] list error:', err?.message || String(err));
|
|
344
|
+
return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── GET /api/segmentation/outliers ───────────────────────────────────────────
|
|
349
|
+
export async function handleSegmentationOutliers(env: Env, request: Request, headers: Headers): Promise<Response> {
|
|
350
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
351
|
+
|
|
352
|
+
const url = new URL(request.url);
|
|
353
|
+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '50'), 200);
|
|
354
|
+
const days = parseInt(url.searchParams.get('days') || '30');
|
|
355
|
+
|
|
356
|
+
try {
|
|
357
|
+
const result = await env.DB.prepare(`
|
|
358
|
+
SELECT msm.lead_id, msm.cluster_id, msm.confidence, msm.is_outlier, msm.outlier_reason, msm.assigned_at,
|
|
359
|
+
l.email, l.phone, l.country, l.state, l.city, l.utm_source, l.bot_score, l.engagement_score, l.intention_level, l.created_at AS lead_created_at
|
|
360
|
+
FROM ml_segment_members msm
|
|
361
|
+
LEFT JOIN leads l ON CAST(msm.lead_id AS INTEGER) = l.id
|
|
362
|
+
WHERE msm.is_outlier = 1 AND msm.assigned_at >= datetime('now', '-' || ? || ' days')
|
|
363
|
+
ORDER BY msm.assigned_at DESC
|
|
364
|
+
LIMIT ?
|
|
365
|
+
`).bind(days, limit).all();
|
|
366
|
+
|
|
367
|
+
return new Response(JSON.stringify({ success: true, total: (result.results || []).length, period_days: days, outliers: result.results || [] }), { status: 200, headers });
|
|
368
|
+
} catch (err: any) {
|
|
369
|
+
console.error('[Segmentation] outliers error:', err?.message || String(err));
|
|
370
|
+
return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers });
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── PUT /api/segmentation/update ─────────────────────────────────────────────
|
|
375
|
+
export async function handleSegmentationUpdate(env: Env, request: Request, headers: Headers): Promise<Response> {
|
|
376
|
+
if (!env.DB) return new Response(JSON.stringify({ error: 'DB não configurado' }), { status: 503, headers });
|
|
377
|
+
|
|
378
|
+
let body: any;
|
|
379
|
+
try { body = await request.json(); }
|
|
380
|
+
catch { return new Response(JSON.stringify({ error: 'JSON inválido no body da requisição' }), { status: 400, headers }); }
|
|
381
|
+
|
|
382
|
+
const { cluster_id, action_recommendations, bid_recommendations, campaign_recommendations } = body;
|
|
383
|
+
if (cluster_id === undefined || cluster_id === null) {
|
|
384
|
+
return new Response(JSON.stringify({ error: 'cluster_id é obrigatório' }), { status: 400, headers });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const sets: string[] = [];
|
|
389
|
+
const bindings: (string | number)[] = [];
|
|
390
|
+
if (action_recommendations !== undefined) { sets.push('action_recommendations = ?'); bindings.push(JSON.stringify(action_recommendations)); }
|
|
391
|
+
if (bid_recommendations !== undefined) { sets.push('bid_recommendations = ?'); bindings.push(JSON.stringify(bid_recommendations)); }
|
|
392
|
+
if (campaign_recommendations !== undefined) { sets.push('campaign_recommendations = ?'); bindings.push(JSON.stringify(campaign_recommendations)); }
|
|
393
|
+
|
|
394
|
+
if (sets.length === 0) {
|
|
395
|
+
return new Response(JSON.stringify({ error: 'Nenhum campo válido para atualizar (action_recommendations, bid_recommendations, campaign_recommendations)' }), { status: 400, headers });
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
sets.push("updated_at = datetime('now')");
|
|
399
|
+
bindings.push(cluster_id);
|
|
400
|
+
|
|
401
|
+
await env.DB.prepare(`UPDATE ml_segments SET ${sets.join(', ')} WHERE id = ?`).bind(...bindings).run();
|
|
402
|
+
return new Response(JSON.stringify({ success: true, cluster_id, fields_updated: sets.length - 1 }), { status: 200, headers });
|
|
403
|
+
} catch (err: any) {
|
|
404
|
+
console.error('[Segmentation] update error:', err?.message || String(err));
|
|
405
|
+
return new Response(JSON.stringify({ error: err?.message || String(err) }), { status: 500, headers });
|
|
406
|
+
}
|
|
407
|
+
}
|