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:
480
tests/unit/test_compression.py
Normal file
480
tests/unit/test_compression.py
Normal 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
|
||||
Reference in New Issue
Block a user