feat: Add Streamlit dashboard with Blueprint compliance (v2.1.0)
Dashboard Features: - 8 navigation sections: Overview, Outcomes, Poor CX, FCR, Churn, Agent, Call Explorer, Export - Beyond Brand Identity styling (colors #6D84E3, Outfit font) - RCA Sankey diagram (Driver → Outcome → Churn Risk flow) - Correlation heatmaps (driver co-occurrence, driver-outcome) - Outcome Deep Dive (root causes, correlation, duration analysis) - Export functionality (Excel, HTML, JSON) Blueprint Compliance: - FCR: 4 categories (Primera Llamada/Rellamada × Sin/Con Riesgo de Fuga) - Churn: Binary view (Sin Riesgo de Fuga / En Riesgo de Fuga) - Agent: Talento Para Replicar / Oportunidades de Mejora - Fixed FCR rate calculation (only FIRST_CALL counts as success) Technical: - Streamlit + Plotly for interactive visualizations - Light theme configuration (.streamlit/config.toml) - Fixed Plotly colorbar titlefont deprecation Documentation: - Updated PROJECT_CONTEXT.md, TODO.md, CHANGELOG.md - Added 4 new technical decisions (TD-014 to TD-017) - Created TROUBLESHOOTING.md with 10 common issues Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
538
dashboard/app.py
Normal file
538
dashboard/app.py
Normal file
@@ -0,0 +1,538 @@
|
||||
"""
|
||||
CXInsights Dashboard - Main Application
|
||||
Rich visualization dashboard for call analysis results.
|
||||
Following Beyond Brand Identity Guidelines v1.0
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
|
||||
from config import COLORS, apply_custom_css
|
||||
from data_loader import (
|
||||
load_batch_data,
|
||||
get_available_batches,
|
||||
calculate_kpis,
|
||||
aggregate_drivers,
|
||||
)
|
||||
from components import (
|
||||
render_kpi_cards,
|
||||
render_outcome_chart,
|
||||
render_driver_analysis,
|
||||
render_driver_detail,
|
||||
render_call_explorer,
|
||||
render_agent_performance,
|
||||
render_fcr_analysis,
|
||||
render_churn_risk_analysis,
|
||||
render_driver_correlation_heatmap,
|
||||
render_driver_outcome_heatmap,
|
||||
render_rca_sankey,
|
||||
render_outcome_deep_dive,
|
||||
)
|
||||
from exports import render_export_section
|
||||
|
||||
# =============================================================================
|
||||
# PAGE CONFIG
|
||||
# =============================================================================
|
||||
|
||||
st.set_page_config(
|
||||
page_title="CXInsights Dashboard | Beyond",
|
||||
page_icon="📊",
|
||||
layout="wide",
|
||||
initial_sidebar_state="expanded",
|
||||
)
|
||||
|
||||
# Apply Beyond brand CSS
|
||||
apply_custom_css()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MAIN APP
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
"""Main dashboard application."""
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# SIDEBAR
|
||||
# -------------------------------------------------------------------------
|
||||
with st.sidebar:
|
||||
# Logo/Brand
|
||||
st.markdown(
|
||||
f"""
|
||||
<div style="padding: 1rem 0; margin-bottom: 1rem;">
|
||||
<span style="font-size: 24px; font-weight: 700; color: {COLORS['black']};">
|
||||
beyond
|
||||
</span>
|
||||
<sup style="font-size: 12px; color: {COLORS['blue']};">cx</sup>
|
||||
<div style="font-size: 12px; color: {COLORS['grey']}; margin-top: 4px;">
|
||||
CXInsights Dashboard
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Batch selector
|
||||
data_dir = Path(__file__).parent.parent / "data" / "output"
|
||||
|
||||
batches = get_available_batches(data_dir)
|
||||
|
||||
if not batches:
|
||||
st.error("No batch data found.")
|
||||
st.markdown(
|
||||
"Run the pipeline first:\n"
|
||||
"```bash\n"
|
||||
"python cli.py run <batch_id> -i <audio_dir>\n"
|
||||
"```"
|
||||
)
|
||||
st.stop()
|
||||
|
||||
selected_batch = st.selectbox(
|
||||
"Select Batch",
|
||||
batches,
|
||||
index=len(batches) - 1, # Most recent
|
||||
help="Select a completed analysis batch to visualize",
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Navigation
|
||||
st.markdown("### Navigation")
|
||||
page = st.radio(
|
||||
"Section",
|
||||
[
|
||||
"📊 Overview",
|
||||
"📈 Outcomes",
|
||||
"😞 Poor CX Analysis",
|
||||
"🎯 FCR Analysis",
|
||||
"⚠️ Churn Risk",
|
||||
"👤 Agent Performance",
|
||||
"🔍 Call Explorer",
|
||||
"📥 Export Insights",
|
||||
],
|
||||
label_visibility="collapsed",
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Metadata
|
||||
st.markdown(
|
||||
f"""
|
||||
<div style="font-size: 11px; color: {COLORS['grey']};">
|
||||
<strong>Last updated:</strong><br>
|
||||
{datetime.now().strftime('%Y-%m-%d %H:%M')}<br><br>
|
||||
<strong>Powered by:</strong><br>
|
||||
Beyond CXInsights v1.0
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# LOAD DATA
|
||||
# -------------------------------------------------------------------------
|
||||
batch_path = data_dir / selected_batch
|
||||
batch_data = load_batch_data(batch_path)
|
||||
|
||||
if batch_data is None:
|
||||
st.error(f"Failed to load batch: {selected_batch}")
|
||||
st.stop()
|
||||
|
||||
summary = batch_data["summary"]
|
||||
analyses = batch_data["analyses"]
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# HEADER
|
||||
# -------------------------------------------------------------------------
|
||||
st.markdown(
|
||||
f"""
|
||||
<h1 style="margin-bottom: 0.25rem;">📊 CXInsights Dashboard</h1>
|
||||
<p style="color: {COLORS['grey']}; margin-bottom: 2rem;">
|
||||
<strong>Batch:</strong> {selected_batch} |
|
||||
<strong>Calls:</strong> {summary['summary']['total_calls']} |
|
||||
<strong>Generated:</strong> {summary.get('generated_at', 'N/A')[:10]}
|
||||
</p>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# PAGE ROUTING
|
||||
# -------------------------------------------------------------------------
|
||||
if page == "📊 Overview":
|
||||
render_overview_page(summary, analyses)
|
||||
|
||||
elif page == "📈 Outcomes":
|
||||
render_outcomes_page(summary, analyses)
|
||||
|
||||
elif page == "😞 Poor CX Analysis":
|
||||
render_poor_cx_page(summary, analyses)
|
||||
|
||||
elif page == "🎯 FCR Analysis":
|
||||
render_fcr_page(summary, analyses)
|
||||
|
||||
elif page == "⚠️ Churn Risk":
|
||||
render_churn_page(summary, analyses)
|
||||
|
||||
elif page == "👤 Agent Performance":
|
||||
render_agent_page(analyses)
|
||||
|
||||
elif page == "🔍 Call Explorer":
|
||||
render_call_explorer(analyses)
|
||||
|
||||
elif page == "📥 Export Insights":
|
||||
render_export_page(summary, analyses, selected_batch)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PAGE RENDERS
|
||||
# =============================================================================
|
||||
|
||||
def render_overview_page(summary: dict, analyses: list[dict]):
|
||||
"""Render overview page with executive summary."""
|
||||
|
||||
# KPI Cards
|
||||
render_kpi_cards(summary, analyses)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Two column layout
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.markdown("### Call Outcomes Distribution")
|
||||
render_outcome_chart(summary, height=350)
|
||||
|
||||
with col2:
|
||||
st.markdown("### Top Poor CX Drivers")
|
||||
render_driver_analysis(summary, "poor_cx", limit=5)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Second row
|
||||
col1, col2 = st.columns(2)
|
||||
|
||||
with col1:
|
||||
st.markdown("### First Call Resolution")
|
||||
render_fcr_analysis(analyses, compact=True)
|
||||
|
||||
with col2:
|
||||
st.markdown("### Churn Risk Distribution")
|
||||
render_churn_risk_analysis(analyses, compact=True)
|
||||
|
||||
# Executive Summary Box
|
||||
st.markdown("---")
|
||||
st.markdown("### Executive Summary")
|
||||
|
||||
kpis = calculate_kpis(summary, analyses)
|
||||
|
||||
# Generate insights
|
||||
insights = []
|
||||
|
||||
if kpis["poor_cx_rate"] > 30:
|
||||
insights.append(
|
||||
f"⚠️ **High Poor CX Rate:** {kpis['poor_cx_rate']:.1f}% of calls show "
|
||||
f"customer experience issues requiring attention."
|
||||
)
|
||||
|
||||
if kpis["churn_risk_rate"] > 20:
|
||||
insights.append(
|
||||
f"⚠️ **Elevated Churn Risk:** {kpis['churn_risk_rate']:.1f}% of customers "
|
||||
f"show elevated churn risk signals."
|
||||
)
|
||||
|
||||
if kpis["fcr_rate"] < 70:
|
||||
insights.append(
|
||||
f"📉 **FCR Below Target:** First call resolution at {kpis['fcr_rate']:.1f}% "
|
||||
f"suggests process improvement opportunities."
|
||||
)
|
||||
|
||||
top_drivers = summary.get("poor_cx", {}).get("top_drivers", [])
|
||||
if top_drivers:
|
||||
top = top_drivers[0]
|
||||
insights.append(
|
||||
f"🔍 **Top Driver:** {top['driver_code']} detected in "
|
||||
f"{top['occurrences']} calls ({top.get('call_rate', 0)*100:.0f}% of total)."
|
||||
)
|
||||
|
||||
if insights:
|
||||
for insight in insights:
|
||||
st.markdown(insight)
|
||||
else:
|
||||
st.success("✅ No critical issues detected. Performance within expected parameters.")
|
||||
|
||||
st.caption(
|
||||
f"Source: CXInsights Analysis | Generated: {summary.get('generated_at', 'N/A')}"
|
||||
)
|
||||
|
||||
|
||||
def render_outcomes_page(summary: dict, analyses: list[dict]):
|
||||
"""Render detailed outcome analysis page."""
|
||||
|
||||
st.markdown("## 📈 Outcome Analysis")
|
||||
st.markdown(
|
||||
"Understanding call outcomes helps identify resolution patterns and opportunities."
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
col1, col2 = st.columns([2, 1])
|
||||
|
||||
with col1:
|
||||
render_outcome_chart(summary, height=450)
|
||||
|
||||
with col2:
|
||||
st.markdown("### Outcome Breakdown")
|
||||
outcomes = summary.get("outcomes", {})
|
||||
total = sum(outcomes.values())
|
||||
|
||||
for outcome, count in sorted(outcomes.items(), key=lambda x: -x[1]):
|
||||
pct = (count / total * 100) if total > 0 else 0
|
||||
st.metric(
|
||||
label=outcome,
|
||||
value=f"{count}",
|
||||
delta=f"{pct:.1f}%",
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Calls by outcome table
|
||||
st.markdown("### Calls by Outcome")
|
||||
|
||||
outcome_filter = st.multiselect(
|
||||
"Filter outcomes",
|
||||
list(summary.get("outcomes", {}).keys()),
|
||||
default=list(summary.get("outcomes", {}).keys()),
|
||||
)
|
||||
|
||||
filtered = [a for a in analyses if a.get("outcome") in outcome_filter]
|
||||
|
||||
if filtered:
|
||||
df = pd.DataFrame([
|
||||
{
|
||||
"Call ID": a["call_id"],
|
||||
"Outcome": a["outcome"],
|
||||
"FCR Status": a.get("fcr_status", "N/A"),
|
||||
"Churn Risk": a.get("churn_risk", "N/A"),
|
||||
"Agent": a.get("agent_classification", "N/A"),
|
||||
"CX Issues": len(a.get("poor_cx_drivers", [])),
|
||||
}
|
||||
for a in filtered
|
||||
])
|
||||
st.dataframe(df, use_container_width=True, hide_index=True)
|
||||
else:
|
||||
st.info("No calls match the selected filters.")
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# DEEP DIVE SECTION
|
||||
# ---------------------------------------------------------------------
|
||||
st.markdown("---")
|
||||
st.markdown("## Deep Dive: Outcome Analysis")
|
||||
|
||||
outcomes_list = list(summary.get("outcomes", {}).keys())
|
||||
if outcomes_list:
|
||||
# Default to the most problematic outcome (not RESOLVED/POSITIVE)
|
||||
problematic = [o for o in outcomes_list if "UNRESOLVED" in o or "COMPLAINT" in o]
|
||||
default_idx = outcomes_list.index(problematic[0]) if problematic else 0
|
||||
|
||||
selected_outcome = st.selectbox(
|
||||
"Select an outcome to analyze in depth",
|
||||
outcomes_list,
|
||||
index=default_idx,
|
||||
help="Choose an outcome to see root causes, driver correlation, and duration analysis.",
|
||||
)
|
||||
|
||||
render_outcome_deep_dive(analyses, selected_outcome)
|
||||
|
||||
|
||||
def render_poor_cx_page(summary: dict, analyses: list[dict]):
|
||||
"""Render detailed Poor CX analysis page."""
|
||||
|
||||
st.markdown("## 😞 Poor CX Driver Analysis")
|
||||
st.markdown(
|
||||
"Root cause analysis of customer experience issues detected across calls."
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Summary metrics
|
||||
poor_cx_data = summary.get("poor_cx", {})
|
||||
total_drivers = poor_cx_data.get("total_drivers_found", 0)
|
||||
unique_drivers = len(poor_cx_data.get("top_drivers", []))
|
||||
|
||||
col1, col2 = st.columns(2)
|
||||
with col1:
|
||||
st.metric("Total Driver Instances", total_drivers)
|
||||
with col2:
|
||||
st.metric("Unique Driver Types", unique_drivers)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# RCA Sankey Diagram
|
||||
st.markdown("### Root Cause Analysis Flow")
|
||||
st.markdown(
|
||||
"Visual flow showing how Poor CX drivers lead to outcomes and churn risk. "
|
||||
"Wider bands indicate more frequent paths."
|
||||
)
|
||||
render_rca_sankey(analyses)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Driver chart
|
||||
st.markdown("### Driver Frequency")
|
||||
render_driver_analysis(summary, "poor_cx", limit=None)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Correlation heatmaps
|
||||
st.markdown("### Driver Correlation Analysis")
|
||||
st.markdown(
|
||||
"Identify patterns where certain drivers frequently appear together "
|
||||
"(e.g., 'LONG_WAIT' always with 'POOR_EMPATHY')."
|
||||
)
|
||||
|
||||
tab1, tab2 = st.tabs(["Driver Co-occurrence", "Driver by Outcome"])
|
||||
|
||||
with tab1:
|
||||
render_driver_correlation_heatmap(analyses, "poor_cx_drivers")
|
||||
|
||||
with tab2:
|
||||
render_driver_outcome_heatmap(analyses)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Detailed evidence explorer
|
||||
st.markdown("### Driver Evidence Explorer")
|
||||
render_driver_detail(analyses, "poor_cx_drivers")
|
||||
|
||||
|
||||
def render_fcr_page(summary: dict, analyses: list[dict]):
|
||||
"""Render FCR analysis page."""
|
||||
|
||||
st.markdown("## 🎯 First Call Resolution Analysis")
|
||||
st.markdown(
|
||||
"Analyzing resolution efficiency and identifying callbacks drivers."
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
render_fcr_analysis(analyses, compact=False)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# FCR failure drivers
|
||||
st.markdown("### FCR Failure Root Causes")
|
||||
|
||||
fcr_drivers = aggregate_drivers(analyses, "fcr_failure_drivers")
|
||||
|
||||
if fcr_drivers:
|
||||
df = pd.DataFrame([
|
||||
{
|
||||
"Driver": code,
|
||||
"Instances": data["count"],
|
||||
"Calls Affected": data["call_count"],
|
||||
"Avg Confidence": f"{data['avg_confidence']:.0%}",
|
||||
}
|
||||
for code, data in sorted(fcr_drivers.items(), key=lambda x: -x[1]["count"])
|
||||
])
|
||||
st.dataframe(df, use_container_width=True, hide_index=True)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# Evidence
|
||||
st.markdown("### Evidence & Recommendations")
|
||||
render_driver_detail(analyses, "fcr_failure_drivers")
|
||||
else:
|
||||
st.success("✅ No FCR failures detected. Excellent first-call resolution!")
|
||||
|
||||
|
||||
def render_churn_page(summary: dict, analyses: list[dict]):
|
||||
"""Render churn risk analysis page."""
|
||||
|
||||
st.markdown("## ⚠️ Churn Risk Analysis")
|
||||
st.markdown(
|
||||
"Identifying customers at risk of churning based on conversation signals."
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
render_churn_risk_analysis(analyses, compact=False)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
# High risk calls
|
||||
st.markdown("### High Risk Customer Calls")
|
||||
|
||||
high_risk = [
|
||||
a for a in analyses
|
||||
if a.get("churn_risk") in ["HIGH", "AT_RISK"]
|
||||
]
|
||||
|
||||
if high_risk:
|
||||
st.warning(
|
||||
f"⚠️ {len(high_risk)} calls show elevated churn risk requiring follow-up."
|
||||
)
|
||||
|
||||
for analysis in high_risk:
|
||||
with st.expander(
|
||||
f"📞 {analysis['call_id']} — Risk: {analysis.get('churn_risk', 'N/A')}"
|
||||
):
|
||||
st.markdown(f"**Outcome:** {analysis.get('outcome', 'N/A')}")
|
||||
|
||||
drivers = analysis.get("churn_risk_drivers", [])
|
||||
if drivers:
|
||||
st.markdown("**Risk Drivers:**")
|
||||
for d in drivers:
|
||||
st.markdown(
|
||||
f"- **{d.get('driver_code')}** "
|
||||
f"({d.get('confidence', 0):.0%}): "
|
||||
f"{d.get('reasoning', 'N/A')}"
|
||||
)
|
||||
|
||||
if d.get("corrective_action"):
|
||||
st.success(f"Action: {d['corrective_action']}")
|
||||
else:
|
||||
st.success("✅ No high churn risk calls detected.")
|
||||
|
||||
|
||||
def render_agent_page(analyses: list[dict]):
|
||||
"""Render agent performance page."""
|
||||
|
||||
st.markdown("## 👤 Agent Performance Analysis")
|
||||
st.markdown(
|
||||
"Evaluating agent skills and identifying coaching opportunities."
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
render_agent_performance(analyses)
|
||||
|
||||
|
||||
def render_export_page(summary: dict, analyses: list[dict], batch_id: str):
|
||||
"""Render export insights page."""
|
||||
|
||||
st.markdown("## 📥 Export Insights")
|
||||
st.markdown(
|
||||
"Download analysis results in multiple formats for reporting and integration."
|
||||
)
|
||||
|
||||
st.markdown("---")
|
||||
|
||||
render_export_section(summary, analyses, batch_id)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RUN
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user