feat: complete phase 4 -- conversation replay API + analytics dashboard
- Replay models: StepType enum, ReplayStep, ReplayPage frozen dataclasses
- Checkpoint transformer: PostgresSaver JSONB -> structured timeline steps
- Replay API: GET /api/conversations (paginated), GET /api/replay/{thread_id}
- Analytics models: AgentUsage, InterruptStats, AnalyticsResult
- Analytics event recorder: Protocol + PostgresAnalyticsRecorder + NoOp
- Analytics queries: resolution_rate, agent_usage, escalation_rate, cost, interrupts
- Analytics API: GET /api/analytics?range=Xd with envelope response
- DB migration: analytics_events table + conversations column additions
- 74 new tests, 399 total passing, 92.87% coverage
This commit is contained in:
155
backend/tests/unit/replay/test_transformer.py
Normal file
155
backend/tests/unit/replay/test_transformer.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""Unit tests for app.replay.transformer."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _make_row(messages: list[dict], metadata: dict | None = None) -> dict:
|
||||
"""Helper to build a checkpoint row with the given messages."""
|
||||
return {
|
||||
"thread_id": "thread-abc",
|
||||
"checkpoint_id": "cp-001",
|
||||
"checkpoint": {"channel_values": {"messages": messages}},
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
|
||||
|
||||
class TestTransformCheckpoints:
|
||||
def test_empty_rows_returns_empty_list(self) -> None:
|
||||
from app.replay.transformer import transform_checkpoints
|
||||
|
||||
result = transform_checkpoints([])
|
||||
assert result == []
|
||||
|
||||
def test_human_message_produces_user_message_step(self) -> None:
|
||||
from app.replay.models import StepType
|
||||
from app.replay.transformer import transform_checkpoints
|
||||
|
||||
rows = [_make_row([{"type": "human", "content": "Hello, I need help"}])]
|
||||
steps = transform_checkpoints(rows)
|
||||
assert len(steps) == 1
|
||||
assert steps[0].type == StepType.user_message
|
||||
assert steps[0].content == "Hello, I need help"
|
||||
assert steps[0].step == 1
|
||||
|
||||
def test_ai_message_with_content_produces_agent_response(self) -> None:
|
||||
from app.replay.models import StepType
|
||||
from app.replay.transformer import transform_checkpoints
|
||||
|
||||
rows = [
|
||||
_make_row(
|
||||
[{"type": "ai", "content": "I can help you with that.", "tool_calls": []}],
|
||||
metadata={"writes": {"some_agent": "response"}},
|
||||
)
|
||||
]
|
||||
steps = transform_checkpoints(rows)
|
||||
assert len(steps) == 1
|
||||
assert steps[0].type == StepType.agent_response
|
||||
assert steps[0].content == "I can help you with that."
|
||||
|
||||
def test_ai_message_with_tool_calls_produces_tool_call_step(self) -> None:
|
||||
from app.replay.models import StepType
|
||||
from app.replay.transformer import transform_checkpoints
|
||||
|
||||
rows = [
|
||||
_make_row(
|
||||
[
|
||||
{
|
||||
"type": "ai",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"name": "get_order_status",
|
||||
"args": {"order_id": "ORD-123"},
|
||||
"id": "call_abc",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
)
|
||||
]
|
||||
steps = transform_checkpoints(rows)
|
||||
assert len(steps) == 1
|
||||
assert steps[0].type == StepType.tool_call
|
||||
assert steps[0].tool == "get_order_status"
|
||||
assert steps[0].params == {"order_id": "ORD-123"}
|
||||
|
||||
def test_tool_message_produces_tool_result_step(self) -> None:
|
||||
from app.replay.models import StepType
|
||||
from app.replay.transformer import transform_checkpoints
|
||||
|
||||
rows = [
|
||||
_make_row(
|
||||
[
|
||||
{
|
||||
"type": "tool",
|
||||
"content": '{"status": "shipped"}',
|
||||
"name": "get_order_status",
|
||||
}
|
||||
]
|
||||
)
|
||||
]
|
||||
steps = transform_checkpoints(rows)
|
||||
assert len(steps) == 1
|
||||
assert steps[0].type == StepType.tool_result
|
||||
assert steps[0].tool == "get_order_status"
|
||||
|
||||
def test_multiple_messages_sequential_steps(self) -> None:
|
||||
from app.replay.transformer import transform_checkpoints
|
||||
|
||||
rows = [
|
||||
_make_row(
|
||||
[
|
||||
{"type": "human", "content": "Help"},
|
||||
{"type": "ai", "content": "Sure!", "tool_calls": []},
|
||||
]
|
||||
)
|
||||
]
|
||||
steps = transform_checkpoints(rows)
|
||||
assert len(steps) == 2
|
||||
assert steps[0].step == 1
|
||||
assert steps[1].step == 2
|
||||
|
||||
def test_unknown_message_type_skipped(self) -> None:
|
||||
from app.replay.transformer import transform_checkpoints
|
||||
|
||||
rows = [_make_row([{"type": "unknown_type", "content": "test"}])]
|
||||
steps = transform_checkpoints(rows)
|
||||
# Should not crash; unknown types may be skipped
|
||||
assert isinstance(steps, list)
|
||||
|
||||
def test_row_missing_checkpoint_skipped(self) -> None:
|
||||
from app.replay.transformer import transform_checkpoints
|
||||
|
||||
rows = [{"thread_id": "t1", "checkpoint_id": "cp1", "checkpoint": None, "metadata": {}}]
|
||||
steps = transform_checkpoints(rows)
|
||||
assert isinstance(steps, list)
|
||||
|
||||
def test_row_missing_messages_key_skipped(self) -> None:
|
||||
from app.replay.transformer import transform_checkpoints
|
||||
|
||||
rows = [{"thread_id": "t1", "checkpoint_id": "cp1", "checkpoint": {}, "metadata": {}}]
|
||||
steps = transform_checkpoints(rows)
|
||||
assert isinstance(steps, list)
|
||||
|
||||
def test_multiple_rows_steps_are_continuous(self) -> None:
|
||||
from app.replay.transformer import transform_checkpoints
|
||||
|
||||
rows = [
|
||||
_make_row([{"type": "human", "content": "Q1"}]),
|
||||
_make_row([{"type": "ai", "content": "A1", "tool_calls": []}]),
|
||||
]
|
||||
steps = transform_checkpoints(rows)
|
||||
assert len(steps) == 2
|
||||
assert steps[0].step == 1
|
||||
assert steps[1].step == 2
|
||||
|
||||
def test_timestamps_are_strings(self) -> None:
|
||||
from app.replay.transformer import transform_checkpoints
|
||||
|
||||
rows = [_make_row([{"type": "human", "content": "Hi"}])]
|
||||
steps = transform_checkpoints(rows)
|
||||
assert isinstance(steps[0].timestamp, str)
|
||||
Reference in New Issue
Block a user