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>
481 lines
15 KiB
Python
481 lines
15 KiB
Python
"""
|
|
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
|