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
This commit is contained in:
135
backend/app/conversation_tracker.py
Normal file
135
backend/app/conversation_tracker.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user