Phong1 commited on
Commit
1cb285d
·
verified ·
1 Parent(s): fca2d83

Update ui_ver_3.py

Browse files
Files changed (1) hide show
  1. ui_ver_3.py +769 -769
ui_ver_3.py CHANGED
@@ -1,770 +1,770 @@
1
- import gradio as gr
2
- import requests
3
- import tempfile
4
- import os
5
- import base64
6
- import pandas as pd
7
- from io import BytesIO
8
- import time
9
- from datetime import datetime
10
- import zipfile
11
-
12
-
13
- # API_BASE_URL = "https://gradio-ocr-audio-demo-i7u7.onrender.com/"
14
- API_BASE_URL = "http://127.0.0.1:8000/"
15
-
16
-
17
- # Global variable để lưu danh sách tất cả sản phẩm từ search
18
- all_search_results = {}
19
-
20
- # Label cho option "Không phải sản phẩm Rạng Đông"
21
- not_rd_label = "Không phải sản phẩm Rạng Đông"
22
-
23
-
24
- custom_css = """
25
- h1 {
26
- font-family: 'Segoe UI', sans-serif;
27
- font-weight: 700;
28
- font-size: 2.5rem;
29
- }
30
- label, .gr-input, .gr-file {
31
- font-family: 'Segoe UI', sans-serif;
32
- font-size: 1rem;
33
- }
34
- .gr-button {
35
- font-weight: bold;
36
- background-color: #4CAF50;
37
- color: white;
38
- border-radius: 8px;
39
- padding: 10px 16px;
40
- }
41
- .gr-button:hover {
42
- background-color: #45a049;
43
- }
44
- body {
45
- background-color: #f8f9fa;
46
- }
47
- .section-box {
48
- border: 1px solid #e0e0e0;
49
- border-radius: 10px;
50
- padding: 15px;
51
- margin: 10px 0;
52
- background-color: #fafafa;
53
- }
54
- """
55
-
56
-
57
- # ====================== FUNCTIONS ======================
58
-
59
- def process_zip_with_api(employee_code, zip_file, llm_model):
60
- """
61
- Xử lý file ZIP chứa ảnh hóa đơn - giống tab nhập đơn hàng
62
- Returns: status, api_time, total_time, df, excel_file, excel_base64
63
- """
64
- if not zip_file:
65
- return (
66
- gr.update(value="⚠️ Vui lòng tải lên file ZIP!"),
67
- 0,
68
- 0,
69
- pd.DataFrame(),
70
- gr.File(visible=False),
71
- "" # excel_base64 empty
72
- )
73
-
74
- start_time = time.time()
75
-
76
- try:
77
- # Handle both string path and file object from Gradio
78
- file_path = zip_file.name if hasattr(zip_file, 'name') else zip_file
79
- with open(file_path, "rb") as f:
80
- files = {'file': (os.path.basename(file_path), f.read(), 'application/zip')}
81
-
82
- data = {
83
- 'employee_code': employee_code or "default",
84
- 'approach': "multimodal",
85
- 'llm_model': llm_model,
86
- 'audio_model': llm_model
87
- }
88
-
89
- response = requests.post(API_BASE_URL + "information_extraction/", files=files, data=data, timeout=600)
90
- response.raise_for_status()
91
- json_resp = response.json()
92
-
93
- # Lưu excel_base64 để dùng cho endpoint /extract-products/
94
- excel_base64 = json_resp.get("excel_data_base64", "")
95
-
96
- excel_bytes = base64.b64decode(excel_base64)
97
- api_duration = json_resp.get("api_duration", 0.)
98
- df = pd.read_excel(BytesIO(excel_bytes))
99
-
100
- # Lưu file Excel để tải về
101
- df_to_save = df
102
- result_file_name = "result_" + datetime.now().strftime("%Y%m%d%H%M%S") + ".xlsx"
103
- excel_path = os.path.join(tempfile.gettempdir(), result_file_name)
104
- df_to_save.to_excel(excel_path, index=False)
105
-
106
- print(f"[DEBUG] process_zip_with_api: excel_base64 length = {len(excel_base64)}")
107
- print(f"[DEBUG] process_zip_with_api: DataFrame columns = {list(df.columns)}")
108
-
109
- return (
110
- gr.update(value="✅ Xử lý thành công!"),
111
- api_duration,
112
- round(time.time() - start_time, 2),
113
- df,
114
- gr.File(value=excel_path, visible=True),
115
- excel_base64 # Return base64 để dùng cho /extract-products/
116
- )
117
-
118
- except requests.exceptions.Timeout:
119
- return (
120
- gr.update(value="⚠️ Request timeout - vui lòng thử lại!"),
121
- 0,
122
- round(time.time() - start_time, 2),
123
- pd.DataFrame(),
124
- gr.File(visible=False),
125
- ""
126
- )
127
- except Exception as e:
128
- return (
129
- gr.update(value=f"❌ Lỗi: {str(e)}"),
130
- 0,
131
- round(time.time() - start_time, 2),
132
- pd.DataFrame(),
133
- gr.File(visible=False),
134
- ""
135
- )
136
-
137
-
138
- def get_products_from_excel_api(excel_base64, product_column="Tên sản phẩm"):
139
- """
140
- Gọi API /extract-products/ để trích xuất sản phẩm từ Excel base64
141
- Workflow: Frontend gửi excel_base64 (từ state) → Backend decode + extract → Return product list
142
- """
143
- print(f"[DEBUG] get_products_from_excel_api: excel_base64 length = {len(excel_base64) if excel_base64 else 0}")
144
-
145
- if not excel_base64 or len(excel_base64) == 0:
146
- return "", "⚠️ Chưa có dữ liệu Excel! Hãy xử lý file ZIP trước."
147
-
148
- try:
149
- # Gọi API /extract-products/
150
- data = {
151
- 'excel_data_base64': excel_base64,
152
- 'product_column': product_column
153
- }
154
-
155
- response = requests.post(API_BASE_URL + "extract-products/", data=data, timeout=30)
156
- response.raise_for_status()
157
- json_resp = response.json()
158
-
159
- if json_resp.get("status") == "success":
160
- product_list = json_resp.get("product_list", [])
161
- total_products = json_resp.get("total_products", len(product_list))
162
- column_name = json_resp.get("column_name", product_column)
163
-
164
- print(f"[DEBUG] Extracted {total_products} products from column '{column_name}'")
165
- print(f"[DEBUG] Sample products: {product_list[:5] if len(product_list) > 5 else product_list}")
166
-
167
- products_text = "\n".join([str(p) for p in product_list])
168
- return products_text, f"✅ Đã lấy {total_products} sản phẩm từ cột '{column_name}'"
169
- else:
170
- error_msg = json_resp.get("detail", "Lỗi không xác định")
171
- return "", f"❌ Lỗi: {error_msg}"
172
-
173
- except requests.exceptions.HTTPError as e:
174
- # Parse error detail from response
175
- try:
176
- error_detail = e.response.json().get("detail", str(e))
177
- except:
178
- error_detail = str(e)
179
- return "", f"❌ Lỗi API: {error_detail}"
180
- except requests.exceptions.Timeout:
181
- return "", "⚠️ Request timeout - vui lòng thử lại!"
182
- except Exception as e:
183
- return "", f"❌ Lỗi: {str(e)}"
184
-
185
-
186
- def call_mapping_api(product_list, method, weight_value, rrf_k_value, excel_base64, use_prediction=False):
187
- """
188
- Gọi API /mapping/ với danh sách sản phẩm text.
189
-
190
- Args:
191
- product_list: text, mỗi dòng 1 sản phẩm
192
- use_prediction: bool, tự động predict L1/L2/L3
193
-
194
- Returns: status, time, df, file, top5_data
195
- """
196
- start_time = time.time()
197
-
198
- empty_return = (
199
- gr.update(value="⚠️ Vui lòng nhập danh sách sản phẩm trước khi mapping!"),
200
- 0,
201
- pd.DataFrame(),
202
- gr.File(visible=False),
203
- {},
204
- )
205
-
206
- if not product_list or not product_list.strip():
207
- return empty_return
208
-
209
- try:
210
- # Build request data
211
- data = {
212
- 'product_list': product_list.strip(),
213
- 'method': method if method else 'weighted',
214
- 'use_prediction': use_prediction,
215
- }
216
-
217
- if method == "weighted":
218
- data['dense_weight'] = weight_value
219
- data['sparse_weight'] = 1.0 - weight_value
220
- elif method == "rrf":
221
- data['rrf_k'] = int(rrf_k_value)
222
- elif method == "rrf_cross_encoder":
223
- data['rrf_k'] = int(rrf_k_value)
224
- else: # weighted_rrf
225
- data['dense_weight'] = weight_value
226
- data['sparse_weight'] = 1.0 - weight_value
227
- data['rrf_k'] = int(rrf_k_value)
228
-
229
- # Gửi Excel gốc để API merge kết quả mapping vào
230
- if excel_base64:
231
- data['excel_data_base64'] = excel_base64
232
- data['product_column'] = 'Tên sản phẩm'
233
-
234
- response = requests.post(API_BASE_URL + "mapping/", data=data, timeout=300)
235
- response.raise_for_status()
236
- json_resp = response.json()
237
-
238
- # Ưu tiên merged Excel (giữ nguyên format gốc + thêm cột "đã chọn")
239
- merged_b64 = json_resp.get("merged_excel_base64", "")
240
- mapping_b64 = json_resp.get("excel_data_base64", "")
241
-
242
- if merged_b64:
243
- excel_bytes = base64.b64decode(merged_b64)
244
- else:
245
- excel_bytes = base64.b64decode(mapping_b64)
246
-
247
- df = pd.read_excel(BytesIO(excel_bytes))
248
-
249
- # Build top5 data keyed by row index for floating menu
250
- top5_data = {}
251
- results = json_resp.get("results", [])
252
-
253
- # First build product_name → top5 mapping
254
- product_to_top5 = {}
255
- for r in results:
256
- name = r.get("Tên sản phẩm gốc", "").strip()
257
- top5 = [r.get(f"Top {i}", "") for i in range(1, 6)]
258
- top5 = [t for t in top5 if t]
259
- if name:
260
- product_to_top5[name.lower()] = top5
261
-
262
- # Then map to row indices in the DataFrame
263
- product_col = None
264
- for col in df.columns:
265
- col_lower = col.lower().strip()
266
- if col_lower == 'tên sản phẩm' or col_lower == 'ten san pham':
267
- product_col = col
268
- break
269
-
270
- if product_col:
271
- for i in range(len(df)):
272
- product_name = str(df.iloc[i].get(product_col, "")).strip()
273
- if product_name.lower() in product_to_top5:
274
- top5_data[i] = product_to_top5[product_name.lower()]
275
-
276
- # Save Excel file for download
277
- result_file_name = "mapping_result_" + datetime.now().strftime("%Y%m%d%H%M%S") + ".xlsx"
278
- excel_path = os.path.join(tempfile.gettempdir(), result_file_name)
279
- df.to_excel(excel_path, index=False)
280
-
281
- api_duration = json_resp.get("api_duration", round(time.time() - start_time, 2))
282
- total_products = json_resp.get("total_products", 0)
283
-
284
- return (
285
- gr.update(value=f"✅ Đã mapping {total_products} sản phẩm thành công!"),
286
- api_duration,
287
- df,
288
- gr.File(value=excel_path, visible=True),
289
- top5_data,
290
- )
291
-
292
- except requests.exceptions.Timeout:
293
- return (
294
- gr.update(value="⚠️ Request timeout - vui lòng thử lại sau!"),
295
- round(time.time() - start_time, 2),
296
- pd.DataFrame(),
297
- gr.File(visible=False),
298
- {},
299
- )
300
- except Exception as e:
301
- return (
302
- gr.update(value=f"❌ Lỗi: {str(e)}"),
303
- round(time.time() - start_time, 2),
304
- pd.DataFrame(),
305
- gr.File(visible=False),
306
- {},
307
- )
308
-
309
-
310
- def search_products_api(keyword):
311
- """
312
- Tìm kiếm sản phẩm như Ctrl+F - substring matching.
313
- Gọi API /search-products/ với keyword.
314
- """
315
- if not keyword or not keyword.strip():
316
- return gr.update(choices=[], value=None), "⚠️ Vui lòng nhập từ khóa tìm kiếm"
317
-
318
- try:
319
- data = {
320
- 'keyword': keyword.strip(),
321
- }
322
-
323
- response = requests.post(API_BASE_URL + "search-products/", data=data, timeout=30)
324
- response.raise_for_status()
325
- json_resp = response.json()
326
-
327
- if json_resp.get("status") == "success":
328
- product_list = json_resp.get("product_list", [])
329
- total = json_resp.get("total_results", len(product_list))
330
- return (
331
- gr.update(choices=product_list, value=None),
332
- f"✅ Tìm thấy {total} sản phẩm chứa '{keyword.strip()}'"
333
- )
334
- else:
335
- return gr.update(choices=[], value=None), "❌ Không tìm thấy kết quả"
336
-
337
- except requests.exceptions.Timeout:
338
- return gr.update(choices=[], value=None), "⚠️ Request timeout - vui lòng thử lại"
339
- except Exception as e:
340
- return gr.update(choices=[], value=None), f"❌ Lỗi: {str(e)}"
341
-
342
-
343
- def save_edited_excel(df_editable):
344
- """
345
- Lưu DataFrame đã chỉnh sửa thành file Excel.
346
- """
347
- if df_editable is None or df_editable.empty:
348
- return gr.File(visible=False), "⚠️ Không có dữ liệu để lưu!"
349
-
350
- try:
351
- # Tìm cột sản phẩm để lọc
352
- target_col = None
353
- for col in df_editable.columns:
354
- col_lower = col.lower().strip()
355
- if col_lower in ('tên sản phẩm', 'ten san pham'):
356
- target_col = col
357
- break
358
-
359
- # Lọc bỏ dòng "Không phải sản phẩm Rạng Đông"
360
- df_filtered = df_editable.copy()
361
- removed_count = 0
362
- if target_col:
363
- mask = df_editable[target_col] == not_rd_label
364
- removed_count = int(mask.sum())
365
- df_filtered = df_editable[~mask].copy()
366
- else:
367
- df_filtered = df_editable.copy()
368
- removed_count = 0
369
-
370
- result_file_name = "mapping_edited_" + datetime.now().strftime("%Y%m%d%H%M%S") + ".xlsx"
371
- excel_path = os.path.join(tempfile.gettempdir(), result_file_name)
372
- df_filtered.to_excel(excel_path, index=False)
373
-
374
- if removed_count > 0:
375
- return gr.File(value=excel_path, visible=True), f"✅ Đã lưu file Excel! (Đã xóa {removed_count} dòng 'Không phải sản phẩm Rạng Đông')"
376
- return gr.File(value=excel_path, visible=True), "✅ Đã lưu file Excel!"
377
- except Exception as e:
378
- return gr.File(visible=False), f"❌ Lỗi lưu file: {str(e)}"
379
-
380
-
381
- def on_cell_select(top5_data, df, evt: gr.SelectData):
382
- """Khi user click vào ô trong bảng mapping → hiện floating menu nếu là cột sản phẩm"""
383
- if df is None or (isinstance(df, pd.DataFrame) and df.empty):
384
- return gr.update(visible=False), "", gr.update(choices=[], value=None), None, ""
385
-
386
- row_idx = evt.index[0]
387
- col_idx = evt.index[1]
388
-
389
- # Tìm cột sản phẩm
390
- product_col_idx = None
391
- product_col_name = None
392
- for i, col in enumerate(df.columns):
393
- col_lower = col.lower().strip()
394
- if col_lower == 'tên sản phẩm' or col_lower == 'ten san pham':
395
- product_col_idx = i
396
- product_col_name = col
397
- break
398
-
399
- # Chỉ hiện floating menu khi click vào cột sản phẩm
400
- if product_col_idx is None or col_idx != product_col_idx:
401
- return gr.update(visible=False), "", gr.update(choices=[], value=None), None, ""
402
- current_value = str(df.iloc[row_idx][product_col_name])
403
-
404
- # Gradio Serialize JSON nên array index (int) có thể bị parse thành kiểu string trong dictionary
405
- top5 = []
406
- if isinstance(top5_data, dict):
407
- top5 = top5_data.get(row_idx, top5_data.get(str(row_idx), []))
408
-
409
- if not top5:
410
- return (
411
- gr.update(visible=True),
412
- f"📝 Dòng {row_idx+1} - SP hiện tại: {current_value}",
413
- gr.update(choices=["Không có đề xuất", not_rd_label], value=None),
414
- row_idx,
415
- current_value
416
- )
417
-
418
- # Thêm option "Không phải sản phẩm Rạng Đông" vào cuối danh sách
419
- choices_with_not_rd = top5 + [not_rd_label]
420
- return (
421
- gr.update(visible=True),
422
- f"📝 Dòng {row_idx+1} - SP hiện tại: {current_value}",
423
- gr.update(choices=choices_with_not_rd, value=None),
424
- row_idx,
425
- current_value
426
- )
427
-
428
-
429
- def apply_product(df, selected_row_idx, selected_product, undo_history):
430
- """Áp dụng sản phẩm đã chọn vào cột sản phẩm tại dòng đã click"""
431
- if df is None or (isinstance(df, pd.DataFrame) and df.empty):
432
- return df, undo_history, "⚠️ Không có dữ liệu"
433
-
434
- if selected_row_idx is None or selected_product is None:
435
- return df, undo_history, "⚠️ Vui lòng click vào ô sản phẩm và chọn đề xuất"
436
-
437
- if selected_product in ["Không có đề xuất"]:
438
- return df, undo_history, "⚠️ Sản phẩm không hợp lệ"
439
-
440
- row_idx = selected_row_idx
441
- if row_idx < 0 or row_idx >= len(df):
442
- return df, undo_history, "❌ Dòng không hợp lệ"
443
-
444
- # Tìm cột sản phẩm
445
- target_col = None
446
- for col in df.columns:
447
- col_lower = col.lower().strip()
448
- if col_lower == 'tên sản phẩm' or col_lower == 'ten san pham':
449
- target_col = col
450
- break
451
-
452
- if target_col is None:
453
- return df, undo_history, "❌ Không tìm thấy cột sản phẩm"
454
-
455
- # Lưu undo history
456
- old_value = str(df.iloc[row_idx][target_col])
457
- if undo_history is None:
458
- undo_history = []
459
- undo_history = list(undo_history) # Copy to avoid mutation
460
- undo_history.append({
461
- 'row': row_idx,
462
- 'col': target_col,
463
- 'old_value': old_value
464
- })
465
-
466
- # Áp dụng giá trị mới
467
- df_updated = df.copy()
468
- df_updated.at[row_idx, target_col] = selected_product
469
-
470
- return df_updated, undo_history, f"✅ Dòng {row_idx+1}: '{old_value}' → '{selected_product}'"
471
-
472
-
473
- def undo_change(df, undo_history):
474
- """Hoàn tác thay đổi cuối cùng"""
475
- if df is None or (isinstance(df, pd.DataFrame) and df.empty):
476
- return df, undo_history, "⚠️ Không có dữ liệu"
477
-
478
- if not undo_history or len(undo_history) == 0:
479
- return df, undo_history, "⚠️ Không có thay đổi nào để hoàn tác"
480
-
481
- undo_history = list(undo_history) # Copy
482
- last_change = undo_history.pop()
483
- row = last_change['row']
484
- col = last_change['col']
485
- old_value = last_change['old_value']
486
-
487
- df_updated = df.copy()
488
- current_value = str(df_updated.iloc[row][col])
489
- df_updated.at[row, col] = old_value
490
-
491
- remaining = len(undo_history)
492
- return df_updated, undo_history, f"↩ Dòng {row+1}: '{current_value}' → '{old_value}' (còn {remaining} thay đổi có thể hoàn tác)"
493
-
494
-
495
- # ====================== GRADIO UI ======================
496
-
497
- with gr.Blocks(title="NHẬP ĐƠN HÀNG ĐA PHƯƠNG THỨC") as demo:
498
-
499
- # State variables
500
- excel_base64_state = gr.State("") # Lưu excel_data_base64 từ /information_extraction/
501
- top5_state = gr.State({}) # Lưu top 5 đề xuất cho mỗi sản phẩm (key: row index)
502
- undo_history_state = gr.State([]) # Lưu lịch sử thay đổi để undo
503
- selected_row_state = gr.State(None) # Lưu row index đang được chọn từ DataFrame click
504
-
505
- # ====================== HEADER ======================
506
- with gr.Row():
507
- with gr.Column(scale=0, min_width=120):
508
- gr.Image(
509
- value="logo_multimodal_invoice.jpg",
510
- show_label=False,
511
- elem_id="logo",
512
- height=100,
513
- width=100,
514
- container=False,
515
- )
516
- with gr.Column():
517
- gr.Markdown(
518
- """
519
- <div style='text-align: center;'>
520
- <h1 style='margin-bottom: 0.5em; font-size: 1.8em;'>📄 NHẬP ĐƠN HÀNG ĐA PHƯƠNG THỨC</h1>
521
- </div>
522
- """,
523
- elem_id="main-title"
524
- )
525
-
526
- gr.Markdown("---")
527
-
528
- # ====================== SECTION 1: MÃ NHÂN VIÊN - LLM MODEL ======================
529
- with gr.Row(equal_height=True):
530
- employee_code_input = gr.Textbox(
531
- label="Mã nhân viên",
532
- placeholder="Nhập mã nhân viên",
533
- scale=1
534
- )
535
- llm_model_input = gr.Dropdown(
536
- ["Gemini 2.5 Flash", "Gemini 2.5 Flash-Lite"],
537
- value="Gemini 2.5 Flash",
538
- label="Mô hình đa phương thức",
539
- scale=1,
540
- interactive=True
541
- )
542
-
543
- # ====================== SECTION 2: OCR + TRẠNG THÁI + CẤU HÌNH ======================
544
- with gr.Row(equal_height=True):
545
- # Cột 1: Upload + Xử lý
546
- with gr.Column(scale=1):
547
- uploaded_files = gr.File(
548
- label="Tải lên file ZIP",
549
- file_count="single",
550
- file_types=[".zip"],
551
- type="filepath"
552
- )
553
- process_btn = gr.Button("⚙️ Xử lý đơn hàng", variant="secondary")
554
- progress = gr.Textbox(label="Trạng thái xử lý", interactive=False)
555
-
556
- # Cột 2: Thông tin tiến trình
557
- with gr.Column(scale=1):
558
- current_file_text = gr.Textbox(label="Đang xử lý file", interactive=False)
559
- file_time_output = gr.Textbox(label="Thời gian xử lý file", interactive=False, lines=4)
560
- total_time_output = gr.Number(label="Tổng thời gian xử lý (giây)", interactive=False)
561
-
562
- # Cột 3: Cấu hình tìm kiếm + Mapping
563
- with gr.Column(scale=2):
564
- gr.Markdown("#### ⚙️ Cấu hình tìm kiếm")
565
- search_method = gr.Radio(
566
- choices=["weighted", "rrf", "weighted_rrf", "rrf_cross_encoder"],
567
- value="weighted",
568
- label="Phương pháp Fusion",
569
- info="Weighted: trọng số điểm | RRF: xếp hạng đảo | Weighted RRF: kết hợp | RRF+CE: rerank"
570
- )
571
- with gr.Group(visible=True) as weighted_config:
572
- weight_slider = gr.Slider(
573
- minimum=0, maximum=1, value=0.7, step=0.1,
574
- label="Dense ↔ Sparse",
575
- info="Kéo trái → ưu tiên Sparse (từ khóa) | Kéo phải → ưu tiên Dense (ngữ nghĩa)"
576
- )
577
- weight_display = gr.Markdown(value="**Dense: 0.7** | **Sparse: 0.3**")
578
- with gr.Group(visible=False) as rrf_config:
579
- rrf_k_slider = gr.Slider(
580
- minimum=30, maximum=100, value=60, step=30,
581
- label="RRF K (hằng số làm mượt)",
582
- info="30: nhạy | 60: cân bằng | 100: ổn định"
583
- )
584
- use_prediction_checkbox = gr.Checkbox(
585
- label="🤖 Tự động dự đoán danh mục (LLM Prediction)",
586
- value=False,
587
- info="Khi bật: tự động predict L1/L2/L3 cho mỗi sản phẩm qua LLM → filter search"
588
- )
589
- with gr.Row():
590
- mapping_status = gr.Textbox(label="Trạng thái mapping", interactive=False, scale=2)
591
- mapping_time = gr.Number(label="Thời gian (giây)", interactive=False, scale=1)
592
- mapping_btn = gr.Button("🚀 BẮT ĐẦU MAPPING", variant="primary", size="lg")
593
-
594
- # Hidden state: danh sách sản phẩm từ OCR
595
- product_list = gr.Textbox(visible=True)
596
-
597
- excel_download = gr.File(
598
- label="Tải file Excel kết quả xử lý",
599
- interactive=False,
600
- visible=False
601
- )
602
-
603
- ocr_result = gr.Dataframe(
604
- label="Kết quả nhận diện (OCR)",
605
- wrap=True,
606
- interactive=False
607
- )
608
-
609
- gr.Markdown("---")
610
-
611
- # ====================== SECTION 3: KẾT QUẢ MAPPING ======================
612
- gr.Markdown("### 📊 Kết quả Mapping")
613
- gr.Markdown("*Click vào ô **Tên sản phẩm** để xem Top 5 đề xuất → Chọn sản phẩm → Áp dụng*")
614
-
615
- mapping_result = gr.Dataframe(
616
- label="Kết quả Mapping (click vào ô sản phẩm để thay thế)",
617
- wrap=True,
618
- interactive=True
619
- )
620
-
621
- # ====================== FLOATING MENU - TOP 5 ĐỀ XUẤT ======================
622
- with gr.Group(visible=False) as floating_panel:
623
- gr.Markdown("#### 🔄 Chọn sản phẩm thay thế")
624
- floating_product_display = gr.Textbox(
625
- label="Sản phẩm đang chọn",
626
- interactive=False
627
- )
628
- floating_top5_radio = gr.Radio(
629
- label="Top 5 sản phẩm đề xuất (chọn 1 để thay thế)",
630
- choices=[],
631
- interactive=True
632
- )
633
- with gr.Row():
634
- floating_apply_btn = gr.Button("✅ Áp dụng", variant="primary", scale=1)
635
- floating_undo_btn = gr.Button("↩ Hoàn tác", variant="secondary", scale=1)
636
- floating_status = gr.Textbox(label="Trạng thái", interactive=False, scale=3)
637
-
638
- gr.Markdown("---")
639
-
640
- # ====================== TÌM KIẾM MỞ RỘNG ======================
641
- with gr.Accordion("🔍 Tìm kiếm mở rộng (nếu Top 5 kh��ng phù hợp)", open=False):
642
- gr.Markdown("*Tìm kiếm như Ctrl+F trong danh sách sản phẩm → chọn sản phẩm → áp dụng vào dòng đã click ở bảng trên*")
643
-
644
- with gr.Row():
645
- search_keyword = gr.Textbox(
646
- label="Từ khóa tìm kiếm (tự động cập nhật khi click vào ô sản phẩm)",
647
- placeholder="Click vào ô sản phẩm ở bảng trên hoặc nhập từ khóa...",
648
- interactive=True,
649
- scale=2
650
- )
651
- search_btn = gr.Button("🔍 Tìm kiếm", variant="secondary", scale=1)
652
-
653
- search_status = gr.Textbox(label="Trạng thái tìm kiếm", interactive=False)
654
-
655
- search_results = gr.Dropdown(
656
- label="Kết quả tìm kiếm (chọn sản phẩm)",
657
- choices=[],
658
- interactive=True,
659
- allow_custom_value=True
660
- )
661
-
662
- apply_search_btn = gr.Button("✅ Áp dụng sản phẩm từ tìm kiếm", variant="primary")
663
-
664
- gr.Markdown("---")
665
-
666
- # ====================== TẢI FILE EXCEL KẾT QUẢ ======================
667
- with gr.Row():
668
- save_btn = gr.Button("💾 Lưu Excel đã chỉnh sửa", variant="primary")
669
- save_status = gr.Textbox(label="Trạng thái lưu", interactive=False, scale=2)
670
-
671
- mapping_download = gr.File(
672
- label="Tải file Excel kết quả",
673
- interactive=False,
674
- visible=False
675
- )
676
-
677
- # ====================== EVENT HANDLERS ======================
678
-
679
- # Xử lý file ZIP → hiện kết quả OCR → cập nhật product_list
680
- process_btn.click(
681
- fn=process_zip_with_api,
682
- inputs=[employee_code_input, uploaded_files, llm_model_input],
683
- outputs=[progress, file_time_output, total_time_output, ocr_result, excel_download, excel_base64_state]
684
- ).then(
685
- fn=lambda excel_base64: get_products_from_excel_api(excel_base64, "Tên sản phẩm"),
686
- inputs=[excel_base64_state],
687
- outputs=[product_list, current_file_text]
688
- )
689
-
690
- # Mapping sản phẩm
691
- mapping_btn.click(
692
- fn=call_mapping_api,
693
- inputs=[product_list, search_method, weight_slider, rrf_k_slider, excel_base64_state, use_prediction_checkbox],
694
- outputs=[mapping_status, mapping_time, mapping_result, mapping_download, top5_state]
695
- )
696
-
697
- # Click vào ô sản phẩm → hiện floating menu + cập nhật ô tìm kiếm
698
- mapping_result.select(
699
- fn=on_cell_select,
700
- inputs=[top5_state, mapping_result],
701
- outputs=[floating_panel, floating_product_display, floating_top5_radio, selected_row_state, search_keyword]
702
- )
703
-
704
- # Áp dụng sản phẩm đã chọn từ Top 5
705
- floating_apply_btn.click(
706
- fn=apply_product,
707
- inputs=[mapping_result, selected_row_state, floating_top5_radio, undo_history_state],
708
- outputs=[mapping_result, undo_history_state, floating_status]
709
- )
710
-
711
- # Hoàn tác thay đổi cuối cùng
712
- floating_undo_btn.click(
713
- fn=undo_change,
714
- inputs=[mapping_result, undo_history_state],
715
- outputs=[mapping_result, undo_history_state, floating_status]
716
- )
717
-
718
- # Tìm kiếm sản phẩm mở rộng
719
- search_btn.click(
720
- fn=search_products_api,
721
- inputs=[search_keyword],
722
- outputs=[search_results, search_status]
723
- )
724
-
725
- # Áp dụng sản phẩm từ tìm kiếm mở rộng
726
- apply_search_btn.click(
727
- fn=apply_product,
728
- inputs=[mapping_result, selected_row_state, search_results, undo_history_state],
729
- outputs=[mapping_result, undo_history_state, floating_status]
730
- )
731
-
732
- # Dynamic UI: Show/hide config based on method selection
733
- def update_method_visibility(method):
734
- if method == "weighted":
735
- return gr.update(visible=True), gr.update(visible=False)
736
- elif method == "rrf":
737
- return gr.update(visible=False), gr.update(visible=True)
738
- elif method == "rrf_cross_encoder":
739
- return gr.update(visible=False), gr.update(visible=True)
740
- else: # weighted_rrf: hiện cả hai
741
- return gr.update(visible=True), gr.update(visible=True)
742
-
743
- search_method.change(
744
- fn=update_method_visibility,
745
- inputs=[search_method],
746
- outputs=[weighted_config, rrf_config]
747
- )
748
-
749
- # Update weight display when slider changes
750
- def update_weight_display(value):
751
- dense = round(value, 1)
752
- sparse = round(1.0 - value, 1)
753
- return f"**Dense: {dense}** | **Sparse: {sparse}**"
754
-
755
- weight_slider.change(
756
- fn=update_weight_display,
757
- inputs=[weight_slider],
758
- outputs=[weight_display]
759
- )
760
-
761
- # Lưu Excel đã chỉnh sửa
762
- save_btn.click(
763
- fn=save_edited_excel,
764
- inputs=[mapping_result],
765
- outputs=[mapping_download, save_status]
766
- )
767
-
768
-
769
- if __name__ == "__main__":
770
  demo.launch(inbrowser=True, share=False)
 
