Files
smart-support/backend/app/conversation_tracker.py
Yaojia Wang 0e78e5b06b feat: complete phase 5 -- error hardening, frontend, Docker, demo, docs
Backend:
- ConversationTracker: Protocol + PostgresConversationTracker for lifecycle tracking
- Error handler: ErrorCategory enum, classify_error(), with_retry() exponential backoff
- Wire PostgresAnalyticsRecorder + ConversationTracker into ws_handler
- Rate limiting (10 msg/10s per thread), edge case hardening
- Health endpoint GET /api/health, version 0.5.0
- Demo seed data script + sample OpenAPI spec

Frontend (all new):
- React Router with NavBar (Chat / Replay / Dashboard / Review)
- ReplayListPage + ReplayPage with ReplayTimeline component
- DashboardPage with MetricCard, range selector, zero-state
- ReviewPage for OpenAPI classification review
- ErrorBanner for WebSocket disconnect handling
- API client (api.ts) with typed fetch wrappers

Infrastructure:
- Frontend Dockerfile (multi-stage node -> nginx)
- nginx.conf with SPA routing + API/WS proxy
- docker-compose.yml with frontend service + healthchecks
- .env.example files (root + backend)

Documentation:
- README.md with quick start and architecture
- Agent configuration guide
- OpenAPI import guide
- Deployment guide
- Demo script

48 new tests, 449 total passing, 92.87% coverage
2026-03-31 21:20:06 +02:00

136 lines
3.7 KiB
Python

"""Conversation tracker -- Protocol and implementations for tracking conversation state."""
from __future__ import annotations
from typing import TYPE_CHECKING, Protocol, runtime_checkable
if TYPE_CHECKING:
from psycopg_pool import AsyncConnectionPool
_ENSURE_SQL = """
INSERT INTO conversations
(thread_id, started_at, last_activity)
VALUES
(%(thread_id)s, NOW(), NOW())
ON CONFLICT (thread_id) DO NOTHING
"""
_RECORD_TURN_SQL = """
UPDATE conversations
SET
turn_count = turn_count + 1,
agents_used = CASE
WHEN %(agent_name)s IS NOT NULL AND NOT (agents_used @> ARRAY[%(agent_name)s]::text[])
THEN agents_used || ARRAY[%(agent_name)s]::text[]
ELSE agents_used
END,
total_tokens = total_tokens + %(tokens)s,
total_cost_usd = total_cost_usd + %(cost)s,
last_activity = NOW()
WHERE thread_id = %(thread_id)s
"""
_RESOLVE_SQL = """
UPDATE conversations
SET
resolution_type = %(resolution_type)s,
ended_at = NOW()
WHERE thread_id = %(thread_id)s
"""
@runtime_checkable
class ConversationTrackerProtocol(Protocol):
"""Protocol for tracking conversation lifecycle and metrics."""
async def ensure_conversation(self, pool: AsyncConnectionPool, thread_id: str) -> None:
"""Create conversation row if it does not already exist."""
...
async def record_turn(
self,
pool: AsyncConnectionPool,
thread_id: str,
agent_name: str | None,
tokens: int,
cost: float,
) -> None:
"""Increment turn count and update aggregated metrics."""
...
async def resolve(
self,
pool: AsyncConnectionPool,
thread_id: str,
resolution_type: str,
) -> None:
"""Mark conversation as resolved with a resolution type."""
...
class NoOpConversationTracker:
"""No-op implementation -- used in tests or when DB is unavailable."""
async def ensure_conversation(self, pool: AsyncConnectionPool, thread_id: str) -> None:
"""Do nothing."""
async def record_turn(
self,
pool: AsyncConnectionPool,
thread_id: str,
agent_name: str | None,
tokens: int,
cost: float,
) -> None:
"""Do nothing."""
async def resolve(
self,
pool: AsyncConnectionPool,
thread_id: str,
resolution_type: str,
) -> None:
"""Do nothing."""
class PostgresConversationTracker:
"""Postgres-backed conversation tracker."""
async def ensure_conversation(self, pool: AsyncConnectionPool, thread_id: str) -> None:
"""Insert conversation row; do nothing if already exists (ON CONFLICT DO NOTHING)."""
params = {"thread_id": thread_id}
async with pool.connection() as conn:
await conn.execute(_ENSURE_SQL, params)
async def record_turn(
self,
pool: AsyncConnectionPool,
thread_id: str,
agent_name: str | None,
tokens: int,
cost: float,
) -> None:
"""Increment turn count, append agent if new, update token/cost totals."""
params = {
"thread_id": thread_id,
"agent_name": agent_name,
"tokens": tokens,
"cost": cost,
}
async with pool.connection() as conn:
await conn.execute(_RECORD_TURN_SQL, params)
async def resolve(
self,
pool: AsyncConnectionPool,
thread_id: str,
resolution_type: str,
) -> None:
"""Set resolution_type and ended_at on the conversation row."""
params = {
"thread_id": thread_id,
"resolution_type": resolution_type,
}
async with pool.connection() as conn:
await conn.execute(_RESOLVE_SQL, params)