Commit ac6be50f authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: add conversation test tool with SSE streaming

parent 178108a8
"""
Conversation Test API Routes
- POST /api/test/upload — Upload Excel/CSV file, parse conversations
- POST /api/test/run — Run test batch with fake device_ids
- GET /api/test/results — Get stored test results
Tool to test chatbot responses across multiple conversation scenarios × versions.
Each conversation_id × version gets a unique fake device_id.
"""
import asyncio
import io
import json
import logging
import time
import uuid
from collections import defaultdict
from typing import Any
import httpx
from fastapi import APIRouter, UploadFile, File
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(tags=["Conversation Test"])
# In-memory store for test results (keyed by test_id)
_test_results: dict[str, Any] = {}
# ─────────────────────────────────────────────────────────
# Models
# ─────────────────────────────────────────────────────────
class TestRunRequest(BaseModel):
endpoint_url: str = "http://localhost:5004/api/agent/chat-dev"
conversations: dict[str, list[str]] # conv_id -> [messages]
num_versions: int = 3
delay_ms: int = 1000
selected_conv_ids: list[str] | None = None # If None, run all
# ─────────────────────────────────────────────────────────
# 1. Upload & Parse
# ─────────────────────────────────────────────────────────
@router.post("/api/test/upload", summary="Upload Excel/CSV file for conversation test")
async def upload_test_file(file: UploadFile = File(...)):
"""
Parse an uploaded Excel (.xlsx) or CSV file.
Expects columns: message_content, conversation_id_test
Returns grouped conversations.
"""
try:
content = await file.read()
filename = file.filename or ""
conversations: dict[str, list[str]] = defaultdict(list)
if filename.endswith(".xlsx"):
import openpyxl
wb = openpyxl.load_workbook(io.BytesIO(content), read_only=True)
# Try to find 'conversation' sheet, else use active
if "conversation" in wb.sheetnames:
ws = wb["conversation"]
else:
ws = wb.active
# Find header row
headers = {}
for col_idx in range(1, (ws.max_column or 10) + 1):
val = ws.cell(1, col_idx).value
if val:
headers[str(val).strip().lower()] = col_idx
msg_col = headers.get("message_content", 1)
conv_col = headers.get("conversation_id_test", 2)
for row_idx in range(2, (ws.max_row or 2) + 1):
msg = ws.cell(row_idx, msg_col).value
conv_id = ws.cell(row_idx, conv_col).value
if msg and conv_id is not None:
conversations[str(int(conv_id))].append(str(msg))
wb.close()
elif filename.endswith(".csv"):
import csv
text = content.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text))
for row in reader:
msg = row.get("message_content", "").strip()
conv_id = row.get("conversation_id_test", "").strip()
if msg and conv_id:
conversations[conv_id].append(msg)
else:
return {"status": "error", "message": f"Unsupported file type: {filename}. Use .xlsx or .csv"}
# Sort by conv_id numerically
sorted_convs = dict(
sorted(conversations.items(), key=lambda x: int(x[0]) if x[0].isdigit() else x[0])
)
return {
"status": "success",
"total_conversations": len(sorted_convs),
"total_messages": sum(len(msgs) for msgs in sorted_convs.values()),
"conversations": sorted_convs,
}
except Exception as e:
logger.error(f"Error parsing test file: {e}", exc_info=True)
return {"status": "error", "message": str(e)}
# ─────────────────────────────────────────────────────────
# 2. Run Test (SSE streaming)
# ─────────────────────────────────────────────────────────
@router.post("/api/test/run", summary="Run conversation test batch")
async def run_test_batch(req: TestRunRequest):
"""
Run test: for each conversation_id × version, generate a fake device_id,
send messages sequentially to the target endpoint, and stream results via SSE.
"""
test_id = str(uuid.uuid4())[:8]
# Filter conversations if selected
conv_ids = req.selected_conv_ids or list(req.conversations.keys())
conversations = {cid: req.conversations[cid] for cid in conv_ids if cid in req.conversations}
total_tasks = len(conversations) * req.num_versions
_test_results[test_id] = {
"test_id": test_id,
"status": "running",
"total_tasks": total_tasks,
"completed": 0,
"results": {},
}
async def event_stream():
"""SSE event stream for real-time progress."""
# Send initial event
yield _sse_event("start", {
"test_id": test_id,
"total_conversations": len(conversations),
"num_versions": req.num_versions,
"total_tasks": total_tasks,
})
completed = 0
async with httpx.AsyncClient(timeout=120.0) as client:
for conv_id in sorted(conversations.keys(), key=lambda x: int(x) if x.isdigit() else x):
messages = conversations[conv_id]
for version in range(1, req.num_versions + 1):
device_id = f"test-conv{conv_id}-v{version}"
result_key = f"{conv_id}_v{version}"
try:
version_responses = []
total_time = 0.0
for msg_idx, message in enumerate(messages):
start_time = time.time()
# Send to target endpoint with fake device_id
response = await client.post(
req.endpoint_url,
json={
"user_query": message,
"device_id": device_id,
},
headers={
"Content-Type": "application/json",
"device_id": device_id,
},
)
elapsed = time.time() - start_time
total_time += elapsed
if response.status_code == 200:
data = response.json()
version_responses.append({
"message": message,
"ai_response": data.get("ai_response", ""),
"product_ids": data.get("product_ids", []),
"response_time": round(elapsed, 2),
})
else:
version_responses.append({
"message": message,
"ai_response": f"[ERROR {response.status_code}] {response.text[:200]}",
"product_ids": [],
"response_time": round(elapsed, 2),
})
# Delay between messages
if msg_idx < len(messages) - 1 and req.delay_ms > 0:
await asyncio.sleep(req.delay_ms / 1000)
# Store result
result = {
"conv_id": conv_id,
"version": version,
"device_id": device_id,
"messages": messages,
"responses": version_responses,
"total_time": round(total_time, 2),
"final_response": version_responses[-1]["ai_response"] if version_responses else "",
}
_test_results[test_id]["results"][result_key] = result
completed += 1
_test_results[test_id]["completed"] = completed
# Stream progress event
yield _sse_event("progress", {
"result_key": result_key,
"conv_id": conv_id,
"version": version,
"device_id": device_id,
"completed": completed,
"total_tasks": total_tasks,
"total_time": result["total_time"],
"final_response": result["final_response"][:300],
"num_messages": len(messages),
})
except Exception as e:
completed += 1
_test_results[test_id]["completed"] = completed
error_result = {
"conv_id": conv_id,
"version": version,
"device_id": device_id,
"messages": messages,
"responses": [],
"total_time": 0,
"final_response": f"[EXCEPTION] {str(e)}",
"error": str(e),
}
_test_results[test_id]["results"][result_key] = error_result
yield _sse_event("error", {
"result_key": result_key,
"conv_id": conv_id,
"version": version,
"error": str(e),
"completed": completed,
"total_tasks": total_tasks,
})
_test_results[test_id]["status"] = "completed"
yield _sse_event("complete", {
"test_id": test_id,
"total_completed": completed,
})
return StreamingResponse(event_stream(), media_type="text/event-stream")
# ─────────────────────────────────────────────────────────
# 3. Get Results
# ─────────────────────────────────────────────────────────
@router.get("/api/test/results/{test_id}", summary="Get test results")
async def get_test_results(test_id: str):
"""Retrieve stored test results by test_id."""
if test_id not in _test_results:
return {"status": "error", "message": f"Test ID {test_id} not found"}
return _test_results[test_id]
@router.get("/api/test/results", summary="List all test results")
async def list_test_results():
"""List all stored test results (summary only)."""
return {
"tests": [
{
"test_id": tid,
"status": data["status"],
"total_tasks": data["total_tasks"],
"completed": data["completed"],
}
for tid, data in _test_results.items()
]
}
# ─────────────────────────────────────────────────────────
# Helper
# ─────────────────────────────────────────────────────────
def _sse_event(event_type: str, data: dict) -> str:
"""Format SSE event string."""
return f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
...@@ -43,6 +43,7 @@ PUBLIC_PATH_PREFIXES = [ ...@@ -43,6 +43,7 @@ PUBLIC_PATH_PREFIXES = [
"/static", "/static",
"/mock", "/mock",
"/api/mock", "/api/mock",
"/api/test",
] ]
......
...@@ -14,6 +14,7 @@ from api.conservation_route import router as conservation_router ...@@ -14,6 +14,7 @@ from api.conservation_route import router as conservation_router
from api.mock_api_route import router as mock_router from api.mock_api_route import router as mock_router
from api.prompt_route import router as prompt_router from api.prompt_route import router as prompt_router
from api.stock_route import router as stock_router from api.stock_route import router as stock_router
from api.test_conversation_route import router as test_conversation_router
from api.tool_prompt_route import router as tool_prompt_router from api.tool_prompt_route import router as tool_prompt_router
from common.cache import redis_cache from common.cache import redis_cache
from common.middleware import middleware_manager from common.middleware import middleware_manager
...@@ -94,6 +95,7 @@ app.include_router(prompt_router) ...@@ -94,6 +95,7 @@ app.include_router(prompt_router)
app.include_router(tool_prompt_router) # Register new router app.include_router(tool_prompt_router) # Register new router
app.include_router(mock_router) app.include_router(mock_router)
app.include_router(stock_router) app.include_router(stock_router)
app.include_router(test_conversation_router)
if __name__ == "__main__": if __name__ == "__main__":
......
...@@ -686,6 +686,7 @@ ...@@ -686,6 +686,7 @@
<div class="nav-links"> <div class="nav-links">
<a href="/static/index.html" class="active">💬 Chatbot</a> <a href="/static/index.html" class="active">💬 Chatbot</a>
<a href="/static/history.html">🧾 Show History</a> <a href="/static/history.html">🧾 Show History</a>
<a href="/static/test_conversation.html">🧪 Test Tool</a>
</div> </div>
</div> </div>
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Conversation Test Tool — Canifa AI</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: #0f0f14;
color: #e0e0e0;
min-height: 100vh;
}
/* ── Navigation ── */
.nav-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 15px 30px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 20px rgba(102, 126, 234, 0.3);
}
.nav-header h1 {
color: white;
font-size: 1.4em;
}
.nav-links {
display: flex;
gap: 12px;
}
.nav-links a {
color: white;
text-decoration: none;
padding: 8px 16px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.15);
transition: all 0.3s;
font-weight: 500;
font-size: 0.9em;
}
.nav-links a:hover {
background: rgba(255, 255, 255, 0.25);
transform: translateY(-1px);
}
.nav-links a.active {
background: rgba(255, 255, 255, 0.35);
}
/* ── Main Layout ── */
.main-content {
max-width: 1600px;
margin: 0 auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
/* ── Card ── */
.card {
background: #1a1a24;
border: 1px solid #2a2a3a;
border-radius: 16px;
padding: 24px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.card h2 {
font-size: 1.15em;
margin-bottom: 16px;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
}
/* ── Config Section ── */
.config-grid {
display: grid;
grid-template-columns: 1fr 120px 120px;
gap: 14px;
align-items: end;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group label {
font-size: 0.8em;
color: #8888aa;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input,
.form-group select {
padding: 10px 14px;
border: 1px solid #2a2a3a;
border-radius: 10px;
background: #12121a;
color: #e0e0e0;
font-size: 0.95em;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.15);
}
/* ── Upload Area ── */
.upload-area {
border: 2px dashed #2a2a3a;
border-radius: 14px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background: #12121a;
}
.upload-area:hover,
.upload-area.dragover {
border-color: #667eea;
background: rgba(102, 126, 234, 0.05);
}
.upload-area .icon {
font-size: 2.5em;
margin-bottom: 12px;
}
.upload-area p {
color: #8888aa;
font-size: 0.9em;
}
.upload-area .filename {
color: #667eea;
font-weight: 600;
margin-top: 8px;
font-size: 1em;
}
/* ── Buttons ── */
.btn {
padding: 10px 22px;
border: none;
border-radius: 10px;
font-weight: 600;
font-size: 0.9em;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn:hover {
transform: translateY(-1px);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-success {
background: linear-gradient(135deg, #43a047, #2e7d32);
color: white;
}
.btn-danger {
background: linear-gradient(135deg, #e53935, #c62828);
color: white;
}
.btn-secondary {
background: #2a2a3a;
color: #ccc;
}
.btn-outline {
background: transparent;
border: 1px solid #2a2a3a;
color: #aaa;
}
/* ── Preview ── */
.preview-container {
max-height: 300px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.conv-preview {
background: #12121a;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.2s;
}
.conv-preview:hover {
background: #1e1e2e;
}
.conv-id-badge {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
font-size: 0.75em;
font-weight: 700;
padding: 4px 10px;
border-radius: 6px;
min-width: 36px;
text-align: center;
}
.conv-messages {
flex: 1;
margin-left: 14px;
font-size: 0.88em;
color: #b0b0cc;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.conv-count {
font-size: 0.75em;
color: #667eea;
font-weight: 600;
background: rgba(102, 126, 234, 0.1);
padding: 4px 10px;
border-radius: 6px;
}
/* ── Progress ── */
.progress-section {
display: none;
}
.progress-bar-wrapper {
background: #12121a;
border-radius: 10px;
height: 8px;
overflow: hidden;
margin: 10px 0;
}
.progress-bar {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 10px;
transition: width 0.3s ease;
}
.progress-text {
font-size: 0.85em;
color: #8888aa;
display: flex;
justify-content: space-between;
}
/* ── Results Table ── */
.results-section {
display: none;
}
.results-table-wrapper {
overflow-x: auto;
border-radius: 12px;
border: 1px solid #2a2a3a;
}
.results-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85em;
}
.results-table th {
background: #1e1e2e;
color: #8888aa;
font-weight: 600;
padding: 12px 14px;
text-align: left;
position: sticky;
top: 0;
border-bottom: 2px solid #2a2a3a;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.results-table td {
padding: 12px 14px;
border-bottom: 1px solid #1e1e2e;
vertical-align: top;
max-width: 400px;
}
.results-table tr:hover td {
background: rgba(102, 126, 234, 0.03);
}
.response-cell {
max-height: 120px;
overflow-y: auto;
font-size: 0.9em;
line-height: 1.5;
color: #ccc;
}
.time-badge {
display: inline-block;
background: rgba(102, 126, 234, 0.1);
color: #667eea;
padding: 3px 8px;
border-radius: 6px;
font-size: 0.8em;
font-weight: 600;
}
.time-badge.slow {
background: rgba(255, 107, 107, 0.1);
color: #ff6b6b;
}
.status-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 6px;
font-size: 0.75em;
font-weight: 600;
}
.status-badge.running {
background: rgba(255, 193, 7, 0.15);
color: #ffc107;
}
.status-badge.done {
background: rgba(76, 175, 80, 0.15);
color: #4caf50;
}
.status-badge.error {
background: rgba(244, 67, 54, 0.15);
color: #f44336;
}
/* ── Log ── */
.log-panel {
background: #0a0a12;
border: 1px solid #1a1a2a;
border-radius: 10px;
padding: 14px;
max-height: 200px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.8em;
line-height: 1.6;
color: #6a6a8a;
}
.log-entry {
padding: 2px 0;
}
.log-entry.success {
color: #4caf50;
}
.log-entry.error {
color: #f44336;
}
.log-entry.info {
color: #667eea;
}
/* ── Actions Bar ── */
.actions-bar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-top: 16px;
}
/* ── Scrollbar ── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #2a2a3a;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #3a3a4a;
}
/* ── Empty State ── */
.empty-state {
text-align: center;
padding: 40px 20px;
color: #5a5a7a;
}
.empty-state .icon {
font-size: 3em;
margin-bottom: 12px;
opacity: 0.5;
}
/* ── Stats row ── */
.stats-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.stat-box {
background: #12121a;
border: 1px solid #2a2a3a;
border-radius: 10px;
padding: 14px 20px;
flex: 1;
min-width: 140px;
}
.stat-box .label {
font-size: 0.7em;
color: #6a6a8a;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-box .value {
font-size: 1.4em;
font-weight: 700;
color: #fff;
margin-top: 4px;
}
.stat-box .value.purple {
color: #667eea;
}
.stat-box .value.green {
color: #4caf50;
}
.stat-box .value.amber {
color: #ffc107;
}
@media (max-width: 768px) {
.config-grid {
grid-template-columns: 1fr;
}
.main-content {
padding: 12px;
}
}
</style>
</head>
<body>
<!-- Navigation -->
<div class="nav-header">
<h1>🤖 Canifa AI System</h1>
<div class="nav-links">
<a href="/static/index.html">💬 Chatbot</a>
<a href="/static/history.html">🧾 History</a>
<a href="/static/test_conversation.html" class="active">🧪 Test Tool</a>
</div>
</div>
<div class="main-content">
<!-- Config Card -->
<div class="card">
<h2>⚙️ Test Configuration</h2>
<div class="config-grid">
<div class="form-group">
<label>Agent Chat Endpoint URL</label>
<input type="text" id="endpointUrl" value="http://localhost:5004/api/agent/chat-dev"
placeholder="http://localhost:5004/api/agent/chat-dev">
</div>
<div class="form-group">
<label>Versions</label>
<input type="number" id="numVersions" value="3" min="1" max="20">
</div>
<div class="form-group">
<label>Delay (ms)</label>
<input type="number" id="delayMs" value="1000" min="0" max="30000" step="500">
</div>
</div>
</div>
<!-- Upload Card -->
<div class="card">
<h2>📁 Upload Test Data</h2>
<div class="upload-area" id="uploadArea" onclick="document.getElementById('fileInput').click()"
ondragover="event.preventDefault(); this.classList.add('dragover')"
ondragleave="this.classList.remove('dragover')" ondrop="handleDrop(event)">
<div class="icon">📄</div>
<p>Click or drag & drop an <strong>.xlsx</strong> or <strong>.csv</strong> file</p>
<p style="font-size:0.8em; margin-top:6px; color:#5a5a7a">
Format: <code>message_content</code> + <code>conversation_id_test</code>
</p>
<div class="filename" id="uploadedFilename"></div>
</div>
<input type="file" id="fileInput" accept=".xlsx,.csv" style="display:none"
onchange="handleFileSelect(event)">
</div>
<!-- Preview Card -->
<div class="card" id="previewCard" style="display:none">
<h2>👁️ Conversation Preview</h2>
<div class="stats-row" id="statsRow"></div>
<div class="preview-container" id="previewContainer" style="margin-top:16px"></div>
<div class="actions-bar">
<button class="btn btn-primary" id="runAllBtn" onclick="runTest()">
▶ Run All Conversations
</button>
<button class="btn btn-outline" onclick="clearAll()">✕ Clear</button>
</div>
</div>
<!-- Progress Card -->
<div class="card progress-section" id="progressCard">
<h2>🔄 Test Progress</h2>
<div class="progress-bar-wrapper">
<div class="progress-bar" id="progressBar"></div>
</div>
<div class="progress-text">
<span id="progressLabel">0 / 0 tasks</span>
<span id="progressPercent">0%</span>
</div>
<div class="log-panel" id="logPanel"></div>
</div>
<!-- Results Card -->
<div class="card results-section" id="resultsCard">
<h2>📊 Test Results</h2>
<div class="stats-row" id="resultStatsRow" style="margin-bottom:16px"></div>
<div class="actions-bar" style="margin-top:0; margin-bottom:16px">
<button class="btn btn-success" onclick="exportCSV()">📥 Export CSV</button>
<button class="btn btn-secondary" onclick="toggleExpandAll()">📖 Expand/Collapse All</button>
</div>
<div class="results-table-wrapper" style="max-height:600px; overflow-y:auto">
<table class="results-table" id="resultsTable">
<thead id="resultsTableHead"></thead>
<tbody id="resultsTableBody"></tbody>
</table>
</div>
</div>
</div>
<script>
// ─── State ───
let parsedConversations = {};
let testResults = {};
let totalTasks = 0;
let completedTasks = 0;
let allExpanded = false;
let currentTestId = '';
// ─── File Upload ───
function handleFileSelect(event) {
const file = event.target.files[0];
if (file) uploadFile(file);
}
function handleDrop(event) {
event.preventDefault();
event.target.closest('.upload-area').classList.remove('dragover');
const file = event.dataTransfer.files[0];
if (file) uploadFile(file);
}
async function uploadFile(file) {
const filename = file.name;
document.getElementById('uploadedFilename').textContent = `📎 ${filename}`;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/test/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.status === 'success') {
parsedConversations = data.conversations;
renderPreview(data);
} else {
alert(`Upload error: ${data.message}`);
}
} catch (err) {
alert(`Upload failed: ${err.message}`);
}
}
// ─── Preview ───
function renderPreview(data) {
const card = document.getElementById('previewCard');
card.style.display = 'block';
// Stats
const numVersions = parseInt(document.getElementById('numVersions').value) || 3;
document.getElementById('statsRow').innerHTML = `
<div class="stat-box">
<div class="label">Conversations</div>
<div class="value purple">${data.total_conversations}</div>
</div>
<div class="stat-box">
<div class="label">Total Messages</div>
<div class="value">${data.total_messages}</div>
</div>
<div class="stat-box">
<div class="label">Versions</div>
<div class="value amber">${numVersions}</div>
</div>
<div class="stat-box">
<div class="label">Total Test Tasks</div>
<div class="value green">${data.total_conversations * numVersions}</div>
</div>
`;
// Conversation list
const container = document.getElementById('previewContainer');
container.innerHTML = '';
for (const [convId, messages] of Object.entries(data.conversations)) {
const preview = messages.map(m => `"${m}"`).join(' → ');
container.innerHTML += `
<div class="conv-preview">
<span class="conv-id-badge">#${convId}</span>
<span class="conv-messages" title="${preview}">${preview}</span>
<span class="conv-count">${messages.length} msg${messages.length > 1 ? 's' : ''}</span>
</div>
`;
}
}
// ─── Run Test ───
async function runTest() {
const endpointUrl = document.getElementById('endpointUrl').value.trim();
const numVersions = parseInt(document.getElementById('numVersions').value) || 3;
const delayMs = parseInt(document.getElementById('delayMs').value) || 1000;
if (!endpointUrl) { alert('Please enter endpoint URL'); return; }
if (Object.keys(parsedConversations).length === 0) { alert('Please upload test data first'); return; }
// Reset
testResults = {};
completedTasks = 0;
totalTasks = Object.keys(parsedConversations).length * numVersions;
// Show progress
document.getElementById('progressCard').style.display = 'block';
document.getElementById('resultsCard').style.display = 'block';
document.getElementById('runAllBtn').disabled = true;
document.getElementById('progressBar').style.width = '0%';
document.getElementById('logPanel').innerHTML = '';
// Build table header
buildResultsTableHeader(numVersions);
// SSE request
try {
const response = await fetch('/api/test/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint_url: endpointUrl,
conversations: parsedConversations,
num_versions: numVersions,
delay_ms: delayMs,
})
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
let eventType = '';
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
handleSSEEvent(eventType, data);
} catch (e) { /* skip parse errors */ }
}
}
}
} catch (err) {
addLog(`Connection error: ${err.message}`, 'error');
}
document.getElementById('runAllBtn').disabled = false;
}
function handleSSEEvent(type, data) {
switch (type) {
case 'start':
currentTestId = data.test_id;
totalTasks = data.total_tasks;
addLog(`🚀 Test started — ${data.total_conversations} conversations × ${data.num_versions} versions = ${data.total_tasks} tasks`, 'info');
break;
case 'progress':
completedTasks = data.completed;
updateProgress(data.completed, totalTasks);
testResults[data.result_key] = data;
updateResultRow(data);
addLog(`✅ Conv #${data.conv_id} v${data.version}${data.total_time}s (${data.num_messages} msgs)`, 'success');
break;
case 'error':
completedTasks = data.completed;
updateProgress(data.completed, totalTasks);
addLog(`❌ Conv #${data.conv_id} v${data.version}${data.error}`, 'error');
break;
case 'complete':
addLog(`🏁 Test completed — ${data.total_completed} tasks finished`, 'info');
updateResultStats();
break;
}
}
// ─── Progress ───
function updateProgress(completed, total) {
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
document.getElementById('progressBar').style.width = pct + '%';
document.getElementById('progressLabel').textContent = `${completed} / ${total} tasks`;
document.getElementById('progressPercent').textContent = pct + '%';
}
// ─── Log ───
function addLog(text, cls = '') {
const panel = document.getElementById('logPanel');
panel.innerHTML += `<div class="log-entry ${cls}">${new Date().toLocaleTimeString()}${text}</div>`;
panel.scrollTop = panel.scrollHeight;
}
// ─── Results Table ───
function buildResultsTableHeader(numVersions) {
const thead = document.getElementById('resultsTableHead');
let html = '<tr><th>Conv ID</th><th>Messages</th>';
for (let v = 1; v <= numVersions; v++) {
html += `<th>V${v} Response</th><th>V${v} Time</th>`;
}
html += '</tr>';
thead.innerHTML = html;
// Clear body
document.getElementById('resultsTableBody').innerHTML = '';
}
function updateResultRow(data) {
const tbody = document.getElementById('resultsTableBody');
const numVersions = parseInt(document.getElementById('numVersions').value) || 3;
const convId = data.conv_id;
// Find or create row
let row = document.getElementById(`row-conv-${convId}`);
if (!row) {
row = document.createElement('tr');
row.id = `row-conv-${convId}`;
// Conv ID cell
const msgs = parsedConversations[convId] || [];
const msgsPreview = msgs.map(m => `"${m}"`).join(' → ');
let html = `
<td><span class="conv-id-badge">#${convId}</span></td>
<td>
<div style="max-width:200px; font-size:0.85em; color:#8888aa; cursor:pointer"
title="${escapeHtml(msgsPreview)}"
onclick="this.style.whiteSpace = this.style.whiteSpace === 'normal' ? 'nowrap' : 'normal'">
${escapeHtml(msgs[0] || '')}${msgs.length > 1 ? ` <span style="color:#667eea">(+${msgs.length - 1})</span>` : ''}
</div>
</td>
`;
for (let v = 1; v <= numVersions; v++) {
html += `<td id="resp-${convId}-v${v}" class="response-cell">⏳</td>`;
html += `<td id="time-${convId}-v${v}">—</td>`;
}
row.innerHTML = html;
tbody.appendChild(row);
}
// Update the version cells
const v = data.version;
const respCell = document.getElementById(`resp-${convId}-v${v}`);
const timeCell = document.getElementById(`time-${convId}-v${v}`);
if (respCell) {
const responseText = data.final_response || '';
respCell.innerHTML = `<div class="response-cell">${escapeHtml(responseText)}</div>`;
}
if (timeCell) {
const t = data.total_time;
const cls = t > 10 ? 'slow' : '';
timeCell.innerHTML = `<span class="time-badge ${cls}">${t}s</span>`;
}
}
function updateResultStats() {
const results = Object.values(testResults);
const totalTime = results.reduce((s, r) => s + (r.total_time || 0), 0);
const avgTime = results.length > 0 ? (totalTime / results.length).toFixed(1) : 0;
const errors = results.filter(r => r.error).length;
document.getElementById('resultStatsRow').innerHTML = `
<div class="stat-box">
<div class="label">Completed</div>
<div class="value green">${results.length}</div>
</div>
<div class="stat-box">
<div class="label">Errors</div>
<div class="value" style="color:${errors > 0 ? '#f44336' : '#4caf50'}">${errors}</div>
</div>
<div class="stat-box">
<div class="label">Avg Time</div>
<div class="value purple">${avgTime}s</div>
</div>
<div class="stat-box">
<div class="label">Total Time</div>
<div class="value">${totalTime.toFixed(1)}s</div>
</div>
`;
}
// ─── Export CSV ───
function exportCSV() {
const numVersions = parseInt(document.getElementById('numVersions').value) || 3;
const convIds = Object.keys(parsedConversations).sort((a, b) => parseInt(a) - parseInt(b));
let csv = 'conversation_id,messages';
for (let v = 1; v <= numVersions; v++) {
csv += `,v${v}_device_id,v${v}_response,v${v}_time_s`;
}
csv += '\n';
for (const convId of convIds) {
const msgs = (parsedConversations[convId] || []).join(' | ');
let row = `${convId},"${msgs.replace(/"/g, '""')}"`;
for (let v = 1; v <= numVersions; v++) {
const key = `${convId}_v${v}`;
const r = testResults[key];
if (r) {
const resp = (r.final_response || '').replace(/"/g, '""');
row += `,test-conv${convId}-v${v},"${resp}",${r.total_time || 0}`;
} else {
row += `,,,`;
}
}
csv += row + '\n';
}
const BOM = '\uFEFF';
const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `conversation_test_${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
}
// ─── Toggle Expand ───
function toggleExpandAll() {
allExpanded = !allExpanded;
document.querySelectorAll('.response-cell').forEach(el => {
el.style.maxHeight = allExpanded ? 'none' : '120px';
});
}
// ─── Clear ───
function clearAll() {
parsedConversations = {};
testResults = {};
document.getElementById('previewCard').style.display = 'none';
document.getElementById('progressCard').style.display = 'none';
document.getElementById('resultsCard').style.display = 'none';
document.getElementById('uploadedFilename').textContent = '';
document.getElementById('fileInput').value = '';
}
// ─── Utils ───
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
</script>
</body>
</html>
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment