cluster -> global. * 6. Perdedores também geram aprendizado. * 7. Rollback é automático. * * IMPORTANTE: * Dados sobre pessoas devem ser públicos, pertinentes e * acompanhados de fonte/evidência. Não armazenar dados sensíveis * desnecessários. *//* ============================================================ * 1. CONFIGURAÇÃO * ============================================================ */$TDB_BRAIN_CONFIG = [ 'db' => [ 'host' => 'localhost', 'name' => 'topdobairro', 'user' => 'SEU_USUARIO', 'pass' => 'SUA_SENHA', 'charset' => 'utf8mb4', ], 'brain' => [ // aggressive | balanced | conservative 'mode' => 'aggressive', // IA pode executar mudanças automaticamente. 'auto_apply' => true, // Rollback automático. 'auto_rollback' => true, // Perda mínima para rollback. // 0.12 = queda de 12%. 'rollback_threshold' => 0.12, // Lift mínimo para considerar vencedor. 'min_lift' => 0.05, // Confiança mínima. 'min_confidence' => 0.80, // Dias mínimos de experimento. 'min_experiment_days' => 7, // Proteção de páginas campeãs. 'protect_winners' => true, // Title/URL nunca devem ser alterados em páginas // protegidas sem oportunidade extraordinária. 'max_auto_risk' => 70, // Chave para execução do cron pelo navegador. // TROQUE O VALOR. 'cron_key' => 'TROQUE-ESTA-CHAVE-GRANDE-E-SECRETA', ], /* * Validade de cada tipo de dado. */ 'ttl' => [ 'telefone' => 90, 'whatsapp' => 90, 'horario' => 60, 'endereco' => 180, 'cnpj' => 365, 'razao_social' => 365, 'cep' => 365, 'bairro' => 730, 'cidade' => 730, 'segmento' => 365, 'site' => 180, ],];/* ============================================================ * 2. CLASSE PRINCIPAL * ============================================================ */class TopDoBairroBrain{ private PDO $db; private array $config; public function __construct(PDO $db, array $config) { $this->db = $db; $this->config = $config; $this->install(); } /* ======================================================== * BANCO / MIGRAÇÃO * ======================================================== */ private function install(): void { $queries = [ " CREATE TABLE IF NOT EXISTS tdb_urls ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, url VARCHAR(700) NOT NULL UNIQUE, cidade VARCHAR(160), estado CHAR(2), bairro VARCHAR(160), categoria VARCHAR(180), segmento VARCHAR(180), intencao VARCHAR(80), cluster_key VARCHAR(255), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_cluster(cluster_key), INDEX idx_local(cidade,bairro,categoria) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ", " CREATE TABLE IF NOT EXISTS tdb_empresas ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, nome VARCHAR(255) NOT NULL, razao_social VARCHAR(255), cnpj VARCHAR(30), categoria VARCHAR(180), segmento VARCHAR(180), telefone VARCHAR(50), whatsapp VARCHAR(50), site VARCHAR(700), endereco VARCHAR(255), numero VARCHAR(50), bairro VARCHAR(160), cidade VARCHAR(160), estado CHAR(2), cep VARCHAR(20), latitude DECIMAL(10,7), longitude DECIMAL(10,7), horario_json JSON, ativo TINYINT(1) DEFAULT 1, confidence DECIMAL(6,5) DEFAULT 0.50000, ultima_validacao DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_empresa_local(cidade,bairro,categoria), INDEX idx_empresa_cnpj(cnpj) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ", " CREATE TABLE IF NOT EXISTS tdb_evidencias ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, entity_type VARCHAR(50) NOT NULL, entity_id BIGINT UNSIGNED NOT NULL, atributo VARCHAR(100) NOT NULL, valor TEXT NOT NULL, fonte VARCHAR(700), fonte_tipo VARCHAR(80), confidence DECIMAL(6,5) DEFAULT 0.50000, validado_usuario TINYINT(1) DEFAULT 0, validado_empresa TINYINT(1) DEFAULT 0, coletado_em DATETIME DEFAULT CURRENT_TIMESTAMP, validado_em DATETIME, INDEX idx_evidence_entity(entity_type,entity_id), INDEX idx_evidence_attribute(atributo) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ", " CREATE TABLE IF NOT EXISTS tdb_eventos ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, url_id BIGINT UNSIGNED NOT NULL, session_id VARCHAR(100), evento VARCHAR(80) NOT NULL, valor DECIMAL(14,5), metadata JSON, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_event_url(url_id,evento), INDEX idx_event_date(created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ", " CREATE TABLE IF NOT EXISTS tdb_metricas_url ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, url_id BIGINT UNSIGNED NOT NULL, data DATE NOT NULL, impressoes INT DEFAULT 0, cliques INT DEFAULT 0, ctr DECIMAL(10,7) DEFAULT 0, posicao DECIMAL(10,4), pageviews INT DEFAULT 0, scroll_avg DECIMAL(8,3), active_time_avg DECIMAL(12,3), bounce_rate DECIMAL(8,5), whatsapp_clicks INT DEFAULT 0, phone_clicks INT DEFAULT 0, leads INT DEFAULT 0, UNIQUE KEY uk_metric_url_date(url_id,data) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ", " CREATE TABLE IF NOT EXISTS tdb_score_url ( url_id BIGINT UNSIGNED PRIMARY KEY, opportunity_score DECIMAL(8,3) DEFAULT 0, seo_score DECIMAL(8,3) DEFAULT 0, engagement_score DECIMAL(8,3) DEFAULT 0, conversion_score DECIMAL(8,3) DEFAULT 0, data_quality_score DECIMAL(8,3) DEFAULT 0, volatility DECIMAL(8,5) DEFAULT 0, status VARCHAR(40), decision VARCHAR(40), calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ", " CREATE TABLE IF NOT EXISTS tdb_versoes_url ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, url_id BIGINT UNSIGNED NOT NULL, version_number INT NOT NULL, title TEXT, meta_description TEXT, h1 TEXT, content LONGTEXT, cta_json JSON, generated_by VARCHAR(100), active TINYINT(1) DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_version_url(url_id,active) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ", " CREATE TABLE IF NOT EXISTS tdb_experimentos ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, url_id BIGINT UNSIGNED NOT NULL, tipo VARCHAR(80) NOT NULL, change_type VARCHAR(80), pattern_key VARCHAR(255), hypothesis TEXT, control_version_id BIGINT UNSIGNED, test_version_id BIGINT UNSIGNED, metric_primary VARCHAR(80), status VARCHAR(40) DEFAULT 'running', phase VARCHAR(30) DEFAULT 'control', traffic_split DECIMAL(5,2) DEFAULT 50, baseline DECIMAL(14,7), result DECIMAL(14,7), improvement DECIMAL(14,7), confidence DECIMAL(8,5) DEFAULT 0, started_at DATETIME DEFAULT CURRENT_TIMESTAMP, phase_changed_at DATETIME, ended_at DATETIME, INDEX idx_experiment_status(status), INDEX idx_experiment_url(url_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ", " CREATE TABLE IF NOT EXISTS tdb_memoria_padroes ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, pattern_key VARCHAR(255) NOT NULL UNIQUE, pattern_type VARCHAR(80), scope ENUM('local','cluster','global') DEFAULT 'local', conditions_json JSON, context_json JSON, action_json JSON, wins INT DEFAULT 0, losses INT DEFAULT 0, samples BIGINT DEFAULT 0, avg_lift DECIMAL(10,7) DEFAULT 0, cities_tested INT DEFAULT 0, categories_tested INT DEFAULT 0, confidence DECIMAL(8,5) DEFAULT 0, active TINYINT(1) DEFAULT 0, last_win_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ", " CREATE TABLE IF NOT EXISTS tdb_feedback ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, empresa_id BIGINT UNSIGNED, url_id BIGINT UNSIGNED, tipo VARCHAR(80), mensagem TEXT, suggested_value TEXT, confidence DECIMAL(6,5) DEFAULT 0.70000, status VARCHAR(30) DEFAULT 'pending', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_feedback_status(status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 " ]; foreach ($queries as $sql) { $this->db->exec($sql); } } /* ======================================================== * URL / CLUSTERIZAÇÃO * ======================================================== */ public function registerUrl( string $url, string $cidade = '', string $estado = '', string $bairro = '', string $categoria = '', string $segmento = '', string $intencao = 'transacional' ): int { $cluster = $this->generateCluster( $categoria, $segmento, $intencao ); $sql = " INSERT INTO tdb_urls ( url,cidade,estado,bairro,categoria, segmento,intencao,cluster_key ) VALUES (?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE cidade=VALUES(cidade), estado=VALUES(estado), bairro=VALUES(bairro), categoria=VALUES(categoria), segmento=VALUES(segmento), intencao=VALUES(intencao), cluster_key=VALUES(cluster_key) "; $stmt = $this->db->prepare($sql); $stmt->execute([ $url, $cidade, $estado, $bairro, $categoria, $segmento, $intencao, $cluster ]); $stmt = $this->db->prepare( "SELECT id FROM tdb_urls WHERE url=? LIMIT 1" ); $stmt->execute([$url]); return (int)$stmt->fetchColumn(); } private function generateCluster( string $categoria, string $segmento, string $intencao ): string { $normalize = static function(string $text): string { $text = mb_strtolower(trim($text)); $text = iconv( 'UTF-8', 'ASCII//TRANSLIT//IGNORE', $text ) ?: $text; return preg_replace( '/[^a-z0-9]+/', '_', $text ); }; return implode(':', [ $normalize($segmento ?: 'geral'), $normalize($categoria ?: 'geral'), $normalize($intencao ?: 'geral') ]); } /* ======================================================== * EVIDENCE ENGINE * ======================================================== */ public function addEvidence( string $entityType, int $entityId, string $attribute, string $value, string $source, string $sourceType, float $confidence = 0.80, bool $validatedUser = false, bool $validatedCompany = false ): void { $confidence = min(1, max(0, $confidence)); $stmt = $this->db->prepare(" INSERT INTO tdb_evidencias ( entity_type, entity_id, atributo, valor, fonte, fonte_tipo, confidence, validado_usuario, validado_empresa, validado_em ) VALUES (?,?,?,?,?,?,?,?,?,NOW()) "); $stmt->execute([ $entityType, $entityId, $attribute, $value, $source, $sourceType, $confidence, $validatedUser ? 1 : 0, $validatedCompany ? 1 : 0 ]); if ($entityType === 'empresa') { $this->recalculateCompanyConfidence($entityId); } } public function getEvidence( string $entityType, int $entityId, string $attribute ): ?array { $stmt = $this->db->prepare(" SELECT * FROM tdb_evidencias WHERE entity_type=? AND entity_id=? AND atributo=? ORDER BY validado_empresa DESC, validado_usuario DESC, confidence DESC, validado_em DESC, coletado_em DESC "); $stmt->execute([ $entityType, $entityId, $attribute ]); while ($fact = $stmt->fetch(PDO::FETCH_ASSOC)) { if ($this->evidenceIsValid($fact)) { return $fact; } } return null; } public function mayPublish( string $entityType, int $entityId, string $attribute ): bool { return $this->getEvidence( $entityType, $entityId, $attribute ) !== null; } private function evidenceIsValid(array $fact): bool { $attribute = $fact['atributo']; $daysLimit = $this->config['ttl'][$attribute] ?? 180; $timestamp = strtotime($fact['coletado_em']); if (!$timestamp) { return false; } $ageDays = (time() - $timestamp) / 86400; return $ageDays <= $daysLimit && (float)$fact['confidence'] >= $this->config['brain']['min_confidence']; } private function recalculateCompanyConfidence(int $companyId): void { $stmt = $this->db->prepare(" SELECT AVG(confidence) FROM tdb_evidencias WHERE entity_type='empresa' AND entity_id=? "); $stmt->execute([$companyId]); $confidence = (float)$stmt->fetchColumn(); $stmt = $this->db->prepare(" UPDATE tdb_empresas SET confidence=?, ultima_validacao=NOW() WHERE id=? "); $stmt->execute([ $confidence, $companyId ]); } /* ======================================================== * DADOS REAIS DE EMPRESAS * ======================================================== */ public function companiesForPage( string $city, string $category, ?string $neighborhood = null, int $limit = 30 ): array { $sql = " SELECT * FROM tdb_empresas WHERE ativo=1 AND cidade=? AND categoria=? AND confidence >= ? "; $params = [ $city, $category, $this->config['brain']['min_confidence'] ]; if ($neighborhood) { $sql .= " AND bairro=? "; $params[] = $neighborhood; } $sql .= " ORDER BY confidence DESC, ultima_validacao DESC LIMIT " . max(1, min(100, $limit)); $stmt = $this->db->prepare($sql); $stmt->execute($params); return $stmt->fetchAll(PDO::FETCH_ASSOC); } /* ======================================================== * PUBLICAÇÃO DE FATOS * ======================================================== */ public function safeFact( string $entityType, int $entityId, string $attribute, string $fallback = '' ): string { $fact = $this->getEvidence( $entityType, $entityId, $attribute ); if (!$fact) { return $fallback; } return htmlspecialchars( $fact['valor'], ENT_QUOTES, 'UTF-8' ); } /* ======================================================== * MÉTRICAS GSC * * Essa função recebe os dados vindos do Search Console. * O OAuth/API pode ser ligado externamente depois. * ======================================================== */ public function ingestMetrics( int $urlId, string $date, int $impressions, int $clicks, float $ctr, float $position ): void { $stmt = $this->db->prepare(" INSERT INTO tdb_metricas_url ( url_id, data, impressoes, cliques, ctr, posicao ) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE impressoes=VALUES(impressoes), cliques=VALUES(cliques), ctr=VALUES(ctr), posicao=VALUES(posicao) "); $stmt->execute([ $urlId, $date, $impressions, $clicks, $ctr, $position ]); } /* ======================================================== * SCORE ENGINE * ======================================================== */ public function calculateScore(int $urlId): array { $stmt = $this->db->prepare(" SELECT SUM(impressoes) impressoes, SUM(cliques) cliques, AVG(ctr) ctr, AVG(posicao) posicao, SUM(pageviews) pageviews, AVG(scroll_avg) scroll_avg, AVG(active_time_avg) active_time, AVG(bounce_rate) bounce, SUM(whatsapp_clicks) whatsapp, SUM(phone_clicks) phone, SUM(leads) leads FROM tdb_metricas_url WHERE url_id=? AND data >= DATE_SUB(CURDATE(),INTERVAL 28 DAY) "); $stmt->execute([$urlId]); $m = $stmt->fetch(PDO::FETCH_ASSOC) ?: []; $impressions = max(1, (int)($m['impressoes'] ?? 0)); $pageviews = max(1, (int)($m['pageviews'] ?? 0)); $ctr = (float)($m['ctr'] ?? 0); $position = (float)($m['posicao'] ?? 100); $leads = (int)($m['leads'] ?? 0); $whatsapp = (int)($m['whatsapp'] ?? 0); $leadRate = $leads / $pageviews; $whatsappRate = $whatsapp / $pageviews; $visibility = min( 100, log10($impressions + 10) * 25 ); /* * CTR esperado aproximado por posição. * * Não é verdade universal. * É referência interna adaptável. */ $expectedCTR = match (true) { $position <= 1 => 0.28, $position <= 3 => 0.15, $position <= 5 => 0.08, $position <= 10 => 0.04, $position <= 20 => 0.02, default => 0.01 }; $ctrProblem = max( 0, min( 100, (($expectedCTR - $ctr) / max(0.001,$expectedCTR)) * 100 ) ); $conversionProblem = max( 0, min( 100, 100 - ($leadRate * 1000) ) ); $rankingOpportunity = match (true) { $position <= 3 => 10, $position <= 7 => 50, $position <= 15 => 90, $position <= 30 => 100, default => 70 }; $volatility = $this->calculateVolatility($urlId); $score = ($visibility * 0.25) + ($ctrProblem * 0.25) + ($conversionProblem * 0.20) + ($rankingOpportunity * 0.30); /* * Alta volatilidade reduz confiança, * mas não mata completamente autonomia. */ $volatilityConfidence = $this->volatilityConfidence( $volatility ); $score = $score * $volatilityConfidence; $score = min( 100, max(0,$score) ); $result = [ 'opportunity_score' => round($score,2), 'volatility' => round($volatility,5), 'metrics' => [ 'impressions' => $impressions, 'pageviews' => $pageviews, 'ctr' => $ctr, 'position' => $position, 'lead_rate' => $leadRate, 'whatsapp_rate' => $whatsappRate, 'bounce' => (float)($m['bounce'] ?? 0) ] ]; $decision = $this->decisionGate($result); $stmt = $this->db->prepare(" INSERT INTO tdb_score_url ( url_id, opportunity_score, volatility, status, decision, calculated_at ) VALUES (?,?,?,?,?,NOW()) ON DUPLICATE KEY UPDATE opportunity_score= VALUES(opportunity_score), volatility= VALUES(volatility), status= VALUES(status), decision= VALUES(decision), calculated_at=NOW() "); $stmt->execute([ $urlId, $result['opportunity_score'], $result['volatility'], 'active', $decision ]); $result['decision'] = $decision; return $result; } /* ======================================================== * VOLATILITY ENGINE * ======================================================== */ private function calculateVolatility(int $urlId): float { $stmt = $this->db->prepare(" SELECT ctr FROM tdb_metricas_url WHERE url_id=? AND data >= DATE_SUB(CURDATE(),INTERVAL 28 DAY) ORDER BY data "); $stmt->execute([$urlId]); $values = array_map( 'floatval', $stmt->fetchAll(PDO::FETCH_COLUMN) ); if (count($values) < 4) { return 0; } $mean = array_sum($values) / count($values); if ($mean <= 0) { return 0; } $sum = 0; foreach ($values as $v) { $sum += pow($v - $mean,2); } $std = sqrt( $sum / count($values) ); return $std / $mean; } private function volatilityConfidence(float $volatility): float { if ($volatility < 0.15) { return 1.00; } if ($volatility < 0.35) { return 0.70; } return 0.30; } /* ======================================================== * DECISION GATE * ======================================================== */ private function decisionGate(array $result): string { $m = $result['metrics']; $score = $result['opportunity_score']; /* * Campeã. */ if ( $this->config['brain']['protect_winners'] && $m['position'] > 0 && $m['position'] <= 3 && $m['ctr'] >= 0.08 ) { return 'PROTECT'; } if ($result['volatility'] >= 0.50) { return 'OBSERVE'; } if ($score >= 75) { return 'AGGRESSIVE'; } if ($score >= 45) { return 'EXPERIMENT'; } return 'OBSERVE'; } /* ======================================================== * RISK ENGINE * ======================================================== */ public function riskScore( string $change, array $scoreData ): float { $baseRisk = [ 'cta' => 10, 'bloco' => 15, 'faq' => 20, 'meta' => 30, 'h1' => 45, 'title' => 55, 'content_30pct' => 65, 'url' => 100 ][$change] ?? 50; $m = $scoreData['metrics']; /* * Quanto maior o tráfego, * maior o risco operacional. */ $trafficFactor = min( 1.8, max( 0.7, log10( ($m['impressions'] ?? 0) + 10 ) / 3 ) ); /* * Top 3 aumenta risco. */ $rankingFactor = ($m['position'] <= 3) ? 1.60 : ( ($m['position'] <= 10) ? 1.20 : 0.90 ); $volatilityFactor = 1 + min( 1, $scoreData['volatility'] ); return min( 100, $baseRisk * $trafficFactor * $rankingFactor * $volatilityFactor ); } public function canAutoApply( string $change, array $scoreData ): bool { if ( !$this->config['brain']['auto_apply'] ) { return false; } $risk = $this->riskScore( $change, $scoreData ); if ( $scoreData['decision'] === 'PROTECT' && $risk > 30 ) { return false; } return $scoreData['opportunity_score'] >= 70 && $risk <= $this->config['brain']['max_auto_risk']; } /* ======================================================== * HYPOTHESIS ENGINE * * V1 heurística. * Depois uma LLM pode usar estes dados + * memoria_padroes como contexto. * ======================================================== */ public function generateHypotheses( array $scoreData ): array { $m = $scoreData['metrics']; $result = []; if ( $m['position'] <= 10 && $m['ctr'] < 0.03 ) { $result[] = [ 'type' => 'title', 'pattern' => 'categoria_intencao_localizacao', 'reason' => 'Ranking competitivo com CTR abaixo do esperado.', 'action' => 'Aumentar aderência do Title à intenção local.' ]; } if ( $m['whatsapp_rate'] < 0.01 ) { $result[] = [ 'type' => 'cta', 'pattern' => 'cta_local_contextual', 'reason' => 'Baixa conversão para WhatsApp.', 'action' => 'Testar CTA específico para localização e serviço.' ]; } if ( $m['position'] > 10 && $m['position'] <= 30 ) { $result[] = [ 'type' => 'content_30pct', 'pattern' => 'enriquecimento_semantico_local', 'reason' => 'URL próxima da primeira página.', 'action' => 'Aumentar entidades, contexto local, FAQ comprovado e links internos.' ]; } return $result; } /* ======================================================== * MEMÓRIA COLETIVA * ======================================================== */ public function recordPatternResult( string $pattern, bool $winner, float $lift, int $samples, string $city, string $category, array $context = [] ): void { $stmt = $this->db->prepare(" SELECT * FROM tdb_memoria_padroes WHERE pattern_key=? LIMIT 1 "); $stmt->execute([$pattern]); $existing = $stmt->fetch(PDO::FETCH_ASSOC); if (!$existing) { $stmt = $this->db->prepare(" INSERT INTO tdb_memoria_padroes ( pattern_key, pattern_type, conditions_json, context_json, action_json ) VALUES (?,?,?,?,?) "); $stmt->execute([ $pattern, 'autolearn', json_encode( $context, JSON_UNESCAPED_UNICODE ), json_encode( [ 'cities' => [$city], 'categories' => [$category] ], JSON_UNESCAPED_UNICODE ), json_encode( [], JSON_UNESCAPED_UNICODE ) ]); } $stmt = $this->db->prepare(" SELECT * FROM tdb_memoria_padroes WHERE pattern_key=? LIMIT 1 "); $stmt->execute([$pattern]); $p = $stmt->fetch(PDO::FETCH_ASSOC); $contextData = json_decode( $p['context_json'] ?: '{}', true ) ?: []; $cities = $contextData['cities'] ?? []; $categories = $contextData['categories'] ?? []; if (!in_array($city,$cities,true)) { $cities[] = $city; } if ( !in_array( $category, $categories, true ) ) { $categories[] = $category; } $oldSamples = (int)$p['samples']; $newSamples = $oldSamples + $samples; $oldLift = (float)$p['avg_lift']; $avgLift = $newSamples > 0 ? ( ($oldLift * $oldSamples) + ($lift * $samples) ) / $newSamples : $lift; $wins = (int)$p['wins'] + ($winner ? 1 : 0); $losses = (int)$p['losses'] + ($winner ? 0 : 1); $confidence = $wins / max( 1, $wins + $losses ); $scope = $this->evaluateMemoryScope( $wins, $losses, $newSamples, $avgLift, count($cities), count($categories) ); $active = ( $wins >= 3 && $confidence >= 0.70 ) ? 1 : 0; $stmt = $this->db->prepare(" UPDATE tdb_memoria_padroes SET wins=?, losses=?, samples=?, avg_lift=?, cities_tested=?, categories_tested=?, confidence=?, scope=?, active=?, context_json=?, last_win_at= CASE WHEN ?=1 THEN NOW() ELSE last_win_at END WHERE pattern_key=? "); $stmt->execute([ $wins, $losses, $newSamples, $avgLift, count($cities), count($categories), $confidence, $scope, $active, json_encode( [ 'cities' => $cities, 'categories' => $categories, 'last_context' => $context ], JSON_UNESCAPED_UNICODE ), $winner ? 1 : 0, $pattern ]); } private function evaluateMemoryScope( int $wins, int $losses, int $samples, float $avgLift, int $cities, int $categories ): string { $score = ($wins * 2) - ($losses * 3) + ($samples / 100) + ($avgLift * 1000) + ($cities * 10) + ($categories * 10); if ( $score > 200 && $cities >= 5 && $categories >= 3 ) { return 'global'; } if ( $score > 80 && $categories >= 2 ) { return 'cluster'; } return 'local'; } public function getPatterns( ?string $scope = null ): array { $sql = " SELECT * FROM tdb_memoria_padroes WHERE active=1 "; $params = []; if ($scope) { $sql .= " AND scope=? "; $params[] = $scope; } $sql .= " ORDER BY confidence DESC, avg_lift DESC, samples DESC "; $stmt = $this->db->prepare($sql); $stmt->execute($params); return $stmt->fetchAll( PDO::FETCH_ASSOC ); } /* ======================================================== * VERSIONAMENTO * ======================================================== */ public function createVersion( int $urlId, array $data, string $generatedBy = 'brain' ): int { $stmt = $this->db->prepare(" SELECT COALESCE(MAX(version_number),0)+1 FROM tdb_versoes_url WHERE url_id=? "); $stmt->execute([$urlId]); $number = (int)$stmt->fetchColumn(); $stmt = $this->db->prepare(" INSERT INTO tdb_versoes_url ( url_id, version_number, title, meta_description, h1, content, cta_json, generated_by ) VALUES (?,?,?,?,?,?,?,?) "); $stmt->execute([ $urlId, $number, $data['title'] ?? '', $data['meta_description'] ?? '', $data['h1'] ?? '', $data['content'] ?? '', json_encode( $data['cta'] ?? [], JSON_UNESCAPED_UNICODE ), $generatedBy ]); return (int)$this->db->lastInsertId(); } public function promoteVersion(int $versionId): void { $stmt = $this->db->prepare(" SELECT url_id FROM tdb_versoes_url WHERE id=? "); $stmt->execute([$versionId]); $urlId = (int)$stmt->fetchColumn(); if (!$urlId) { return; } $stmt = $this->db->prepare(" UPDATE tdb_versoes_url SET active=0 WHERE url_id=? "); $stmt->execute([$urlId]); $stmt = $this->db->prepare(" UPDATE tdb_versoes_url SET active=1 WHERE id=? "); $stmt->execute([$versionId]); } public function activeVersion(int $urlId): ?array { $stmt = $this->db->prepare(" SELECT * FROM tdb_versoes_url WHERE url_id=? AND active=1 LIMIT 1 "); $stmt->execute([$urlId]); return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; } /* ======================================================== * EXPERIMENT ENGINE * ======================================================== */ public function createExperiment( int $urlId, string $experimentType, string $changeType, string $pattern, string $hypothesis, int $controlVersion, int $testVersion, string $metric = 'ctr' ): int { /* * seo_temporal: * mesma URL, períodos diferentes. * * cro_split: * usuários divididos no front-end. */ if ( !in_array( $experimentType, [ 'seo_temporal', 'cro_split' ], true ) ) { throw new InvalidArgumentException( 'Tipo de experimento inválido.' ); } $stmt = $this->db->prepare(" INSERT INTO tdb_experimentos ( url_id, tipo, change_type, pattern_key, hypothesis, control_version_id, test_version_id, metric_primary, status, phase, phase_changed_at ) VALUES ( ?,?,?,?,?,?,?,?, 'running', 'control', NOW() ) "); $stmt->execute([ $urlId, $experimentType, $changeType, $pattern, $hypothesis, $controlVersion, $testVersion, $metric ]); return (int)$this->db->lastInsertId(); } /* ======================================================== * ANALISADOR AUTÔNOMO * ======================================================== */ public function analyze(): array { $report = [ 'urls_analyzed' => 0, 'experiments_checked' => 0, 'winners' => 0, 'losers' => 0, 'inconclusive' => 0, 'rollbacks' => 0 ]; $urls = $this->db->query(" SELECT * FROM tdb_urls ")->fetchAll( PDO::FETCH_ASSOC ); foreach ($urls as $url) { $scoreData = $this->calculateScore( (int)$url['id'] ); $report['urls_analyzed']++; if ( !in_array( $scoreData['decision'], [ 'AGGRESSIVE', 'EXPERIMENT' ], true ) ) { continue; } $hypotheses = $this->generateHypotheses( $scoreData ); /* * Aqui o sistema já identifica * possibilidades. * * A geração de conteúdo final pode * ser ligada posteriormente a uma LLM. */ foreach ($hypotheses as $hypothesis) { if ( !$this->canAutoApply( $hypothesis['type'], $scoreData ) ) { continue; } /* * Evita abrir vários testes * simultâneos da mesma URL. */ $stmt = $this->db->prepare(" SELECT COUNT(*) FROM tdb_experimentos WHERE url_id=? AND status='running' "); $stmt->execute([ $url['id'] ]); if ( (int)$stmt->fetchColumn() > 0 ) { break; } /* * Neste ponto o Brain está autorizado * a criar a próxima variante. * * O conteúdo real deve vir do template * atual + LLM/PageGenerator. */ break; } } /* * ANALISA EXPERIMENTOS EXISTENTES */ $stmt = $this->db->query(" SELECT e.*, u.cidade, u.categoria FROM tdb_experimentos e JOIN tdb_urls u ON u.id=e.url_id WHERE e.status='running' "); $experiments = $stmt->fetchAll( PDO::FETCH_ASSOC ); foreach ($experiments as $exp) { $report['experiments_checked']++; $days = ( time() - strtotime( $exp['started_at'] ) ) / 86400; if ( $days < $this->config['brain'] ['min_experiment_days'] ) { continue; } /* * No SEO temporal usamos janelas. * * Primeiros 7 dias = controle. * Próximos 7 dias = teste. */ if ( $exp['tipo'] === 'seo_temporal' ) { if ( $exp['phase'] === 'control' ) { $baseline = $this->metricForPeriod( $exp['url_id'], $exp['metric_primary'], $exp['started_at'], date('Y-m-d H:i:s') ); $this->promoteVersion( (int)$exp['test_version_id'] ); $stmt = $this->db->prepare(" UPDATE tdb_experimentos SET baseline=?, phase='test', phase_changed_at=NOW() WHERE id=? "); $stmt->execute([ $baseline, $exp['id'] ]); continue; } if ( $exp['phase'] === 'test' ) { $testDays = ( time() - strtotime( $exp['phase_changed_at'] ) ) / 86400; if ( $testDays < $this->config['brain'] ['min_experiment_days'] ) { continue; } $baseline = (float)$exp['baseline']; $result = $this->metricForPeriod( $exp['url_id'], $exp['metric_primary'], $exp['phase_changed_at'], date('Y-m-d H:i:s') ); $this->finishExperiment( $exp, $baseline, $result, $report ); } } } return $report; } private function finishExperiment( array $exp, float $baseline, float $result, array &$report ): void { if ($baseline <= 0) { $improvement = 0; } else { $improvement = ($result - $baseline) / $baseline; } /* * Confiança simplificada. * * Pode ser substituída depois * por teste estatístico completo. */ $sample = $this->sampleSize( (int)$exp['url_id'] ); $confidence = min( 0.99, $sample / 1000 ); $minLift = $this->config['brain'] ['min_lift']; if ( $improvement >= $minLift && $confidence >= 0.80 ) { $status = 'winner'; } elseif ( $improvement <= -$minLift && $confidence >= 0.80 ) { $status = 'loser'; } else { $status = 'inconclusive'; } /* * CORREÇÃO DO BUG: * * vencedor promove B. * perdedor pode fazer rollback. */ if ($status === 'winner') { $this->promoteVersion( (int)$exp['test_version_id'] ); $this->recordPatternResult( $exp['pattern_key'] ?: 'unknown', true, $improvement, $sample, $exp['cidade'] ?: '', $exp['categoria'] ?: '', [ 'experiment_id' => $exp['id'], 'change_type' => $exp['change_type'], 'metric' => $exp['metric_primary'] ] ); $report['winners']++; } elseif ($status === 'loser') { $this->recordPatternResult( $exp['pattern_key'] ?: 'unknown', false, $improvement, $sample, $exp['cidade'] ?: '', $exp['categoria'] ?: '', [ 'experiment_id' => $exp['id'], 'change_type' => $exp['change_type'], 'metric' => $exp['metric_primary'] ] ); if ( $this->config['brain'] ['auto_rollback'] && $improvement <= -$this->config['brain'] ['rollback_threshold'] ) { $this->promoteVersion( (int)$exp['control_version_id'] ); $report['rollbacks']++; } $report['losers']++; } else { /* * Resultado inconclusivo não vira * memória vencedora nem perdedora. */ $report['inconclusive']++; } $stmt = $this->db->prepare(" UPDATE tdb_experimentos SET status=?, result=?, improvement=?, confidence=?, ended_at=NOW() WHERE id=? "); $stmt->execute([ $status, $result, $improvement, $confidence, $exp['id'] ]); } private function metricForPeriod( int $urlId, string $metric, string $start, string $end ): float { $allowed = [ 'ctr' => 'AVG(ctr)', 'position' => 'AVG(posicao)', 'leads' => 'SUM(leads)', 'whatsapp' => 'SUM(whatsapp_clicks)', 'pageviews' => 'SUM(pageviews)' ]; $expression = $allowed[$metric] ?? 'AVG(ctr)'; $stmt = $this->db->prepare(" SELECT {$expression} FROM tdb_metricas_url WHERE url_id=? AND data >= DATE(?) AND data <= DATE(?) "); $stmt->execute([ $urlId, $start, $end ]); return (float)$stmt->fetchColumn(); } private function sampleSize(int $urlId): int { $stmt = $this->db->prepare(" SELECT COALESCE( SUM(pageviews), SUM(impressoes), 0 ) FROM tdb_metricas_url WHERE url_id=? AND data >= DATE_SUB( CURDATE(), INTERVAL 28 DAY ) "); $stmt->execute([$urlId]); return max( 0, (int)$stmt->fetchColumn() ); } /* ======================================================== * EVENTOS / COMPORTAMENTO * ======================================================== */ public function receiveEvent(): void { $json = json_decode( file_get_contents( 'php://input' ), true ); if ( !$json || empty($json['url_id']) || empty($json['evento']) ) { http_response_code(400); exit; } $allowed = [ 'pageview', 'scroll', 'active_time', 'whatsapp_click', 'phone_click', 'internal_search', 'lead', 'feedback', 'exit_intent', 'copy_text' ]; if ( !in_array( $json['evento'], $allowed, true ) ) { http_response_code(400); exit; } $stmt = $this->db->prepare(" INSERT INTO tdb_eventos ( url_id, session_id, evento, valor, metadata ) VALUES (?,?,?,?,?) "); $stmt->execute([ (int)$json['url_id'], substr( (string)( $json['session_id'] ?? '' ), 0, 100 ), $json['evento'], isset($json['valor']) ? (float)$json['valor'] : null, json_encode( $json['metadata'] ?? [], JSON_UNESCAPED_UNICODE ) ]); http_response_code(204); exit; } /* ======================================================== * TRACKER JAVASCRIPT * ======================================================== */ public function tracker(int $urlId): string { $endpoint = strtok( $_SERVER['REQUEST_URI'] ?? '/', '?' ); $endpoint .= '?tdb_brain_event=1'; $urlId = (int)$urlId; return <<(function(){ const URL_ID = {$urlId}; const ENDPOINT = {$this->jsString($endpoint)}; let sid = localStorage.getItem( 'tdb_brain_sid' ); if(!sid){ sid = (crypto.randomUUID) ? crypto.randomUUID() : Date.now() + '_' + Math.random(); localStorage.setItem( 'tdb_brain_sid', sid ); } const send = function(evento,valor=null,metadata={}){ try{ const payload = JSON.stringify({ url_id: URL_ID, session_id: sid, evento: evento, valor: valor, metadata: metadata }); if(navigator.sendBeacon){ navigator.sendBeacon( ENDPOINT, new Blob( [payload], { type: 'application/json' } ) ); }else{ fetch( ENDPOINT, { method:'POST', headers:{ 'Content-Type': 'application/json' }, body:payload, keepalive:true } ); } }catch(e){} }; send('pageview',1); let maxScroll = 0; let activeSeconds = 0; let lastActive = Date.now(); const markActive = function(){ const now = Date.now(); if( document.visibilityState === 'visible' ){ activeSeconds += Math.min( 30, (now-lastActive)/1000 ); } lastActive = now; }; ['mousemove','keydown','touchstart'] .forEach(function(evt){ document.addEventListener( evt, markActive, { passive:true } ); }); window.addEventListener( 'scroll', function(){ const max = document.documentElement .scrollHeight - innerHeight; if(max <= 0) return; const percent = Math.round( scrollY/max*100 ); maxScroll = Math.max( maxScroll, percent ); }, { passive:true } ); document.addEventListener( 'click', function(e){ const a = e.target.closest('a'); if(!a) return; const href = a.href || ''; if( href.includes('wa.me') || href.includes('whatsapp') ){ send( 'whatsapp_click', 1, { href:href } ); } if( href.startsWith('tel:') ){ send( 'phone_click', 1, { href:href } ); } } ); document.addEventListener( 'copy', function(){ let txt = ''; try{ txt = String( window .getSelection() ) .substring( 0, 250 ); }catch(e){} send( 'copy_text', 1, { text:txt } ); } ); document.addEventListener( 'mouseout', function(e){ if( e.clientY <= 0 && !window.__tdbExitSent ){ window.__tdbExitSent = true; send( 'exit_intent', 1 ); } } ); window.addEventListener( 'pagehide', function(){ markActive(); send( 'scroll', maxScroll ); send( 'active_time', Math.round( activeSeconds ) ); } );})();HTML; } private function jsString(string $value): string { return json_encode( $value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); }}/* ============================================================ * 3. CONEXÃO * ============================================================ *//* * Se o Top do Bairro já possui um objeto PDO, * você pode substituir esta parte por: * * $pdo = $pdoExistente; */try { $dbConfig = $TDB_BRAIN_CONFIG['db']; $dsn = 'mysql:host=' . $dbConfig['host'] . ';dbname=' . $dbConfig['name'] . ';charset=' . $dbConfig['charset']; $pdo = new PDO( $dsn, $dbConfig['user'], $dbConfig['pass'], [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false ] ); $TDB_BRAIN = new TopDoBairroBrain( $pdo, $TDB_BRAIN_CONFIG );} catch(Throwable $e) { error_log( 'Top do Bairro Brain: ' . $e->getMessage() ); $TDB_BRAIN = null;}/* ============================================================ * 4. ENDPOINT AUTOMÁTICO DE EVENTOS * ============================================================ */if ( isset($_GET['tdb_brain_event']) && $_GET['tdb_brain_event'] === '1' && $TDB_BRAIN) { $TDB_BRAIN->receiveEvent();}/* ============================================================ * 5. CRON / EXECUÇÃO DO CÉREBRO * * Exemplo: * * https://www.topdobairro.com/?tdb_brain_cron=CHAVE * * Melhor ainda: * executar PHP via cron no servidor. * ============================================================ */if ( isset($_GET['tdb_brain_cron']) && $TDB_BRAIN) { $provided = (string) $_GET['tdb_brain_cron']; $expected = (string) $TDB_BRAIN_CONFIG ['brain'] ['cron_key']; if ( !hash_equals( $expected, $provided ) ) { http_response_code(403); exit('Forbidden'); } header( 'Content-Type: application/json; charset=utf-8' ); echo json_encode( $TDB_BRAIN->analyze(), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ); exit;}/* ============================================================ * FIM DO TOP DO BAIRRO BRAIN * ============================================================ */