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:
153
backend/fixtures/demo_data.py
Normal file
153
backend/fixtures/demo_data.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""Seed script -- inserts sample conversations and analytics events for demo purposes.
|
||||
|
||||
Usage:
|
||||
cd backend
|
||||
python fixtures/demo_data.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import psycopg
|
||||
|
||||
DATABASE_URL = os.environ.get(
|
||||
"DATABASE_URL",
|
||||
"postgresql://smart_support:dev_password@localhost:5432/smart_support",
|
||||
)
|
||||
|
||||
SAMPLE_CONVERSATIONS = [
|
||||
{
|
||||
"thread_id": "demo-thread-001",
|
||||
"agents_used": ["order_agent"],
|
||||
"turn_count": 3,
|
||||
"total_tokens": 1250,
|
||||
"total_cost_usd": 0.00375,
|
||||
"resolution_type": "resolved",
|
||||
"minutes_ago": 5,
|
||||
},
|
||||
{
|
||||
"thread_id": "demo-thread-002",
|
||||
"agents_used": ["order_agent", "refund_agent"],
|
||||
"turn_count": 6,
|
||||
"total_tokens": 3200,
|
||||
"total_cost_usd": 0.0096,
|
||||
"resolution_type": "resolved",
|
||||
"minutes_ago": 30,
|
||||
},
|
||||
{
|
||||
"thread_id": "demo-thread-003",
|
||||
"agents_used": ["general_agent"],
|
||||
"turn_count": 2,
|
||||
"total_tokens": 800,
|
||||
"total_cost_usd": 0.0024,
|
||||
"resolution_type": None,
|
||||
"minutes_ago": 60,
|
||||
},
|
||||
{
|
||||
"thread_id": "demo-thread-004",
|
||||
"agents_used": ["order_agent", "general_agent"],
|
||||
"turn_count": 8,
|
||||
"total_tokens": 4500,
|
||||
"total_cost_usd": 0.0135,
|
||||
"resolution_type": "escalated",
|
||||
"minutes_ago": 120,
|
||||
},
|
||||
{
|
||||
"thread_id": "demo-thread-005",
|
||||
"agents_used": ["refund_agent"],
|
||||
"turn_count": 4,
|
||||
"total_tokens": 2100,
|
||||
"total_cost_usd": 0.0063,
|
||||
"resolution_type": "resolved",
|
||||
"minutes_ago": 240,
|
||||
},
|
||||
]
|
||||
|
||||
SAMPLE_EVENTS = [
|
||||
{"thread_id": "demo-thread-001", "event_type": "message", "agent_name": "order_agent", "tokens_used": 400, "cost_usd": 0.0012, "success": True},
|
||||
{"thread_id": "demo-thread-001", "event_type": "tool_call", "agent_name": "order_agent", "tool_name": "get_order_status", "tokens_used": 0, "cost_usd": 0.0, "success": True},
|
||||
{"thread_id": "demo-thread-002", "event_type": "message", "agent_name": "order_agent", "tokens_used": 1600, "cost_usd": 0.0048, "success": True},
|
||||
{"thread_id": "demo-thread-002", "event_type": "message", "agent_name": "refund_agent", "tokens_used": 1600, "cost_usd": 0.0048, "success": True},
|
||||
{"thread_id": "demo-thread-002", "event_type": "tool_call", "agent_name": "refund_agent", "tool_name": "process_refund", "tokens_used": 0, "cost_usd": 0.0, "success": True},
|
||||
{"thread_id": "demo-thread-003", "event_type": "message", "agent_name": "general_agent", "tokens_used": 800, "cost_usd": 0.0024, "success": True},
|
||||
{"thread_id": "demo-thread-004", "event_type": "message", "agent_name": "order_agent", "tokens_used": 2000, "cost_usd": 0.006, "success": True},
|
||||
{"thread_id": "demo-thread-004", "event_type": "escalation", "agent_name": "general_agent", "tokens_used": 2500, "cost_usd": 0.0075, "success": False},
|
||||
{"thread_id": "demo-thread-005", "event_type": "message", "agent_name": "refund_agent", "tokens_used": 2100, "cost_usd": 0.0063, "success": True},
|
||||
]
|
||||
|
||||
_INSERT_CONVERSATION = """
|
||||
INSERT INTO conversations
|
||||
(thread_id, started_at, last_activity, turn_count, agents_used,
|
||||
total_tokens, total_cost_usd, resolution_type, ended_at)
|
||||
VALUES
|
||||
(%(thread_id)s, %(started_at)s, %(last_activity)s, %(turn_count)s,
|
||||
%(agents_used)s, %(total_tokens)s, %(total_cost_usd)s,
|
||||
%(resolution_type)s, %(ended_at)s)
|
||||
ON CONFLICT (thread_id) DO NOTHING
|
||||
"""
|
||||
|
||||
_INSERT_EVENT = """
|
||||
INSERT INTO analytics_events
|
||||
(thread_id, event_type, agent_name, tool_name, tokens_used, cost_usd, success)
|
||||
VALUES
|
||||
(%(thread_id)s, %(event_type)s, %(agent_name)s, %(tool_name)s,
|
||||
%(tokens_used)s, %(cost_usd)s, %(success)s)
|
||||
"""
|
||||
|
||||
|
||||
async def seed() -> None:
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
async with await psycopg.AsyncConnection.connect(DATABASE_URL) as conn:
|
||||
print("Seeding conversations...")
|
||||
for conv in SAMPLE_CONVERSATIONS:
|
||||
started_at = now - timedelta(minutes=conv["minutes_ago"])
|
||||
last_activity = started_at + timedelta(minutes=conv["turn_count"] * 2)
|
||||
ended_at = last_activity if conv["resolution_type"] else None
|
||||
|
||||
await conn.execute(
|
||||
_INSERT_CONVERSATION,
|
||||
{
|
||||
"thread_id": conv["thread_id"],
|
||||
"started_at": started_at,
|
||||
"last_activity": last_activity,
|
||||
"turn_count": conv["turn_count"],
|
||||
"agents_used": conv["agents_used"],
|
||||
"total_tokens": conv["total_tokens"],
|
||||
"total_cost_usd": conv["total_cost_usd"],
|
||||
"resolution_type": conv["resolution_type"],
|
||||
"ended_at": ended_at,
|
||||
},
|
||||
)
|
||||
print(f" Inserted conversation {conv['thread_id']}")
|
||||
|
||||
print("Seeding analytics events...")
|
||||
for event in SAMPLE_EVENTS:
|
||||
await conn.execute(
|
||||
_INSERT_EVENT,
|
||||
{
|
||||
"thread_id": event["thread_id"],
|
||||
"event_type": event["event_type"],
|
||||
"agent_name": event.get("agent_name"),
|
||||
"tool_name": event.get("tool_name"),
|
||||
"tokens_used": event.get("tokens_used", 0),
|
||||
"cost_usd": event.get("cost_usd", 0.0),
|
||||
"success": event.get("success"),
|
||||
},
|
||||
)
|
||||
print(f" Inserted event {event['event_type']} for {event['thread_id']}")
|
||||
|
||||
await conn.commit()
|
||||
|
||||
print("Done. Demo data seeded successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(seed())
|
||||
238
backend/fixtures/sample_openapi.yaml
Normal file
238
backend/fixtures/sample_openapi.yaml
Normal file
@@ -0,0 +1,238 @@
|
||||
openapi: "3.0.3"
|
||||
info:
|
||||
title: "E-Commerce API"
|
||||
description: "Sample e-commerce API for Smart Support demo."
|
||||
version: "1.0.0"
|
||||
|
||||
servers:
|
||||
- url: "https://api.example-shop.com/v1"
|
||||
description: "Production server"
|
||||
|
||||
paths:
|
||||
/orders/{order_id}:
|
||||
get:
|
||||
operationId: getOrder
|
||||
summary: "Get order details"
|
||||
description: "Retrieve the full details of a specific order."
|
||||
parameters:
|
||||
- name: order_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Order details"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Order"
|
||||
|
||||
/orders/{order_id}/cancel:
|
||||
post:
|
||||
operationId: cancelOrder
|
||||
summary: "Cancel an order"
|
||||
description: "Cancel an order that has not yet been shipped."
|
||||
parameters:
|
||||
- name: order_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
reason:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Order cancelled"
|
||||
"400":
|
||||
description: "Order cannot be cancelled (already shipped)"
|
||||
|
||||
/orders/{order_id}/refund:
|
||||
post:
|
||||
operationId: refundOrder
|
||||
summary: "Request a refund"
|
||||
description: "Submit a refund request for a completed order."
|
||||
parameters:
|
||||
- name: order_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
amount:
|
||||
type: number
|
||||
description: "Refund amount in USD. Leave null for full refund."
|
||||
reason:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Refund submitted"
|
||||
"400":
|
||||
description: "Invalid refund request"
|
||||
|
||||
/customers/{customer_id}:
|
||||
get:
|
||||
operationId: getCustomer
|
||||
summary: "Get customer profile"
|
||||
description: "Retrieve customer profile and account information."
|
||||
parameters:
|
||||
- name: customer_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Customer profile"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Customer"
|
||||
|
||||
/customers/{customer_id}/orders:
|
||||
get:
|
||||
operationId: listCustomerOrders
|
||||
summary: "List customer orders"
|
||||
description: "Get a paginated list of orders for a customer."
|
||||
parameters:
|
||||
- name: customer_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 1
|
||||
- name: per_page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
responses:
|
||||
"200":
|
||||
description: "List of orders"
|
||||
|
||||
/products/{product_id}:
|
||||
get:
|
||||
operationId: getProduct
|
||||
summary: "Get product details"
|
||||
description: "Retrieve product information including inventory status."
|
||||
parameters:
|
||||
- name: product_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Product details"
|
||||
|
||||
/support/tickets:
|
||||
post:
|
||||
operationId: createSupportTicket
|
||||
summary: "Create support ticket"
|
||||
description: "Open a new support ticket for a customer issue."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CreateTicketRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: "Ticket created"
|
||||
|
||||
/support/tickets/{ticket_id}:
|
||||
get:
|
||||
operationId: getSupportTicket
|
||||
summary: "Get support ticket"
|
||||
description: "Retrieve a support ticket and its conversation history."
|
||||
parameters:
|
||||
- name: ticket_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Ticket details"
|
||||
|
||||
components:
|
||||
schemas:
|
||||
Order:
|
||||
type: object
|
||||
properties:
|
||||
order_id:
|
||||
type: string
|
||||
customer_id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [pending, processing, shipped, delivered, cancelled, refunded]
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/OrderItem"
|
||||
total_usd:
|
||||
type: number
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
OrderItem:
|
||||
type: object
|
||||
properties:
|
||||
product_id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
quantity:
|
||||
type: integer
|
||||
unit_price_usd:
|
||||
type: number
|
||||
|
||||
Customer:
|
||||
type: object
|
||||
properties:
|
||||
customer_id:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
tier:
|
||||
type: string
|
||||
enum: [standard, premium, vip]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
|
||||
CreateTicketRequest:
|
||||
type: object
|
||||
required: [customer_id, subject, description]
|
||||
properties:
|
||||
customer_id:
|
||||
type: string
|
||||
subject:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
priority:
|
||||
type: string
|
||||
enum: [low, medium, high, urgent]
|
||||
default: medium
|
||||
Reference in New Issue
Block a user