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>
269 lines
8.6 KiB
Python
269 lines
8.6 KiB
Python
"""
|
|
CXInsights - Excel Export
|
|
|
|
Exports analysis results to Excel format with multiple sheets.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from src.aggregation.models import BatchAggregation
|
|
from src.models.call_analysis import CallAnalysis
|
|
|
|
# Try to import openpyxl, provide fallback
|
|
try:
|
|
from openpyxl import Workbook
|
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
|
|
from openpyxl.utils import get_column_letter
|
|
OPENPYXL_AVAILABLE = True
|
|
except ImportError:
|
|
OPENPYXL_AVAILABLE = False
|
|
|
|
|
|
def export_to_excel(
|
|
batch_id: str,
|
|
aggregation: BatchAggregation,
|
|
analyses: list[CallAnalysis],
|
|
output_dir: Path,
|
|
) -> Path:
|
|
"""
|
|
Export results to Excel file.
|
|
|
|
Creates workbook with sheets:
|
|
- Summary: High-level metrics
|
|
- Lost Sales Drivers: Driver frequencies and severity
|
|
- Poor CX Drivers: Driver frequencies and severity
|
|
- Call Details: Individual call results
|
|
- Emergent Patterns: New patterns found
|
|
|
|
Args:
|
|
batch_id: Batch identifier
|
|
aggregation: Aggregation results
|
|
analyses: Individual call analyses
|
|
output_dir: Output directory
|
|
|
|
Returns:
|
|
Path to Excel file
|
|
"""
|
|
if not OPENPYXL_AVAILABLE:
|
|
raise ImportError(
|
|
"openpyxl is required for Excel export. "
|
|
"Install with: pip install openpyxl"
|
|
)
|
|
|
|
output_dir = Path(output_dir)
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
wb = Workbook()
|
|
|
|
# Style definitions
|
|
header_font = Font(bold=True, color="FFFFFF")
|
|
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
|
header_alignment = Alignment(horizontal="center", vertical="center")
|
|
|
|
# Sheet 1: Summary
|
|
ws_summary = wb.active
|
|
ws_summary.title = "Summary"
|
|
_create_summary_sheet(ws_summary, batch_id, aggregation, analyses, header_font, header_fill)
|
|
|
|
# Sheet 2: Lost Sales Drivers
|
|
ws_lost_sales = wb.create_sheet("Lost Sales Drivers")
|
|
_create_drivers_sheet(
|
|
ws_lost_sales,
|
|
aggregation.lost_sales_frequencies,
|
|
aggregation.lost_sales_severities,
|
|
header_font,
|
|
header_fill,
|
|
)
|
|
|
|
# Sheet 3: Poor CX Drivers
|
|
ws_poor_cx = wb.create_sheet("Poor CX Drivers")
|
|
_create_drivers_sheet(
|
|
ws_poor_cx,
|
|
aggregation.poor_cx_frequencies,
|
|
aggregation.poor_cx_severities,
|
|
header_font,
|
|
header_fill,
|
|
)
|
|
|
|
# Sheet 4: Call Details
|
|
ws_calls = wb.create_sheet("Call Details")
|
|
_create_calls_sheet(ws_calls, analyses, header_font, header_fill)
|
|
|
|
# Sheet 5: Emergent Patterns
|
|
if aggregation.emergent_patterns:
|
|
ws_emergent = wb.create_sheet("Emergent Patterns")
|
|
_create_emergent_sheet(ws_emergent, aggregation.emergent_patterns, header_font, header_fill)
|
|
|
|
# Save workbook
|
|
output_path = output_dir / f"{batch_id}_analysis.xlsx"
|
|
wb.save(output_path)
|
|
|
|
return output_path
|
|
|
|
|
|
def _create_summary_sheet(ws, batch_id, aggregation, analyses, header_font, header_fill):
|
|
"""Create summary sheet."""
|
|
# Title
|
|
ws["A1"] = "CXInsights Analysis Report"
|
|
ws["A1"].font = Font(bold=True, size=16)
|
|
ws.merge_cells("A1:D1")
|
|
|
|
ws["A2"] = f"Batch ID: {batch_id}"
|
|
ws["A3"] = f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}"
|
|
|
|
# Metrics section
|
|
row = 5
|
|
ws[f"A{row}"] = "Key Metrics"
|
|
ws[f"A{row}"].font = Font(bold=True, size=12)
|
|
|
|
metrics = [
|
|
("Total Calls Analyzed", aggregation.total_calls_processed),
|
|
("Successful Analyses", aggregation.successful_analyses),
|
|
("Failed Analyses", aggregation.failed_analyses),
|
|
("Success Rate", f"{aggregation.successful_analyses / aggregation.total_calls_processed * 100:.1f}%" if aggregation.total_calls_processed > 0 else "N/A"),
|
|
]
|
|
|
|
if aggregation.rca_tree:
|
|
tree = aggregation.rca_tree
|
|
metrics.extend([
|
|
("", ""),
|
|
("Calls with Lost Sales Issues", tree.calls_with_lost_sales),
|
|
("Calls with Poor CX Issues", tree.calls_with_poor_cx),
|
|
("Calls with Both Issues", tree.calls_with_both),
|
|
])
|
|
|
|
for i, (label, value) in enumerate(metrics):
|
|
ws[f"A{row + 1 + i}"] = label
|
|
ws[f"B{row + 1 + i}"] = value
|
|
|
|
# Top drivers section
|
|
row = row + len(metrics) + 3
|
|
ws[f"A{row}"] = "Top Lost Sales Drivers"
|
|
ws[f"A{row}"].font = Font(bold=True, size=12)
|
|
|
|
for i, freq in enumerate(aggregation.lost_sales_frequencies[:5]):
|
|
ws[f"A{row + 1 + i}"] = freq.driver_code
|
|
ws[f"B{row + 1 + i}"] = f"{freq.call_rate:.1%}"
|
|
|
|
row = row + 7
|
|
ws[f"A{row}"] = "Top Poor CX Drivers"
|
|
ws[f"A{row}"].font = Font(bold=True, size=12)
|
|
|
|
for i, freq in enumerate(aggregation.poor_cx_frequencies[:5]):
|
|
ws[f"A{row + 1 + i}"] = freq.driver_code
|
|
ws[f"B{row + 1 + i}"] = f"{freq.call_rate:.1%}"
|
|
|
|
# Adjust column widths
|
|
ws.column_dimensions["A"].width = 30
|
|
ws.column_dimensions["B"].width = 20
|
|
|
|
|
|
def _create_drivers_sheet(ws, frequencies, severities, header_font, header_fill):
|
|
"""Create driver analysis sheet."""
|
|
headers = [
|
|
"Rank",
|
|
"Driver Code",
|
|
"Occurrences",
|
|
"Calls Affected",
|
|
"Call Rate",
|
|
"Avg Confidence",
|
|
"Severity Score",
|
|
"Impact Level",
|
|
]
|
|
|
|
# Write headers
|
|
for col, header in enumerate(headers, 1):
|
|
cell = ws.cell(row=1, column=col, value=header)
|
|
cell.font = header_font
|
|
cell.fill = header_fill
|
|
|
|
# Create severity lookup
|
|
sev_map = {s.driver_code: s for s in severities}
|
|
|
|
# Write data
|
|
for row, freq in enumerate(frequencies, 2):
|
|
sev = sev_map.get(freq.driver_code)
|
|
|
|
ws.cell(row=row, column=1, value=row - 1)
|
|
ws.cell(row=row, column=2, value=freq.driver_code)
|
|
ws.cell(row=row, column=3, value=freq.total_occurrences)
|
|
ws.cell(row=row, column=4, value=freq.calls_affected)
|
|
ws.cell(row=row, column=5, value=f"{freq.call_rate:.1%}")
|
|
ws.cell(row=row, column=6, value=f"{freq.avg_confidence:.2f}")
|
|
ws.cell(row=row, column=7, value=f"{sev.severity_score:.1f}" if sev else "N/A")
|
|
ws.cell(row=row, column=8, value=sev.impact_level.value if sev else "N/A")
|
|
|
|
# Adjust column widths
|
|
for col in range(1, len(headers) + 1):
|
|
ws.column_dimensions[get_column_letter(col)].width = 15
|
|
|
|
|
|
def _create_calls_sheet(ws, analyses, header_font, header_fill):
|
|
"""Create call details sheet."""
|
|
headers = [
|
|
"Call ID",
|
|
"Outcome",
|
|
"Status",
|
|
"Lost Sales Drivers",
|
|
"Poor CX Drivers",
|
|
"Audio Duration (s)",
|
|
]
|
|
|
|
# Write headers
|
|
for col, header in enumerate(headers, 1):
|
|
cell = ws.cell(row=1, column=col, value=header)
|
|
cell.font = header_font
|
|
cell.fill = header_fill
|
|
|
|
# Write data
|
|
for row, analysis in enumerate(analyses, 2):
|
|
lost_sales = ", ".join(d.driver_code for d in analysis.lost_sales_drivers)
|
|
poor_cx = ", ".join(d.driver_code for d in analysis.poor_cx_drivers)
|
|
|
|
ws.cell(row=row, column=1, value=analysis.call_id)
|
|
ws.cell(row=row, column=2, value=analysis.outcome.value)
|
|
ws.cell(row=row, column=3, value=analysis.status.value)
|
|
ws.cell(row=row, column=4, value=lost_sales or "-")
|
|
ws.cell(row=row, column=5, value=poor_cx or "-")
|
|
ws.cell(row=row, column=6, value=analysis.observed.audio_duration_sec)
|
|
|
|
# Adjust column widths
|
|
ws.column_dimensions["A"].width = 15
|
|
ws.column_dimensions["B"].width = 20
|
|
ws.column_dimensions["C"].width = 12
|
|
ws.column_dimensions["D"].width = 40
|
|
ws.column_dimensions["E"].width = 40
|
|
ws.column_dimensions["F"].width = 18
|
|
|
|
|
|
def _create_emergent_sheet(ws, emergent_patterns, header_font, header_fill):
|
|
"""Create emergent patterns sheet."""
|
|
headers = [
|
|
"Proposed Label",
|
|
"Occurrences",
|
|
"Avg Confidence",
|
|
"Sample Evidence",
|
|
]
|
|
|
|
# Write headers
|
|
for col, header in enumerate(headers, 1):
|
|
cell = ws.cell(row=1, column=col, value=header)
|
|
cell.font = header_font
|
|
cell.fill = header_fill
|
|
|
|
# Write data
|
|
for row, pattern in enumerate(emergent_patterns, 2):
|
|
evidence = "; ".join(pattern.get("sample_evidence", [])[:2])
|
|
|
|
ws.cell(row=row, column=1, value=pattern.get("proposed_label", "N/A"))
|
|
ws.cell(row=row, column=2, value=pattern.get("occurrences", 0))
|
|
ws.cell(row=row, column=3, value=f"{pattern.get('avg_confidence', 0):.2f}")
|
|
ws.cell(row=row, column=4, value=evidence[:100] if evidence else "-")
|
|
|
|
# Adjust column widths
|
|
ws.column_dimensions["A"].width = 30
|
|
ws.column_dimensions["B"].width = 12
|
|
ws.column_dimensions["C"].width = 15
|
|
ws.column_dimensions["D"].width = 60
|