1
+ import gradio as gr
2
+ import requests
3
+ import tempfile
4
+ import os
5
+ import base64
6
+ import pandas as pd
7
+ from io import BytesIO
8
+ import time
9
+ from datetime import datetime
10
+ import zipfile
11
+
12
+ API_BASE_URL = "https://gradio-ocr-audio-demo-i7u7.onrender.com/"
13
+
14
+ # API_BASE_URL = "http://127.0.0.1:8000/"
15
+
16
+
17
+ # Global variable để lưu danh sách tất cả sản phẩm từ search
18
+ all_search_results = {}
19
+
20
+ # Label cho option "Không phải sản phẩm Rạng Đông"
21
+ not_rd_label = "Không phải sản phẩm Rạng Đông"
22
+
23
+
24
+ custom_css = """
25
+ h1 {
26
+ font-family: 'Segoe UI', sans-serif;
27
+ font-weight: 700;
28
+ font-size: 2.5rem;
29
+ }
30
+ label, .gr-input, .gr-file {
31
+ font-family: 'Segoe UI', sans-serif;
32
+ font-size: 1rem;
33
+ }
34
+ .gr-button {
35
+ font-weight: bold;
36
+ background-color: #4CAF50;
37
+ color: white;
38
+ border-radius: 8px;
39
+ padding: 10px 16px;
40
+ }
41
+ .gr-button:hover {
42
+ background-color: #45a049;
43
+ }
44
+ body {
45
+ background-color: #f8f9fa;
46
+ }
47
+ .section-box {
48
+ border: 1px solid #e0e0e0;
49
+ border-radius: 10px;
50
+ padding: 15px;
51
+ margin: 10px 0;
52
+ background-color: #fafafa;
53
+ }
54
+ """
55
+
56
+
57
+ # ====================== FUNCTIONS ======================
58
+
59
+ def process_zip_with_api(employee_code, zip_file, llm_model):
60
+ """
61
+ Xử lý file ZIP chứa ảnh hóa đơn - giống tab nhập đơn hàng
62
+ Returns: status, api_time, total_time, df, excel_file, excel_base64
63
+ """
64
+ if not zip_file:
65
+ return (
66
+ gr.update(value="⚠️ Vui lòng tải lên file ZIP!"),
67
+ 0,
68
+ 0,
69
+ pd.DataFrame(),
70
+ gr.File(visible=False),
71
+ "" # excel_base64 empty
72
+ )
73
+
74
+ start_time = time.time()
75
+
76
+ try:
77
+ # Handle both string path and file object from Gradio
78
+ file_path = zip_file.name if hasattr(zip_file, 'name') else zip_file
79
+ with open(file_path, "rb") as f:
80
+ files = {'file': (os.path.basename(file_path), f.read(), 'application/zip')}
81
+
82
+ data = {
83
+ 'employee_code': employee_code or "default",
84
+ 'approach': "multimodal",
85
+ 'llm_model': llm_model,
86
+ 'audio_model': llm_model
87
+ }
88
+
89
+ response = requests.post(API_BASE_URL + "information_extraction/", files=files, data=data, timeout=600)
90
+ response.raise_for_status()
91
+ json_resp = response.json()
92
+
93
+ # Lưu excel_base64 để dùng cho endpoint /extract-products/
94
+ excel_base64 = json_resp.get("excel_data_base64", "")
95
+
96
+ excel_bytes = base64.b64decode(excel_base64)
97
+ api_duration = json_resp.get("api_duration", 0.)
98
+ df = pd.read_excel(BytesIO(excel_bytes))
99
+
100
+ # Lưu file Excel để tải về
101
+ df_to_save = df
102
+ result_file_name = "result_" + datetime.now().strftime("%Y%m%d%H%M%S") + ".xlsx"
103
+ excel_path = os.path.join(tempfile.gettempdir(), result_file_name)
104
+ df_to_save.to_excel(excel_path, index=False)
105
+
106
+ print(f"[DEBUG] process_zip_with_api: excel_base64 length = {len(excel_base64)}")
107
+ print(f"[DEBUG] process_zip_with_api: DataFrame columns = {list(df.columns)}")
108
+
109
+ return (
110
+ gr.update(value="✅ Xử lý thành công!"),
111
+ api_duration,
112
+ round(time.time() - start_time, 2),
113
+ df,
114
+ gr.File(value=excel_path, visible=True),
115
+ excel_base64 # Return base64 để dùng cho /extract-products/
116
+ )
117
+
118
+ except requests.exceptions.Timeout:
119
+ return (
120
+ gr.update(value="⚠️ Request timeout - vui lòng thử lại!"),
121
+ 0,
122
+ round(time.time() - start_time, 2),
123
+ pd.DataFrame(),
124
+ gr.File(visible=False),
125
+ ""
126
+ )
127
+ except Exception as e:
128
+ return (
129
+ gr.update(value=f"❌ Lỗi: {str(e)}"),
130
+ 0,
131
+ round(time.time() - start_time, 2),
132
+ pd.DataFrame(),
133
+ gr.File(visible=False),
134
+ ""
135
+ )
136
+
137
+
138
+ def get_products_from_excel_api(excel_base64, product_column="Tên sản phẩm"):
139
+ """
140
+ Gọi API /extract-products/ để trích xuất sản phẩm từ Excel base64
141
+ Workflow: Frontend gửi excel_base64 (từ state) → Backend decode + extract → Return product list
142
+ """
143
+ print(f"[DEBUG] get_products_from_excel_api: excel_base64 length = {len(excel_base64) if excel_base64 else 0}")
144
+
145
+ if not excel_base64 or len(excel_base64) == 0:
146
+ return "", "⚠️ Chưa có dữ liệu Excel! Hãy xử lý file ZIP trước."
147
+
148
+ try:
149
+ # Gọi API /extract-products/
150
+ data = {
151
+ 'excel_data_base64': excel_base64,
152
+ 'product_column': product_column
153
+ }
154
+
155
+ response = requests.post(API_BASE_URL + "extract-products/", data=data, timeout=30)
156
+ response.raise_for_status()
157
+ json_resp = response.json()
158
+
159
+ if json_resp.get("status") == "success":
160
+ product_list = json_resp.get("product_list", [])
161
+ total_products = json_resp.get("total_products", len(product_list))
162
+ column_name = json_resp.get("column_name", product_column)
163
+
164
+ print(f"[DEBUG] Extracted {total_products} products from column '{column_name}'")
165
+ print(f"[DEBUG] Sample products: {product_list[:5] if len(product_list) > 5 else product_list}")
166
+
167
+ products_text = "\n".join([str(p) for p in product_list])
168
+ return products_text, f"✅ Đã lấy {total_products} sản phẩm từ cột '{column_name}'"
169
+ else:
170
+ error_msg = json_resp.get("detail", "Lỗi không xác định")
171
+ return "", f"❌ Lỗi: {error_msg}"
172
+
173
+ except requests.exceptions.HTTPError as e:
174
+ # Parse error detail from response
175
+ try:
176
+ error_detail = e.response.json().get("detail", str(e))
177
+ except:
178
+ error_detail = str(e)
179
+ return "", f"❌ Lỗi API: {error_detail}"
180
+ except requests.exceptions.Timeout:
181
+ return "", "⚠️ Request timeout - vui lòng thử lại!"
182
+ except Exception as e:
183
+ return "", f"❌ Lỗi: {str(e)}"
184
+
185
+
186
+ def call_mapping_api(product_list, method, weight_value, rrf_k_value, excel_base64, use_prediction=False):
187
+ """
188
+ Gọi API /mapping/ với danh sách sản phẩm text.
189
+
190
+ Args:
191
+ product_list: text, mỗi dòng 1 sản phẩm
192
+ use_prediction: bool, tự động predict L1/L2/L3
193
+
194
+ Returns: status, time, df, file, top5_data
195
+ """
196
+ start_time = time.time()
197
+
198
+ empty_return = (
199
+ gr.update(value="⚠️ Vui lòng nhập danh sách sản phẩm trước khi mapping!"),
200
+ 0,
201
+ pd.DataFrame(),
202
+ gr.File(visible=False),
203
+ {},
204
+ )
205
+
206
+ if not product_list or not product_list.strip():
207
+ return empty_return
208
+
209
+ try:
210
+ # Build request data
211
+ data = {
212
+ 'product_list': product_list.strip(),
213
+ 'method': method if method else 'weighted',
214
+ 'use_prediction': use_prediction,
215
+ }
216
+
217
+ if method == "weighted":
218
+ data['dense_weight'] = weight_value
219
+ data['sparse_weight'] = 1.0 - weight_value
220
+ elif method == "rrf":
221
+ data['rrf_k'] = int(rrf_k_value)
222
+ elif method == "rrf_cross_encoder":
223
+ data['rrf_k'] = int(rrf_k_value)
224
+ else: # weighted_rrf
225
+ data['dense_weight'] = weight_value
226
+ data['sparse_weight'] = 1.0 - weight_value
227
+ data['rrf_k'] = int(rrf_k_value)
228
+
229
+ # Gửi Excel gốc để API merge kết quả mapping vào
230
+ if excel_base64:
231
+ data['excel_data_base64'] = excel_base64
232
+ data['product_column'] = 'Tên sản phẩm'
233
+
234
+ response = requests.post(API_BASE_URL + "mapping/", data=data, timeout=300)
235
+ response.raise_for_status()
236
+ json_resp = response.json()
237
+
238
+ # Ưu tiên merged Excel (giữ nguyên format gốc + thêm cột "đã chọn")
239
+ merged_b64 = json_resp.get("merged_excel_base64", "")
240
+ mapping_b64 = json_resp.get("excel_data_base64", "")
241
+
242
+ if merged_b64:
243
+ excel_bytes = base64.b64decode(merged_b64)
244
+ else:
245
+ excel_bytes = base64.b64decode(mapping_b64)
246
+
247
+ df = pd.read_excel(BytesIO(excel_bytes))
248
+
249
+ # Build top5 data keyed by row index for floating menu
250
+ top5_data = {}
251
+ results = json_resp.get("results", [])
252
+
253
+ # First build product_name → top5 mapping
254
+ product_to_top5 = {}
255
+ for r in results:
256
+ name = r.get("Tên sản phẩm gốc", "").strip()
257
+ top5 = [r.get(f"Top {i}", "") for i in range(1, 6)]
258
+ top5 = [t for t in top5 if t]
259
+ if name:
260
+ product_to_top5[name.lower()] = top5
261
+
262
+ # Then map to row indices in the DataFrame
263
+ product_col = None
264
+ for col in df.columns:
265
+ col_lower = col.lower().strip()
266
+ if col_lower == 'tên sản phẩm' or col_lower == 'ten san pham':
267
+ product_col = col
268
+ break
269
+
270
+ if product_col:
271
+ for i in range(len(df)):
272
+ product_name = str(df.iloc[i].get(product_col, "")).strip()
273
+ if product_name.lower() in product_to_top5:
274
+ top5_data[i] = product_to_top5[product_name.lower()]
275
+
276
+ # Save Excel file for download
277
+ result_file_name = "mapping_result_" + datetime.now().strftime("%Y%m%d%H%M%S") + ".xlsx"
278
+ excel_path = os.path.join(tempfile.gettempdir(), result_file_name)
279
+ df.to_excel(excel_path, index=False)
280
+
281
+ api_duration = json_resp.get("api_duration", round(time.time() - start_time, 2))
282
+ total_products = json_resp.get("total_products", 0)
283
+
284
+ return (
285
+ gr.update(value=f"✅ Đã mapping {total_products} sản phẩm thành công!"),
286
+ api_duration,
287
+ df,
288
+ gr.File(value=excel_path, visible=True),
289
+ top5_data,
290
+ )
291
+
292
+ except requests.exceptions.Timeout:
293
+ return (
294
+ gr.update(value="⚠️ Request timeout - vui lòng thử lại sau!"),
295
+ round(time.time() - start_time, 2),
296
+ pd.DataFrame(),
297
+ gr.File(visible=False),
298
+ {},
299
+ )
300
+ except Exception as e:
301
+ return (
302
+ gr.update(value=f"❌ Lỗi: {str(e)}"),
303
+ round(time.time() - start_time, 2),
304
+ pd.DataFrame(),
305
+ gr.File(visible=False),
306
+ {},
307
+ )
308
+
309
+
310
+ def search_products_api(keyword):
311
+ """
312
+ Tìm kiếm sản phẩm như Ctrl+F - substring matching.
313
+ Gọi API /search-products/ với keyword.
314
+ """
315
+ if not keyword or not keyword.strip():
316
+ return gr.update(choices=[], value=None), "⚠️ Vui lòng nhập từ khóa tìm kiếm"
317
+
318
+ try:
319
+ data = {
320
+ 'keyword': keyword.strip(),
321
+ }
322
+
323
+ response = requests.post(API_BASE_URL + "search-products/", data=data, timeout=30)
324
+ response.raise_for_status()
325
+ json_resp = response.json()
326
+
327
+ if json_resp.get("status") == "success":
328
+ product_list = json_resp.get("product_list", [])
329
+ total = json_resp.get("total_results", len(product_list))
330
+ return (
331
+ gr.update(choices=product_list, value=None),
332
+ f"✅ Tìm thấy {total} sản phẩm chứa '{keyword.strip()}'"
333
+ )
334
+ else:
335
+ return gr.update(choices=[], value=None), "❌ Không tìm thấy kết quả"
336
+
337
+ except requests.exceptions.Timeout:
338
+ return gr.update(choices=[], value=None), "⚠️ Request timeout - vui lòng thử lại"
339
+ except Exception as e:
340
+ return gr.update(choices=[], value=None), f"❌ Lỗi: {str(e)}"
341
+
342
+
343
+ def save_edited_excel(df_editable):
344
+ """
345
+ Lưu DataFrame đã chỉnh sửa thành file Excel.
346
+ """
347
+ if df_editable is None or df_editable.empty:
348
+ return gr.File(visible=False), "⚠️ Không có dữ liệu để lưu!"
349
+
350
+ try:
351
+ # Tìm cột sản phẩm để lọc
352
+ target_col = None
353
+ for col in df_editable.columns:
354
+ col_lower = col.lower().strip()
355
+ if col_lower in ('tên sản phẩm', 'ten san pham'):
356
+ target_col = col
357
+ break
358
+
359
+ # Lọc bỏ dòng "Không phải sản phẩm Rạng Đông"
360
+ df_filtered = df_editable.copy()
361
+ removed_count = 0
362
+ if target_col:
363
+ mask = df_editable[target_col] == not_rd_label
364
+ removed_count = int(mask.sum())
365
+ df_filtered = df_editable[~mask].copy()
366
+ else:
367
+ df_filtered = df_editable.copy()
368
+ removed_count = 0
369
+
370
+ result_file_name = "mapping_edited_" + datetime.now().strftime("%Y%m%d%H%M%S") + ".xlsx"
371
+ excel_path = os.path.join(tempfile.gettempdir(), result_file_name)
372
+ df_filtered.to_excel(excel_path, index=False)
373
+
374
+ if removed_count > 0:
375
+ return gr.File(value=excel_path, visible=True), f"✅ Đã lưu file Excel! (Đã xóa {removed_count} dòng 'Không phải sản phẩm Rạng Đông')"
376
+ return gr.File(value=excel_path, visible=True), "✅ Đã lưu file Excel!"
377
+ except Exception as e:
378
+ return gr.File(visible=False), f"❌ Lỗi lưu file: {str(e)}"
379
+
380
+
381
+ def on_cell_select(top5_data, df, evt: gr.SelectData):
382
+ """Khi user click vào ô trong bảng mapping → hiện floating menu nếu là cột sản phẩm"""
383
+ if df is None or (isinstance(df, pd.DataFrame) and df.empty):
384
+ return gr.update(visible=False), "", gr.update(choices=[], value=None), None, ""
385
+
386
+ row_idx = evt.index[0]
387
+ col_idx = evt.index[1]
388
+
389
+ # Tìm cột sản phẩm
390
+ product_col_idx = None
391
+ product_col_name = None
392
+ for i, col in enumerate(df.columns):
393
+ col_lower = col.lower().strip()
394
+ if col_lower == 'tên sản phẩm' or col_lower == 'ten san pham':
395
+ product_col_idx = i
396
+ product_col_name = col
397
+ break
398
+
399
+ # Chỉ hiện floating menu khi click vào cột sản phẩm
400
+ if product_col_idx is None or col_idx != product_col_idx:
401
+ return gr.update(visible=False), "", gr.update(choices=[], value=None), None, ""
402
+ current_value = str(df.iloc[row_idx][product_col_name])
403
+
404
+ # Gradio Serialize JSON nên array index (int) có thể bị parse thành kiểu string trong dictionary
405
+ top5 = []
406
+ if isinstance(top5_data, dict):
407
+ top5 = top5_data.get(row_idx, top5_data.get(str(row_idx), []))
408
+
409
+ if not top5:
410
+ return (
411
+ gr.update(visible=True),
412
+ f"📝 Dòng {row_idx+1} - SP hiện tại: {current_value}",
413
+ gr.update(choices=["Không có đề xuất", not_rd_label], value=None),
414
+ row_idx,
415
+ current_value
416
+ )
417
+
418
+ # Thêm option "Không phải sản phẩm Rạng Đông" vào cuối danh sách
419
+ choices_with_not_rd = top5 + [not_rd_label]
420
+ return (
421
+ gr.update(visible=True),
422
+ f"📝 Dòng {row_idx+1} - SP hiện tại: {current_value}",
423
+ gr.update(choices=choices_with_not_rd, value=None),
424
+ row_idx,
425
+ current_value
426
+ )
427
+
428
+
429
+ def apply_product(df, selected_row_idx, selected_product, undo_history):
430
+ """Áp dụng sản phẩm đã chọn vào cột sản phẩm tại dòng đã click"""
431
+ if df is None or (isinstance(df, pd.DataFrame) and df.empty):
432
+ return df, undo_history, "⚠️ Không có dữ liệu"
433
+
434
+ if selected_row_idx is None or selected_product is None:
435
+ return df, undo_history, "⚠️ Vui lòng click vào ô sản phẩm và chọn đề xuất"
436
+
437
+ if selected_product in ["Không có đề xuất"]:
438
+ return df, undo_history, "⚠️ Sản phẩm không hợp lệ"
439
+
440
+ row_idx = selected_row_idx
441
+ if row_idx < 0 or row_idx >= len(df):
442
+ return df, undo_history, "❌ Dòng không hợp lệ"
443
+
444
+ # Tìm cột sản phẩm
445
+ target_col = None
446
+ for col in df.columns:
447
+ col_lower = col.lower().strip()
448
+ if col_lower == 'tên sản phẩm' or col_lower == 'ten san pham':
449
+ target_col = col
450
+ break
451
+
452
+ if target_col is None:
453
+ return df, undo_history, "❌ Không tìm thấy cột sản phẩm"
454
+
455
+ # Lưu undo history
456
+ old_value = str(df.iloc[row_idx][target_col])
457
+ if undo_history is None:
458
+ undo_history = []
459
+ undo_history = list(undo_history) # Copy to avoid mutation
460
+ undo_history.append({
461
+ 'row': row_idx,
462
+ 'col': target_col,
463
+ 'old_value': old_value
464
+ })
465
+
466
+ # Áp dụng giá trị mới
467
+ df_updated = df.copy()
468
+ df_updated.at[row_idx, target_col] = selected_product
469
+
470
+ return df_updated, undo_history, f"✅ Dòng {row_idx+1}: '{old_value}' → '{selected_product}'"
471
+
472
+
473
+ def undo_change(df, undo_history):
474
+ """Hoàn tác thay đổi cuối cùng"""
475
+ if df is None or (isinstance(df, pd.DataFrame) and df.empty):
476
+ return df, undo_history, "⚠️ Không có dữ liệu"
477
+
478
+ if not undo_history or len(undo_history) == 0:
479
+ return df, undo_history, "⚠️ Không có thay đổi nào để hoàn tác"
480
+
481
+ undo_history = list(undo_history) # Copy
482
+ last_change = undo_history.pop()
483
+ row = last_change['row']
484
+ col = last_change['col']
485
+ old_value = last_change['old_value']
486
+
487
+ df_updated = df.copy()
488
+ current_value = str(df_updated.iloc[row][col])
489
+ df_updated.at[row, col] = old_value
490
+
491
+ remaining = len(undo_history)
492
+ return df_updated, undo_history, f"↩ Dòng {row+1}: '{current_value}' → '{old_value}' (còn {remaining} thay đổi có thể hoàn tác)"
493
+
494
+
495
+ # ====================== GRADIO UI ======================
496
+
497
+ with gr.Blocks(title="NHẬP ĐƠN HÀNG ĐA PHƯƠNG THỨC") as demo:
498
+
499
+ # State variables
500
+ excel_base64_state = gr.State("") # Lưu excel_data_base64 từ /information_extraction/
501
+ top5_state = gr.State({}) # Lưu top 5 đề xuất cho mỗi sản phẩm (key: row index)
502
+ undo_history_state = gr.State([]) # Lưu lịch sử thay đổi để undo
503
+ selected_row_state = gr.State(None) # Lưu row index đang được chọn từ DataFrame click
504
+
505
+ # ====================== HEADER ======================
506
+ with gr.Row():
507
+ with gr.Column(scale=0, min_width=120):
508
+ gr.Image(
509
+ value="logo_multimodal_invoice.jpg",
510
+ show_label=False,
511
+ elem_id="logo",
512
+ height=100,
513
+ width=100,
514
+ container=False,
515
+ )
516
+ with gr.Column():
517
+ gr.Markdown(
518
+ """
519
+ <div style='text-align: center;'>
520
+ <h1 style='margin-bottom: 0.5em; font-size: 1.8em;'>📄 NHẬP ĐƠN HÀNG ĐA PHƯƠNG THỨC</h1>
521
+ </div>
522
+ """,
523
+ elem_id="main-title"
524
+ )
525
+
526
+ gr.Markdown("---")
527
+
528
+ # ====================== SECTION 1: MÃ NHÂN VIÊN - LLM MODEL ======================
529
+ with gr.Row(equal_height=True):
530
+ employee_code_input = gr.Textbox(
531
+ label="Mã nhân viên",
532
+ placeholder="Nhập mã nhân viên",
533
+ scale=1
534
+ )
535
+ llm_model_input = gr.Dropdown(
536
+ ["Gemini 2.5 Flash", "Gemini 2.5 Flash-Lite"],
537
+ value="Gemini 2.5 Flash",
538
+ label="Mô hình đa phương thức",
539
+ scale=1,
540
+ interactive=True
541
+ )
542
+
543
+ # ====================== SECTION 2: OCR + TRẠNG THÁI + CẤU HÌNH ======================
544
+ with gr.Row(equal_height=True):
545
+ # Cột 1: Upload + Xử lý
546
+ with gr.Column(scale=1):
547
+ uploaded_files = gr.File(
548
+ label="Tải lên file ZIP",
549
+ file_count="single",
550
+ file_types=[".zip"],
551
+ type="filepath"
552
+ )
553
+ process_btn = gr.Button("⚙️ Xử lý đơn hàng", variant="secondary")
554
+ progress = gr.Textbox(label="Trạng thái xử lý", interactive=False)
555
+
556
+ # Cột 2: Thông tin tiến trình
557
+ with gr.Column(scale=1):
558
+ current_file_text = gr.Textbox(label="Đang xử lý file", interactive=False)
559
+ file_time_output = gr.Textbox(label="Thời gian xử lý file", interactive=False, lines=4)
560
+ total_time_output = gr.Number(label="Tổng thời gian xử lý (giây)", interactive=False)
561
+
562
+ # Cột 3: Cấu hình tìm kiếm + Mapping
563
+ with gr.Column(scale=2):
564
+ gr.Markdown("#### ⚙️ Cấu hình tìm kiếm")
565
+ search_method = gr.Radio(
566
+ choices=["weighted", "rrf", "weighted_rrf", "rrf_cross_encoder"],
567
+ value="weighted",
568
+ label="Phương pháp Fusion",
569
+ info="Weighted: trọng số điểm | RRF: xếp hạng đảo | Weighted RRF: kết hợp | RRF+CE: rerank"
570
+ )
571
+ with gr.Group(visible=True) as weighted_config:
572
+ weight_slider = gr.Slider(
573
+ minimum=0, maximum=1, value=0.7, step=0.1,
574
+ label="Dense ↔ Sparse",
575
+ info="Kéo trái → ưu tiên Sparse (từ khóa) | Kéo phải → ưu tiên Dense (ngữ nghĩa)"
576
+ )
577
+ weight_display = gr.Markdown(value="**Dense: 0.7** | **Sparse: 0.3**")
578
+ with gr.Group(visible=False) as rrf_config:
579
+ rrf_k_slider = gr.Slider(
580
+ minimum=30, maximum=100, value=60, step=30,
581
+ label="RRF K (hằng số làm mượt)",
582
+ info="30: nhạy | 60: cân bằng | 100: ổn định"
583
+ )
584
+ use_prediction_checkbox = gr.Checkbox(
585
+ label="🤖 Tự động dự đoán danh mục (LLM Prediction)",
586
+ value=False,
587
+ info="Khi bật: tự động predict L1/L2/L3 cho mỗi sản phẩm qua LLM → filter search"
588
+ )
589
+ with gr.Row():
590
+ mapping_status = gr.Textbox(label="Trạng thái mapping", interactive=False, scale=2)
591
+ mapping_time = gr.Number(label="Thời gian (giây)", interactive=False, scale=1)
592
+ mapping_btn = gr.Button("🚀 BẮT ĐẦU MAPPING", variant="primary", size="lg")
593
+
594
+ # Hidden state: danh sách sản phẩm từ OCR
595
+ product_list = gr.Textbox(visible=True)
596
+
597
+ excel_download = gr.File(
598
+ label="Tải file Excel kết quả xử lý",
599
+ interactive=False,
600
+ visible=False
601
+ )
602
+
603
+ ocr_result = gr.Dataframe(
604
+ label="Kết quả nhận diện (OCR)",
605
+ wrap=True,
606
+ interactive=False
607
+ )
608
+
609
+ gr.Markdown("---")
610
+
611
+ # ====================== SECTION 3: KẾT QUẢ MAPPING ======================
612
+ gr.Markdown("### 📊 Kết quả Mapping")
613
+ gr.Markdown("*Click vào ô **Tên sản phẩm** để xem Top 5 đề xuất → Chọn sản phẩm → Áp dụng*")
614
+
615
+ mapping_result = gr.Dataframe(
616
+ label="Kết quả Mapping (click vào ô sản phẩm để thay thế)",
617
+ wrap=True,
618
+ interactive=True
619
+ )
620
+
621
+ # ====================== FLOATING MENU - TOP 5 ĐỀ XUẤT ======================
622
+ with gr.Group(visible=False) as floating_panel:
623
+ gr.Markdown("#### 🔄 Chọn sản phẩm thay thế")
624
+ floating_product_display = gr.Textbox(
625
+ label="Sản phẩm đang chọn",
626
+ interactive=False
627
+ )
628
+ floating_top5_radio = gr.Radio(
629
+ label="Top 5 sản phẩm đề xuất (chọn 1 để thay thế)",
630
+ choices=[],
631
+ interactive=True
632
+ )
633
+ with gr.Row():
634
+ floating_apply_btn = gr.Button("✅ Áp dụng", variant="primary", scale=1)
635
+ floating_undo_btn = gr.Button("↩ Hoàn tác", variant="secondary", scale=1)
636
+ floating_status = gr.Textbox(label="Trạng thái", interactive=False, scale=3)
637
+
638
+ gr.Markdown("---")
639
+
640
+ # ====================== TÌM KIẾM MỞ RỘNG ======================
641
+ with gr.Accordion("🔍 Tìm kiếm mở rộng (nếu Top 5 không phù hợp)", open=False):
642
+ gr.Markdown("*Tìm kiếm như Ctrl+F trong danh sách sản phẩm → chọn sản phẩm → áp dụng vào dòng đã click ở bảng trên*")
643
+
644
+ with gr.Row():
645
+ search_keyword = gr.Textbox(
646
+ label="Từ khóa tìm kiếm (tự động cập nhật khi click vào ô sản phẩm)",
647
+ placeholder="Click vào ô sản phẩm ở bảng trên hoặc nhập từ khóa...",
648
+ interactive=True,
649
+ scale=2
650
+ )
651
+ search_btn = gr.Button("🔍 Tìm kiếm", variant="secondary", scale=1)
652
+
653
+ search_status = gr.Textbox(label="Trạng thái tìm kiếm", interactive=False)
654
+
655
+ search_results = gr.Dropdown(
656
+ label="Kết quả tìm kiếm (chọn sản phẩm)",
657
+ choices=[],
658
+ interactive=True,
659
+ allow_custom_value=True
660
+ )
661
+
662
+ apply_search_btn = gr.Button("✅ Áp dụng sản phẩm từ tìm kiếm", variant="primary")
663
+
664
+ gr.Markdown("---")
665
+
666
+ # ====================== TẢI FILE EXCEL KẾT QUẢ ======================
667
+ with gr.Row():
668
+ save_btn = gr.Button("💾 Lưu Excel đã chỉnh sửa", variant="primary")
669
+ save_status = gr.Textbox(label="Trạng thái lưu", interactive=False, scale=2)
670
+
671
+ mapping_download = gr.File(
672
+ label="Tải file Excel kết quả",
673
+ interactive=False,
674
+ visible=False
675
+ )
676
+
677
+ # ====================== EVENT HANDLERS ======================
678
+
679
+ # Xử lý file ZIP → hiện kết quả OCR → cập nhật product_list
680
+ process_btn.click(
681
+ fn=process_zip_with_api,
682
+ inputs=[employee_code_input, uploaded_files, llm_model_input],
683
+ outputs=[progress, file_time_output, total_time_output, ocr_result, excel_download, excel_base64_state]
684
+ ).then(
685
+ fn=lambda excel_base64: get_products_from_excel_api(excel_base64, "Tên sản phẩm"),
686
+ inputs=[excel_base64_state],
687
+ outputs=[product_list, current_file_text]
688
+ )
689
+
690
+ # Mapping sản phẩm
691
+ mapping_btn.click(
692
+ fn=call_mapping_api,
693
+ inputs=[product_list, search_method, weight_slider, rrf_k_slider, excel_base64_state, use_prediction_checkbox],
694
+ outputs=[mapping_status, mapping_time, mapping_result, mapping_download, top5_state]
695
+ )
696
+
697
+ # Click vào ô sản phẩm → hiện floating menu + cập nhật ô tìm kiếm
698
+ mapping_result.select(
699
+ fn=on_cell_select,
700
+ inputs=[top5_state, mapping_result],
701
+ outputs=[floating_panel, floating_product_display, floating_top5_radio, selected_row_state, search_keyword]
702
+ )
703
+
704
+ # Áp dụng sản phẩm đã chọn từ Top 5
705
+ floating_apply_btn.click(
706
+ fn=apply_product,
707
+ inputs=[mapping_result, selected_row_state, floating_top5_radio, undo_history_state],
708
+ outputs=[mapping_result, undo_history_state, floating_status]
709
+ )
710
+
711
+ # Hoàn tác thay đổi cuối cùng
712
+ floating_undo_btn.click(
713
+ fn=undo_change,
714
+ inputs=[mapping_result, undo_history_state],
715
+ outputs=[mapping_result, undo_history_state, floating_status]
716
+ )
717
+
718
+ # Tìm kiếm sản phẩm mở rộng
719
+ search_btn.click(
720
+ fn=search_products_api,
721
+ inputs=[search_keyword],
722
+ outputs=[search_results, search_status]
723
+ )
724
+
725
+ # Áp dụng sản phẩm từ tìm kiếm mở rộng
726
+ apply_search_btn.click(
727
+ fn=apply_product,
728
+ inputs=[mapping_result, selected_row_state, search_results, undo_history_state],
729
+ outputs=[mapping_result, undo_history_state, floating_status]
730
+ )
731
+
732
+ # Dynamic UI: Show/hide config based on method selection
733
+ def update_method_visibility(method):
734
+ if method == "weighted":
735
+ return gr.update(visible=True), gr.update(visible=False)
736
+ elif method == "rrf":
737
+ return gr.update(visible=False), gr.update(visible=True)
738
+ elif method == "rrf_cross_encoder":
739
+ return gr.update(visible=False), gr.update(visible=True)
740
+ else: # weighted_rrf: hiện cả hai
741
+ return gr.update(visible=True), gr.update(visible=True)
742
+
743
+ search_method.change(
744
+ fn=update_method_visibility,
745
+ inputs=[search_method],
746
+ outputs=[weighted_config, rrf_config]
747
+ )
748
+
749
+ # Update weight display when slider changes
750
+ def update_weight_display(value):
751
+ dense = round(value, 1)
752
+ sparse = round(1.0 - value, 1)
753
+ return f"**Dense: {dense}** | **Sparse: {sparse}**"
754
+
755
+ weight_slider.change(
756
+ fn=update_weight_display,
757
+ inputs=[weight_slider],
758
+ outputs=[weight_display]
759
+ )
760
+
761
+ # Lưu Excel đã chỉnh sửa
762
+ save_btn.click(
763
+ fn=save_edited_excel,
764
+ inputs=[mapping_result],
765
+ outputs=[mapping_download, save_status]
766
+ )
767
+
768
+
769
+ if __name__ == "__main__":
770
  demo.launch(inbrowser=True, share=False)