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:
sujucu70
2026-01-19 16:27:30 +01:00
commit 75e7b9da3d
110 changed files with 28247 additions and 0 deletions

View File

@@ -0,0 +1,480 @@
"""
CXInsights - Compression Module Tests
Tests for transcript compression and semantic extraction.
"""
import pytest
from src.compression.compressor import (
TranscriptCompressor,
compress_for_prompt,
compress_transcript,
)
from src.compression.models import (
AgentOffer,
CompressionConfig,
CompressedTranscript,
CustomerIntent,
CustomerObjection,
IntentType,
KeyMoment,
ObjectionType,
ResolutionStatement,
ResolutionType,
)
from src.transcription.models import SpeakerTurn, Transcript, TranscriptMetadata
class TestCustomerIntent:
"""Tests for CustomerIntent model."""
def test_to_prompt_text(self):
"""Test prompt text generation."""
intent = CustomerIntent(
intent_type=IntentType.CANCEL,
description="Customer wants to cancel service",
confidence=0.9,
verbatim_quotes=["quiero cancelar mi servicio"],
)
text = intent.to_prompt_text()
assert "CANCEL" in text
assert "quiero cancelar" in text
def test_to_prompt_text_no_quotes(self):
"""Test prompt text without quotes."""
intent = CustomerIntent(
intent_type=IntentType.INQUIRY,
description="Customer asking about prices",
confidence=0.8,
)
text = intent.to_prompt_text()
assert "INQUIRY" in text
assert "Evidence:" not in text
class TestCustomerObjection:
"""Tests for CustomerObjection model."""
def test_addressed_status(self):
"""Test addressed status in prompt text."""
addressed = CustomerObjection(
objection_type=ObjectionType.PRICE,
description="Too expensive",
turn_index=5,
verbatim="Es muy caro",
addressed=True,
)
unaddressed = CustomerObjection(
objection_type=ObjectionType.PRICE,
description="Too expensive",
turn_index=5,
verbatim="Es muy caro",
addressed=False,
)
assert "[ADDRESSED]" in addressed.to_prompt_text()
assert "[UNADDRESSED]" in unaddressed.to_prompt_text()
class TestAgentOffer:
"""Tests for AgentOffer model."""
def test_acceptance_status(self):
"""Test acceptance status in prompt text."""
accepted = AgentOffer(
offer_type="discount",
description="10% discount",
turn_index=10,
verbatim="Le ofrezco un 10% de descuento",
accepted=True,
)
rejected = AgentOffer(
offer_type="discount",
description="10% discount",
turn_index=10,
verbatim="Le ofrezco un 10% de descuento",
accepted=False,
)
pending = AgentOffer(
offer_type="discount",
description="10% discount",
turn_index=10,
verbatim="Le ofrezco un 10% de descuento",
accepted=None,
)
assert "[ACCEPTED]" in accepted.to_prompt_text()
assert "[REJECTED]" in rejected.to_prompt_text()
assert "[ACCEPTED]" not in pending.to_prompt_text()
assert "[REJECTED]" not in pending.to_prompt_text()
class TestCompressedTranscript:
"""Tests for CompressedTranscript model."""
def test_to_prompt_text_basic(self):
"""Test basic prompt text generation."""
compressed = CompressedTranscript(
call_id="TEST001",
customer_intents=[
CustomerIntent(
intent_type=IntentType.CANCEL,
description="Wants to cancel",
confidence=0.9,
)
],
objections=[
CustomerObjection(
objection_type=ObjectionType.PRICE,
description="Too expensive",
turn_index=5,
verbatim="Es caro",
)
],
)
text = compressed.to_prompt_text()
assert "CUSTOMER INTENT" in text
assert "CUSTOMER OBJECTIONS" in text
assert "CANCEL" in text
assert "price" in text.lower()
def test_to_prompt_text_empty(self):
"""Test prompt text with no elements."""
compressed = CompressedTranscript(call_id="EMPTY001")
text = compressed.to_prompt_text()
# Should be mostly empty but not fail
assert len(text) >= 0
def test_to_prompt_text_truncation(self):
"""Test prompt text truncation."""
compressed = CompressedTranscript(
call_id="LONG001",
key_moments=[
KeyMoment(
moment_type="test",
description="x" * 500,
turn_index=i,
start_time=float(i),
verbatim="y" * 200,
speaker="customer",
)
for i in range(50)
],
)
text = compressed.to_prompt_text(max_chars=1000)
assert len(text) <= 1000
assert "truncated" in text
def test_get_stats(self):
"""Test statistics generation."""
compressed = CompressedTranscript(
call_id="STATS001",
original_turn_count=50,
original_char_count=10000,
compressed_char_count=2000,
compression_ratio=0.8,
customer_intents=[
CustomerIntent(IntentType.CANCEL, "test", 0.9)
],
objections=[
CustomerObjection(ObjectionType.PRICE, "test", 0, "test")
],
)
stats = compressed.get_stats()
assert stats["original_turns"] == 50
assert stats["original_chars"] == 10000
assert stats["compressed_chars"] == 2000
assert stats["compression_ratio"] == 0.8
assert stats["intents_extracted"] == 1
assert stats["objections_extracted"] == 1
class TestTranscriptCompressor:
"""Tests for TranscriptCompressor."""
@pytest.fixture
def sample_transcript(self):
"""Create a sample transcript for testing."""
return Transcript(
call_id="COMP001",
turns=[
SpeakerTurn(
speaker="agent",
text="Hola, buenos días, gracias por llamar.",
start_time=0.0,
end_time=2.0,
),
SpeakerTurn(
speaker="customer",
text="Hola, quiero cancelar mi servicio porque es muy caro.",
start_time=2.5,
end_time=5.0,
),
SpeakerTurn(
speaker="agent",
text="Entiendo. Le puedo ofrecer un 20% de descuento.",
start_time=5.5,
end_time=8.0,
),
SpeakerTurn(
speaker="customer",
text="No gracias, ya tomé la decisión.",
start_time=8.5,
end_time=10.0,
),
SpeakerTurn(
speaker="agent",
text="Entiendo, si cambia de opinión estamos para ayudarle.",
start_time=10.5,
end_time=13.0,
),
],
metadata=TranscriptMetadata(
audio_duration_sec=60.0,
language="es",
),
)
def test_compress_extracts_intent(self, sample_transcript):
"""Test that cancel intent is extracted."""
compressor = TranscriptCompressor()
compressed = compressor.compress(sample_transcript)
assert len(compressed.customer_intents) > 0
assert any(
i.intent_type == IntentType.CANCEL
for i in compressed.customer_intents
)
def test_compress_extracts_price_objection(self, sample_transcript):
"""Test that price objection is extracted."""
compressor = TranscriptCompressor()
compressed = compressor.compress(sample_transcript)
assert len(compressed.objections) > 0
assert any(
o.objection_type == ObjectionType.PRICE
for o in compressed.objections
)
def test_compress_extracts_offer(self, sample_transcript):
"""Test that agent offer is extracted."""
compressor = TranscriptCompressor()
compressed = compressor.compress(sample_transcript)
assert len(compressed.agent_offers) > 0
def test_compress_extracts_key_moments(self, sample_transcript):
"""Test that key moments are extracted."""
compressor = TranscriptCompressor()
compressed = compressor.compress(sample_transcript)
# Should find rejection and firm_decision
moment_types = [m.moment_type for m in compressed.key_moments]
assert len(moment_types) > 0
def test_compression_ratio(self, sample_transcript):
"""Test that compression ratio is calculated."""
compressor = TranscriptCompressor()
compressed = compressor.compress(sample_transcript)
assert compressed.compression_ratio > 0
assert compressed.original_char_count > compressed.compressed_char_count
def test_compression_respects_max_limits(self, sample_transcript):
"""Test that max limits are respected."""
config = CompressionConfig(
max_intents=1,
max_offers=1,
max_objections=1,
max_key_moments=2,
)
compressor = TranscriptCompressor(config=config)
compressed = compressor.compress(sample_transcript)
assert len(compressed.customer_intents) <= 1
assert len(compressed.agent_offers) <= 1
assert len(compressed.objections) <= 1
assert len(compressed.key_moments) <= 2
def test_generates_summary(self, sample_transcript):
"""Test that summary is generated."""
compressor = TranscriptCompressor()
compressed = compressor.compress(sample_transcript)
assert len(compressed.call_summary) > 0
assert "cancel" in compressed.call_summary.lower()
class TestIntentExtraction:
"""Tests for specific intent patterns."""
def make_transcript(self, customer_text: str) -> Transcript:
"""Helper to create transcript with customer turn."""
return Transcript(
call_id="INT001",
turns=[
SpeakerTurn(speaker="agent", text="Hola", start_time=0, end_time=1),
SpeakerTurn(speaker="customer", text=customer_text, start_time=1, end_time=3),
],
)
def test_cancel_intent_patterns(self):
"""Test various cancel intent patterns."""
patterns = [
"Quiero cancelar mi servicio",
"Quiero dar de baja mi cuenta",
"No quiero continuar con el servicio",
]
compressor = TranscriptCompressor()
for pattern in patterns:
transcript = self.make_transcript(pattern)
compressed = compressor.compress(transcript)
assert any(
i.intent_type == IntentType.CANCEL
for i in compressed.customer_intents
), f"Failed for: {pattern}"
def test_purchase_intent_patterns(self):
"""Test purchase intent patterns."""
patterns = [
"Quiero contratar el plan premium",
"Me interesa comprar el servicio",
]
compressor = TranscriptCompressor()
for pattern in patterns:
transcript = self.make_transcript(pattern)
compressed = compressor.compress(transcript)
assert any(
i.intent_type == IntentType.PURCHASE
for i in compressed.customer_intents
), f"Failed for: {pattern}"
def test_complaint_intent_patterns(self):
"""Test complaint intent patterns."""
patterns = [
"Tengo un problema con mi factura",
"Estoy muy molesto con el servicio",
"Quiero poner una queja",
]
compressor = TranscriptCompressor()
for pattern in patterns:
transcript = self.make_transcript(pattern)
compressed = compressor.compress(transcript)
assert any(
i.intent_type == IntentType.COMPLAINT
for i in compressed.customer_intents
), f"Failed for: {pattern}"
class TestObjectionExtraction:
"""Tests for objection pattern extraction."""
def make_transcript(self, customer_text: str) -> Transcript:
"""Helper to create transcript with customer turn."""
return Transcript(
call_id="OBJ001",
turns=[
SpeakerTurn(speaker="agent", text="Hola", start_time=0, end_time=1),
SpeakerTurn(speaker="customer", text=customer_text, start_time=1, end_time=3),
],
)
def test_price_objection_patterns(self):
"""Test price objection patterns."""
patterns = [
"Es muy caro para mí",
"Es demasiado costoso",
"No tengo el dinero ahora",
"Está fuera de mi presupuesto",
]
compressor = TranscriptCompressor()
for pattern in patterns:
transcript = self.make_transcript(pattern)
compressed = compressor.compress(transcript)
assert any(
o.objection_type == ObjectionType.PRICE
for o in compressed.objections
), f"Failed for: {pattern}"
def test_timing_objection_patterns(self):
"""Test timing objection patterns."""
patterns = [
"No es buen momento",
"Déjame pensarlo",
"Lo voy a pensar",
]
compressor = TranscriptCompressor()
for pattern in patterns:
transcript = self.make_transcript(pattern)
compressed = compressor.compress(transcript)
assert any(
o.objection_type == ObjectionType.TIMING
for o in compressed.objections
), f"Failed for: {pattern}"
class TestConvenienceFunctions:
"""Tests for convenience functions."""
@pytest.fixture
def sample_transcript(self):
"""Create sample transcript."""
return Transcript(
call_id="CONV001",
turns=[
SpeakerTurn(speaker="agent", text="Hola", start_time=0, end_time=1),
SpeakerTurn(
speaker="customer",
text="Quiero cancelar, es muy caro",
start_time=1,
end_time=3,
),
],
)
def test_compress_transcript(self, sample_transcript):
"""Test compress_transcript function."""
compressed = compress_transcript(sample_transcript)
assert isinstance(compressed, CompressedTranscript)
assert compressed.call_id == "CONV001"
def test_compress_for_prompt(self, sample_transcript):
"""Test compress_for_prompt function."""
text = compress_for_prompt(sample_transcript)
assert isinstance(text, str)
assert len(text) > 0
def test_compress_for_prompt_max_chars(self, sample_transcript):
"""Test max_chars parameter."""
text = compress_for_prompt(sample_transcript, max_chars=100)
assert len(text) <= 100