| print("APP STARTED", flush=True) |
|
|
| import html |
| import gradio as gr |
| import pandas as pd |
| import spaces |
|
|
| from app.db.repo import init_db |
| print("IMPORT 1 OK", flush=True) |
| from app.controller.campaign_controller import on_campaign_select |
| print("IMPORT 2 OK", flush=True) |
| from app.controller.session_loader import load_google_ads_data |
| print("IMPORT 3 OK", flush=True) |
| from app.ads1.ads_analyst import run_ads_analyst_card |
| print("IMPORT 4 OK", flush=True) |
| from app.ads1.search_term_optimizer import run_search_term_optimizer |
| from app.ads1.keyword_inspector import run_keyword_inspector |
| from app.ads1.budget_optimizer import run_budget_optimizer |
| from app.ads1.growth_finder import run_growth_finder |
| from app.ads1.campaign_doctor import run_campaign_doctor |
|
|
| |
| |
| |
|
|
| CSS = """ |
| @import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;520;600;700&family=Inter:wght@400;500;600&family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap'); |
| |
| :root { |
| --custom-bg: #070708; |
| --custom-divider: #232426; |
| --custom-card-bg: #101112; |
| --custom-sidebar: #0d0e0f; |
| --custom-panel: #111214; |
| --custom-text-muted: #9A9DA3; |
| --custom-btn-primary: #5E6BFF; |
| --on-surface: #e5e2e3; |
| --on-surface-variant: #c6c5d8; |
| --secondary: #50d8e9; |
| --tertiary: #ffb689; |
| --primary: #bec2ff; |
| } |
| |
| html, |
| body, |
| .gradio-container { |
| background: var(--custom-bg) !important; |
| color: var(--on-surface) !important; |
| font-family: Inter, sans-serif !important; |
| color-scheme: dark !important; |
| } |
| |
| .gradio-container { |
| max-width: 100% !important; |
| padding: 0 !important; |
| } |
| |
| .main, |
| .contain { |
| max-width: none !important; |
| } |
| |
| footer, |
| .api-docs, |
| .built-with { |
| display: none !important; |
| } |
| |
| .gradio-container, |
| .gradio-container * { |
| color: var(--on-surface); |
| } |
| |
| /* ================================ |
| HEADER |
| ================================ */ |
| |
| #advisor-header { |
| position: fixed; |
| top: 0; |
| left: 0; |
| right: 0; |
| height: 80px; |
| width: 100%; |
| background: var(--custom-bg); |
| border-bottom: 1px solid var(--custom-divider); |
| z-index: 50; |
| } |
| |
| #advisor-header .header-inner { |
| max-width: 1728px; |
| height: 100%; |
| margin: 0 auto; |
| padding: 0 32px; |
| display: flex; |
| align-items: center; |
| justify-content: space-between; |
| } |
| |
| #advisor-header .brand { |
| display: flex; |
| align-items: center; |
| gap: 8px; |
| } |
| |
| #advisor-header .avatar { |
| width: 32px; |
| height: 32px; |
| border-radius: 8px; |
| border: 1px solid var(--custom-text-muted); |
| display: flex; |
| align-items: center; |
| justify-content: center; |
| font-family: Manrope, sans-serif; |
| font-weight: 700; |
| font-size: 14px; |
| color: var(--on-surface); |
| } |
| |
| #advisor-header .title { |
| font-family: Manrope, sans-serif; |
| font-size: 24px; |
| line-height: 1.2; |
| font-weight: 700; |
| letter-spacing: 0; |
| color: var(--on-surface); |
| } |
| |
| /* ================================ |
| HERO |
| ================================ */ |
| |
| #advisor-shell { |
| max-width: 1728px; |
| margin: 80px auto 0; |
| padding: 0 32px 56px; |
| } |
| |
| .hero-section { |
| position: relative; |
| padding: 128px 0 80px; |
| } |
| |
| .hero-inner { |
| max-width: 1516px; |
| margin: 0 auto; |
| width: 100%; |
| } |
| |
| .hero-row { |
| display: flex; |
| justify-content: space-between; |
| align-items: flex-end; |
| margin-bottom: 40px; |
| } |
| |
| .hero-title { |
| font-family: Manrope, sans-serif; |
| font-size: 76px !important; |
| line-height: 1.1 !important; |
| letter-spacing: 0; |
| font-weight: 520 !important; |
| color: var(--on-surface) !important; |
| max-width: 1000px; |
| margin-bottom: 16px; |
| } |
| |
| .hero-subtitle { |
| font-family: Inter, sans-serif; |
| font-size: 19px; |
| line-height: 1.6; |
| color: var(--custom-text-muted) !important; |
| margin-bottom: 24px; |
| } |
| |
| /* ================================ |
| DASHBOARD SHELL |
| ================================ */ |
| |
| #dashboard-shell { |
| width: 100%; |
| min-height: 620px; |
| display: flex; |
| overflow: hidden; |
| position: relative; |
| background: var(--custom-card-bg); |
| border: 1px solid rgba(255, 255, 255, 0.05); |
| border-radius: 8px; |
| box-shadow: 0 25px 50px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.10); |
| } |
| |
| #dashboard-shell > .form, |
| #dashboard-shell > .block, |
| #dashboard-shell > div { |
| border: 0 !important; |
| } |
| |
| .dashboard-row { |
| gap: 0 !important; |
| width: 100%; |
| } |
| |
| .campaign-sidebar { |
| flex: 0 0 256px !important; |
| min-width: 256px !important; |
| max-width: 256px !important; |
| background: var(--custom-sidebar); |
| border-right: 1px solid var(--custom-divider); |
| padding: 16px !important; |
| } |
| |
| .center-workspace { |
| background: #0a0a0b; |
| padding: 24px !important; |
| gap: 24px !important; |
| min-width: 0; |
| } |
| |
| .right-panel { |
| flex: 0 0 320px !important; |
| min-width: 320px !important; |
| max-width: 320px !important; |
| background: var(--custom-panel); |
| border-left: 1px solid var(--custom-divider); |
| padding: 16px !important; |
| } |
| |
| .panel-label { |
| color: #ffffff !important; |
| font-size: 10px; |
| line-height: 1; |
| letter-spacing: 0.2em; |
| text-transform: uppercase; |
| font-weight: 700; |
| opacity: 1; |
| margin: 0 0 24px; |
| padding: 0 16px; |
| } |
| |
| /* ================================ |
| GRADIO COMPONENT RESET |
| ================================ */ |
| |
| .campaign-sidebar .block, |
| .center-workspace .block, |
| .right-panel .block { |
| background: transparent !important; |
| border: 0 !important; |
| box-shadow: none !important; |
| } |
| |
| .campaign-radio, |
| .campaign-radio > div, |
| .campaign-radio fieldset, |
| .campaign-radio .wrap { |
| background: transparent !important; |
| border: 0 !important; |
| box-shadow: none !important; |
| padding: 0 !important; |
| margin: 0 !important; |
| } |
| |
| .campaign-radio .container, |
| .campaign-radio .options, |
| .campaign-radio [role="radiogroup"] { |
| display: flex !important; |
| flex-direction: column !important; |
| gap: 4px !important; |
| overflow: visible !important; |
| max-height: none !important; |
| } |
| |
| .campaign-radio label { |
| display: flex !important; |
| align-items: center !important; |
| gap: 12px !important; |
| width: 100% !important; |
| min-height: 36px !important; |
| margin: 0 !important; |
| padding: 8px 12px !important; |
| border: 1px solid transparent !important; |
| border-radius: 6px !important; |
| background: transparent !important; |
| color: var(--custom-text-muted) !important; |
| font-size: 14px !important; |
| line-height: 1.35 !important; |
| cursor: pointer !important; |
| } |
| |
| .campaign-radio label::before { |
| content: "grid_view"; |
| font-family: "Material Symbols Outlined"; |
| font-size: 18px; |
| line-height: 1; |
| color: rgba(154, 157, 163, 0.55); |
| flex: 0 0 auto; |
| } |
| |
| .campaign-radio label:hover, |
| .campaign-radio label:has(input:checked) { |
| background: rgba(255, 255, 255, 0.04) !important; |
| border-color: rgba(255, 255, 255, 0.05) !important; |
| color: var(--on-surface) !important; |
| } |
| |
| .campaign-radio label:has(input:checked)::before { |
| color: var(--secondary); |
| } |
| |
| .campaign-radio input { |
| display: none !important; |
| } |
| |
| .campaign-radio span { |
| color: inherit !important; |
| white-space: normal !important; |
| } |
| |
| /* ================================ |
| KPI / CHART / CARDS |
| ================================ */ |
| |
| .kpi-grid { |
| display: grid; |
| grid-template-columns: repeat(4, minmax(0, 1fr)); |
| gap: 16px; |
| } |
| |
| .kpi-card { |
| background: var(--custom-card-bg); |
| border: 1px solid rgba(255, 255, 255, 0.05); |
| border-radius: 8px; |
| padding: 20px; |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.10); |
| } |
| |
| .kpi-top { |
| display: flex; |
| justify-content: space-between; |
| align-items: flex-start; |
| gap: 12px; |
| margin-bottom: 12px; |
| } |
| |
| .kpi-label { |
| color: #ffffff !important; |
| font-size: 10px; |
| text-transform: uppercase; |
| letter-spacing: 0.12em; |
| font-weight: 700; |
| opacity: 0.6; |
| } |
| |
| .kpi-status { |
| font-size: 9px; |
| color: rgba(255, 255, 255, 0.68) !important; |
| text-transform: uppercase; |
| letter-spacing: 0; |
| white-space: nowrap; |
| } |
| |
| .kpi-value { |
| font-family: Manrope, sans-serif; |
| font-size: 24px; |
| line-height: 1.2; |
| color: #ffffff !important; |
| font-weight: 700; |
| } |
| |
| .kpi-note { |
| margin-top: 12px; |
| font-size: 11px; |
| font-weight: 600; |
| } |
| |
| .note-sky { |
| color: #50d8e9 !important; |
| } |
| |
| .note-brown { |
| color: #c88a5a !important; |
| } |
| |
| .note-olive { |
| color: #a9b75f !important; |
| } |
| |
| .note-violet { |
| color: #bec2ff !important; |
| } |
| |
| .signal-card { |
| background: var(--custom-card-bg); |
| border: 1px solid var(--custom-divider); |
| border-radius: 8px; |
| padding: 16px; |
| height: 220px; |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.10); |
| display: flex; |
| flex-direction: column; |
| } |
| |
| .signal-header { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| margin-bottom: 24px; |
| } |
| |
| .signal-title { |
| font-size: 12px; |
| font-weight: 700; |
| text-transform: uppercase; |
| letter-spacing: 0.12em; |
| color: #ffffff !important; |
| } |
| |
| .signal-box { |
| position: relative; |
| flex: 1; |
| overflow: hidden; |
| border-radius: 6px; |
| background: rgba(7, 7, 8, 0.5); |
| border: 1px solid rgba(255, 255, 255, 0.02); |
| } |
| |
| .grid-lines, |
| .vertical-lines { |
| position: absolute; |
| inset: 0; |
| pointer-events: none; |
| } |
| |
| .grid-lines { |
| display: flex; |
| flex-direction: column; |
| justify-content: space-between; |
| padding: 8px 0; |
| opacity: 0.12; |
| } |
| |
| .grid-lines span { |
| border-top: 1px solid rgba(255, 255, 255, 0.1); |
| } |
| |
| .vertical-lines { |
| display: grid; |
| grid-template-columns: repeat(6, 1fr); |
| } |
| |
| .vertical-lines span { |
| border-right: 1px solid rgba(255, 255, 255, 0.05); |
| } |
| |
| .signal-card svg { |
| position: absolute; |
| bottom: 0; |
| width: 100%; |
| height: 80%; |
| } |
| |
| .ai-row { |
| gap: 12px !important; |
| align-items: stretch !important; |
| } |
| |
| .ai-card-html, |
| .ai-button-card { |
| background: var(--custom-card-bg) !important; |
| border: 1px solid var(--custom-divider) !important; |
| border-radius: 8px !important; |
| padding: 16px !important; |
| min-height: 116px !important; |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08) !important; |
| } |
| |
| .ai-row > *, |
| .ai-row .block { |
| align-self: stretch !important; |
| } |
| |
| .ai-card-html h3 { |
| margin: 0 0 12px; |
| padding-bottom: 12px; |
| border-bottom: 1px solid rgba(255, 255, 255, 0.05); |
| color: #ffffff !important; |
| font-size: 14px; |
| font-weight: 700; |
| } |
| |
| .ai-card-html p { |
| margin: 0; |
| color: #ffffff !important; |
| font-size: 14px; |
| line-height: 1.5; |
| } |
| |
| .ai-button-card { |
| background: var(--custom-card-bg) !important; |
| border: 1px solid var(--custom-divider) !important; |
| border-radius: 8px !important; |
| color: var(--on-surface) !important; |
| text-align: left !important; |
| font-size: 14px !important; |
| font-weight: 700 !important; |
| transition: border-color 0.15s ease, transform 0.15s ease, background 0.15s ease; |
| white-space: pre-line !important; |
| margin: 0 !important; |
| height: 116px !important; |
| position: relative !important; |
| overflow: hidden !important; |
| } |
| |
| .ai-button-card button, |
| button.ai-button-card { |
| height: 100% !important; |
| min-height: 116px !important; |
| margin: 0 !important; |
| padding: 16px !important; |
| align-items: flex-start !important; |
| justify-content: flex-start !important; |
| text-align: left !important; |
| white-space: pre-line !important; |
| background: var(--custom-card-bg) !important; |
| color: var(--on-surface) !important; |
| border: 1px solid var(--custom-divider) !important; |
| border-radius: 8px !important; |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08) !important; |
| line-height: 1.55 !important; |
| position: relative !important; |
| font-size: 14px !important; |
| font-weight: 700 !important; |
| } |
| |
| .ai-button-card button::after, |
| button.ai-button-card::after { |
| content: ""; |
| position: absolute; |
| left: 16px; |
| right: 16px; |
| top: 48px; |
| height: 1px; |
| background: rgba(255, 255, 255, 0.05); |
| } |
| |
| .ai-button-card button::before, |
| button.ai-button-card::before { |
| position: absolute; |
| left: 16px; |
| right: 16px; |
| top: 64px; |
| color: #ffffff !important; |
| font-size: 14px; |
| font-weight: 400; |
| line-height: 1.5; |
| } |
| |
| .ads-analyst-card button::before, |
| button.ads-analyst-card::before { |
| content: "Campaign insights."; |
| } |
| |
| .budget-optimizer-card button::before, |
| button.budget-optimizer-card::before { |
| content: "Where to adjust spend?"; |
| } |
| |
| .keyword-inspector-card button::before, |
| button.keyword-inspector-card::before { |
| content: "Winning versus wasting keywords."; |
| } |
| |
| .search-term-cleaner-card button::before, |
| button.search-term-cleaner-card::before { |
| content: "Optimize search terms."; |
| } |
| |
| .ai-button-card:hover, |
| .ai-button-card:hover button, |
| button.ai-button-card:hover { |
| border-color: rgba(94, 107, 255, 0.7) !important; |
| background: #141519 !important; |
| transform: translateY(-1px); |
| } |
| |
| .clickable-card { |
| cursor: pointer; |
| transition: border-color 0.15s ease, |
| transform 0.15s ease, |
| background 0.15s ease; |
| } |
| |
| .clickable-card:hover { |
| border-color: rgba(94, 107, 255, 0.7) !important; |
| background: #141519 !important; |
| transform: translateY(-1px); |
| } |
| |
| .snapshot { |
| background: var(--custom-card-bg); |
| border: 1px solid var(--custom-divider); |
| border-radius: 8px; |
| padding: 16px; |
| color: var(--on-surface); |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); |
| } |
| |
| .snapshot h3 { |
| margin: 0 0 8px; |
| font-size: 14px; |
| font-weight: 700; |
| } |
| |
| .snapshot p { |
| margin: 0; |
| color: var(--custom-text-muted); |
| font-size: 14px; |
| } |
| |
| #ads-output { |
| margin-top: 0; |
| padding: 16px; |
| min-height: 140px; |
| border: 1px solid var(--custom-divider); |
| border-radius: 8px; |
| background: var(--custom-card-bg); |
| color: var(--on-surface); |
| box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08); |
| } |
| |
| #ads-output, |
| #ads-output * { |
| color: var(--on-surface) !important; |
| } |
| |
| .advisor-intel-title { |
| display: flex; |
| justify-content: space-between; |
| align-items: center; |
| gap: 12px; |
| padding: 0 4px 20px; |
| margin-bottom: 8px; |
| border-bottom: 1px solid rgba(255, 255, 255, 0.05); |
| font-size: 14px; |
| font-weight: 700; |
| color: #ffffff !important; |
| } |
| |
| .advisor-intel-title * { |
| color: #ffffff !important; |
| } |
| |
| .advisor-intel-title .material-symbols-outlined { |
| color: #ffffff !important; |
| font-size: 20px; |
| } |
| |
| .pulse-dot { |
| width: 6px; |
| height: 6px; |
| border-radius: 999px; |
| background: var(--secondary); |
| display: inline-block; |
| } |
| |
| .intel-section { |
| margin-top: 28px; |
| } |
| |
| .intel-label { |
| color: #c7c9d1 !important; |
| font-size: 10px; |
| font-weight: 700; |
| text-transform: uppercase; |
| letter-spacing: 0.12em; |
| opacity: 1; |
| margin-bottom: 12px; |
| } |
| |
| .intel-box { |
| background: rgba(255, 255, 255, 0.03); |
| border: 1px solid rgba(255, 255, 255, 0.05); |
| border-radius: 8px; |
| padding: 16px; |
| color: #ffffff !important; |
| font-size: 14px; |
| line-height: 1.5; |
| } |
| |
| .intel-box * { |
| color: #ffffff !important; |
| } |
| |
| .text-primary { |
| color: #ffffff !important; |
| font-weight: 700; |
| } |
| |
| .text-secondary { |
| color: #ffffff !important; |
| font-weight: 600; |
| } |
| |
| @media (max-width: 1200px) { |
| #dashboard-shell, |
| .dashboard-row { |
| flex-direction: column !important; |
| } |
| |
| .campaign-sidebar, |
| .right-panel { |
| flex: auto !important; |
| min-width: 100% !important; |
| max-width: 100% !important; |
| border-left: 0; |
| } |
| |
| .campaign-sidebar { |
| border-right: 0; |
| border-bottom: 1px solid var(--custom-divider); |
| } |
| |
| .right-panel { |
| border-top: 1px solid var(--custom-divider); |
| } |
| } |
| |
| @media (max-width: 800px) { |
| #advisor-shell { |
| padding: 0 16px 32px; |
| } |
| |
| #advisor-header .header-inner { |
| padding: 0 16px; |
| } |
| |
| .hero-section { |
| padding: 80px 0 48px; |
| } |
| |
| .hero-title { |
| font-size: 42px !important; |
| } |
| |
| .kpi-grid { |
| grid-template-columns: 1fr; |
| } |
| } |
| """ |
|
|
|
|
| |
| |
| |
|
|
| def startup(): |
| try: |
| init_db() |
| print("DB initialized successfully", flush=True) |
| except Exception as e: |
| print("DB failed:", e, flush=True) |
|
|
|
|
| |
| |
| |
|
|
| def format_money(value): |
| try: |
| return f"£{float(value):,.2f}" |
| except (TypeError, ValueError): |
| return "£0.00" |
|
|
|
|
| def format_number(value): |
| try: |
| return f"{float(value):,.0f}" |
| except (TypeError, ValueError): |
| return "0" |
|
|
|
|
| def format_percent(value): |
| try: |
| numeric = float(value) |
| if abs(numeric) <= 1: |
| numeric *= 100 |
| return f"{numeric:.2f}%" |
| except (TypeError, ValueError): |
| return "0.00%" |
|
|
|
|
| def build_hero_html(spend, leads, cpl, count): |
| return f""" |
| <section class="hero-section"> |
| <div class="hero-inner"> |
| <div class="hero-row"> |
| <div> |
| <h1 class="hero-title">AI-powered Google Ads optimization</h1> |
| <p class="hero-subtitle"> |
| Spend {format_money(spend)} | Leads {leads} | Avg CPL {format_money(cpl)} | Campaigns {count} |
| </p> |
| </div> |
| <div></div> |
| </div> |
| </div> |
| </section> |
| """ |
|
|
|
|
| def build_kpi_html(spend, leads, cpl, count): |
| return f""" |
| <div class="kpi-grid"> |
| <div class="kpi-card"> |
| <div class="kpi-top"> |
| <div class="kpi-label">Spend</div> |
| <div class="kpi-status">[SYNC: ACTIVE]</div> |
| </div> |
| <div class="kpi-value">{format_money(spend)}</div> |
| <div class="kpi-note note-sky">Live Google Ads data</div> |
| </div> |
| <div class="kpi-card"> |
| <div class="kpi-top"> |
| <div class="kpi-label">Leads</div> |
| <div class="kpi-status">[QUEUE: {leads}]</div> |
| </div> |
| <div class="kpi-value">{leads}</div> |
| <div class="kpi-note note-brown">Tracked conversions</div> |
| </div> |
| <div class="kpi-card"> |
| <div class="kpi-top"> |
| <div class="kpi-label">CPL</div> |
| <div class="kpi-status">[HEARTBEAT: OK]</div> |
| </div> |
| <div class="kpi-value">{format_money(cpl)}</div> |
| <div class="kpi-note note-olive">Cost per lead</div> |
| </div> |
| <div class="kpi-card"> |
| <div class="kpi-top"> |
| <div class="kpi-label">Campaigns</div> |
| <div class="kpi-status">[LOAD: OPT]</div> |
| </div> |
| <div class="kpi-value">{count}</div> |
| <div class="kpi-note note-violet">Available for analysis</div> |
| </div> |
| </div> |
| """ |
|
|
|
|
| def build_campaign_kpi_html(campaign_df): |
| if campaign_df is None or campaign_df.empty: |
| return build_kpi_html(0, 0, 0, 0) |
|
|
| spend = campaign_df["cost"].sum() if "cost" in campaign_df else 0 |
| clicks = campaign_df["clicks"].sum() if "clicks" in campaign_df else 0 |
| conversions = campaign_df["conversions"].sum() if "conversions" in campaign_df else 0 |
| cpl = spend / conversions if conversions else 0 |
|
|
| if "ctr" in campaign_df: |
| ctr = campaign_df["ctr"].mean() |
| else: |
| impressions = campaign_df["impressions"].sum() if "impressions" in campaign_df else 0 |
| ctr = clicks / impressions if impressions else 0 |
|
|
| return f""" |
| <div class="kpi-grid"> |
| <div class="kpi-card"> |
| <div class="kpi-top"> |
| <div class="kpi-label">Spend</div> |
| <div class="kpi-status">[CAMPAIGN]</div> |
| </div> |
| <div class="kpi-value">{format_money(spend)}</div> |
| <div class="kpi-note note-sky">Selected campaign spend</div> |
| </div> |
| <div class="kpi-card"> |
| <div class="kpi-top"> |
| <div class="kpi-label">Clicks</div> |
| <div class="kpi-status">[TRAFFIC]</div> |
| </div> |
| <div class="kpi-value">{format_number(clicks)}</div> |
| <div class="kpi-note note-brown">Campaign traffic</div> |
| </div> |
| <div class="kpi-card"> |
| <div class="kpi-top"> |
| <div class="kpi-label">CPL</div> |
| <div class="kpi-status">[EFFICIENCY]</div> |
| </div> |
| <div class="kpi-value">{format_money(cpl)}</div> |
| <div class="kpi-note note-olive">{format_number(conversions)} leads recorded</div> |
| </div> |
| <div class="kpi-card"> |
| <div class="kpi-top"> |
| <div class="kpi-label">CTR</div> |
| <div class="kpi-status">[RESPONSE]</div> |
| </div> |
| <div class="kpi-value">{format_percent(ctr)}</div> |
| <div class="kpi-note note-violet">Click-through rate</div> |
| </div> |
| </div> |
| """ |
|
|
|
|
| SIGNAL_FLOW_HTML = """ |
| <div class="signal-card"> |
| <div class="signal-header"> |
| <div> |
| <span class="signal-title">Signal Flow</span> |
| <span class="kpi-status"> [LATENCY: 12ms]</span> |
| </div> |
| <div class="kpi-status"> |
| <span style="color: var(--primary);">Revenue</span> |
| <span style="margin-left: 16px; color: var(--custom-text-muted);">Goal</span> |
| </div> |
| </div> |
| <div class="signal-box"> |
| <div class="grid-lines"><span></span><span></span><span></span><span></span></div> |
| <div class="vertical-lines"><span></span><span></span><span></span><span></span><span></span><span></span></div> |
| <svg preserveAspectRatio="none" viewBox="0 0 100 100"> |
| <defs> |
| <linearGradient id="gradient-flow-v3" x1="0%" x2="0%" y1="0%" y2="100%"> |
| <stop offset="0%" stop-color="#5E6BFF" stop-opacity="0.15"></stop> |
| <stop offset="100%" stop-color="#5E6BFF" stop-opacity="0"></stop> |
| </linearGradient> |
| <filter height="140%" id="glow" width="140%" x="-20%" y="-20%"> |
| <feGaussianBlur result="blur" stdDeviation="1.5"></feGaussianBlur> |
| <feComposite in="SourceGraphic" in2="blur" operator="over"></feComposite> |
| </filter> |
| </defs> |
| <path d="M0,80 C20,78 35,85 50,65 C65,45 80,30 100,25 L100,100 L0,100 Z" fill="url(#gradient-flow-v3)"></path> |
| <path d="M0,80 C20,78 35,85 50,65 C65,45 80,30 100,25" fill="none" filter="url(#glow)" stroke="#5E6BFF" stroke-linecap="round" stroke-width="1"></path> |
| <circle cx="20" cy="78" fill="#5E6BFF" r="1"></circle> |
| <circle cx="50" cy="65" fill="white" r="1.5" stroke="#5E6BFF" stroke-width="0.5"></circle> |
| <circle cx="80" cy="30" fill="#5E6BFF" r="1"></circle> |
| </svg> |
| <div style="position: absolute; bottom: 4px; right: 8px; color: rgba(154,157,163,0.35); font-size: 9px;">t: 127.4s</div> |
| <div style="position: absolute; top: 8px; left: 8px; color: rgba(154,157,163,0.35); font-size: 9px;">y: $V</div> |
| </div> |
| </div> |
| """ |
|
|
|
|
| def build_signal_flow_html(dfs=None): |
| if not dfs or "hourly" not in dfs or dfs["hourly"] is None or dfs["hourly"].empty: |
| return """ |
| <div class="signal-card"> |
| <div class="signal-header"> |
| <div> |
| <span class="signal-title">Traffic Trend</span> |
| <span class="kpi-status"> [WAITING]</span> |
| </div> |
| </div> |
| <div class="signal-box" style="display:flex;align-items:center;justify-content:center;color:var(--custom-text-muted);font-size:14px;"> |
| Waiting for Google Ads API data streaming... |
| </div> |
| </div> |
| """ |
|
|
| hourly_df = dfs["hourly"].copy() |
| required_columns = {"date", "clicks", "cost"} |
| if not required_columns.issubset(hourly_df.columns): |
| return """ |
| <div class="signal-card"> |
| <div class="signal-header"> |
| <div> |
| <span class="signal-title">Traffic Trend</span> |
| <span class="kpi-status"> [DATA CHECK]</span> |
| </div> |
| </div> |
| <div class="signal-box" style="display:flex;align-items:center;justify-content:center;color:var(--custom-text-muted);font-size:14px;"> |
| Hourly data is missing date, clicks, or cost columns. |
| </div> |
| </div> |
| """ |
|
|
| hourly_df["date"] = pd.to_datetime(hourly_df["date"], errors="coerce").dt.date |
| hourly_df["clicks"] = pd.to_numeric(hourly_df["clicks"], errors="coerce").fillna(0) |
| hourly_df["cost"] = pd.to_numeric(hourly_df["cost"], errors="coerce").fillna(0) |
| hourly_df = hourly_df.dropna(subset=["date"]) |
|
|
| df_daily = ( |
| hourly_df.groupby("date", as_index=False) |
| .agg({"clicks": "sum", "cost": "sum"}) |
| .sort_values("date") |
| .tail(14) |
| .reset_index(drop=True) |
| ) |
|
|
| if df_daily.empty: |
| return build_signal_flow_html(None) |
|
|
| total_days = len(df_daily) |
| total_clicks = int(df_daily["clicks"].sum()) |
| total_cost = float(df_daily["cost"].sum()) |
|
|
| if ( |
| "campaigns" in dfs |
| and dfs["campaigns"] is not None |
| and not dfs["campaigns"].empty |
| and "conversions" in dfs["campaigns"].columns |
| ): |
| total_tours = int(dfs["campaigns"]["conversions"].fillna(0).sum()) |
| else: |
| total_tours = int(total_clicks * 0.06) |
|
|
| max_clicks = df_daily["clicks"].max() |
| max_clicks = max_clicks if max_clicks > 0 else 1 |
|
|
| points = [] |
| for idx, row in df_daily.iterrows(): |
| x = (idx / (total_days - 1)) * 100 if total_days > 1 else 50 |
| y = 100 - ((row["clicks"] / max_clicks) * 75 + 15) |
| points.append((x, y)) |
|
|
| line_path = "M " + " L ".join(f"{x:.1f},{y:.1f}" for x, y in points) |
| first_x = points[0][0] |
| last_x = points[-1][0] |
| area_path = f"{line_path} L {last_x:.1f},100 L {first_x:.1f},100 Z" |
|
|
| peak_idx = int(df_daily["clicks"].idxmax()) |
| dot_indices = sorted({0, peak_idx, total_days - 1}) |
| dots_html = "".join( |
| f'<circle cx="{points[i][0]:.1f}" cy="{points[i][1]:.1f}" fill="#5E6BFF" r="1.8" stroke="white" stroke-width="0.6"></circle>' |
| for i in dot_indices |
| if i < len(points) |
| ) |
|
|
| return f""" |
| <div class="signal-card"> |
| <div class="signal-header"> |
| <div> |
| <span class="signal-title">Traffic Trend</span> |
| <span class="kpi-status"></span> |
| </div> |
| <div class="kpi-status"> |
| <span style="color: var(--primary);">{format_number(total_clicks)} Clicks</span> |
| <span style="margin-left: 16px; color: var(--secondary);">{format_number(total_tours)} Tours Booked</span> |
| <span style="margin-left: 16px; color: var(--custom-text-muted);">{format_money(total_cost)} Spent</span> |
| </div> |
| </div> |
| <div class="signal-box"> |
| <div class="grid-lines"><span></span><span></span><span></span><span></span></div> |
| <div class="vertical-lines"><span></span><span></span><span></span><span></span><span></span><span></span></div> |
| <svg preserveAspectRatio="none" viewBox="0 0 100 100"> |
| <defs> |
| <linearGradient id="gradient-flow-daily" x1="0%" x2="0%" y1="0%" y2="100%"> |
| <stop offset="0%" stop-color="#5E6BFF" stop-opacity="0.25"></stop> |
| <stop offset="100%" stop-color="#5E6BFF" stop-opacity="0"></stop> |
| </linearGradient> |
| <filter height="140%" id="glow-daily" width="140%" x="-20%" y="-20%"> |
| <feGaussianBlur result="blur" stdDeviation="1"></feGaussianBlur> |
| <feComposite in="SourceGraphic" in2="blur" operator="over"></feComposite> |
| </filter> |
| </defs> |
| <path d="{area_path}" fill="url(#gradient-flow-daily)"></path> |
| <path d="{line_path}" fill="none" filter="url(#glow-daily)" stroke="#5E6BFF" stroke-linecap="round" stroke-width="1.2"></path> |
| {dots_html} |
| </svg> |
| <div style="position: absolute; bottom: 6px; right: 10px; color: #ffffff !important; font-size: 10px; font-weight: 500;">Last 14 days</div> |
| <div style="position: absolute; top: 6px; left: 10px; color: #ffffff !important; font-size: 10px; font-weight: 500;">Daily Clicks (Last 14 Days)</div> |
| </div> |
| </div> |
| """ |
|
|
|
|
| SIGNAL_FLOW_HTML = build_signal_flow_html() |
|
|
|
|
| def build_right_panel_html(doctor_result=None): |
| doctor_result = doctor_result or {} |
|
|
| def campaign_block(key, fallback_name, fallback_reason): |
| item = doctor_result.get(key) or {} |
| name = html.escape(str(item.get("name") or fallback_name)) |
| reason = html.escape(str(item.get("reason") or fallback_reason)) |
| return f'<span class="text-primary">{name}</span><br>{reason}' |
|
|
| best_html = campaign_block( |
| "best_campaign", |
| "Awaiting campaign data", |
| "Top performer will appear here after data loads.", |
| ) |
|
|
| worst_html = campaign_block( |
| "budget_drain", |
| "Awaiting campaign data", |
| "Lowest-performing campaign will appear here.", |
| ) |
|
|
| scale_html = campaign_block( |
| "scale_candidate", |
| "Awaiting campaign data", |
| "Best growth opportunity will appear here.", |
| ) |
|
|
| return f""" |
| <div class="advisor-intel-title"> |
| <div style="display:flex;align-items:center;gap:10px;"> |
| <span class="material-symbols-outlined">bolt</span> |
| Advisor Intelligence |
| </div> |
| <span class="pulse-dot"></span> |
| </div> |
| <div class="intel-section"> |
| <div class="intel-label">Best Campaign</div> |
| <div class="intel-box"> |
| {best_html} |
| </div> |
| </div> |
| <div class="intel-section"> |
| <div class="intel-label">Budget Drain</div> |
| <div class="intel-box"> |
| {worst_html} |
| </div> |
| </div> |
| <div class="intel-section"> |
| <div class="intel-label">Scalable Candidate</div> |
| <div class="intel-box"> |
| {scale_html} |
| </div> |
| </div> |
| """ |
|
|
|
|
| RIGHT_PANEL_HTML = build_right_panel_html() |
|
|
|
|
| def ai_card(title, body, onclick=""): |
| return f""" |
| <div class="ai-card-html clickable-card" onclick="{onclick}"> |
| <h3>{title}</h3> |
| <p>{body}</p> |
| </div> |
| """ |
|
|
|
|
| def ai_button_card(title, body): |
| return f"{title}\n{body}" |
|
|
|
|
| |
| |
| |
|
|
| def initial_data_load(): |
| print("STARTING DATA LOAD", flush=True) |
| dfs = load_google_ads_data() |
| print("GOOGLE ADS DATA LOADED", flush=True) |
| from app.ui.dashboard import get_dashboard_data |
|
|
| spend, leads, cpl, count, formatted_df = get_dashboard_data() |
| print("DASHBOARD DATA LOADED", flush=True) |
| campaign_choices = ( |
| formatted_df["Campaign"].dropna().astype(str).tolist() |
| if "Campaign" in formatted_df.columns |
| else [] |
| ) |
| hero_html = build_hero_html(spend, leads, cpl, count) |
| kpi_html = build_kpi_html(spend, leads, cpl, count) |
| try: |
| signal_flow_html = build_signal_flow_html(dfs) |
| except Exception as e: |
| print(f"SIGNAL FLOW CHART FAILED: {e}", flush=True) |
| signal_flow_html = build_signal_flow_html() |
| try: |
| doctor_result = run_campaign_doctor(dfs) |
| except Exception as e: |
| print(f"CAMPAIGN DOCTOR FAILED: {e}", flush=True) |
| doctor_result = None |
| right_panel_html = build_right_panel_html(doctor_result) |
| return dfs, gr.update(choices=campaign_choices, value=None), hero_html, kpi_html, signal_flow_html, right_panel_html |
|
|
|
|
| |
| |
| |
|
|
| def campaign_selected(campaign_name, full_state): |
| if not campaign_name: |
| return None, gr.update() |
|
|
| campaign_state = on_campaign_select(full_state, campaign_name) |
| return campaign_state, build_campaign_kpi_html(campaign_state.get("campaign_df")) |
|
|
| |
| |
| |
|
|
| |
| |
| |
|
|
| @spaces.GPU(duration=120) |
| def run_ads_card(state): |
| try: |
| if not state: |
| return "Select a campaign first." |
|
|
| dfs = state.get("full_dfs") |
| campaign_name = state.get("campaign_name") |
|
|
| if dfs is None or campaign_name is None: |
| return "Campaign state is not properly initialized." |
|
|
| return run_ads_analyst_card(dfs, campaign_name=campaign_name) |
|
|
| except Exception as e: |
| return f"Analysis failed: {e}" |
| |
| @spaces.GPU(duration=120) |
| def run_budget_card(state): |
| try: |
| if not state: |
| return "Select a campaign first." |
|
|
| dfs = state.get("full_dfs") |
| campaign_name = state.get("campaign_name") |
|
|
| if dfs is None or campaign_name is None: |
| return "Campaign state is not properly initialized." |
|
|
| return run_budget_optimizer(dfs, campaign_name=campaign_name) |
|
|
| except Exception as e: |
| return f"Analysis failed: {e}" |
| |
| @spaces.GPU(duration=120) |
| def run_search_term_optimizer_card(state): |
| try: |
| if not state: |
| return "Select a campaign first." |
|
|
| dfs = state.get("full_dfs") |
| campaign_name = state.get("campaign_name") |
|
|
| if dfs is None or campaign_name is None: |
| return "Campaign state is not properly initialized." |
|
|
| return run_search_term_optimizer(dfs, campaign_name=campaign_name) |
|
|
| except Exception as e: |
| return f"Search term optimization failed: {e}" |
| |
| @spaces.GPU(duration=120) |
| def run_growth_finder_card(state): |
| try: |
| if not state: |
| return "Select a campaign first." |
|
|
| dfs = state.get("full_dfs") |
| campaign_name = state.get("campaign_name") |
|
|
| if dfs is None or campaign_name is None: |
| return "Campaign state is not properly initialized." |
|
|
| return run_growth_finder(dfs, campaign_name=campaign_name) |
|
|
| except Exception as e: |
| return f"Search term optimization failed: {e}" |
|
|
| @spaces.GPU(duration=120) |
| def run_keyword_inspector_card(state): |
| try: |
| if not state: |
| return "Select a campaign first." |
|
|
| dfs = state.get("full_dfs") |
| campaign_name = state.get("campaign_name") |
|
|
| if dfs is None or campaign_name is None: |
| return "Campaign state is not properly initialized." |
|
|
| return run_keyword_inspector(dfs, campaign_name=campaign_name) |
|
|
| except Exception as e: |
| return f"Search term optimization failed: {e}" |
|
|
| |
| |
| |
|
|
| with gr.Blocks(fill_height=True, fill_width=True, css=CSS) as demo: |
| full_state = gr.State() |
| campaign_state = gr.State() |
|
|
| gr.HTML(""" |
| <div id="advisor-header"> |
| <div class="header-inner"> |
| <div class="brand"> |
| <div class="avatar">A</div> |
| <div class="title">Advisor</div> |
| </div> |
| </div> |
| </div> |
| """) |
|
|
| with gr.Column(elem_id="advisor-shell"): |
| hero_html = gr.HTML() |
|
|
| with gr.Row(elem_id="dashboard-shell", elem_classes=["dashboard-row"]): |
| with gr.Column(elem_classes=["campaign-sidebar"]): |
| gr.HTML('<div class="panel-label">Campaigns</div>') |
| campaign_picker = gr.Radio( |
| choices=[], |
| show_label=False, |
| container=False, |
| elem_classes=["campaign-radio"], |
| ) |
|
|
| with gr.Column(scale=1, elem_classes=["center-workspace"]): |
| kpi_html = gr.HTML() |
| signal_flow_html = gr.HTML(SIGNAL_FLOW_HTML) |
|
|
| with gr.Row(elem_classes=["ai-row"]): |
| analyze_ads_card = gr.Button( |
| value="Ads Analyst", |
| elem_classes=["ai-button-card", "ads-analyst-card"], |
| ) |
| budget_optimizer_card = gr.Button( |
| value="Budget Optimizer", |
| elem_classes=["ai-button-card", "budget-optimizer-card"], |
| ) |
| keyword_inspector_card = gr.Button( |
| value="Keyword Inspector", |
| elem_classes=["ai-button-card", "keyword-inspector-card"], |
| ) |
|
|
| with gr.Row(elem_classes=["ai-row"]): |
| search_term_cleaner_card = gr.Button( |
| value="Search Term Cleaner", |
| elem_classes=["ai-button-card", "search-term-cleaner-card"], |
| ) |
| growth_finder_card = gr.Button( |
| value="Growth Finder", |
| elem_classes=["ai-button-card", "growth-finder-card"], |
| ) |
| |
| ads_output = gr.Markdown( |
| value="Select a campaign and click Ads Analyst.", |
| elem_id="ads-output", |
| ) |
|
|
| with gr.Column(elem_classes=["right-panel"]): |
| right_panel_html = gr.HTML(RIGHT_PANEL_HTML) |
|
|
| campaign_picker.change( |
| fn=campaign_selected, |
| inputs=[campaign_picker, full_state], |
| outputs=[campaign_state, kpi_html], |
| ) |
|
|
| analyze_ads_card.click( |
| fn=run_ads_card, |
| inputs=campaign_state, |
| outputs=ads_output, |
| ) |
|
|
| budget_optimizer_card.click( |
| fn=run_budget_card, |
| inputs=campaign_state, |
| outputs=ads_output, |
| ) |
|
|
| search_term_cleaner_card.click( |
| fn=run_search_term_optimizer_card, |
| inputs=campaign_state, |
| outputs=ads_output, |
| ) |
|
|
| growth_finder_card.click( |
| fn=run_growth_finder_card, |
| inputs=campaign_state, |
| outputs=ads_output, |
| ) |
|
|
| keyword_inspector_card.click( |
| fn=run_keyword_inspector_card, |
| inputs=campaign_state, |
| outputs=ads_output, |
| ) |
|
|
| demo.load( |
| fn=initial_data_load, |
| outputs=[full_state, campaign_picker, hero_html, kpi_html, signal_flow_html, right_panel_html], |
| ) |
|
|
| demo.load(fn=startup) |
|
|
| demo.queue() |
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|