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>
467 lines
17 KiB
Python
467 lines
17 KiB
Python
"""
|
|
CXInsights Dashboard - Export Functions
|
|
Export insights to Excel, PDF, and other formats.
|
|
"""
|
|
|
|
import io
|
|
import json
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
import streamlit as st
|
|
|
|
from config import COLORS
|
|
|
|
|
|
def create_excel_export(summary: dict, analyses: list[dict], batch_id: str) -> io.BytesIO:
|
|
"""
|
|
Create comprehensive Excel export with multiple sheets.
|
|
|
|
Sheets:
|
|
- Executive Summary
|
|
- Call Details
|
|
- Poor CX Drivers
|
|
- FCR Analysis
|
|
- Churn Risk
|
|
- Agent Performance
|
|
"""
|
|
output = io.BytesIO()
|
|
|
|
with pd.ExcelWriter(output, engine='openpyxl') as writer:
|
|
# Sheet 1: Executive Summary
|
|
summary_data = {
|
|
"Metric": [
|
|
"Batch ID",
|
|
"Generated At",
|
|
"Total Calls Analyzed",
|
|
"Successful Analyses",
|
|
"Failed Analyses",
|
|
"Poor CX Drivers Found",
|
|
"Lost Sales Drivers Found",
|
|
],
|
|
"Value": [
|
|
batch_id,
|
|
summary.get("generated_at", "N/A"),
|
|
summary.get("summary", {}).get("total_calls", 0),
|
|
summary.get("summary", {}).get("successful_analyses", 0),
|
|
summary.get("summary", {}).get("failed_analyses", 0),
|
|
summary.get("poor_cx", {}).get("total_drivers_found", 0),
|
|
summary.get("lost_sales", {}).get("total_drivers_found", 0),
|
|
]
|
|
}
|
|
df_summary = pd.DataFrame(summary_data)
|
|
df_summary.to_excel(writer, sheet_name="Executive Summary", index=False)
|
|
|
|
# Sheet 2: Outcomes Distribution
|
|
outcomes = summary.get("outcomes", {})
|
|
if outcomes:
|
|
df_outcomes = pd.DataFrame([
|
|
{"Outcome": k, "Count": v, "Percentage": f"{v/sum(outcomes.values())*100:.1f}%"}
|
|
for k, v in sorted(outcomes.items(), key=lambda x: -x[1])
|
|
])
|
|
df_outcomes.to_excel(writer, sheet_name="Outcomes", index=False)
|
|
|
|
# Sheet 3: Call Details
|
|
call_data = []
|
|
for a in analyses:
|
|
call_data.append({
|
|
"Call ID": a.get("call_id", ""),
|
|
"Outcome": a.get("outcome", ""),
|
|
"FCR Status": a.get("fcr_status", ""),
|
|
"Churn Risk": a.get("churn_risk", ""),
|
|
"Agent Classification": a.get("agent_classification", ""),
|
|
"Poor CX Drivers": len(a.get("poor_cx_drivers", [])),
|
|
"FCR Failure Drivers": len(a.get("fcr_failure_drivers", [])),
|
|
"Churn Risk Drivers": len(a.get("churn_risk_drivers", [])),
|
|
"Duration (sec)": a.get("observed", {}).get("audio_duration_sec", ""),
|
|
"Total Turns": a.get("observed", {}).get("turn_metrics", {}).get("total_turns", ""),
|
|
})
|
|
df_calls = pd.DataFrame(call_data)
|
|
df_calls.to_excel(writer, sheet_name="Call Details", index=False)
|
|
|
|
# Sheet 4: Poor CX Drivers Detail
|
|
poor_cx_data = []
|
|
for a in analyses:
|
|
for d in a.get("poor_cx_drivers", []):
|
|
poor_cx_data.append({
|
|
"Call ID": a.get("call_id", ""),
|
|
"Driver Code": d.get("driver_code", ""),
|
|
"Confidence": f"{d.get('confidence', 0):.0%}",
|
|
"Origin": d.get("origin", ""),
|
|
"Reasoning": d.get("reasoning", ""),
|
|
"Corrective Action": d.get("corrective_action", ""),
|
|
"Evidence": "; ".join([e.get("text", "") for e in d.get("evidence_spans", [])]),
|
|
})
|
|
if poor_cx_data:
|
|
df_poor_cx = pd.DataFrame(poor_cx_data)
|
|
df_poor_cx.to_excel(writer, sheet_name="Poor CX Drivers", index=False)
|
|
|
|
# Sheet 5: FCR Failure Drivers
|
|
fcr_data = []
|
|
for a in analyses:
|
|
for d in a.get("fcr_failure_drivers", []):
|
|
fcr_data.append({
|
|
"Call ID": a.get("call_id", ""),
|
|
"Driver Code": d.get("driver_code", ""),
|
|
"Confidence": f"{d.get('confidence', 0):.0%}",
|
|
"Origin": d.get("origin", ""),
|
|
"Reasoning": d.get("reasoning", ""),
|
|
"Corrective Action": d.get("corrective_action", ""),
|
|
})
|
|
if fcr_data:
|
|
df_fcr = pd.DataFrame(fcr_data)
|
|
df_fcr.to_excel(writer, sheet_name="FCR Failures", index=False)
|
|
|
|
# Sheet 6: Churn Risk Drivers
|
|
churn_data = []
|
|
for a in analyses:
|
|
for d in a.get("churn_risk_drivers", []):
|
|
churn_data.append({
|
|
"Call ID": a.get("call_id", ""),
|
|
"Risk Level": a.get("churn_risk", ""),
|
|
"Driver Code": d.get("driver_code", ""),
|
|
"Confidence": f"{d.get('confidence', 0):.0%}",
|
|
"Reasoning": d.get("reasoning", ""),
|
|
"Corrective Action": d.get("corrective_action", ""),
|
|
})
|
|
if churn_data:
|
|
df_churn = pd.DataFrame(churn_data)
|
|
df_churn.to_excel(writer, sheet_name="Churn Risk", index=False)
|
|
|
|
# Sheet 7: Agent Performance
|
|
agent_data = []
|
|
for a in analyses:
|
|
positive = [s.get("skill_code", "") for s in a.get("agent_positive_skills", [])]
|
|
improvement = [s.get("skill_code", "") for s in a.get("agent_improvement_areas", [])]
|
|
agent_data.append({
|
|
"Call ID": a.get("call_id", ""),
|
|
"Classification": a.get("agent_classification", ""),
|
|
"Positive Skills": ", ".join(positive),
|
|
"Improvement Areas": ", ".join(improvement),
|
|
})
|
|
df_agent = pd.DataFrame(agent_data)
|
|
df_agent.to_excel(writer, sheet_name="Agent Performance", index=False)
|
|
|
|
# Sheet 8: Top Drivers Summary
|
|
top_drivers = []
|
|
for d in summary.get("poor_cx", {}).get("top_drivers", []):
|
|
top_drivers.append({
|
|
"Type": "Poor CX",
|
|
"Driver Code": d.get("driver_code", ""),
|
|
"Occurrences": d.get("occurrences", 0),
|
|
"Call Rate": f"{d.get('call_rate', 0)*100:.1f}%",
|
|
"Avg Confidence": f"{d.get('avg_confidence', 0):.0%}",
|
|
})
|
|
for d in summary.get("lost_sales", {}).get("top_drivers", []):
|
|
top_drivers.append({
|
|
"Type": "Lost Sales",
|
|
"Driver Code": d.get("driver_code", ""),
|
|
"Occurrences": d.get("occurrences", 0),
|
|
"Call Rate": f"{d.get('call_rate', 0)*100:.1f}%",
|
|
"Avg Confidence": f"{d.get('avg_confidence', 0):.0%}",
|
|
})
|
|
if top_drivers:
|
|
df_top = pd.DataFrame(top_drivers)
|
|
df_top.to_excel(writer, sheet_name="Top Drivers Summary", index=False)
|
|
|
|
output.seek(0)
|
|
return output
|
|
|
|
|
|
def create_executive_summary_html(summary: dict, analyses: list[dict], batch_id: str) -> str:
|
|
"""
|
|
Create HTML executive summary report for PDF export.
|
|
"""
|
|
total_calls = summary.get("summary", {}).get("total_calls", 0)
|
|
|
|
# Calculate metrics
|
|
poor_cx_calls = sum(1 for a in analyses if len(a.get("poor_cx_drivers", [])) > 0)
|
|
poor_cx_rate = (poor_cx_calls / total_calls * 100) if total_calls > 0 else 0
|
|
|
|
high_churn = sum(1 for a in analyses if a.get("churn_risk") in ["HIGH", "AT_RISK"])
|
|
churn_rate = (high_churn / total_calls * 100) if total_calls > 0 else 0
|
|
|
|
# FCR rate - Per blueprint: Primera Llamada = FCR success
|
|
fcr_success = sum(1 for a in analyses if a.get("fcr_status") == "FIRST_CALL")
|
|
fcr_rate = (fcr_success / total_calls * 100) if total_calls > 0 else 0
|
|
|
|
# Top drivers
|
|
top_drivers = summary.get("poor_cx", {}).get("top_drivers", [])[:5]
|
|
|
|
# Outcomes
|
|
outcomes = summary.get("outcomes", {})
|
|
|
|
html = f"""
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>CXInsights Executive Report - {batch_id}</title>
|
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
body {{
|
|
font-family: 'Outfit', sans-serif;
|
|
color: #000000;
|
|
background: #FFFFFF;
|
|
padding: 40px;
|
|
max-width: 900px;
|
|
margin: 0 auto;
|
|
}}
|
|
.header {{
|
|
border-bottom: 3px solid #6D84E3;
|
|
padding-bottom: 20px;
|
|
margin-bottom: 30px;
|
|
}}
|
|
.header h1 {{
|
|
font-size: 32px;
|
|
font-weight: 700;
|
|
color: #000000;
|
|
}}
|
|
.header .subtitle {{
|
|
color: #B1B1B0;
|
|
font-size: 14px;
|
|
margin-top: 8px;
|
|
}}
|
|
.brand {{
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
margin-bottom: 8px;
|
|
}}
|
|
.brand sup {{
|
|
color: #6D84E3;
|
|
font-size: 12px;
|
|
}}
|
|
.kpi-grid {{
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 20px;
|
|
margin-bottom: 40px;
|
|
}}
|
|
.kpi-card {{
|
|
background: #F8F8F8;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
text-align: center;
|
|
}}
|
|
.kpi-value {{
|
|
font-size: 36px;
|
|
font-weight: 700;
|
|
color: #000000;
|
|
}}
|
|
.kpi-label {{
|
|
font-size: 12px;
|
|
color: #B1B1B0;
|
|
margin-top: 8px;
|
|
}}
|
|
.section {{ margin-bottom: 40px; }}
|
|
.section h2 {{
|
|
font-size: 21px;
|
|
font-weight: 700;
|
|
margin-bottom: 16px;
|
|
padding-bottom: 8px;
|
|
border-bottom: 2px solid #6D84E3;
|
|
}}
|
|
table {{
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin-top: 16px;
|
|
}}
|
|
th {{
|
|
background: #F8F8F8;
|
|
padding: 12px;
|
|
text-align: left;
|
|
font-weight: 700;
|
|
border-bottom: 2px solid #6D84E3;
|
|
}}
|
|
td {{
|
|
padding: 12px;
|
|
border-bottom: 1px solid #E4E4E4;
|
|
}}
|
|
tr:nth-child(even) {{ background: #FAFAFA; }}
|
|
.insight {{
|
|
background: #F8F8F8;
|
|
border-left: 4px solid #6D84E3;
|
|
padding: 16px;
|
|
margin: 16px 0;
|
|
}}
|
|
.insight strong {{ color: #6D84E3; }}
|
|
.footer {{
|
|
margin-top: 40px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid #E4E4E4;
|
|
font-size: 12px;
|
|
color: #B1B1B0;
|
|
}}
|
|
@media print {{
|
|
body {{ padding: 20px; }}
|
|
.kpi-grid {{ grid-template-columns: repeat(2, 1fr); }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div class="brand">beyond<sup>cx</sup></div>
|
|
<h1>CXInsights Executive Report</h1>
|
|
<div class="subtitle">
|
|
Batch: {batch_id} |
|
|
Generated: {datetime.now().strftime('%Y-%m-%d %H:%M')} |
|
|
Calls Analyzed: {total_calls}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="kpi-grid">
|
|
<div class="kpi-card">
|
|
<div class="kpi-value">{total_calls}</div>
|
|
<div class="kpi-label">Total Calls</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-value">{poor_cx_rate:.1f}%</div>
|
|
<div class="kpi-label">Poor CX Rate</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-value">{fcr_rate:.1f}%</div>
|
|
<div class="kpi-label">FCR Rate</div>
|
|
</div>
|
|
<div class="kpi-card">
|
|
<div class="kpi-value">{churn_rate:.1f}%</div>
|
|
<div class="kpi-label">Churn Risk</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>Key Insights</h2>
|
|
{"".join([f'<div class="insight"><strong>{d.get("driver_code", "")}</strong> detected in {d.get("occurrences", 0)} calls ({d.get("call_rate", 0)*100:.0f}% of total)</div>' for d in top_drivers[:3]]) if top_drivers else '<p>No critical drivers detected.</p>'}
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>Outcome Distribution</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Outcome</th>
|
|
<th>Count</th>
|
|
<th>Percentage</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{"".join([f'<tr><td>{k}</td><td>{v}</td><td>{v/sum(outcomes.values())*100:.1f}%</td></tr>' for k, v in sorted(outcomes.items(), key=lambda x: -x[1])]) if outcomes else '<tr><td colspan="3">No data</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<h2>Top Poor CX Drivers</h2>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Driver</th>
|
|
<th>Occurrences</th>
|
|
<th>Call Rate</th>
|
|
<th>Confidence</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{"".join([f'<tr><td>{d.get("driver_code", "")}</td><td>{d.get("occurrences", 0)}</td><td>{d.get("call_rate", 0)*100:.1f}%</td><td>{d.get("avg_confidence", 0):.0%}</td></tr>' for d in top_drivers]) if top_drivers else '<tr><td colspan="4">No drivers detected</td></tr>'}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<p>Generated by Beyond CXInsights | {datetime.now().strftime('%Y-%m-%d %H:%M')}</p>
|
|
<p>This report contains AI-generated insights. Please review with domain expertise.</p>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
return html
|
|
|
|
|
|
def create_json_export(summary: dict, analyses: list[dict], batch_id: str) -> str:
|
|
"""Create JSON export of all data."""
|
|
export_data = {
|
|
"batch_id": batch_id,
|
|
"exported_at": datetime.now().isoformat(),
|
|
"summary": summary,
|
|
"analyses": analyses,
|
|
}
|
|
return json.dumps(export_data, indent=2, ensure_ascii=False)
|
|
|
|
|
|
def render_export_section(summary: dict, analyses: list[dict], batch_id: str):
|
|
"""Render export options in the dashboard."""
|
|
|
|
st.markdown("### Export Options")
|
|
|
|
col1, col2, col3 = st.columns(3)
|
|
|
|
with col1:
|
|
st.markdown("#### Excel Report")
|
|
st.caption("Complete analysis with multiple sheets")
|
|
|
|
excel_data = create_excel_export(summary, analyses, batch_id)
|
|
st.download_button(
|
|
label="Download Excel",
|
|
data=excel_data,
|
|
file_name=f"cxinsights_{batch_id}_{datetime.now().strftime('%Y%m%d')}.xlsx",
|
|
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
use_container_width=True,
|
|
)
|
|
|
|
with col2:
|
|
st.markdown("#### Executive Summary")
|
|
st.caption("HTML report (print to PDF)")
|
|
|
|
html_data = create_executive_summary_html(summary, analyses, batch_id)
|
|
st.download_button(
|
|
label="Download HTML",
|
|
data=html_data,
|
|
file_name=f"cxinsights_{batch_id}_executive_{datetime.now().strftime('%Y%m%d')}.html",
|
|
mime="text/html",
|
|
use_container_width=True,
|
|
)
|
|
|
|
with col3:
|
|
st.markdown("#### Raw Data")
|
|
st.caption("JSON format for integration")
|
|
|
|
json_data = create_json_export(summary, analyses, batch_id)
|
|
st.download_button(
|
|
label="Download JSON",
|
|
data=json_data,
|
|
file_name=f"cxinsights_{batch_id}_{datetime.now().strftime('%Y%m%d')}.json",
|
|
mime="application/json",
|
|
use_container_width=True,
|
|
)
|
|
|
|
st.markdown("---")
|
|
|
|
# Quick stats
|
|
st.markdown("#### Export Preview")
|
|
|
|
col1, col2 = st.columns(2)
|
|
|
|
with col1:
|
|
st.markdown("**Excel sheets included:**")
|
|
st.markdown("""
|
|
- Executive Summary
|
|
- Outcomes Distribution
|
|
- Call Details
|
|
- Poor CX Drivers
|
|
- FCR Failures
|
|
- Churn Risk
|
|
- Agent Performance
|
|
- Top Drivers Summary
|
|
""")
|
|
|
|
with col2:
|
|
st.markdown("**Data summary:**")
|
|
st.markdown(f"""
|
|
- **Calls:** {len(analyses)}
|
|
- **Poor CX instances:** {sum(len(a.get('poor_cx_drivers', [])) for a in analyses)}
|
|
- **FCR failures:** {sum(len(a.get('fcr_failure_drivers', [])) for a in analyses)}
|
|
- **Churn risk drivers:** {sum(len(a.get('churn_risk_drivers', [])) for a in analyses)}
|
|
""")
|