"
for s in critical[:5]
)
return f"""
Fendt PESTEL-EL Intelligence Report — {now_str}
Fendt PESTEL-EL Strategic Intelligence Report
Generated: {now_str} | EU Data Act 2026 Compliant
{stats['total']}
Total Signals
{stats['critical']}
Critical (≥0.75)
{stats['high']}
High (0.50–0.75)
{stats['avg_disruption']:.3f}
Avg Disruption
Urgency Matrix — 12M Critical Signals
Dimension
Signal
Score
Source
{critical_rows if critical_rows else '
No critical signals at this time.
'}
Top 10 Signals by Disruption Score
Dimension
Signal
Score
Severity
Source
{rows if rows else '
No signals found. Run the Scout to ingest intelligence.
'}
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.
"""
# ─────────────────────────────────────────────────────────────
# Pre-flight — crash before Dash starts if any builder breaks
# ─────────────────────────────────────────────────────────────
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)
# ─────────────────────────────────────────────────────────────
# Micro-components (CSS className throughout)
# ─────────────────────────────────────────────────────────────
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")
# ─────────────────────────────────────────────────────────────
# Urgency Matrix — 12M CRITICAL signals
# ─────────────────────────────────────────────────────────────
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")
# ─────────────────────────────────────────────────────────────
# Tab Content Builders
# ─────────────────────────────────────────────────────────────
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([
# KPI Row
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"),
# Charts Row
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([
# Ring guide
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"}),
# Filters
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([
# ── Left: Chat Window ──────────────────────────────────
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),
# ── Right: Try Questions + Agent Info ──────────────────
dbc.Col(html.Div([
# Strategic prompts
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"}),
# Agent capabilities
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"}),
# Coverage areas
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"}),
# Model info
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"),
])
# ─────────────────────────────────────────────────────────────
# Knowledge Graph components
# ─────────────────────────────────────────────────────────────
_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())
# Only include nodes that participate in at least one edge
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()
]
# Cytoscape is always rendered so the callback target always exists.
# Empty state is an absolute overlay that disappears once nodes arrive.
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"},
),
# Empty-state overlay — covers graph area when there are no nodes
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 helpers ───────────────────────────────────────────
_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"})
# Extract title and generated date from first lines if present
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()
# Strip first heading + metadata from content before rendering body
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([
# ── Document header ──────────────────────────────────
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"),
# ── Report body ───────────────────────────────────────
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", # £ (already Latin-1, but keep for completeness)
"\u2013": "-", # – en-dash
"\u2014": "--", # — em-dash
"\u2018": "'", # ' left single quote
"\u2019": "'", # ' right single quote / apostrophe
"\u201c": '"', # " left double quote
"\u201d": '"', # " right double quote
"\u2026": "...", # … ellipsis
"\u00b7": "*", # · middle dot
"\u00a0": " ", # non-breaking space
"\u2022": "*", # • bullet
"\u25cf": "*", # ● filled circle
"\u2192": "->", # → right arrow
"\u2190": "<-", # ← left arrow
}
for char, replacement in _MAP.items():
text = text.replace(char, replacement)
# Drop any remaining non-Latin-1 characters silently
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 # type: ignore[import]
import markdown as md_lib # type: ignore[import]
html_body = md_lib.markdown(_sanitize_for_pdf(content), extensions=["tables", "fenced_code"])
# fpdf2's write_html understands / not /
html_body = (
html_body
.replace("", "").replace("", "")
.replace("", "").replace("", "")
.replace("