| """ |
| app.py β Fendt PESTEL-EL Sentinel: Strategic War Room Dashboard |
| =============================================================== |
| Architecture rules (CLAUDE.md): |
| - Imports only from core/. Zero business logic in callbacks. |
| - Callbacks are pure functions. No global state mutation. |
| - All Plotly colors use rgba() β never 8-char hex (#rrggbbaa). |
| - All chart builders tested in _preflight() before Dash starts. |
| - Astra DB access only via SignalDB. Never call astrapy directly. |
| - Styling via CSS className. Inline style only for dynamic values. |
| |
| Sponsor requirements implemented: |
| 1. Verifiable source hyperlinks on every signal row. |
| 2. Bold 12M/24M/36M time-horizon rings; Urgency Matrix at Overview top. |
| 3. Universal strategic LLM prompt β no company-specific copy. |
| 4. Default radar filter β₯ 0.50 (HIGH+CRITICAL only). |
| 5. dcc.Interval wired to DB; sidebar + canvas auto-refresh every 6h. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import atexit |
| import hashlib |
| import os |
| import sys |
| import textwrap |
| import threading as _threading |
| from collections import defaultdict |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Optional |
|
|
| |
| import logging |
| logging.getLogger("werkzeug").setLevel(logging.ERROR) |
| logging.getLogger("dash").setLevel(logging.ERROR) |
|
|
| import json |
|
|
| import dash |
| import dash_bootstrap_components as dbc |
| import dash_cytoscape as cyto |
| import diskcache |
| from flask_caching import Cache as _FlaskCache |
|
|
| |
| try: |
| import markdown as _md_lib |
| import fpdf as _fpdf_lib |
| _PDF_OK = True |
| except ImportError: |
| _PDF_OK = False |
| import numpy as np |
| import plotly.graph_objects as go |
| from dash import Input, Output, State, callback_context, dcc, html, no_update |
| from dash import DiskcacheManager |
|
|
| |
| try: |
| from dotenv import load_dotenv |
| load_dotenv(override=False) |
| except ImportError: |
| _env = Path(__file__).parent / ".env" |
| if _env.exists(): |
| for line in _env.read_text().splitlines(): |
| line = line.strip() |
| if line and not line.startswith("#") and "=" in line: |
| k, _, v = line.partition("=") |
| os.environ.setdefault(k.strip(), v.strip()) |
|
|
| sys.path.insert(0, str(Path(__file__).parent)) |
| from core.database import PESTELDimension, Signal, SignalDB |
| from core.scheduler import HEALTH, engine as _scheduler_engine |
| from core.logger import get_logger |
| from core.summary_engine import generate_brief_markdown |
| from core.agents import run_agent_query |
| from core.graph_engine import get_causal_chains, rebuild_graph_from_db, infer_hidden_relationships |
|
|
| log = get_logger(__name__) |
|
|
| |
| |
| |
|
|
| _HF_TOKEN = os.getenv("HUGGINGFACEHUB_API_TOKEN", "") |
| _HF_OK = bool(_HF_TOKEN) |
| _HF_REPO_ID = "meta-llama/Llama-3.1-8B-Instruct" |
|
|
| |
| |
| |
|
|
| _db: Optional[SignalDB] = None |
|
|
| def _get_db() -> SignalDB: |
| global _db |
| if _db is None: |
| _db = SignalDB() |
| return _db |
|
|
|
|
| |
|
|
|
|
| |
| |
| |
|
|
| _CAT_COLOUR = { |
| "POLITICAL": "#64b5f6", |
| "ECONOMIC": "#a5d6a7", |
| "SOCIAL": "#ffcc80", |
| "TECHNOLOGICAL": "#ce93d8", |
| "ENVIRONMENTAL": "#80deea", |
| "LEGAL": "#ef9a9a", |
| } |
| _DIM_COLOUR = _CAT_COLOUR |
|
|
| _DIM_PILL_CODE = { |
| "POLITICAL": "P", "ECONOMIC": "E", "SOCIAL": "S", |
| "TECHNOLOGICAL": "T", "ENVIRONMENTAL": "En", "LEGAL": "L", |
| } |
|
|
| _SEV_COLOUR = {"critical": "#ff1744", "high": "#ffab00", "moderate": "#00e5ff", "low": "#607d8b"} |
|
|
|
|
| def _hex_to_rgba(hex_colour: str, alpha: float = 0.30) -> str: |
| """Convert '#rrggbb' β 'rgba(r,g,b,alpha)'. Plotly rejects 8-char hex.""" |
| h = hex_colour.lstrip("#") |
| r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) |
| return f"rgba({r},{g},{b},{alpha})" |
|
|
|
|
| def _sev(score: float) -> str: |
| if score >= 0.75: return "critical" |
| if score >= 0.50: return "high" |
| if score >= 0.30: return "moderate" |
| return "low" |
|
|
|
|
| |
| _CHART_BASE = dict( |
| template="plotly_dark", |
| paper_bgcolor="rgba(0,0,0,0)", |
| plot_bgcolor="rgba(0,0,0,0)", |
| font_family="Inter, -apple-system, system-ui, sans-serif", |
| font_color="#a8bcd0", |
| margin=dict(l=36, r=16, t=44, b=36), |
| hoverlabel=dict( |
| bgcolor="rgba(6,8,13,0.97)", |
| bordercolor="rgba(255,255,255,0.12)", |
| font_size=11, |
| font_family="Inter, sans-serif", |
| font_color="#e8edf5", |
| ), |
| ) |
|
|
| |
| _AXIS_Y = dict( |
| gridcolor="rgba(255,255,255,0.04)", |
| zerolinecolor="rgba(255,255,255,0.06)", |
| showline=False, |
| tickfont=dict(size=10, color="#6a8099"), |
| ) |
| _AXIS_X = dict( |
| gridcolor="rgba(0,0,0,0)", |
| zerolinecolor="rgba(0,0,0,0)", |
| showline=False, |
| tickfont=dict(size=10, color="#6a8099"), |
| ) |
| _AXIS_NONE = dict(showgrid=False, zeroline=False, showline=False, |
| tickfont=dict(size=10, color="#6a8099")) |
|
|
|
|
| |
| |
| |
|
|
| def _chart_velocity(signals: list[Signal]) -> go.Figure: |
| """Stacked area: signal ingest per day per dimension, last 30 days.""" |
| now = datetime.now(timezone.utc) |
| day_dim: dict[str, list[int]] = {d: [0] * 30 for d in _DIM_COLOUR} |
|
|
| for s in signals: |
| ts = s.date_ingested |
| if ts.tzinfo is None: |
| ts = ts.replace(tzinfo=timezone.utc) |
| delta = (now - ts).days |
| if 0 <= delta < 30: |
| day_dim[s.pestel_dimension.value][29 - delta] += 1 |
|
|
| fig = go.Figure() |
| for dim, col in _DIM_COLOUR.items(): |
| fig.add_trace(go.Scatter( |
| x=list(range(30)), |
| y=day_dim[dim], |
| name=dim, |
| mode="lines", |
| stackgroup="one", |
| line=dict(width=0.5, color=col), |
| fillcolor=_hex_to_rgba(col, 0.25), |
| hovertemplate=f"{dim}: %{{y}}<extra></extra>", |
| )) |
|
|
| fig.update_layout( |
| **_CHART_BASE, |
| title=dict(text=f"Signal Ingest Β· Last 30 Days ({len(signals)} total)", |
| font_size=11, x=0.5, xanchor="center", y=0.96, font_color="#c4d2de"), |
| xaxis=dict(title="", **_AXIS_X, |
| tickvals=[0, 9, 19, 29], ticktext=["30d ago", "20d ago", "10d ago", "Today"]), |
| yaxis=dict(title="Signals", **_AXIS_Y), |
| legend=dict(orientation="h", y=-0.20, font_size=10, bgcolor="rgba(0,0,0,0)"), |
| height=260, |
| ) |
| return fig |
|
|
|
|
| def _chart_pestel_bar(signals: list[Signal]) -> go.Figure: |
| """Horizontal bar: avg disruption per dimension.""" |
| dim_scores: dict[str, list[float]] = defaultdict(list) |
| for s in signals: |
| dim_scores[s.pestel_dimension.value].append(s.disruption_score) |
|
|
| dims = ["LEGAL", "TECHNOLOGICAL", "POLITICAL", "ECONOMIC", "ENVIRONMENTAL", "SOCIAL"] |
| avgs = [round(sum(dim_scores[d]) / len(dim_scores[d]), 3) if dim_scores[d] else 0.0 |
| for d in dims] |
| cols = [_DIM_COLOUR[d] for d in dims] |
|
|
| fig = go.Figure(go.Bar( |
| x=avgs, y=dims, orientation="h", |
| marker=dict(color=cols, opacity=0.80, |
| line=dict(color="rgba(0,0,0,0)", width=0)), |
| hovertemplate="%{y}: %{x:.3f}<extra></extra>", |
| )) |
| fig.update_layout( |
| **_CHART_BASE, |
| title=dict(text="Avg Disruption by Dimension", font_size=11, x=0.5, xanchor="center", y=0.96, font_color="#c4d2de"), |
| xaxis=dict(range=[0, 1], title="", **_AXIS_X), |
| yaxis=dict(**_AXIS_X), |
| showlegend=False, |
| height=240, |
| ) |
| return fig |
|
|
|
|
| def _chart_histogram(signals: list[Signal]) -> go.Figure: |
| """Disruption score distribution β stacked bar by PESTEL dimension.""" |
| |
| buckets = ["LOW\n<0.40", "MODERATE\n0.40β0.60", "HIGH\n0.60β0.75", "CRITICAL\nβ₯0.75"] |
|
|
| def _bucket(score: float) -> int: |
| if score < 0.40: return 0 |
| if score < 0.60: return 1 |
| if score < 0.75: return 2 |
| return 3 |
|
|
| |
| counts: dict[str, list[int]] = {d: [0, 0, 0, 0] for d in _DIM_COLOUR} |
| for s in signals: |
| counts[s.pestel_dimension.value][_bucket(s.disruption_score)] += 1 |
|
|
| fig = go.Figure() |
|
|
| if not signals: |
| fig.add_annotation( |
| text="No signals yet β run the Scout pipeline.", |
| x=0.5, y=0.5, xref="paper", yref="paper", |
| showarrow=False, font=dict(size=12, color="#a8bcd0"), |
| ) |
| else: |
| for dim, col in _DIM_COLOUR.items(): |
| fig.add_trace(go.Bar( |
| name=dim, |
| x=buckets, |
| y=counts[dim], |
| marker_color=_hex_to_rgba(col, 0.82), |
| marker_line_width=0, |
| hovertemplate=f"{dim}: %{{y}} signals<extra></extra>", |
| )) |
|
|
| fig.update_layout( |
| **_CHART_BASE, |
| barmode="stack", |
| title=dict( |
| text=f"Disruption Distribution Β· {len(signals)} signals", |
| font_size=11, x=0.5, xanchor="center", y=0.96, font_color="#ffffff", |
| ), |
| xaxis=dict(title="", **_AXIS_X), |
| yaxis=dict(title="Signals", **_AXIS_Y), |
| bargap=0.22, |
| legend=dict(orientation="h", y=-0.22, font_size=9, bgcolor="rgba(0,0,0,0)", |
| font_color="#c4d2de"), |
| height=240, |
| ) |
| return fig |
|
|
|
|
| def _chart_radar(signals: list[Signal], |
| dim_filter: str = "All", |
| min_score: float = 0.50) -> go.Figure: |
| """ |
| Innovation Radar β disruption score β 12M/24M/36M time ring. |
| Default min_score=0.50 shows only HIGH+CRITICAL (sponsor requirement #4). |
| """ |
| filtered = [ |
| s for s in signals |
| if s.disruption_score >= min_score |
| and (dim_filter == "All" or s.pestel_dimension.value == dim_filter) |
| ] |
|
|
| fig = go.Figure() |
|
|
| |
| for x0, x1, fill in [ |
| (0, 12, "rgba(255,23,68,0.08)"), |
| (12, 24, "rgba(255,171,0,0.05)"), |
| (24, 37, "rgba(0,230,118,0.03)"), |
| ]: |
| fig.add_vrect(x0=x0, x1=x1, fillcolor=fill, line_width=0, layer="below") |
|
|
| if not filtered: |
| fig.add_annotation( |
| text="No signals at this threshold. Lower the filter or run the Scout.", |
| x=18, y=0.5, showarrow=False, |
| font=dict(size=13, color="#6a8099"), |
| ) |
| else: |
| for dim, col in _DIM_COLOUR.items(): |
| sigs = [s for s in filtered if s.pestel_dimension.value == dim] |
| if not sigs: |
| continue |
| xs, ys, sizes, labels = [], [], [], [] |
| for s in sigs: |
| seed = int(hashlib.md5(s.id.encode()).hexdigest(), 16) % (2 ** 32) |
| rng = np.random.default_rng(seed) |
| j = rng.uniform(-3.0, 3.0) |
| if s.disruption_score >= 0.75: |
| x = float(np.clip(6.5 + j, 2, 11)) |
| elif s.disruption_score >= 0.50: |
| x = float(np.clip(18.0 + j, 13, 23)) |
| else: |
| x = float(np.clip(30.0 + j, 25, 35)) |
| xs.append(x) |
| ys.append(s.disruption_score) |
| sizes.append(max(10, s.disruption_score * 36)) |
| labels.append(s.title[:55] + ("β¦" if len(s.title) > 55 else "")) |
| fig.add_trace(go.Scatter( |
| x=xs, y=ys, name=dim, mode="markers", |
| marker=dict(size=sizes, color=col, opacity=0.85, |
| line=dict(color="rgba(255,255,255,0.15)", width=1)), |
| text=labels, |
| customdata=[[s.disruption_score, s.pestel_dimension.value, s.source_url] |
| for s in sigs], |
| hovertemplate=( |
| "<b>%{text}</b><br>" |
| "Dimension: %{customdata[1]}<br>" |
| "Disruption: %{customdata[0]:.3f}<br>" |
| "<a href='%{customdata[2]}'>Source β</a>" |
| "<extra></extra>" |
| ), |
| )) |
|
|
| |
| for x, lbl, col in [ |
| (12, "β 12M Β· CRITICAL", "#ff1744"), |
| (24, "β 24M Β· HIGH", "#ffab00"), |
| (36, "β 36M Β· MONITOR", "#00e676"), |
| ]: |
| fig.add_vline( |
| x=x, |
| line=dict(color=col, width=2.5, dash="solid"), |
| annotation_text=lbl, |
| annotation=dict( |
| font_size=10, font_color=col, y=1.04, yref="paper", |
| bgcolor=_hex_to_rgba(col, 0.12), |
| bordercolor=col, borderwidth=1, borderpad=4, |
| ), |
| ) |
|
|
| fig.update_layout( |
| **_CHART_BASE, |
| title=dict( |
| text=f"Innovation Radar Β· {len(filtered)} of {len(signals)} signals Β· score β₯ {min_score:.2f}", |
| font_size=11, x=0.5, xanchor="center", y=0.97, font_color="#c4d2de", |
| ), |
| xaxis=dict(title="Time to Impact (months)", range=[0, 38], **_AXIS_NONE), |
| yaxis=dict(title="Disruption Score", range=[0, 1.10], **_AXIS_NONE), |
| legend=dict(orientation="v", x=1.01, font_size=10, bgcolor="rgba(0,0,0,0)"), |
| height=480, |
| ) |
| return fig |
|
|
|
|
| def _build_export_html() -> str: |
| """Build a self-contained HTML report string for download.""" |
| signals = _get_all_signals_cached() |
| stats = _db_stats_cached() |
| top10 = sorted(signals, key=lambda s: s.disruption_score, reverse=True)[:10] |
| critical = [s for s in signals if s.disruption_score >= 0.75] |
|
|
| now_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") |
|
|
| rows = "\n".join( |
| f"<tr>" |
| f"<td>{s.pestel_dimension.value}</td>" |
| f"<td>{s.title[:120]}</td>" |
| f"<td>{s.disruption_score:.3f}</td>" |
| f"<td>{_sev(s.disruption_score).upper()}</td>" |
| f"<td><a href='{s.source_url}' target='_blank'>Source</a></td>" |
| f"</tr>" |
| for s in top10 |
| ) |
|
|
| critical_rows = "\n".join( |
| f"<tr><td>{s.pestel_dimension.value}</td><td>{s.title[:120]}</td>" |
| f"<td>{s.disruption_score:.3f}</td><td><a href='{s.source_url}'>Source</a></td></tr>" |
| for s in critical[:5] |
| ) |
|
|
| return f"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <title>Fendt PESTEL-EL Intelligence Report β {now_str}</title> |
| <style> |
| body {{ font-family: Arial, sans-serif; max-width: 1100px; margin: 40px auto; color: #1a1a2e; }} |
| h1 {{ font-size: 22px; color: #0d1b2a; border-bottom: 2px solid #0d1b2a; padding-bottom: 8px; }} |
| h2 {{ font-size: 16px; color: #1b3a6b; margin-top: 32px; }} |
| table {{ width: 100%; border-collapse: collapse; font-size: 13px; margin-top: 12px; }} |
| th {{ background: #1b3a6b; color: #fff; padding: 8px 12px; text-align: left; }} |
| td {{ padding: 7px 12px; border-bottom: 1px solid #dde1ec; }} |
| tr:hover {{ background: #f0f4ff; }} |
| .meta {{ font-size: 12px; color: #6b7a99; margin-top: 4px; }} |
| .kpi-row {{ display: flex; gap: 24px; margin: 16px 0; }} |
| .kpi {{ background: #f0f4ff; border-radius: 8px; padding: 14px 20px; min-width: 120px; }} |
| .kpi-val {{ font-size: 28px; font-weight: 700; color: #1b3a6b; }} |
| .kpi-lbl {{ font-size: 11px; color: #6b7a99; margin-top: 2px; }} |
| a {{ color: #1b3a6b; }} |
| </style> |
| </head> |
| <body> |
| <h1>Fendt PESTEL-EL Strategic Intelligence Report</h1> |
| <p class="meta">Generated: {now_str} | EU Data Act 2026 Compliant</p> |
| |
| <div class="kpi-row"> |
| <div class="kpi"><div class="kpi-val">{stats['total']}</div><div class="kpi-lbl">Total Signals</div></div> |
| <div class="kpi"><div class="kpi-val">{stats['critical']}</div><div class="kpi-lbl">Critical (≥0.75)</div></div> |
| <div class="kpi"><div class="kpi-val">{stats['high']}</div><div class="kpi-lbl">High (0.50–0.75)</div></div> |
| <div class="kpi"><div class="kpi-val">{stats['avg_disruption']:.3f}</div><div class="kpi-lbl">Avg Disruption</div></div> |
| </div> |
| |
| <h2>Urgency Matrix β 12M Critical Signals</h2> |
| <table> |
| <tr><th>Dimension</th><th>Signal</th><th>Score</th><th>Source</th></tr> |
| {critical_rows if critical_rows else '<tr><td colspan="4">No critical signals at this time.</td></tr>'} |
| </table> |
| |
| <h2>Top 10 Signals by Disruption Score</h2> |
| <table> |
| <tr><th>Dimension</th><th>Signal</th><th>Score</th><th>Severity</th><th>Source</th></tr> |
| {rows if rows else '<tr><td colspan="5">No signals found. Run the Scout to ingest intelligence.</td></tr>'} |
| </table> |
| |
| <p class="meta" style="margin-top:32px;"> |
| This report was generated by the Fendt PESTEL-EL Sentinel. |
| All signals include verifiable source URLs in compliance with EU Data Act 2026 provenance requirements. |
| </p> |
| </body> |
| </html>""" |
|
|
|
|
| |
| |
| |
|
|
| def _preflight() -> None: |
| try: |
| signals = _get_all_signals_cached() |
| _chart_velocity(signals) |
| _chart_pestel_bar(signals) |
| _chart_histogram(signals) |
| _chart_radar(signals) |
| log.info("Pre-flight passed β all chart builders OK (%d signals)", len(signals)) |
| except Exception as exc: |
| log.warning("Pre-flight skipped or failed (likely DB connection): %s", exc) |
|
|
|
|
| |
| |
| |
|
|
| def _metric(label: str, value: str, sub: str = "", glow: str = "") -> html.Div: |
| cls = f"kpi-card{(' glow-' + glow) if glow else ''}" |
| return html.Div([ |
| html.Div(label, className="kpi-label"), |
| html.Div(value, className="kpi-value"), |
| html.Div(sub, className="kpi-sub") if sub else html.Span(), |
| ], className=cls) |
|
|
|
|
| def _dot(label: str, kind: str = "idle") -> html.Div: |
| return html.Div([ |
| html.Div(className=f"sb-dot dot-{kind}"), |
| html.Span(label), |
| ], className="sb-status") |
|
|
|
|
| |
| |
| |
|
|
| def _urgency_card(s: Signal) -> html.Div: |
| dim_code = _DIM_PILL_CODE.get(s.pestel_dimension.value, "P") |
| return html.Div([ |
| html.Span(s.pestel_dimension.value[:3], className=f"dim-pill dp-{dim_code}"), |
| html.Div(f"{s.disruption_score:.3f}", className="urgency-score"), |
| html.Div(s.title, className="urgency-title"), |
| html.A("β Verify Source", href=s.source_url, target="_blank", className="source-link"), |
| ], className="urgency-card") |
|
|
|
|
| def _urgency_matrix(signals: list[Signal]) -> html.Div: |
| critical = sorted( |
| [s for s in signals if s.disruption_score >= 0.75], |
| key=lambda s: s.disruption_score, |
| reverse=True, |
| )[:3] |
|
|
| if not critical: |
| return html.Div([ |
| html.Div("URGENCY MATRIX β 12M CRITICAL", className="section-label"), |
| html.Div( |
| "No critical signals (score β₯ 0.75). Run the Scout to ingest intelligence.", |
| style={"fontSize": "12px", "color": "#e8edf5"}, |
| ), |
| ], className="mb-4") |
|
|
| return html.Div([ |
| html.Div("URGENCY MATRIX β 12M CRITICAL SIGNALS", className="section-label"), |
| dbc.Row( |
| [dbc.Col(_urgency_card(s), md=4) for s in critical], |
| className="g-3", |
| ), |
| ], className="mb-4") |
|
|
|
|
| |
| |
| |
|
|
| def _tab_overview() -> html.Div: |
| """Field Intelligence Overview β KPIs and high-level distribution.""" |
| signals = _get_unique_signals_cached() |
| stats = _db_stats_cached() |
| top3 = sorted(signals, key=lambda s: s.disruption_score, reverse=True)[:3] |
|
|
| return html.Div([ |
| |
| dbc.Row([ |
| dbc.Col(_metric("Total Signals", str(stats["total"]), "Astra DB Vector Store"), md=3), |
| dbc.Col(_metric("Critical", str(stats["critical"]), "Score β₯ 0.75", "red"), md=3), |
| dbc.Col(_metric("High", str(stats["high"]), "Score 0.50β0.75", "amber"), md=3), |
| dbc.Col(_metric("Avg Disruption", f"{stats['avg_disruption']:.3f}", "Global Mean"), md=3), |
| ], className="g-3 mb-3"), |
|
|
| |
| dbc.Row([ |
| dbc.Col(html.Div(dcc.Graph(figure=_chart_velocity(signals), id="chart-velocity", |
| config={"displayModeBar": False}), |
| className="chart-card"), md=7), |
| dbc.Col(html.Div(dcc.Graph(figure=_chart_pestel_bar(signals), id="chart-pestel-bar", |
| config={"displayModeBar": False}), |
| className="chart-card"), md=5), |
| ], className="g-3 mb-3"), |
|
|
| dbc.Row([ |
| dbc.Col(html.Div( |
| dcc.Graph(figure=_chart_histogram(signals), config={"displayModeBar": False}), |
| className="chart-card", |
| ), md=6), |
| dbc.Col(html.Div([ |
| html.Div("TOP SIGNALS β HIGH + CRITICAL", className="section-label"), |
| *([_urgency_card(s) for s in top3] if top3 else [ |
| html.P("No signals yet. Run the Scout.", |
| style={"color": "#e8edf5", "fontSize": "12px"}), |
| ]), |
| ], className="war-card"), md=6), |
| ], className="g-3"), |
| ]) |
|
|
|
|
| def _tab_radar() -> html.Div: |
| """Disruption Horizon β innovation radar view.""" |
| return html.Div([ |
| dbc.Row([ |
| dbc.Col(html.Div([ |
| |
| html.Div("RING GUIDE", className="section-label"), |
| *[html.Div([ |
| html.Div(style={"width": "10px", "height": "10px", "borderRadius": "50%", |
| "background": col, "flexShrink": "0", |
| "boxShadow": f"0 0 6px {col}"}), |
| html.Span(f"{ring} β {lbl}", style={"fontSize": "11px", "color": "#ffffff"}), |
| ], style={"display": "flex", "alignItems": "center", "gap": "8px", "marginBottom": "7px"}) |
| for ring, lbl, col in [ |
| ("12M", "CRITICAL Β· Immediate", "#ff1744"), |
| ("24M", "HIGH Β· Plan Now", "#ffab00"), |
| ("36M", "MONITOR Β· Watch Horizon", "#00e676"), |
| ]], |
|
|
| html.Hr(style={"borderColor": "rgba(255,255,255,0.07)", "margin": "16px 0"}), |
|
|
| |
| html.Div("FILTERS", className="section-label"), |
| html.Div("PESTEL Dimension", className="filter-label"), |
| dcc.Dropdown( |
| id="radar-dim-filter", |
| options=[{"label": d, "value": d} for d in |
| ["All", "POLITICAL", "ECONOMIC", "SOCIAL", |
| "TECHNOLOGICAL", "ENVIRONMENTAL", "LEGAL"]], |
| value="All", clearable=False, |
| className="dark-dropdown", |
| style={"marginBottom": "14px"}, |
| ), |
| html.Div("Min Disruption Score", className="filter-label"), |
| dcc.Slider( |
| id="radar-score-slider", |
| min=0, max=1, step=0.05, |
| value=0.50, |
| marks={0: "0", 0.50: "0.50", 0.75: "0.75", 1: "1"}, |
| tooltip={"placement": "bottom", "always_visible": False}, |
| className="dark-slider", |
| ), |
| html.Div( |
| "Default shows HIGH + CRITICAL only (β₯ 0.50)", |
| style={"fontSize": "9.5px", "color": "#ffffff", "marginTop": "8px"}, |
| ), |
| ], className="war-card"), md=3), |
|
|
| dbc.Col(html.Div([ |
| dcc.Graph(id="radar-chart", config={"displayModeBar": False}), |
| ], className="chart-card"), md=9), |
| ], className="g-3"), |
| dbc.Row([ |
| dbc.Col(html.Div(id="radar-table-container", style={"marginTop": "20px"}), md=12), |
| ], className="g-3"), |
| ]) |
|
|
|
|
| def _row(s: Signal) -> html.Tr: |
| """Module-level row builder for the Signal Feed table.""" |
| return html.Tr([ |
| html.Td(s.date_ingested.strftime("%Y-%m-%d")), |
| html.Td(html.Span(_DIM_PILL_CODE.get(s.pestel_dimension.value, "?"), |
| className=f"dim-pill dp-{_DIM_PILL_CODE.get(s.pestel_dimension.value, 'P')}")), |
| html.Td(html.Div([ |
| html.Div(s.title, style={"fontWeight": "600", "color": "#e8edf5"}), |
| html.Div(s.content[:140] + "...", style={"fontSize": "11px", "color": "#7d8fa8"}), |
| ])), |
| html.Td(f"{s.disruption_score:.3f}", style={"fontFamily": "JetBrains Mono", |
| "color": _SEV_COLOUR.get(_sev(s.disruption_score))}), |
| html.Td(html.A("β", href=s.source_url, target="_blank", className="source-link")), |
| ]) |
|
|
|
|
| def _tab_feed() -> html.Div: |
| """Signal Feed β raw intelligence data table.""" |
| stats = _db_stats_cached() |
| by_dim = stats.get("by_dimension", {}) |
|
|
| return html.Div([ |
| dbc.Row([ |
| dbc.Col([ |
| html.Div([ |
| dcc.Dropdown( |
| id="feed-sort-dropdown", |
| options=[ |
| {"label": "Newest First", "value": "newest"}, |
| {"label": "Highest Disruption", "value": "score_desc"}, |
| {"label": "Lowest Disruption", "value": "score_asc"}, |
| ], |
| value="newest", clearable=False, |
| className="dark-dropdown", |
| style={"width": "200px"}, |
| ), |
| dcc.Dropdown( |
| id="feed-dim-dropdown", |
| options=[{"label": "All Dimensions", "value": "ALL"}] + |
| [{"label": d, "value": d} for d in |
| ["POLITICAL", "ECONOMIC", "SOCIAL", |
| "TECHNOLOGICAL", "ENVIRONMENTAL", "LEGAL"]], |
| value="ALL", clearable=False, |
| className="dark-dropdown", |
| style={"width": "220px"}, |
| ), |
| ], style={"display": "flex", "gap": "12px", "marginBottom": "14px", "flexWrap": "wrap"}), |
| html.Div( |
| id="feed-count-label", |
| style={"fontSize": "11px", "color": "#e8edf5", "marginBottom": "16px"}, |
| ), |
| html.Table([ |
| html.Thead(html.Tr([ |
| html.Th("Date"), html.Th("Dim"), html.Th("Signal"), |
| html.Th("Score"), html.Th("Src"), |
| ])), |
| html.Tbody([], id="feed-table-body"), |
| ], className="war-table"), |
| ], md=8), |
| dbc.Col(html.Div([ |
| html.Div("DATABASE", className="section-label"), |
| _metric("Total", str(stats["total"]) if stats["total"] else "β"), |
| html.Div(style={"height": "10px"}), |
| _metric("Critical", str(stats["critical"]) if stats["total"] else "β", |
| glow="red"), |
| html.Div(style={"height": "10px"}), |
| _metric("Avg Score", f'{stats["avg_disruption"]:.3f}' if stats["total"] else "β"), |
|
|
| html.Hr(style={"borderColor": "rgba(255,255,255,0.07)", "margin": "16px 0"}), |
| html.Div("BY DIMENSION", className="section-label"), |
| *[html.Div([ |
| html.Span(d[:3], style={"fontSize": "10px", "fontWeight": "600", |
| "color": _DIM_COLOUR.get(d, "#7d8fa8"), |
| "minWidth": "36px", "display": "inline-block"}), |
| html.Span(str(by_dim.get(d, 0)), |
| style={"fontFamily": "JetBrains Mono, monospace", |
| "fontSize": "11px", "color": "#e8edf5"}), |
| ], style={"marginBottom": "6px"}) |
| for d in ["POLITICAL", "ECONOMIC", "SOCIAL", |
| "TECHNOLOGICAL", "ENVIRONMENTAL", "LEGAL"]], |
| ], className="war-card"), md=4), |
| ], className="g-3"), |
| ]) |
|
|
|
|
| def _chat_bubble(text: str, role: str = "assistant") -> html.Div: |
| cls = f"chat-bubble bubble-{role}" |
| return html.Div([ |
| html.Div(textwrap.fill(text, 100) if role == "user" else dcc.Markdown(text), |
| className=cls), |
| ], style={"display": "flex", "justifyContent": "flex-end" if role == "user" else "flex-start", |
| "marginBottom": "12px"}) |
|
|
|
|
| def _tab_chatbot(history: list) -> html.Div: |
| """Strategic Advisor β conversational AI interface.""" |
| welcome = _chat_bubble( |
| "**Fendt Commercial Intelligence Advisor**\n\n" |
| "I'm your embedded Fendt/AGCO marketing & sales analyst. " |
| "Ask me about dealer positioning, competitive differentiation against Deere/CNH/Claas, " |
| "precision farming upsell narratives, regulatory urgency messaging, or pipeline priorities " |
| "β all grounded in live PESTEL signals from the database.\n\n" |
| "Questions are routed automatically: data questions go to the **Calculator Agent**, " |
| "strategic and messaging questions go to the **Analyst Agent**.", |
| role="assistant", |
| ) |
| bubbles = [welcome] |
| for msg in history: |
| bubble = _chat_bubble(msg["text"], msg["role"]) |
| if msg["role"] == "assistant" and msg.get("badge"): |
| badge = html.Div( |
| msg["badge"], |
| style={ |
| "fontSize": "9px", |
| "fontFamily": "JetBrains Mono, monospace", |
| "color": msg.get("badge_colour", "#7d8fa8"), |
| "marginTop": "6px", |
| "opacity": "0.75", |
| }, |
| ) |
| bubble = html.Div([bubble, badge]) |
| bubbles.append(bubble) |
|
|
| chips = [ |
| "Which signals should Fendt's sales team lead with in dealer conversations this quarter?", |
| "How should Fendt marketing position the Vario tractor line against CNH and Deere given current EU signals?", |
| "What precision farming trends give AGCO the strongest upsell narrative to existing customers?", |
| "Which regulatory changes create urgency for farmers to upgrade equipment β and how do we message that?", |
| "What competitive threats from John Deere, CNH, or Claas should Fendt sales reps be prepared to counter?", |
| ] |
|
|
| return html.Div([ |
| dbc.Row([ |
| |
| dbc.Col([ |
| html.Div(bubbles, id="chat-messages", className="chat-window"), |
| html.Div([ |
| dcc.Input(id="chat-input", placeholder="Ask a strategic question...", |
| className="chat-input-field", n_submit=0), |
| dbc.Button("Send", id="chat-send", color="primary", className="chat-btn"), |
| ], className="chat-input-group"), |
| ], md=7), |
|
|
| |
| dbc.Col(html.Div([ |
| |
| html.Div("STRATEGIC PROMPTS", className="section-label"), |
| *[html.Button( |
| c, id=f"chip-{i}", n_clicks=0, className="advisor-chip", |
| ) for i, c in enumerate(chips)], |
|
|
| html.Hr(style={"borderColor": "rgba(255,255,255,0.07)", "margin": "18px 0"}), |
|
|
| |
| html.Div("AGENT CAPABILITIES", className="section-label"), |
| html.Div([ |
| html.Div([ |
| html.Div("β", style={"color": "#00e5ff", "fontSize": "8px", "marginRight": "8px", "marginTop": "2px"}), |
| html.Div([ |
| html.Div("Calculator Agent", style={"color": "#00e5ff", "fontSize": "11px", "fontWeight": "600"}), |
| html.Div("Signal scoring, pipeline metrics, disruption rankings by dimension.", |
| style={"fontSize": "10px", "color": "#7d8fa8", "marginTop": "2px"}), |
| ]), |
| ], style={"display": "flex", "marginBottom": "12px"}), |
| html.Div([ |
| html.Div("β", style={"color": "#ffd93d", "fontSize": "8px", "marginRight": "8px", "marginTop": "2px"}), |
| html.Div([ |
| html.Div("Analyst Agent", style={"color": "#ffd93d", "fontSize": "11px", "fontWeight": "600"}), |
| html.Div("Sales positioning, competitive messaging, marketing actions vs Deere/CNH/Claas.", |
| style={"fontSize": "10px", "color": "#7d8fa8", "marginTop": "2px"}), |
| ]), |
| ], style={"display": "flex"}), |
| ]), |
|
|
| html.Hr(style={"borderColor": "rgba(255,255,255,0.07)", "margin": "18px 0"}), |
|
|
| |
| html.Div("KNOWLEDGE AREAS", className="section-label"), |
| *[html.Div(area, style={"fontSize": "10px", "color": "#7d8fa8", |
| "marginBottom": "4px", "paddingLeft": "8px", |
| "borderLeft": "2px solid rgba(0,229,255,0.2)"}) |
| for area in [ |
| "Fendt Vario / IDEAL / FendtONE portfolio", |
| "AGCO Β· Massey Ferguson Β· Valtra Β· Challenger", |
| "Competitive: Deere, CNH, Claas, Kubota", |
| "EU CAP reform & subsidy dynamics", |
| "Precision ag adoption & dealer economics", |
| "Emissions regs Β· electrification pipeline", |
| "Eastern EU market expansion signals", |
| "Commodity price β farmer buying intent", |
| ]], |
|
|
| html.Hr(style={"borderColor": "rgba(255,255,255,0.07)", "margin": "18px 0"}), |
|
|
| |
| html.Div("MODEL", className="section-label"), |
| html.Div(_HF_REPO_ID, |
| style={"fontFamily": "JetBrains Mono, monospace", |
| "fontSize": "10px", "color": "#e8edf5"}), |
| html.Div("Fendt/AGCO Commercial Intelligence", |
| style={"fontSize": "10px", "color": "#7d8fa8", "marginTop": "4px"}), |
| ], className="war-card"), md=5), |
| ], className="g-3"), |
| ]) |
|
|
|
|
| |
| |
| |
|
|
| _GRAPH_JSON = Path(__file__).parent / "data" / "graph.json" |
|
|
| _CAT_COLOUR = { |
| "POLITICAL": "#64b5f6", |
| "ECONOMIC": "#a5d6a7", |
| "SOCIAL": "#ffcc80", |
| "TECHNOLOGICAL": "#ce93d8", |
| "ENVIRONMENTAL": "#80deea", |
| "LEGAL": "#ef9a9a", |
| } |
|
|
| _CYTO_STYLESHEET = [ |
| { |
| "selector": "node", |
| "style": { |
| "label": "data(label)", |
| "background-color": "data(colour)", |
| "color": "#ffffff", |
| "font-size": "11px", |
| "font-weight": "600", |
| "font-family": "Inter, -apple-system, sans-serif", |
| "text-wrap": "wrap", |
| "text-max-width": "130px", |
| "text-valign": "bottom", |
| "text-margin-y": "6px", |
| "text-background-color": "rgba(6,8,13,0.80)", |
| "text-background-opacity": "1", |
| "text-background-padding": "3px", |
| "width": "34px", |
| "height": "34px", |
| "border-width": "2px", |
| "border-color": "rgba(255,255,255,0.25)", |
| "box-shadow": "0 0 8px data(colour)", |
| }, |
| }, |
| { |
| "selector": "edge", |
| "style": { |
| "line-color": "#4fc3f7", |
| "target-arrow-color": "#4fc3f7", |
| "target-arrow-shape": "triangle", |
| "arrow-scale": "1.4", |
| "curve-style": "bezier", |
| "opacity": "0.9", |
| "width": "data(weight_px)", |
| "label": "data(relationship)", |
| "font-size": "10px", |
| "font-family": "Inter, sans-serif", |
| "color": "#e8edf5", |
| "text-opacity": "1", |
| "text-rotation": "autorotate", |
| "text-background-color": "rgba(6,8,13,0.85)", |
| "text-background-opacity": "1", |
| "text-background-padding": "3px", |
| }, |
| }, |
| { |
| "selector": "node:selected", |
| "style": { |
| "border-color": "#00e5ff", |
| "border-width": "3px", |
| "box-shadow": "0 0 14px #00e5ff", |
| }, |
| }, |
| { |
| "selector": "edge:selected", |
| "style": { |
| "line-color": "#00e5ff", |
| "target-arrow-color": "#00e5ff", |
| "opacity": "1", |
| "width": "3", |
| }, |
| }, |
| ] |
|
|
|
|
| def _load_graph_elements() -> list[dict]: |
| """Load data/graph.json and convert to cytoscape elements format. |
| |
| Only nodes that participate in at least one edge are included β isolated |
| nodes add visual noise without conveying relationships. |
| """ |
| if not _GRAPH_JSON.exists(): |
| return [] |
| try: |
| raw = json.loads(_GRAPH_JSON.read_text()) |
|
|
| |
| connected_ids: set[str] = set() |
| for link in raw.get("links", []): |
| connected_ids.add(link["source"]) |
| connected_ids.add(link["target"]) |
|
|
| elements: list[dict] = [] |
| for node in raw.get("nodes", []): |
| if node["id"] not in connected_ids: |
| continue |
| cat = node.get("category", "") |
| elements.append({ |
| "data": { |
| "id": node["id"], |
| "label": node.get("label", node["id"])[:40], |
| "colour": _CAT_COLOUR.get(cat, "#9cb3c9"), |
| "category": cat, |
| }, |
| }) |
| for link in raw.get("links", []): |
| weight = link.get("weight", 0.5) |
| elements.append({ |
| "data": { |
| "source": link["source"], |
| "target": link["target"], |
| "relationship": link.get("relationship", ""), |
| "weight_px": max(2, int(weight * 6)), |
| }, |
| }) |
| return elements |
| except Exception as exc: |
| log.warning("_load_graph_elements failed: %s", exc) |
| return [] |
|
|
|
|
| def _render_causal_chains() -> list: |
| """Build sidebar widgets for the top causal cascade chains.""" |
| try: |
| chains = get_causal_chains(top_n=5) |
| except Exception: |
| chains = [] |
|
|
| if not chains: |
| return [html.Div( |
| "No cascade chains yet β chains build as signals relate to each other.", |
| style={"fontSize": "9px", "color": "#e8edf5", "lineHeight": "1.6"}, |
| )] |
| items = [] |
| for c in chains: |
| chain_parts = c["chain"] |
| arrow_chain_nodes: list = [] |
| for i, p in enumerate(chain_parts): |
| arrow_chain_nodes.append( |
| html.Span(p[:3], style={"color": _CAT_COLOUR.get(p, "#7d8fa8")}) |
| ) |
| if i < len(chain_parts) - 1: |
| arrow_chain_nodes.append(" β ") |
| items.append(html.Div([ |
| html.Div( |
| f"depth {c['depth']} Β· {c['predicate']}", |
| style={"fontSize": "9px", "color": "#e8edf5", "fontFamily": "JetBrains Mono, monospace"}, |
| ), |
| html.Div( |
| arrow_chain_nodes, |
| style={"fontSize": "10px", "marginTop": "2px"}, |
| ), |
| ], style={"marginBottom": "8px", "paddingLeft": "4px", |
| "borderLeft": "2px solid rgba(0,229,255,0.3)"})) |
| return items |
|
|
|
|
| def _render_inferred_relationships() -> list: |
| """Build sidebar widgets for inferred cross-PESTEL cascade relationships.""" |
| if not _GRAPH_JSON.exists(): |
| return [] |
| try: |
| graph = json.loads(_GRAPH_JSON.read_text()) |
| inferred = [ |
| t for t in graph.get("triples", []) |
| if t.get("metadata", {}).get("inferred") |
| ] |
| except Exception as exc: |
| log.warning("_render_inferred_relationships: %s", exc) |
| return [] |
|
|
| if not inferred: |
| return [html.Div( |
| "No inferred cascades yet β click 'Run Inference' to surface hidden cross-PESTEL relationships.", |
| style={"fontSize": "9px", "color": "#e8edf5", "lineHeight": "1.6"}, |
| )] |
|
|
| items = [] |
| for t in inferred[:5]: |
| chain = t.get("metadata", {}).get("causal_chain", []) |
| hops = t.get("metadata", {}).get("hop_count", 0) |
| subj = t.get("subject", {}).get("label", "?") |
| obj = t.get("object", {}).get("label", "?") |
| arrow_nodes: list = [] |
| for i, p in enumerate(chain): |
| arrow_nodes.append(html.Span(p[:3], style={"color": _CAT_COLOUR.get(p, "#7d8fa8")})) |
| if i < len(chain) - 1: |
| arrow_nodes.append(" β ") |
| items.append(html.Div([ |
| html.Div( |
| f"{hops}-hop cascade", |
| style={"fontSize": "9px", "color": "#00e5ff", |
| "fontFamily": "JetBrains Mono, monospace"}, |
| ), |
| html.Div( |
| arrow_nodes, |
| style={"fontSize": "10px", "marginTop": "2px"}, |
| ), |
| html.Div( |
| f"{subj[:28]} β {obj[:28]}", |
| style={"fontSize": "9px", "color": "#e8edf5", "marginTop": "2px"}, |
| ), |
| ], style={"marginBottom": "8px", "paddingLeft": "4px", |
| "borderLeft": "2px solid rgba(0,229,255,0.15)"})) |
| return items |
|
|
|
|
| def _tab_graph(status: str = "") -> html.Div: |
| """Knowledge Graph β causal interdependency visualisation.""" |
| elements = _load_graph_elements_cached() |
| node_count = sum(1 for e in elements if "source" not in e.get("data", {})) |
| edge_count = len(elements) - node_count |
| has_data = node_count > 0 |
|
|
| graph_controls = html.Div([ |
| dbc.Button( |
| "Rebuild Graph", id="rebuild-graph-btn", |
| color="warning", size="sm", outline=True, |
| style={"marginRight": "8px", "fontSize": "10px"}, |
| ), |
| dbc.Button( |
| "Run Inference", id="run-inference-btn", |
| color="info", size="sm", outline=True, |
| style={"fontSize": "10px"}, |
| ), |
| html.Div(id="graph-action-status", children=status, |
| style={"fontSize": "10px", "color": "#e8edf5", "marginTop": "6px"}), |
| ], style={"marginBottom": "12px"}) |
|
|
| legend = [ |
| html.Div([ |
| html.Div(style={"width": "10px", "height": "10px", "borderRadius": "50%", |
| "background": col, "flexShrink": "0", |
| "boxShadow": f"0 0 5px {col}"}), |
| html.Span(cat, style={"fontSize": "10px", "color": "#e8edf5"}), |
| ], style={"display": "flex", "alignItems": "center", "gap": "8px", "marginBottom": "6px"}) |
| for cat, col in _CAT_COLOUR.items() |
| ] |
|
|
| |
| |
| graph_canvas = html.Div([ |
| cyto.Cytoscape( |
| id="knowledge-graph", |
| elements=elements, |
| layout={ |
| "name": "cose", |
| "animate": False, |
| "nodeRepulsion": 8000, |
| "idealEdgeLength": 140, |
| "gravity": 0.03, |
| "numIter": 2500, |
| "padding": 50, |
| "componentSpacing": 100, |
| "nodeDimensionsIncludeLabels": True, |
| "randomize": True, |
| }, |
| stylesheet=_CYTO_STYLESHEET, |
| style={"width": "100%", "height": "580px", |
| "background": "rgba(13,17,23,0.95)", |
| "borderRadius": "8px"}, |
| ), |
| |
| html.Div([ |
| html.Div("β", className="empty-state-icon"), |
| html.Div("No graph data yet", className="empty-state-title"), |
| html.Div( |
| "Run the Scout to ingest signals. The Knowledge Graph builds automatically " |
| "after each cycle. Use 'Rebuild Graph' to reconstruct from the current DB state.", |
| className="empty-state-body", |
| ), |
| ], className="empty-state", style={ |
| "display": "none" if has_data else "flex", |
| "position": "absolute", |
| "top": "0", |
| "left": "0", |
| "right": "0", |
| "bottom": "0", |
| "borderRadius": "8px", |
| "background": "rgba(13,17,23,0.95)", |
| "zIndex": "10", |
| }), |
| ], style={"position": "relative"}) |
|
|
| return html.Div([ |
| dbc.Row([ |
| dbc.Col(html.Div(graph_canvas, className="chart-card"), md=9), |
| dbc.Col(html.Div([ |
| graph_controls, |
| html.Div("GRAPH INFO", className="section-label"), |
| _metric("Nodes", str(node_count) if has_data else "β"), |
| html.Div(style={"height": "8px"}), |
| _metric("Edges", str(edge_count) if has_data else "β"), |
| html.Hr(style={"borderColor": "rgba(255,255,255,0.07)", "margin": "14px 0"}), |
| html.Div("INFERRED CASCADES", className="section-label"), |
| *_render_inferred_relationships(), |
| html.Hr(style={"borderColor": "rgba(255,255,255,0.07)", "margin": "14px 0"}), |
| html.Div("CAUSAL CHAINS", className="section-label"), |
| *_render_causal_chains(), |
| html.Hr(style={"borderColor": "rgba(255,255,255,0.07)", "margin": "14px 0"}), |
| html.Div("DIMENSION KEY", className="section-label"), |
| *legend, |
| html.Hr(style={"borderColor": "rgba(255,255,255,0.07)", "margin": "14px 0"}), |
| html.Div("Click a node to inspect Β· Drag to rearrange", |
| style={"fontSize": "10px", "color": "#a8bcd0", "marginTop": "6px"}), |
| ], className="war-card"), md=3), |
| ], className="g-3"), |
| ]) |
|
|
|
|
| |
|
|
| _REPORTS_DIR = Path(__file__).parent / "outputs" / "reports" |
|
|
|
|
| def _glob_reports() -> list[dict]: |
| """Return sorted list of {label, value} dicts for available .md reports.""" |
| try: |
| if not _REPORTS_DIR.exists(): |
| return [] |
| paths = sorted(_REPORTS_DIR.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True) |
| return [{"label": p.stem.replace("_", " ").title(), "value": str(p)} for p in paths] |
| except Exception: |
| return [] |
|
|
|
|
| def _render_report_body(path: str | None) -> html.Div: |
| """Build the full styled report viewer for a given .md path.""" |
| if not path: |
| return html.Div([ |
| html.Div("π", className="empty-state-icon"), |
| html.Div("No reports available", className="empty-state-title"), |
| html.Div( |
| "Generate a report by running the Sentinel pipeline, then place the .md file " |
| "in outputs/reports/ to register it here.", |
| className="empty-state-body", |
| ), |
| ], className="empty-state") |
|
|
| try: |
| content = Path(path).read_text(encoding="utf-8") |
| except OSError as exc: |
| log.error("_render_report_body: cannot read %s: %s", path, exc) |
| return html.P(f"Could not read report: {exc}", |
| style={"color": "#ff6090", "fontSize": "12px", "padding": "16px"}) |
|
|
| |
| lines = content.splitlines() |
| doc_title = lines[0].lstrip("# ").strip() if lines else Path(path).stem |
| doc_date = "" |
| doc_class = "CONFIDENTIAL β C-SUITE ONLY" |
| for line in lines[1:6]: |
| if line.startswith("**Generated:**"): |
| doc_date = line.replace("**Generated:**", "").strip() |
| if line.startswith("**Classification:**"): |
| doc_class = line.replace("**Classification:**", "").strip() |
|
|
| |
| body_start = 0 |
| for i, ln in enumerate(lines): |
| if i > 0 and ln.startswith("---"): |
| body_start = i + 1 |
| break |
| body_md = "\n".join(lines[body_start:]) if body_start else content |
|
|
| return html.Div([ |
| |
| html.Div([ |
| html.Div([ |
| html.Div(doc_class, className="report-classification"), |
| html.Div(doc_title, className="report-title"), |
| html.Div([ |
| html.Span("Generated: ", style={"color": "#e8edf5"}), |
| html.Span(doc_date or "β", style={"color": "#e8edf5"}), |
| html.Span(" Β· Source: Fendt PESTEL-EL Sentinel", |
| style={"color": "#e8edf5"}), |
| ], className="report-meta"), |
| ], className="report-doc-header-left"), |
| ], className="report-doc-header"), |
|
|
| |
| dcc.Markdown(body_md, dangerously_allow_html=True, className="report-markdown"), |
| ]) |
|
|
|
|
| def _sanitize_for_pdf(text: str) -> str: |
| """Translate non-Latin-1 characters that fpdf Helvetica can't render. |
| |
| fpdf2's built-in fonts are Latin-1 only. Rather than bundle a TTF, |
| we map the most common Unicode glyphs to ASCII equivalents so the |
| export never raises a UnicodeEncodeError. |
| """ |
| _MAP = { |
| "\u20ac": "EUR", |
| "\u00a3": "GBP", |
| "\u2013": "-", |
| "\u2014": "--", |
| "\u2018": "'", |
| "\u2019": "'", |
| "\u201c": '"', |
| "\u201d": '"', |
| "\u2026": "...", |
| "\u00b7": "*", |
| "\u00a0": " ", |
| "\u2022": "*", |
| "\u25cf": "*", |
| "\u2192": "->", |
| "\u2190": "<-", |
| } |
| for char, replacement in _MAP.items(): |
| text = text.replace(char, replacement) |
| |
| return text.encode("latin-1", errors="ignore").decode("latin-1") |
|
|
|
|
| def _md_to_pdf_bytes(content: str) -> bytes: |
| """Convert markdown content to a PDF byte string using fpdf2.""" |
| from fpdf import FPDF |
| import markdown as md_lib |
|
|
| html_body = md_lib.markdown(_sanitize_for_pdf(content), extensions=["tables", "fenced_code"]) |
| |
| html_body = ( |
| html_body |
| .replace("<strong>", "<b>").replace("</strong>", "</b>") |
| .replace("<em>", "<i>").replace("</em>", "</i>") |
| .replace("<blockquote>", "<p><i> ").replace("</blockquote>", "</i></p>") |
| .replace("<code>", "").replace("</code>", "") |
| .replace("<pre>", "<p>").replace("</pre>", "</p>") |
| .replace("<del>", "").replace("</del>", "") |
| ) |
|
|
| pdf = FPDF() |
| pdf.set_margins(25, 22, 25) |
| pdf.set_auto_page_break(auto=True, margin=20) |
| pdf.add_page() |
| pdf.set_font("Helvetica", size=11) |
| pdf.write_html(html_body) |
| return bytes(pdf.output()) |
|
|
|
|
| def _tab_reports() -> html.Div: |
| """Strategic Reports β Markdown viewer for AI-generated C-Suite briefs.""" |
| options = _glob_reports() |
| default = options[0]["value"] if options else None |
| initial_body = _render_report_body(default) |
|
|
| return html.Div([ |
| dbc.Row([ |
| dbc.Col([ |
| |
| html.Div([ |
| html.Div([ |
| html.Div("SELECT REPORT", className="section-label", |
| style={"marginBottom": "6px"}), |
| dcc.Dropdown( |
| id="reports-dropdown", |
| options=options, |
| value=default, |
| clearable=False, |
| placeholder="No reports found in outputs/reports/", |
| className="dark-dropdown", |
| ), |
| ], style={"flex": "1"}), |
| dbc.Button( |
| "β¦ Generate New Intelligence Brief", |
| id="reports-gen-btn", |
| color="primary", size="sm", |
| className="btn-refresh", |
| style={"alignSelf": "flex-end", "whiteSpace": "nowrap"}, |
| ), |
| dbc.Button( |
| "β¬ Export PDF", id="reports-export-pdf-btn", |
| color="secondary", size="sm", outline=True, |
| className="btn-refresh", |
| style={"alignSelf": "flex-end", "whiteSpace": "nowrap", |
| "opacity": "1" if _PDF_OK else "0.35"}, |
| disabled=not _PDF_OK, |
| ), |
| ], style={"display": "flex", "gap": "16px", "alignItems": "flex-end", |
| "marginBottom": "12px"}), |
|
|
| |
| html.Div("", id="reports-gen-status", |
| style={"fontSize": "11px", "color": "#e8edf5", |
| "marginBottom": "16px", "minHeight": "18px"}), |
|
|
| |
| dcc.Loading( |
| html.Div(initial_body, id="reports-body", className="war-card"), |
| id="reports-body-loading", |
| type="circle", |
| color="#00e5ff", |
| ), |
| ], md=10), |
|
|
| dbc.Col(html.Div([ |
| html.Div("REPORTS", className="section-label"), |
| _metric("Available", str(len(options))), |
| html.Hr(style={"borderColor": "rgba(255,255,255,0.07)", "margin": "14px 0"}), |
| html.Div("PDF EXPORT", className="section-label"), |
| html.Div( |
| "PDF export ready" if _PDF_OK else "Install fpdf2 + markdown to enable PDF export", |
| style={"fontSize": "9px", |
| "color": "#00e676" if _PDF_OK else "#6a8099", |
| "lineHeight": "1.6"}, |
| ), |
| html.Hr(style={"borderColor": "rgba(255,255,255,0.07)", "margin": "14px 0"}), |
| html.Div( |
| "Place .md files in outputs/reports/ to register them here.", |
| style={"fontSize": "9px", "color": "#e8edf5", "lineHeight": "1.6"}, |
| ), |
| ], className="war-card"), md=2), |
| ], className="g-3"), |
| ]) |
|
|
|
|
| |
|
|
| _LENS_PRESETS = [ |
| "CAP Reform", |
| "Electric Tractor Adoption", |
| "Precision Farming Regulation", |
| "Grain Price Volatility", |
| "EU Green Deal Agriculture", |
| "Right to Repair Policy", |
| "Labour Shortages in Farming", |
| "Custom Searchβ¦", |
| ] |
|
|
|
|
| def _lens_signal_card(sig: "Signal", score: float) -> html.Div: |
| """Render a single signal result card for the Intelligence Lens.""" |
| relevance_pct = f"{score * 100:.0f}%" |
| dim_col = _DIM_COLOUR.get(sig.pestel_dimension.value, "#9cb3c9") |
| bar_w = max(4, int(score * 100)) |
| return html.Div([ |
| html.Div([ |
| html.Span(sig.pestel_dimension.value[:3], |
| style={"color": dim_col, "fontWeight": "700", |
| "fontSize": "10px", "minWidth": "36px"}), |
| html.Span(sig.title, |
| style={"color": "#e8edf5", "fontSize": "12.5px", "fontWeight": "600", |
| "flex": "1", "lineHeight": "1.45"}), |
| html.Span(f"{relevance_pct}", |
| style={"color": dim_col, "fontSize": "10px", |
| "fontFamily": "JetBrains Mono, monospace", |
| "fontWeight": "700", "whiteSpace": "nowrap"}), |
| ], style={"display": "flex", "gap": "10px", "alignItems": "flex-start", |
| "marginBottom": "8px"}), |
| |
| html.Div(html.Div(style={ |
| "height": "2px", "width": f"{bar_w}%", |
| "background": dim_col, "borderRadius": "1px", |
| "boxShadow": f"0 0 6px {dim_col}", |
| }), style={"background": "rgba(255,255,255,0.06)", "borderRadius": "1px", |
| "marginBottom": "10px", "marginLeft": "46px"}), |
| html.P(sig.content[:220] + ("β¦" if len(sig.content) > 220 else ""), |
| style={"fontSize": "12px", "color": "#c4d0dc", "margin": "0 0 8px 46px", |
| "lineHeight": "1.7"}), |
| html.A( |
| "β verify source", href=sig.source_url, target="_blank", |
| className="source-link", style={"marginLeft": "46px"}, |
| ), |
| ], className="war-card", style={"marginBottom": "10px"}) |
|
|
|
|
| def _run_lens_search(topic: str | None, custom: str | None = None) -> html.Div: |
| """Execute a semantic search and return result cards (or empty state).""" |
| is_custom = topic == "Custom Searchβ¦" |
| query = (custom or "").strip() if is_custom else (topic or "").strip() |
|
|
| if not query: |
| return html.Div([ |
| html.Div("π", className="empty-state-icon"), |
| html.Div("Enter a search query", className="empty-state-title"), |
| html.Div("Select a macro-trend topic above or type a custom query.", |
| className="empty-state-body"), |
| ], className="empty-state") |
|
|
| try: |
| total_signals = len(_get_all_signals_cached()) |
| if total_signals == 0: |
| return html.Div([ |
| html.Div("β", className="empty-state-icon"), |
| html.Div("No signals in Astra DB", className="empty-state-title"), |
| html.Div( |
| 'Click "Run Scout Now" in the sidebar to ingest intelligence. ' |
| "The Intelligence Lens will populate after the first scout cycle completes.", |
| className="empty-state-body", |
| ), |
| ], className="empty-state") |
|
|
| results = _get_db().search(query, n_results=5) |
| except Exception as exc: |
| log.error("_run_lens_search crashed: %s", exc) |
| return html.P(f"Search error: {exc}", |
| style={"color": "#ff6090", "fontSize": "12px"}) |
|
|
| if not results: |
| return html.Div([ |
| html.Div("β", className="empty-state-icon"), |
| html.Div(f'No matches for "{query}"', className="empty-state-title"), |
| html.Div("Try a broader query or run the Scout to ingest more signals.", |
| className="empty-state-body"), |
| ], className="empty-state") |
|
|
| header = html.Div( |
| f'{len(results)} signal(s) matched Β· query: "{query}"', |
| style={"fontSize": "10px", "color": "#e8edf5", |
| "fontFamily": "JetBrains Mono, monospace", "marginBottom": "14px"}, |
| ) |
| return html.Div([header, *[_lens_signal_card(sig, score) for sig, score in results]]) |
|
|
|
|
| def _tab_lens() -> html.Div: |
| """Strategic Intelligence Lens β semantic deep-dive via Astra DB.""" |
| initial_results = _run_lens_search(_LENS_PRESETS[0]) |
|
|
| return html.Div([ |
| dbc.Row([ |
| dbc.Col([ |
| html.Div("MACRO-TREND TOPIC", className="section-label"), |
| dcc.Dropdown( |
| id="lens-topic-dropdown", |
| options=[{"label": t, "value": t} for t in _LENS_PRESETS], |
| value=_LENS_PRESETS[0], |
| clearable=False, |
| className="dark-dropdown", |
| style={"marginBottom": "10px"}, |
| ), |
| dcc.Input( |
| id="lens-custom-input", |
| type="text", |
| placeholder="Type a custom query (active when 'Custom Searchβ¦' selected)β¦", |
| debounce=True, |
| n_submit=0, |
| className="chat-input-field", |
| style={"marginBottom": "16px", "width": "100%"}, |
| ), |
| dcc.Loading( |
| html.Div(initial_results, id="lens-results"), |
| type="circle", color="#00e5ff", |
| ), |
| ], md=9), |
| dbc.Col(html.Div([ |
| html.Div("HOW IT WORKS", className="section-label"), |
| html.P( |
| "Astra DB semantic search surfaces the most relevant signals for any " |
| "macro-trend query. Results are ranked by cosine similarity using the " |
| "all-MiniLM-L6-v2 embedding model.", |
| style={"fontSize": "10.5px", "color": "#c4d0dc", "lineHeight": "1.7"}, |
| ), |
| html.Hr(style={"borderColor": "rgba(255,255,255,0.07)", "margin": "14px 0"}), |
| html.Div("TOP-K", className="section-label"), |
| html.Div("5 signals per query", |
| style={"fontFamily": "JetBrains Mono, monospace", |
| "fontSize": "10px", "color": "#e8edf5"}), |
| html.Hr(style={"borderColor": "rgba(255,255,255,0.07)", "margin": "14px 0"}), |
| html.Div("TIP", className="section-label"), |
| html.Div('Select "Custom Searchβ¦" and type any free-form topic.', |
| style={"fontSize": "9.5px", "color": "#e8edf5", "lineHeight": "1.6"}), |
| ], className="war-card"), md=3), |
| ], className="g-3"), |
| ]) |
|
|
|
|
| |
| |
| |
| |
|
|
|
|
| |
| |
| |
|
|
| |
| _CACHE_DIR = Path(__file__).parent / "data" / ".dash_cache" |
| _CACHE_DIR.mkdir(parents=True, exist_ok=True) |
| _disk_cache = diskcache.Cache(str(_CACHE_DIR)) |
| _background_manager = DiskcacheManager(_disk_cache) |
|
|
| app = dash.Dash( |
| __name__, |
| external_stylesheets=[ |
| dbc.themes.CYBORG, |
| "https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700" |
| "&family=JetBrains+Mono:wght@400;600&display=swap", |
| ], |
| suppress_callback_exceptions=True, |
| title="Fendt Sentinel", |
| background_callback_manager=_background_manager, |
| meta_tags=[ |
| {"name": "viewport", "content": "width=device-width, initial-scale=1"}, |
| {"charset": "utf-8"}, |
| ], |
| ) |
|
|
| |
| |
| |
| app.index_string = """<!DOCTYPE html> |
| <html data-bs-theme="dark"> |
| <head> |
| {%metas%} |
| <title>{%title%}</title> |
| {%favicon%} |
| {%css%} |
| <style> |
| /* Ultimate Anti-Cache Zero-Tolerance Override */ |
| |
| /* 1. Closed Dropdown Bar & All Nested Spans/Divs */ |
| .dark-dropdown .Select-control, |
| .dark-dropdown .Select-control * { |
| background-color: transparent !important; /* let wrapper set it */ |
| color: #000000 !important; |
| font-weight: 800 !important; |
| opacity: 1 !important; |
| text-shadow: none !important; |
| } |
| .dark-dropdown .Select-control { |
| background-color: #ffffff !important; |
| border: 1px solid #ced4da !important; |
| } |
| .dark-dropdown .Select-arrow { |
| border-color: #000000 transparent transparent !important; |
| } |
| |
| /* 2. Open Dropdown Menu & All Nested Options */ |
| .dark-dropdown .Select-menu-outer, |
| .dark-dropdown .Select-menu-outer * { |
| background-color: #0f1623 !important; |
| color: #ffffff !important; |
| } |
| .dark-dropdown .Select-option.is-focused, |
| .dark-dropdown .Select-option.is-focused * { |
| background-color: rgba(0,229,255,0.2) !important; |
| } |
| </style> |
| </head> |
| <body> |
| {%app_entry%} |
| <footer> |
| {%config%} |
| {%scripts%} |
| {%renderer%} |
| </footer> |
| </body> |
| </html> |
| """ |
| server = app.server |
|
|
| |
| |
| |
| _flask_cache = _FlaskCache( |
| app.server, |
| config={"CACHE_TYPE": "SimpleCache", "CACHE_DEFAULT_TIMEOUT": 30}, |
| ) |
|
|
| |
| |
| |
| _GRAPH_REBUILD: dict = {"running": False, "status": ""} |
|
|
|
|
| @_flask_cache.memoize(timeout=30) |
| def _get_all_signals_cached() -> list: |
| """Cached get_all() β avoids hitting Astra DB on every tab render.""" |
| try: |
| return _get_db().get_all() |
| except Exception as exc: |
| log.error("_get_all_signals_cached failed: %s", exc) |
| return [] |
|
|
|
|
| @_flask_cache.memoize(timeout=30) |
| def _get_unique_signals_cached() -> list: |
| """One signal per source_url β highest disruption_score wins.""" |
| seen: set[str] = set() |
| unique: list = [] |
| for s in sorted(_get_all_signals_cached(), key=lambda s: s.disruption_score, reverse=True): |
| if s.source_url not in seen: |
| seen.add(s.source_url) |
| unique.append(s) |
| return unique |
|
|
|
|
| @_flask_cache.memoize(timeout=30) |
| def _db_stats_cached() -> dict: |
| """Cached DB stats β avoids a full get_all() on every sidebar tick.""" |
| try: |
| signals = _get_unique_signals_cached() |
|
|
| scores = [s.disruption_score for s in signals] |
| by_dim: dict[str, int] = {} |
| for s in signals: |
| dim = s.pestel_dimension.value |
| by_dim[dim] = by_dim.get(dim, 0) + 1 |
| return { |
| "total": len(signals), |
| "critical": sum(1 for sc in scores if sc >= 0.75), |
| "high": sum(1 for sc in scores if 0.50 <= sc < 0.75), |
| "avg_disruption": round(sum(scores) / len(scores), 3) if scores else 0.0, |
| "by_dim": by_dim, |
| "status": "ok", |
| } |
| except Exception as exc: |
| log.warning("_db_stats_cached failed: %s", exc) |
| return {"total": 0, "critical": 0, "high": 0, "avg_disruption": 0.0, |
| "by_dim": {}, "status": "error"} |
|
|
|
|
| @_flask_cache.memoize(timeout=30) |
| def _load_graph_elements_cached() -> list[dict]: |
| """Cached graph.json parse β prevents re-reading file on every graph tab load.""" |
| return _load_graph_elements() |
|
|
|
|
| _TABS = [ |
| ("overview", "Field Intelligence"), |
| ("radar", "Disruption Horizon"), |
| ("feed", "Signal Feed"), |
| ("chatbot", "Strategic Advisor"), |
| ("graph", "Knowledge Graph"), |
| ("reports", "Strategic Reports"), |
| ("lens", "Intelligence Lens"), |
| ] |
|
|
| |
| sidebar = html.Div([ |
| html.Div([ |
| html.Div("SENTINEL", className="sb-brand-name"), |
| html.Div("AGRO-MARKET INTELLIGENCE", className="sb-brand-sub"), |
| ], className="sb-brand"), |
|
|
| dcc.Loading( |
| html.Div(id="sidebar-body"), |
| id="sidebar-body-loading", |
| type="dot", |
| color="#7d8fa8", |
| style={"minHeight": "40px"}, |
| ), |
|
|
| html.Div([ |
| dbc.Button("Run Scout Now", id="run-scout-btn", className="btn-scout", |
| color="success", size="sm", outline=True), |
| html.Div(id="run-scout-status", className="sb-run-status"), |
| ], className="sb-action"), |
|
|
| html.Div([ |
| html.Div("v4.0 Β· EU Data Act 2026"), |
| html.Div("AGCO / Fendt Intelligence Platform"), |
| ], className="sb-footer"), |
| ], className="war-sidebar") |
|
|
| |
| topbar = html.Header([ |
| html.Div("Fendt PESTEL-EL Strategic Sentinel", className="topbar-title"), |
| html.Div(id="topbar-badge"), |
| dbc.Button("Export Report", id="export-btn", className="btn-refresh", |
| color="secondary", size="sm", outline=True, |
| style={"marginRight": "8px"}), |
| dbc.Button("Refresh", id="refresh-btn", className="btn-refresh", |
| color="secondary", size="sm", outline=True), |
| html.Div(id="topbar-ts", className="topbar-ts"), |
| ], className="war-topbar") |
|
|
| |
| def _layout() -> html.Div: |
| return html.Div([ |
| sidebar, |
| html.Div([ |
| topbar, |
| html.Nav( |
| dbc.Tabs( |
| [dbc.Tab(label=lbl, tab_id=tid) for tid, lbl in _TABS], |
| id="main-tabs", |
| active_tab="overview", |
| className="war-tabs", |
| ), |
| className="war-tabnav", |
| ), |
| dcc.Loading( |
| html.Div(id="page-canvas", className="war-canvas"), |
| id="page-canvas-loading", |
| type="circle", |
| color="#00e5ff", |
| style={"position": "relative"}, |
| ), |
| ], className="war-main"), |
|
|
| |
| dcc.Store(id="chat-store", data=[]), |
| dcc.Store(id="signals-store", data=[], storage_type="memory"), |
| dcc.Store(id="reports-last-selection", data=None), |
| dcc.Store(id="chip-echo-store", data=""), |
| dcc.Download(id="export-download"), |
| dcc.Download(id="reports-pdf-download"), |
| |
| dcc.Interval(id="interval-30s", interval=6 * 60 * 60 * 1_000, n_intervals=0), |
| |
| |
| html.Div( |
| cyto.Cytoscape(id="dummy-cyto", elements=[], layout={"name": "preset"}), |
| style={"display": "none"}, |
| ), |
| ], className="war-shell") |
|
|
| app.layout = _layout() |
|
|
| |
| |
| |
| _scheduler_engine.start() |
| atexit.register(_scheduler_engine.stop) |
| log.info("SchedulerEngine started at module load. Next scout: %s", |
| HEALTH.get("next_run_utc")) |
|
|
|
|
| |
| |
| |
|
|
| @app.callback( |
| Output("signals-store", "data"), |
| Input("interval-30s", "n_intervals"), |
| Input("refresh-btn", "n_clicks"), |
| ) |
| def refresh_signals_store(_i: int, _n: int) -> list[dict]: |
| """Populate signals-store with top-50 signals every 30 s for cross-tab access.""" |
| try: |
| db = _get_db() |
| total = db.count() |
| if total == 0: |
| return [] |
| results = db.search("EU agricultural market", n_results=min(50, total)) |
| return [ |
| { |
| "id": s.id, |
| "title": s.title, |
| "dim": s.pestel_dimension.value, |
| "score": s.disruption_score, |
| } |
| for s, _ in results |
| ] |
| except Exception as exc: |
| log.error("refresh_signals_store failed: %s", exc) |
| return [] |
|
|
|
|
| @app.callback( |
| Output("page-canvas", "children"), |
| Input("main-tabs", "active_tab"), |
| Input("interval-30s", "n_intervals"), |
| Input("refresh-btn", "n_clicks"), |
| State("chat-store", "data"), |
| ) |
| def render_tab(tab: str, _i: int, _n: int, history: list) -> html.Div: |
| triggered = callback_context.triggered_id |
| |
| |
| |
| if tab == "chatbot": |
| return no_update if triggered != "main-tabs" else _tab_chatbot(history or []) |
| if tab == "graph": |
| if triggered == "interval-30s": |
| return no_update |
| try: |
| |
| return _tab_graph(status=_GRAPH_REBUILD.get("status", "")) |
| except Exception as exc: |
| log.error("render_tab(graph) crashed: %s", exc, exc_info=True) |
| return html.Div( |
| f"Render error in 'graph' β check logs for details: {exc}", |
| style={"color": "#ff6090", "padding": "24px", |
| "fontFamily": "JetBrains Mono, monospace", "fontSize": "12px"}, |
| ) |
| dispatch = { |
| "overview": _tab_overview, |
| "radar": _tab_radar, |
| "feed": _tab_feed, |
| "graph": _tab_graph, |
| "reports": _tab_reports, |
| "lens": _tab_lens, |
| } |
| try: |
| return dispatch.get(tab, _tab_overview)() |
| except Exception as exc: |
| log.error("render_tab(%s) crashed: %s", tab, exc, exc_info=True) |
| return html.Div( |
| f"Render error in '{tab}' β check logs for details: {exc}", |
| style={"color": "#ff6090", "padding": "24px", |
| "fontFamily": "JetBrains Mono, monospace", "fontSize": "12px"}, |
| ) |
|
|
|
|
| @app.callback( |
| Output("radar-chart", "figure"), |
| Output("radar-table-container", "children"), |
| Input("radar-dim-filter", "value"), |
| Input("radar-score-slider", "value"), |
| Input("interval-30s", "n_intervals"), |
| Input("refresh-btn", "n_clicks"), |
| ) |
| def update_radar(dim_filter: str, min_score: float, _i: int, _n: int): |
| try: |
| signals = _get_unique_signals_cached() |
| fig = _chart_radar(signals, dim_filter or "All", min_score or 0.50) |
| filtered = [s for s in signals |
| if (dim_filter in (None, "All") or s.pestel_dimension.value == dim_filter) |
| and s.disruption_score >= (min_score or 0.50)] |
| filtered.sort(key=lambda s: s.disruption_score, reverse=True) |
| if not filtered: |
| table = html.Div("No signals match current filters.", |
| style={"fontSize": "11px", "color": "#6a8099", "padding": "12px 0"}) |
| else: |
| table = html.Table([ |
| html.Thead(html.Tr([ |
| html.Th("Dim"), html.Th("Signal Title"), html.Th("Score"), html.Th("Src"), |
| ])), |
| html.Tbody([ |
| html.Tr([ |
| html.Td(html.Span( |
| _DIM_PILL_CODE.get(s.pestel_dimension.value, "?"), |
| className=f"dim-pill dp-{_DIM_PILL_CODE.get(s.pestel_dimension.value, 'P')}", |
| )), |
| html.Td(s.title, style={"color": "#e8edf5", "fontSize": "12px"}), |
| html.Td(f"{s.disruption_score:.3f}", |
| style={"fontFamily": "JetBrains Mono, monospace", "fontSize": "11px", |
| "color": _SEV_COLOUR.get(_sev(s.disruption_score))}), |
| html.Td(html.A("β Source", href=s.source_url, target="_blank", |
| className="source-link")), |
| ]) for s in filtered[:50] |
| ]), |
| ], className="war-table") |
| return fig, table |
| except Exception as exc: |
| log.error("update_radar crashed: %s", exc, exc_info=True) |
| return go.Figure(), html.Div("Error loading radar table.") |
|
|
|
|
| @app.callback( |
| Output("feed-table-body", "children"), |
| Output("feed-count-label", "children"), |
| Input("feed-sort-dropdown", "value"), |
| Input("feed-dim-dropdown", "value"), |
| Input("interval-30s", "n_intervals"), |
| Input("refresh-btn", "n_clicks"), |
| ) |
| def update_feed(sort_by: str, dim_filter: str, _i: int, _n: int): |
| try: |
| signals = _get_unique_signals_cached() |
| if dim_filter and dim_filter != "ALL": |
| signals = [s for s in signals if s.pestel_dimension.value == dim_filter] |
| if sort_by == "score_desc": |
| signals = sorted(signals, key=lambda s: s.disruption_score, reverse=True) |
| elif sort_by == "score_asc": |
| signals = sorted(signals, key=lambda s: s.disruption_score) |
| else: |
| signals = sorted(signals, key=lambda s: s.date_ingested, reverse=True) |
| label = f"{len(signals)} signal(s) Β· {(sort_by or 'newest').replace('_', ' ')} Β· live from Astra DB" |
| return [_row(s) for s in signals[:100]], label |
| except Exception as exc: |
| log.error("update_feed crashed: %s", exc, exc_info=True) |
| return [], f"Error loading signals: {exc}" |
|
|
|
|
| @app.callback( |
| Output("sidebar-body", "children"), |
| Output("topbar-badge", "children"), |
| Output("topbar-ts", "children"), |
| Input("interval-30s", "n_intervals"), |
| Input("refresh-btn", "n_clicks"), |
| ) |
| def update_sidebar(_i: int, _n: int): |
| stats = _db_stats_cached() |
| total = stats["total"] |
| by_dim = stats.get("by_dim", {}) |
|
|
| db_kind = "live" if total else "idle" |
| gem_kind = "live" if _HF_OK else "warn" |
| sched_kind = "live" if HEALTH["scheduler_alive"] else "idle" |
| scout_kind = "warn" if HEALTH["scout_running"] else sched_kind |
|
|
| body = html.Div([ |
| html.Div([ |
| html.Div("ANALYTICS", className="sb-section-label"), |
| html.Div([ |
| html.Div("Signals", className="sb-kpi-label"), |
| html.Div(str(total) if total else "β", className="sb-kpi-value"), |
| ], className="sb-kpi"), |
| html.Div([ |
| html.Div("Critical", className="sb-kpi-label"), |
| html.Div(str(stats["critical"]) if total else "β", className="sb-kpi-value"), |
| ], className="sb-kpi"), |
| html.Div([ |
| html.Div("Avg Score", className="sb-kpi-label"), |
| html.Div(f'{stats["avg_disruption"]:.3f}' if total else "β", |
| className="sb-kpi-value"), |
| ], className="sb-kpi"), |
| ], className="sb-section"), |
|
|
| html.Div(className="sb-divider"), |
|
|
| html.Div([ |
| html.Div("SERVICES", className="sb-section-label"), |
| _dot("Astra DB", db_kind), |
| _dot("HuggingFace API", gem_kind), |
| _dot("Scheduler", sched_kind), |
| _dot("Scout", scout_kind), |
| ], className="sb-section"), |
|
|
| html.Div(className="sb-divider"), |
|
|
| html.Div([ |
| html.Div("COVERAGE", className="sb-section-label"), |
| *[html.Div([ |
| html.Span(d[:3], className="sb-cov-dim", |
| style={"color": _DIM_COLOUR.get(d, "#7d8fa8")}), |
| html.Span(str(by_dim.get(d, 0)), className="sb-cov-count"), |
| ], className="sb-cov-row") |
| for d in ["POLITICAL", "ECONOMIC", "SOCIAL", |
| "TECHNOLOGICAL", "ENVIRONMENTAL", "LEGAL"]], |
| ], className="sb-section"), |
| ]) |
|
|
| badge = html.Div( |
| f"{total} signals" if total else "NO DATA", |
| className="topbar-badge", |
| style={ |
| "color": "#00e676" if total else "#ff1744", |
| "borderColor": "rgba(0,230,118,0.5)" if total else "rgba(255,23,68,0.5)", |
| "background": "rgba(0,230,118,0.08)" if total else "rgba(255,23,68,0.08)", |
| }, |
| ) |
| ts = datetime.now(timezone.utc).strftime("UTC %H:%M:%S Β· auto-refresh 6h") |
| return body, badge, ts |
|
|
|
|
| _CHIP_TEXTS = [ |
| "Which signals should Fendt's sales team lead with in dealer conversations this quarter?", |
| "How should Fendt marketing position the Vario tractor line against CNH and Deere given current EU signals?", |
| "What precision farming trends give AGCO the strongest upsell narrative to existing customers?", |
| "Which regulatory changes create urgency for farmers to upgrade equipment β and how do we message that?", |
| "What competitive threats from John Deere, CNH, or Claas should Fendt sales reps be prepared to counter?", |
| ] |
|
|
|
|
| @app.callback( |
| Output("chip-echo-store", "data"), |
| [Input(f"chip-{i}", "n_clicks") for i in range(5)], |
| prevent_initial_call=True, |
| ) |
| def _fill_input_from_chip(*_clicks): |
| ctx = callback_context |
| if not ctx.triggered: |
| return no_update |
| tid = ctx.triggered_id |
| if tid and str(tid).startswith("chip-"): |
| return _CHIP_TEXTS[int(str(tid).split("-")[1])] |
| return no_update |
|
|
|
|
| @app.callback( |
| Output("chat-input", "value", allow_duplicate=True), |
| Input("chip-echo-store", "data"), |
| prevent_initial_call=True, |
| ) |
| def _echo_chip_to_input(text: str): |
| return text if text else no_update |
|
|
|
|
| @app.callback( |
| output=[ |
| Output("chat-messages", "children"), |
| Output("chat-store", "data"), |
| Output("chat-input", "value"), |
| ], |
| inputs=[ |
| Input("chat-send", "n_clicks"), |
| Input("chat-input", "n_submit"), |
| Input("chip-0", "n_clicks"), |
| Input("chip-1", "n_clicks"), |
| Input("chip-2", "n_clicks"), |
| Input("chip-3", "n_clicks"), |
| Input("chip-4", "n_clicks"), |
| ], |
| state=[ |
| State("chat-input", "value"), |
| State("chat-store", "data"), |
| ], |
| running=[ |
| (Output("chat-send", "disabled"), True, False), |
| (Output("chat-input", "disabled"), True, False), |
| ], |
| prevent_initial_call=True, |
| background=True, |
| ) |
| def send_message(n_send, n_sub, c0, c1, c2, c3, c4, question_val, history_data): |
| chip_texts = _CHIP_TEXTS |
|
|
| question = question_val or "" |
| history = list(history_data or []) |
|
|
| triggered = callback_context.triggered_id |
| if triggered and str(triggered).startswith("chip-"): |
| question = chip_texts[int(str(triggered).split("-")[1])] |
|
|
| if not question.strip(): |
| return no_update, no_update, no_update |
|
|
| question = question.strip() |
| try: |
| results = _get_db().search(question, n_results=6) |
| context = [sig for sig, _ in results] |
| except Exception: |
| context = [] |
|
|
| |
| agent_result = run_agent_query(question, context) |
| answer = agent_result.get("final_answer", "Agent returned no answer.") |
| route = agent_result.get("route", "synthesis") |
| trace = agent_result.get("agent_trace", []) |
| confidence = agent_result.get("confidence", "medium") |
|
|
| |
| route_label = "QUANTITATIVE Β· Calculator" if route == "quantitative" else "SYNTHESIS Β· Analyst" |
| conf_colour = {"high": "#00e676", "medium": "#ffd93d", "low": "#ff6090"}.get(confidence, "#7d8fa8") |
| badge_text = f"[{route_label} Β· confidence={confidence} Β· agents={' β '.join(trace)}]" |
|
|
| history.append({"role": "user", "text": question}) |
| history.append({"role": "assistant", "text": answer, "badge": badge_text, "badge_colour": conf_colour}) |
| if len(history) > 20: |
| history = history[-20:] |
|
|
| welcome = _chat_bubble( |
| f"Fendt Relational Brain β Multi-Agent Strategic Advisor\n\n" |
| f"{_db_stats_cached()['total']} signal(s) in Astra DB. " |
| f"Router automatically directs queries to the Calculator Agent " |
| f"(quantitative) or Analyst Agent (synthesis).", |
| role="assistant", |
| ) |
| bubbles = [welcome] |
| for msg in history: |
| bubble = _chat_bubble(msg["text"], msg["role"]) |
| if msg["role"] == "assistant" and msg.get("badge"): |
| badge = html.Div( |
| msg["badge"], |
| style={ |
| "fontSize": "9px", |
| "fontFamily": "JetBrains Mono, monospace", |
| "color": msg.get("badge_colour", "#7d8fa8"), |
| "marginTop": "6px", |
| "opacity": "0.75", |
| }, |
| ) |
| bubble = html.Div([bubble, badge]) |
| bubbles.append(bubble) |
| return bubbles, history, "" |
|
|
|
|
| @app.callback( |
| Output("run-scout-status", "children"), |
| Input("run-scout-btn", "n_clicks"), |
| prevent_initial_call=True, |
| ) |
| def trigger_scout(n: int) -> str: |
| if not _HF_OK: |
| return "HuggingFace API token missing." |
| _scheduler_engine.trigger_now() |
| log.info("Manual scout triggered via UI (n_clicks=%d)", n) |
| return "Scout running in background β check sidebar for updates." |
|
|
|
|
| @app.callback( |
| Output("page-canvas", "children", allow_duplicate=True), |
| Input("rebuild-graph-btn", "n_clicks"), |
| Input("run-inference-btn", "n_clicks"), |
| prevent_initial_call=True, |
| ) |
| def graph_action(rebuild_n: int, infer_n: int): |
| """Handle Rebuild Graph and Run Inference buttons.""" |
| triggered = callback_context.triggered_id |
|
|
| if triggered == "rebuild-graph-btn": |
| if _GRAPH_REBUILD["running"]: |
| try: |
| return _tab_graph(status="Rebuild already in progress β click Refresh when complete.") |
| except Exception as exc: |
| log.error("graph_action _tab_graph failed: %s", exc) |
| return html.Div(f"Graph render error: {exc}", |
| style={"color": "#ff6090", "padding": "24px", |
| "fontFamily": "JetBrains Mono, monospace"}) |
|
|
| def _do_rebuild() -> None: |
| _GRAPH_REBUILD["running"] = True |
| _GRAPH_REBUILD["status"] = "Rebuilding in backgroundβ¦" |
| try: |
| counts = rebuild_graph_from_db() |
| _flask_cache.delete_memoized(_load_graph_elements_cached) |
| _GRAPH_REBUILD["status"] = ( |
| f"Graph rebuilt: {counts['nodes']} nodes, " |
| f"{counts['links']} edges, {counts['triples']} triples" |
| ) |
| log.info("graph_action: background rebuild complete β %s", _GRAPH_REBUILD["status"]) |
| except Exception as exc: |
| log.error("graph_action background rebuild failed: %s", exc) |
| _GRAPH_REBUILD["status"] = f"Rebuild failed: {exc}" |
| finally: |
| _GRAPH_REBUILD["running"] = False |
|
|
| _threading.Thread(target=_do_rebuild, daemon=True).start() |
| try: |
| return _tab_graph(status="Rebuilding in background β click Refresh when complete (~60 s).") |
| except Exception as exc: |
| log.error("graph_action _tab_graph failed: %s", exc) |
| return html.Div(f"Graph render error: {exc}", |
| style={"color": "#ff6090", "padding": "24px", |
| "fontFamily": "JetBrains Mono, monospace"}) |
|
|
| elif triggered == "run-inference-btn": |
| try: |
| result = infer_hidden_relationships() |
| _flask_cache.delete_memoized(_load_graph_elements_cached) |
| added = result["inferred_added"] |
| total = result["total_triples"] |
| status = f"Inference complete: +{added} hidden cascades ({total} total triples)" |
| except Exception as exc: |
| log.error("graph_action inference failed: %s", exc) |
| status = f"Inference failed: {exc}" |
| try: |
| return _tab_graph(status=status) |
| except Exception as exc: |
| log.error("graph_action _tab_graph failed: %s", exc) |
| return html.Div(f"Graph render error: {exc}", |
| style={"color": "#ff6090", "padding": "24px", |
| "fontFamily": "JetBrains Mono, monospace"}) |
|
|
| return no_update |
|
|
|
|
| @app.callback( |
| Output("export-download", "data"), |
| Input("export-btn", "n_clicks"), |
| prevent_initial_call=True, |
| ) |
| def export_report(n_clicks: int): |
| try: |
| html_content = _build_export_html() |
| filename = f"fendt-pestel-report-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M')}.html" |
| return dcc.send_string(html_content, filename) |
| except Exception as exc: |
| log.error("export_report failed: %s", exc) |
| return no_update |
|
|
|
|
| |
|
|
| @app.callback( |
| Output("reports-body", "children"), |
| Input("reports-dropdown", "value"), |
| prevent_initial_call=True, |
| ) |
| def render_report(path: str | None) -> html.Div: |
| if not path: |
| return no_update |
| |
| |
| return _render_report_body(path) |
|
|
|
|
| @app.callback( |
| Output("reports-pdf-download", "data"), |
| Output("reports-last-selection", "data"), |
| Input("reports-export-pdf-btn", "n_clicks"), |
| Input("reports-dropdown", "value"), |
| State("reports-last-selection", "data"), |
| prevent_initial_call=True, |
| ) |
| def export_report_pdf(n_clicks: int, current_path: str | None, last_path: str | None): |
| triggered = callback_context.triggered_id |
|
|
| |
| if triggered == "reports-dropdown": |
| return no_update, current_path |
|
|
| |
| if triggered == "reports-export-pdf-btn" and n_clicks: |
| if not current_path or not _PDF_OK: |
| return no_update, no_update |
| try: |
| content = Path(current_path).read_text(encoding="utf-8") |
| pdf_bytes = _md_to_pdf_bytes(content) |
| filename = f"{Path(current_path).stem}-{datetime.now(timezone.utc).strftime('%Y%m%d')}.pdf" |
| return dcc.send_bytes(pdf_bytes, filename), current_path |
| except Exception as exc: |
| log.error("export_report_pdf failed: %s", exc) |
| return no_update, current_path |
|
|
| return no_update, current_path |
|
|
|
|
| |
|
|
| @app.callback( |
| output=[ |
| Output("reports-dropdown", "options"), |
| Output("reports-dropdown", "value"), |
| Output("reports-gen-status", "children"), |
| ], |
| inputs=[Input("reports-gen-btn", "n_clicks")], |
| running=[ |
| (Output("reports-gen-btn", "disabled"), True, False), |
| ( |
| Output("reports-gen-status", "children"), |
| html.Span("β Generating brief β LLM workingβ¦", |
| style={"color": "#ffd93d", "fontSize": "11px"}), |
| "", |
| ), |
| ], |
| prevent_initial_call=True, |
| background=True, |
| ) |
| def generate_intelligence_brief(n_clicks: int): |
| """Fetch top 10 signals, call generate_brief_markdown, write .md, refresh dropdown. |
| |
| Setting reports-dropdown.value triggers render_report automatically β |
| no need to also output reports-body.children (that would be a duplicate output). |
| """ |
| try: |
| db = _get_db() |
| total = db.count() |
| if total == 0: |
| return no_update, no_update, "No signals in database β run Scout first." |
|
|
| results = db.search("agricultural market disruption EU Fendt", n_results=min(10, total)) |
| signals = sorted([sig for sig, _ in results], |
| key=lambda s: s.disruption_score, reverse=True) |
|
|
| md_text = generate_brief_markdown(signals) |
|
|
| _REPORTS_DIR.mkdir(parents=True, exist_ok=True) |
| ts_str = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") |
| out_path = _REPORTS_DIR / f"Strategic_Brief_{ts_str}.md" |
| out_path.write_text(md_text, encoding="utf-8") |
| log.info("Generated brief: %s", out_path.name) |
|
|
| new_options = _glob_reports() |
| new_value = str(out_path) |
| status_msg = f"β Brief generated: {out_path.name}" |
| return new_options, new_value, status_msg |
|
|
| except Exception as exc: |
| log.error("generate_intelligence_brief failed: %s", exc) |
| return no_update, no_update, f"Error: {exc}" |
|
|
|
|
| |
|
|
| @app.callback( |
| Output("lens-results", "children"), |
| Input("lens-topic-dropdown", "value"), |
| Input("lens-custom-input", "value"), |
| Input("lens-custom-input", "n_submit"), |
| prevent_initial_call=True, |
| ) |
| def lens_search(topic: str | None, custom: str | None, _ns: int) -> html.Div: |
| return _run_lens_search(topic, custom) |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| _preflight() |
|
|
| |
| stats = _db_stats_cached() |
| log.info("App starting β Astra DB: %d signals, HuggingFace: %s", |
| stats["total"], "OK" if _HF_OK else "NO KEY") |
|
|
| print(f"\n Fendt Sentinel Β· http://localhost:8050") |
| print(f" Astra DB : {stats['total']} signal(s)") |
| print(f" HuggingFace: {'OK' if _HF_OK else 'no API key β set HUGGINGFACEHUB_API_TOKEN'}") |
| print(f" Scheduler: active (6-hour scout cycle)") |
| print(f" Auto-refresh: 30 seconds\n") |
|
|
| |
| port = int(os.environ.get("PORT", 7860)) |
| app.run(debug=False, host="0.0.0.0", port=port) |
|
|