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.
Files changed (176) hide show
  1. package/README.md +324 -0
  2. package/bin/cdp-edge.js +71 -0
  3. package/contracts/agent-versions.json +679 -0
  4. package/contracts/api-versions.json +372 -0
  5. package/contracts/types.ts +81 -0
  6. package/dist/commands/analyze.js +52 -0
  7. package/dist/commands/infra.js +54 -0
  8. package/dist/commands/install.js +191 -0
  9. package/dist/commands/server.js +174 -0
  10. package/dist/commands/setup.js +355 -0
  11. package/dist/commands/validate.js +248 -0
  12. package/dist/index.js +12 -0
  13. package/dist/sdk/cdpTrack.js +2095 -0
  14. package/dist/sdk/cdpTrack.min.js +64 -0
  15. package/dist/sdk/install-snippet.html +10 -0
  16. package/docs/CI-CD-SETUP.md +217 -0
  17. package/docs/events-reference.md +359 -0
  18. package/docs/installation.md +155 -0
  19. package/docs/quick-start.md +185 -0
  20. package/docs/sdk-reference.md +371 -0
  21. package/docs/whatsapp-ctwa.md +210 -0
  22. package/extracted-skill/tracking-events-generator/INDEX.md +94 -0
  23. package/extracted-skill/tracking-events-generator/INSTALACAO-CDPEDGE.md +58 -0
  24. package/extracted-skill/tracking-events-generator/INTEGRACAO-COMPLETA.md +683 -0
  25. package/extracted-skill/tracking-events-generator/MELHORIAS-IMPLEMENTADAS.md +513 -0
  26. package/extracted-skill/tracking-events-generator/Premium-Tracking-Intelligence-Resumo.md +333 -0
  27. package/extracted-skill/tracking-events-generator/SKILL.md +257 -0
  28. package/extracted-skill/tracking-events-generator/advanced-matching.js +364 -0
  29. package/extracted-skill/tracking-events-generator/agents/ab-ltv-agent.md +196 -0
  30. package/extracted-skill/tracking-events-generator/agents/ab-testing-agent.md +54 -0
  31. package/extracted-skill/tracking-events-generator/agents/attribution-agent.md +1304 -0
  32. package/extracted-skill/tracking-events-generator/agents/bidding-agent.md +347 -0
  33. package/extracted-skill/tracking-events-generator/agents/bing-agent.md +66 -0
  34. package/extracted-skill/tracking-events-generator/agents/browser-tracking.md +364 -0
  35. package/extracted-skill/tracking-events-generator/agents/code-guardian-agent.md +149 -0
  36. package/extracted-skill/tracking-events-generator/agents/compliance-agent.md +2097 -0
  37. package/extracted-skill/tracking-events-generator/agents/crm-integration-agent.md +1459 -0
  38. package/extracted-skill/tracking-events-generator/agents/dashboard-agent.md +456 -0
  39. package/extracted-skill/tracking-events-generator/agents/database-agent.md +668 -0
  40. package/extracted-skill/tracking-events-generator/agents/debug-agent.md +1455 -0
  41. package/extracted-skill/tracking-events-generator/agents/devops-agent.md +232 -0
  42. package/extracted-skill/tracking-events-generator/agents/domain-setup-agent.md +238 -0
  43. package/extracted-skill/tracking-events-generator/agents/email-agent.md +88 -0
  44. package/extracted-skill/tracking-events-generator/agents/fingerprint-agent.md +257 -0
  45. package/extracted-skill/tracking-events-generator/agents/fraud-detection-agent.md +143 -0
  46. package/extracted-skill/tracking-events-generator/agents/google-agent.md +235 -0
  47. package/extracted-skill/tracking-events-generator/agents/intelligence-agent.md +525 -0
  48. package/extracted-skill/tracking-events-generator/agents/lead-scoring-agent.md +282 -0
  49. package/extracted-skill/tracking-events-generator/agents/linkedin-agent.md +173 -0
  50. package/extracted-skill/tracking-events-generator/agents/localization-agent.md +55 -0
  51. package/extracted-skill/tracking-events-generator/agents/ltv-predictor-agent.md +59 -0
  52. package/extracted-skill/tracking-events-generator/agents/master-feedback-loop.md +960 -0
  53. package/extracted-skill/tracking-events-generator/agents/master-orchestrator.md +2154 -0
  54. package/extracted-skill/tracking-events-generator/agents/match-quality-agent.md +304 -0
  55. package/extracted-skill/tracking-events-generator/agents/memory-agent.json +25 -0
  56. package/extracted-skill/tracking-events-generator/agents/memory-agent.md +878 -0
  57. package/extracted-skill/tracking-events-generator/agents/meta-agent.md +118 -0
  58. package/extracted-skill/tracking-events-generator/agents/ml-clustering-agent.md +749 -0
  59. package/extracted-skill/tracking-events-generator/agents/page-analyzer.md +272 -0
  60. package/extracted-skill/tracking-events-generator/agents/performance-agent.md +1167 -0
  61. package/extracted-skill/tracking-events-generator/agents/performance-optimization-agent.md +1442 -0
  62. package/extracted-skill/tracking-events-generator/agents/pinterest-agent.md +318 -0
  63. package/extracted-skill/tracking-events-generator/agents/premium-tracking-intelligence-agent.md +849 -0
  64. package/extracted-skill/tracking-events-generator/agents/r2-setup-agent.md +258 -0
  65. package/extracted-skill/tracking-events-generator/agents/reddit-agent.md +321 -0
  66. package/extracted-skill/tracking-events-generator/agents/security-enterprise-agent.md +1861 -0
  67. package/extracted-skill/tracking-events-generator/agents/server-tracking.md +1188 -0
  68. package/extracted-skill/tracking-events-generator/agents/spotify-agent.md +391 -0
  69. package/extracted-skill/tracking-events-generator/agents/tiktok-agent.md +182 -0
  70. package/extracted-skill/tracking-events-generator/agents/tracking-plan-agent.md +459 -0
  71. package/extracted-skill/tracking-events-generator/agents/utm-agent.md +322 -0
  72. package/extracted-skill/tracking-events-generator/agents/validator-agent.md +271 -0
  73. package/extracted-skill/tracking-events-generator/agents/webhook-agent.md +177 -0
  74. package/extracted-skill/tracking-events-generator/agents/whatsapp-agent.md +129 -0
  75. package/extracted-skill/tracking-events-generator/agents/whatsapp-ctwa-setup-agent.md +707 -0
  76. package/extracted-skill/tracking-events-generator/agents/youtube-agent.md +537 -0
  77. package/extracted-skill/tracking-events-generator/anti-blocking.js +285 -0
  78. package/extracted-skill/tracking-events-generator/cdpTrack.js +640 -0
  79. package/extracted-skill/tracking-events-generator/contracts/api-versions.json +372 -0
  80. package/extracted-skill/tracking-events-generator/docs/guia-cloudflare-iniciante.md +107 -0
  81. package/extracted-skill/tracking-events-generator/engagement-scoring.js +226 -0
  82. package/extracted-skill/tracking-events-generator/evals/evals.json +235 -0
  83. package/extracted-skill/tracking-events-generator/integration-test.js +497 -0
  84. package/extracted-skill/tracking-events-generator/knowledge-base.md +3066 -0
  85. package/extracted-skill/tracking-events-generator/micro-events.js +992 -0
  86. package/extracted-skill/tracking-events-generator/models/captura-de-lead.md +78 -0
  87. package/extracted-skill/tracking-events-generator/models/captura-lead-evento-externo.md +99 -0
  88. package/extracted-skill/tracking-events-generator/models/checkout-proprio.md +111 -0
  89. package/extracted-skill/tracking-events-generator/models/lancamento-imobiliario.md +344 -0
  90. package/extracted-skill/tracking-events-generator/models/multi-step-checkout.md +672 -0
  91. package/extracted-skill/tracking-events-generator/models/pagina-obrigado.md +55 -0
  92. package/extracted-skill/tracking-events-generator/models/pinterest/conversions-api-template.js +144 -0
  93. package/extracted-skill/tracking-events-generator/models/pinterest/event-mappings.json +48 -0
  94. package/extracted-skill/tracking-events-generator/models/pinterest/tag-template.js +28 -0
  95. package/extracted-skill/tracking-events-generator/models/quiz-funnel.md +132 -0
  96. package/extracted-skill/tracking-events-generator/models/reddit/conversions-api-template.js +205 -0
  97. package/extracted-skill/tracking-events-generator/models/reddit/event-mappings.json +56 -0
  98. package/extracted-skill/tracking-events-generator/models/reddit/pixel-template.js +19 -0
  99. package/extracted-skill/tracking-events-generator/models/scenarios/behavior-engine.js +425 -0
  100. package/extracted-skill/tracking-events-generator/models/scenarios/real-estate-logic.md +50 -0
  101. package/extracted-skill/tracking-events-generator/models/scenarios/sales-page-logic.md +50 -0
  102. package/extracted-skill/tracking-events-generator/models/trafego-direto.md +582 -0
  103. package/extracted-skill/tracking-events-generator/models/webinar-registration.md +63 -0
  104. package/extracted-skill/tracking-events-generator/route-intent-capture.js +222 -0
  105. package/extracted-skill/tracking-events-generator/tracking.config.js +46 -0
  106. package/extracted-skill/tracking-events-generator/walkthrough.md +26 -0
  107. package/package.json +89 -0
  108. package/scripts/build-sdk.js +106 -0
  109. package/server-edge-tracker/.client.env.example +14 -0
  110. package/server-edge-tracker/INSTALAR.md +527 -0
  111. package/server-edge-tracker/SEGMENTATION-DOCS.md +513 -0
  112. package/server-edge-tracker/config/utm-mapping.json +64 -0
  113. package/server-edge-tracker/deploy-client.cjs +76 -0
  114. package/server-edge-tracker/index.ts +1164 -0
  115. package/server-edge-tracker/migrate-new-db.sql +137 -0
  116. package/server-edge-tracker/migrate-v2.sql +16 -0
  117. package/server-edge-tracker/migrate-v3.sql +6 -0
  118. package/server-edge-tracker/migrate-v4.sql +18 -0
  119. package/server-edge-tracker/migrate-v5.sql +17 -0
  120. package/server-edge-tracker/migrate-v6.sql +24 -0
  121. package/server-edge-tracker/migrate-v7.sql +64 -0
  122. package/server-edge-tracker/migrate.sql +111 -0
  123. package/server-edge-tracker/modules/db.ts +702 -0
  124. package/server-edge-tracker/modules/dispatch/ga4.ts +72 -0
  125. package/server-edge-tracker/modules/dispatch/meta.ts +143 -0
  126. package/server-edge-tracker/modules/dispatch/platforms.ts +255 -0
  127. package/server-edge-tracker/modules/dispatch/tiktok.ts +107 -0
  128. package/server-edge-tracker/modules/dispatch/whatsapp.ts +279 -0
  129. package/server-edge-tracker/modules/intelligence.ts +589 -0
  130. package/server-edge-tracker/modules/ml/bidding.ts +247 -0
  131. package/server-edge-tracker/modules/ml/fraud.ts +302 -0
  132. package/server-edge-tracker/modules/ml/logistic.ts +226 -0
  133. package/server-edge-tracker/modules/ml/ltv.ts +531 -0
  134. package/server-edge-tracker/modules/ml/matchquality.ts +232 -0
  135. package/server-edge-tracker/modules/ml/quiz.ts +343 -0
  136. package/server-edge-tracker/modules/ml/roas.ts +255 -0
  137. package/server-edge-tracker/modules/ml/segmentation.ts +407 -0
  138. package/server-edge-tracker/modules/nurture.ts +257 -0
  139. package/server-edge-tracker/modules/utils.ts +311 -0
  140. package/server-edge-tracker/modules/utm/utm-enricher.ts +231 -0
  141. package/server-edge-tracker/schema-ab-ltv.sql +97 -0
  142. package/server-edge-tracker/schema-bidding.sql +86 -0
  143. package/server-edge-tracker/schema-fraud.sql +90 -0
  144. package/server-edge-tracker/schema-indexes.sql +67 -0
  145. package/server-edge-tracker/schema-ltv-feedback.sql +11 -0
  146. package/server-edge-tracker/schema-quiz.sql +52 -0
  147. package/server-edge-tracker/schema-sales-engine.sql +113 -0
  148. package/server-edge-tracker/schema-segmentation.sql +219 -0
  149. package/server-edge-tracker/schema-utm.sql +82 -0
  150. package/server-edge-tracker/schema.sql +265 -0
  151. package/server-edge-tracker/types.ts +258 -0
  152. package/server-edge-tracker/wrangler.toml +136 -0
  153. package/templates/afiliado-sem-landing.md +312 -0
  154. package/templates/captura-de-lead.md +78 -0
  155. package/templates/captura-lead-evento-externo.md +99 -0
  156. package/templates/checkout-proprio.md +111 -0
  157. package/templates/install/.claude/commands/cdp.md +1 -0
  158. package/templates/install/CLAUDE.md +65 -0
  159. package/templates/lancamento-imobiliario.md +344 -0
  160. package/templates/linkedin/tag-template.js +46 -0
  161. package/templates/multi-step-checkout.md +672 -0
  162. package/templates/pagina-obrigado.md +55 -0
  163. package/templates/pinterest/conversions-api-template.js +144 -0
  164. package/templates/pinterest/event-mappings.json +48 -0
  165. package/templates/pinterest/tag-template.js +28 -0
  166. package/templates/quiz-funnel.md +132 -0
  167. package/templates/reddit/conversions-api-template.js +205 -0
  168. package/templates/reddit/event-mappings.json +56 -0
  169. package/templates/reddit/pixel-template.js +19 -0
  170. package/templates/scenarios/behavior-engine.js +425 -0
  171. package/templates/scenarios/real-estate-logic.md +50 -0
  172. package/templates/scenarios/sales-page-logic.md +50 -0
  173. package/templates/spotify/pixel-template.js +46 -0
  174. package/templates/trafego-direto.md +582 -0
  175. package/templates/vsl-page.md +292 -0
  176. 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
+ }