Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
chatbot_canifa_test_conservation_tools
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
chatbot_canifa_test_conservation_tools
Commits
ac6be50f
Commit
ac6be50f
authored
Feb 11, 2026
by
Vũ Hoàng Anh
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add conversation test tool with SSE streaming
parent
178108a8
Changes
6
Show whitespace changes
Inline
Side-by-side
Showing
6 changed files
with
1259 additions
and
0 deletions
+1259
-0
test_conversation_route.py
backend/api/test_conversation_route.py
+299
-0
middleware.py
backend/common/middleware.py
+1
-0
server.py
backend/server.py
+2
-0
index.html
backend/static/index.html
+1
-0
test_conversation.html
backend/static/test_conversation.html
+956
-0
Dự án Chatbot RSA.xlsx
public/Dự án Chatbot RSA.xlsx
+0
-0
No files found.
backend/api/test_conversation_route.py
0 → 100644
View file @
ac6be50f
"""
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}
\n
data: {json.dumps(data, ensure_ascii=False)}
\n\n
"
backend/common/middleware.py
View file @
ac6be50f
...
...
@@ -43,6 +43,7 @@ PUBLIC_PATH_PREFIXES = [
"/static"
,
"/mock"
,
"/api/mock"
,
"/api/test"
,
]
...
...
backend/server.py
View file @
ac6be50f
...
...
@@ -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.prompt_route
import
router
as
prompt_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
common.cache
import
redis_cache
from
common.middleware
import
middleware_manager
...
...
@@ -94,6 +95,7 @@ app.include_router(prompt_router)
app
.
include_router
(
tool_prompt_router
)
# Register new router
app
.
include_router
(
mock_router
)
app
.
include_router
(
stock_router
)
app
.
include_router
(
test_conversation_router
)
if
__name__
==
"__main__"
:
...
...
backend/static/index.html
View file @
ac6be50f
...
...
@@ -686,6 +686,7 @@
<div
class=
"nav-links"
>
<a
href=
"/static/index.html"
class=
"active"
>
💬 Chatbot
</a>
<a
href=
"/static/history.html"
>
🧾 Show History
</a>
<a
href=
"/static/test_conversation.html"
>
🧪 Test Tool
</a>
</div>
</div>
...
...
backend/static/test_conversation.html
0 → 100644
View file @
ac6be50f
<!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
:
1
fr
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
:
1
fr
;
}
.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
public/Dự án Chatbot RSA.xlsx
0 → 100644
View file @
ac6be50f
File added
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment