Spaces:
Running
Running
File size: 101,572 Bytes
06bb839 ac9fe0e 06bb839 ac9fe0e 06bb839 ac9fe0e 06bb839 ac9fe0e 06bb839 ac9fe0e 06bb839 ac9fe0e 06bb839 ac9fe0e 06bb839 18b665a 06bb839 18b665a 06bb839 ac9fe0e 06bb839 7801366 c2a713d 06bb839 7801366 ac9fe0e 06bb839 ac9fe0e d351c90 ac9fe0e 3707b40 7801366 ac9fe0e c2a713d ac9fe0e 06bb839 fda1c17 06bb839 ac9fe0e 7801366 ac9fe0e 7801366 ac9fe0e 86e783e ac9fe0e 86e783e ac9fe0e 86e783e ac9fe0e 3707b40 ac9fe0e 3707b40 ac9fe0e 3707b40 85fc93b 3707b40 ac9fe0e c2a713d 01ed6b4 e2266ab 01ed6b4 e2266ab 01ed6b4 06bb839 55e2d81 01ed6b4 55e2d81 06bb839 c2a713d 55e2d81 06bb839 55e2d81 9c641f0 55e2d81 2621817 01ed6b4 55e2d81 9e467ce 55e2d81 01ed6b4 55e2d81 d351c90 a238f67 55e2d81 01ed6b4 d351c90 ac9fe0e 06bb839 a238f67 3707b40 58f30a4 3707b40 58f30a4 fda1c17 58f30a4 fda1c17 3707b40 58f30a4 3707b40 fda1c17 3707b40 ac9fe0e 6779f06 4a0cc1d 6779f06 4a0cc1d 6779f06 4a0cc1d 6779f06 9c641f0 2621817 e2266ab c35045c e2266ab c35045c e2266ab c35045c e2266ab | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 | # ================================================================
# yui_code_verifier.py — CODE-BACKED NUMBERS VERIFIER v2.0 (Two-Pass)
#
# VẤN ĐỀ PHÁT HIỆN THỰC TẾ (Boss test 14/07 + 16/07): Boss yêu cầu Yui
# viết code mô phỏng tài chính "và chạy trong Sandbox" — Yui trả về SỐ
# LIỆU CỤ THỂ hoàn toàn bịa (không khớp cả code_1.py lẫn code khác Boss
# đưa), có lần còn tự thú "(chạy trong sandbox sẽ cho ra số chính xác)"
# — nghĩa là Yui biết mình chưa chạy nhưng vẫn trình bày như đã tính.
#
# v1.0 (bản cũ): chạy sandbox rồi APPEND thêm khối "✅ Đã xác minh" phía
# dưới câu trả lời cũ — Boss phải tự đối chiếu 2 phần số khác nhau, trải
# nghiệm không tốt.
#
# v2.0 (bản này) — TWO-PASS REFINEMENT theo đúng thiết kế Boss + Gemini
# thống nhất, với 1 lớp AN TOÀN THÊM mà Gemini gợi ý nhưng chưa bắt buộc:
#
# Pass 1 (đã xảy ra trước khi vào module này): LLM code-gen viết code
# Python để giải bài toán — CHƯA cần tự đoán số liệu.
# Sandbox: trích code, chạy thật qua yui_sandbox.run_sandboxed() → có
# SỰ THẬT KHÁCH QUAN duy nhất (sandbox_output).
# Pass 2 (Refinement — LLM RẺ, KHÔNG PHẢI model chính): gom
# [yêu cầu gốc + code + sandbox_output] → yêu cầu 1 LLM rẻ
# (Cohere Command A/A+ free tier — tính theo REQUEST không
# theo token, rất hợp cho việc "định dạng lại" không cần suy
# luận phức tạp) viết LẠI câu trả lời hoàn chỉnh, dùng ĐÚNG số
# từ sandbox_output.
# Cross-check (Python, 0 LLM call thêm — ĐÂY LÀ ĐIỂM KHÁC BIỆT CHÍNH
# so với đề xuất "Mẹo 1" của Gemini, chọn "Mẹo 2"): KHÔNG tin
# tưởng mù quáng lời hứa "không đổi số" của LLM lượt 2 (Gemini
# tự thừa nhận vẫn có ~0.5% tỷ lệ gõ nhầm). Thay vào đó, tự
# trích các con số "kết quả" trong sandbox_output và ÉP chúng
# phải xuất hiện y nguyên trong bản Pass 2 — nếu Pass 2 làm
# tròn/gõ sai, PHÁT HIỆN và TỰ SỬA bằng string-replace (không
# cần gọi lại LLM lần 3), đạt chính xác THẬT 100% chứ không
# phải "99.9% nhờ tin tưởng prompt".
#
# TẠI SAO DÙNG COHERE CHO PASS 2 (theo đúng đề xuất của Boss):
# Cohere free tier tính theo SỐ REQUEST/tháng, KHÔNG tính theo token —
# trong khi Cerebras (model chính viết code ở Pass 1) tính theo token.
# Việc "định dạng lại 1 bảng Markdown từ số liệu đã có sẵn" không cần
# suy luận phức tạp, rất hợp để giao cho model rẻ/nhàn rỗi này — tách
# quota Pass 2 ra khỏi ngân sách token của Cerebras hoàn toàn.
#
# TỐI ƯU QUOTA — CHỈ TỐN THÊM PASS 2 KHI THỰC SỰ CẦN:
# - Nếu câu trả lời KHÔNG có code + số liệu kết quả → bỏ qua hoàn
# toàn, 0 lệnh gọi thêm (giữ y hệt v1.0).
# - Nếu sandbox chạy LỖI → không gọi Pass 2 (không có gì để "xào nấu"
# — refine dựa trên số liệu sai/không có thì vô nghĩa), chỉ cảnh báo.
# - Pass 2 CHỈ chạy đúng 1 lần/câu trả lời cần verify — không có vòng
# lặp refine nhiều lần.
# ================================================================
import os
import re
import random
from typing import Optional, Tuple
try:
from yui_sandbox import (
run_sandboxed, ALLOWED_MODULES, ALLOWED_MODULES_EXTENDED, ALLOWED_MODULES_DATA,
)
SANDBOX_OK = True
except Exception as _sb_e:
SANDBOX_OK = False
print(f"[CODE-VERIFIER] ⚠️ yui_sandbox không load được: {_sb_e}")
def run_sandboxed(code, timeout_sec=5, allowed_modules=None):
return False, "[SANDBOX unavailable]"
ALLOWED_MODULES = ALLOWED_MODULES_EXTENDED = ALLOWED_MODULES_DATA = set()
def _pick_module_tier(code: str) -> set:
"""
[SANDBOX ĐA DẠNG 2026-07-17] TRƯỚC ĐÂY: run_sandboxed(code) không
truyền allowed_modules → LUÔN rơi về mức hẹp nhất (ALLOWED_MODULES,
chỉ 7 module cơ bản), dù code Pass 1 thường là tính toán tài chính/
dữ liệu cần pandas/numpy/statistics — khiến code hợp lệ bị từ chối
oan (KHÔNG phải lỗi bảo mật, chỉ là thiếu module).
Giải pháp: quét NHẸ (regex trên các dòng "import x"/"from x import")
để xem code THỰC SỰ định dùng module gì, rồi chọn mức tối thiểu đủ
dùng — KHÔNG tự động mở mức cao nhất cho mọi trường hợp (giữ
nguyên tinh thần least-privilege của AST whitelist gốc). Việc code
có an toàn hay không vẫn do validate_ast() bên trong run_sandboxed
quyết định — hàm này CHỈ chọn "cửa nào để thử", không nới lỏng bất
kỳ điều kiện an toàn nào.
"""
import re as _re_tier
imported = set(_re_tier.findall(r"^\s*(?:import|from)\s+([a-zA-Z_][a-zA-Z0-9_]*)",
code, flags=_re_tier.MULTILINE))
if imported & {"pandas", "numpy"}:
return ALLOWED_MODULES_DATA
if imported & {"statistics"}:
return ALLOWED_MODULES_EXTENDED
return ALLOWED_MODULES
# ─── CẤU HÌNH PASS 2 (REFINEMENT) ─────────────────────────────────
_REFINE_MODEL = "command-a-03-2025" # Cohere free tier, tính theo request
_REFINE_TIMEOUT_SEC = 12
_REFINE_MAX_TOKENS = 1500
# ─── PHÁT HIỆN CODE BLOCK PYTHON TRONG CÂU TRẢ LỜI ────────────────
_PY_CODE_BLOCK = re.compile(r"```python\s*\n(.*?)```", re.DOTALL)
# ─── PHÁT HIỆN "SỐ LIỆU TRÌNH BÀY NHƯ KẾT QUẢ TÍNH TOÁN" ─────────
_RESULT_NUMBER_PATTERNS = [
re.compile(r"\d{1,3}(?:[.,]\d{3}){2,}"), # 1.642.893.456 kiểu VN
re.compile(r"\d+[.,]\d+\s*%"), # 111.8%
re.compile(r"\|\s*[\d.,]+\s*\|"), # số trong bảng markdown | 123 |
# 🆕 (2026-07-23) Số kết quả NHỎ, không có phân cách hàng nghìn —
# bỏ sót ở bản trước khiến câu đố Euler (n=40, A=1681) và số liệu
# không phân cách (1139.24) hoàn toàn không kích hoạt verify. Chỉ
# bắt khi đứng NGAY SAU từ khóa kết quả/biến số rõ ràng (không bắt
# tràn lan mọi con số trong câu, VD "10 năm", "3 phần" không khớp).
re.compile(r"(?:kết quả|đáp số|answer|n\s*=|a\s*=|tại\s+n)\s*[:=]?\s*\d+(?:[.,]\d+)?", re.I),
# 🆕 (2026-07-23) Số phân cách bằng KHOẢNG TRẮNG (kiểu Pháp/quốc tế,
# VD "187 463 800") — bản trước chỉ bắt dấu chấm/phẩy, bỏ sót định
# dạng này hoàn toàn dù rất phổ biến khi model viết số tiền lớn.
re.compile(r"\d{1,3}(?: \d{3}){2,}"),
]
# Ngưỡng tối thiểu để 1 "số kết quả nhỏ" được coi là đáng verify — tránh
# bắt nhầm số quá bé không mang ý nghĩa (VD "n = 1" trong 1 câu mô tả
# chung, không phải kết quả cụ thể của phép tính).
_MIN_SMALL_RESULT_DIGITS = 1 # cố ý thấp — thà verify thừa còn hơn bỏ sót
# Cụm từ tự thú nhận CHƯA CHẠY THẬT — dấu hiệu Boss đã bắt được thực tế
_UNVERIFIED_ADMISSION_PATTERNS = [
re.compile(r"chạy trong sandbox sẽ cho ra", re.I),
re.compile(r"sẽ cho ra số chính xác", re.I),
re.compile(r"kết quả giả lập.*sẽ", re.I),
re.compile(r"khi chạy thực tế", re.I),
]
# Số liệu "quan trọng" cần cross-check sau Pass 2 — khớp CẢ CỤM số có định
# dạng phân cách (VD "1.090.139.238" hoặc "1,090,139,238") NHƯ MỘT KHỐI,
# sau đó chuẩn hóa (bỏ dấu phân cách) để so sánh — thay vì chỉ tìm cụm
# digit liên tiếp không dấu ngăn cách (lỗi cũ: pattern \d{4,} không khớp
# được gì với số đã có dấu chấm/phẩy phân cách hàng nghìn kiểu
# "1.090.139.238", vì sau mỗi dấu tách chỉ còn nhóm 3 chữ số).
#
# 🐛 FIX (2026-07-28): bản trước nhánh "space-group" (\d{1,3}(?: \d{3}){2,})
# KHÔNG bắt phần thập phân theo sau (VD "983 382 255,55" chỉ khớp
# "983 382 255", bỏ sót ",55"). Hậu quả: _try_direct_patch_wrong_numbers()
# chỉ .replace() được phần đầu, để sót đuôi thập phân cũ trong văn bản,
# tạo ra số lai vô nghĩa (VD patch "983 382 255,55" → "938,568,732.64"
# nhưng chỉ thay được phần trước dấu phẩy, để lại "938,568,732.64,55").
# Giờ thêm hậu tố thập phân tùy chọn vào CẢ 2 nhánh của regex.
_FORMATTED_NUMBER = re.compile(r"\d[\d.,]{3,}\d(?:[.,]\d+)?|\d{1,3}(?: \d{3}){2,}(?:[.,]\d+)?")
# 🆕 (2026-07-25) Bắt MỌI số (kể cả ngắn, VD "40") để thay bằng "#" khi
# so sánh khuôn mẫu dòng trong _split_essential_vs_illustrative — khác
# _FORMATTED_NUMBER (chỉ bắt số dài/có định dạng phân cách).
_NUMBER_IN_LINE = re.compile(r"\d+(?:[.,]\d+)?")
# 🆕 (2026-07-23) Số kết quả NHỎ (2-4 chữ số, không phân cách hàng
# nghìn) đứng gần từ khóa kết quả/biến số — bổ sung cho _FORMATTED_
# NUMBER vốn chỉ bắt số ≥5 ký tự. Thiếu pattern này khiến câu đố Euler
# (n=40, A=1681) hoàn toàn không được cross-check dù có sai lệch thật.
# Cố ý hẹp (chỉ bắt số ngay sau từ khóa) để không lẫn với số ngày/số
# thứ tự không liên quan (VD "phần 1", "10 năm").
_SMALL_RESULT_NUMBER = re.compile(
r"(?:kết quả|đáp số|answer|n\s*=|a\s*=|tại\s+n)\s*[:=]?\s*(\d+(?:[.,]\d+)?)", re.I
)
def _normalize_number(raw: str) -> str:
"""Chuẩn hóa 1 chuỗi số đã định dạng về dạng thuần chữ số, để so sánh
'1.090.139.238' (kiểu VN), '1,090,139,238' (kiểu US), và '1 090 139 238'
(kiểu Pháp/quốc tế) là CÙNG 1 số."""
return re.sub(r"[.,\s]", "", raw)
def _integer_part_length(raw: str) -> int:
"""
🆕 (2026-07-29) FIX BUG THẬT: số tiền tính bằng Decimal với precision
cao (VD getcontext().prec=20) có thể sinh ra kết quả kiểu
"938,568,732.63468005390" — phần thập phân dài 11 chữ số, VÔ NGHĨA về
mặt tiền tệ (VNĐ luôn là số nguyên) nhưng _normalize_number() vẫn nối
liền cả phần nguyên lẫn thập phân thành 1 chuỗi 20 chữ số. Mọi so sánh
ĐỘ DÀI dựa trên _normalize_number() (dùng để tìm "số tương tự cần vá")
vì vậy bị lệch nghiêm trọng (20 chữ số thay vì 9 chữ số thật), khiến
số sai thật (VD "983065693", chỉ lệch với "938568732" — cùng 9 chữ số)
không còn được coi là "tương tự" nữa vì so lệch tới 11+ chữ số.
Hàm này trả về độ dài của CHỈ PHẦN NGUYÊN (trước dấu thập phân) sau
khi chuẩn hóa dấu phân cách nghìn — dùng RIÊNG cho việc SO SÁNH ĐỘ DÀI
khi tìm candidate, không thay _normalize_number() ở những chỗ cần giá
trị chính xác tuyệt đối (VD so khớp có bằng nhau hay không)."""
# Tách phần nguyên: mọi thứ trước dấu thập phân CUỐI CÙNG (giả định
# phần thập phân là phần sau dấu . hoặc , đứng ngay trước 1-2 chữ số
# cuối không phải nhóm-3-chữ-số-phân-cách — heuristic đơn giản: nếu
# có nhiều hơn 1 dấu phân cách, dấu CUỐI CÙNG với đúng 1-2 chữ số theo
# sau là dấu thập phân thật (VD "938,568,732.63..." → phần thập phân
# là ".63468005390", phần nguyên là "938,568,732").
m = re.match(r"^([\d.,\s]*?\d)[.,](\d+)$", raw)
if m and len(m.group(2)) == 3:
# Nhóm cuối ĐÚNG 3 chữ số → đây là dấu phân cách nghìn bình thường
# (VD "983.065.693" kiểu VN — không có phần thập phân thật nào),
# KHÔNG PHẢI thập phân tiền tệ — toàn bộ chuỗi là phần nguyên.
return len(_normalize_number(raw))
if m and len(m.group(2)) <= 2:
# Dấu cuối cùng có 1-2 chữ số theo sau → khả năng cao là thập phân
# tiền tệ thật (VD "732.63" — 2 chữ số sau dấu chấm)
return len(_normalize_number(m.group(1)))
if m:
# Dấu cuối cùng có >3 chữ số theo sau (VD ".63468005390", 11 chữ
# số) → chắc chắn không phải thập phân tiền tệ thông thường lẫn
# nhóm phân cách nghìn chuẩn (luôn đúng 3), coi toàn bộ phần trước
# dấu đó là phần nguyên thật, bỏ đuôi bất thường.
return len(_normalize_number(m.group(1)))
return len(_normalize_number(raw))
def _extract_python_code(text: str) -> Optional[str]:
m = _PY_CODE_BLOCK.search(text)
return m.group(1).strip() if m else None
def _has_result_looking_numbers(text: str) -> bool:
text_without_code = _PY_CODE_BLOCK.sub("", text)
return any(p.search(text_without_code) for p in _RESULT_NUMBER_PATTERNS)
def _has_unverified_admission(text: str) -> bool:
return any(p.search(text) for p in _UNVERIFIED_ADMISSION_PATTERNS)
def _extract_significant_numbers(text: str) -> list:
"""Trích các số 'đáng kể' từ text — bắt CẢ CỤM số có định dạng phân
cách (VD '1.090.139.238') làm 1 khối, rồi chuẩn hóa về dạng thuần số
(bỏ dấu chấm/phẩy) để so sánh đúng GIÁ TRỊ, không phải chuỗi ký tự
thô — nhờ vậy '1.090.139.238' (VN) và '1,090,139,238' (US) được coi
là cùng 1 số, và số bị lệch dù chỉ 1 chữ số vẫn bị phát hiện đúng.
🆕 Bổ sung số kết quả NHỎ đứng gần từ khóa (n=, A=, kết quả...) —
không có bước này, đáp số câu đố như "n=40" hoàn toàn lọt lưới vì
_FORMATTED_NUMBER yêu cầu tối thiểu 5 ký tự."""
raw_matches = _FORMATTED_NUMBER.findall(text)
# Chỉ giữ số có ĐỦ DÀI sau khi chuẩn hóa (>= 4 chữ số) — tránh bắt
# nhầm số nhỏ không liên quan (VD "10 năm", "3 kịch bản").
normalized = [_normalize_number(m) for m in raw_matches if len(_normalize_number(m)) >= 4]
# Số nhỏ có ngữ cảnh rõ ràng (n=40, A=1681...) — không áp ngưỡng độ
# dài ≥4 vì chính mục đích là bắt số NHỎ như đáp số câu đố (n=40 chỉ
# có 2 chữ số nhưng vẫn là số quan trọng cần cross-check).
small_matches = _SMALL_RESULT_NUMBER.findall(text)
normalized += [_normalize_number(m) for m in small_matches]
return list(dict.fromkeys(normalized)) # loại trùng, giữ thứ tự
def _call_cohere_refine(user_query: str, code: str, sandbox_output: str) -> Optional[str]:
"""
PASS 2 — viết LẠI câu trả lời hoàn chỉnh dựa trên SỰ THẬT từ
sandbox_output. Trả về text mới, hoặc None nếu lỗi/không có key
(caller sẽ fallback về hành vi v1.0: chỉ append khối xác minh thay
vì thay thế toàn bộ).
🆕 FIX (2026-08-08): TRƯỚC ĐÂY chỉ gọi thẳng Cohere — theo log thực
tế của Boss, Cohere free tier unhealthy 80-100% thời gian phần lớn
lúc trong ngày. Khi Cohere fail, hàm trả None ngay, khiến câu trả
lời của Yui rơi vào fallback cứng ("Đã kiểm tra lại bằng tính toán
thật: ...") thay vì câu văn tự nhiên — không sai, nhưng mất trải
nghiệm mượt mà thường xuyên hơn cần thiết vì chỉ phụ thuộc 1
provider. Giờ dùng yui_model_router.call_task_group (chuỗi đa
provider đã chuẩn hoá, tự xoay vòng Mistral/OpenRouter/Cohere/...)
— cùng cơ chế fail-open, chỉ đổi provider bên trong, KHÔNG đổi hành
vi cross-check/fallback của caller (verify_puzzle_answer) — nếu cả
chuỗi provider đều fail, vẫn trả None như cũ, caller vẫn tự lo phần
fallback an toàn."""
prompt = (
"Bạn là Yui — một trợ lý AI. Đây là yêu cầu của Boss:\n"
f"{user_query[:500]}\n\n"
"Đây là đoạn code bạn đã viết để giải quyết yêu cầu:\n"
f"```python\n{code[:2000]}\n```\n\n"
"Và đây là KẾT QUẢ CHẠY THỰC TẾ 100% CHÍNH XÁC từ sandbox (đã chạy "
"code thật, không phải ước lượng):\n"
f"```\n{sandbox_output[:1000]}\n```\n\n"
"Nhiệm vụ: viết câu trả lời CUỐI CÙNG gửi cho Boss — gồm phần diễn "
"giải ngắn gọn + bảng Markdown/thanh tiến trình nếu yêu cầu có đề "
"cập, dùng ĐÚNG các con số trong kết quả sandbox ở trên.\n"
"ĐIỀU KIỆN BẮT BUỘC: PHẢI copy chính xác tuyệt đối từng chữ số từ "
"kết quả sandbox vào câu trả lời — không được làm tròn, không được "
"tự ý đổi bất kỳ chữ số nào. Không thêm giải thích về việc bạn đang "
"làm gì (không nói 'tôi sẽ...'), chỉ đưa thẳng câu trả lời hoàn chỉnh."
)
try:
import asyncio
import concurrent.futures
from yui_model_router import call_task_group as _ctg_refine
async def _go():
return await _ctg_refine(
group="fast_light", prompt=prompt, system="",
max_tokens=_REFINE_MAX_TOKENS, timeout_sec=12,
)
def _run_in_new_loop():
return asyncio.run(_go())
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as _ex:
_future = _ex.submit(_run_in_new_loop)
text = _future.result(timeout=14)
return text.strip() if text else None
except Exception as e:
print(f"[CODE-VERIFIER] Pass 2 (router) lỗi: {type(e).__name__}: {str(e)[:150]}")
return None
def _cross_check_and_fix(refined_text: str, sandbox_output: str) -> Tuple[str, bool]:
"""
🆕 LỚP AN TOÀN THÊM (chọn "Mẹo 2" của Gemini thay vì chỉ tin prompt
"Mẹo 1"): KHÔNG tin tưởng mù quáng rằng Pass 2 đã copy đúng 100% số
liệu — tự kiểm tra bằng Python. Đây là cách duy nhất đạt chính xác
THẬT 100% thay vì "99.9% nhờ hy vọng LLM nghe lời".
Nếu phát hiện có số "đáng kể" trong sandbox_output mà KHÔNG xuất
hiện trong bản refined_text → coi như Pass 2 đã sai lệch, KHÔNG dùng
bản refined này, rơi về hành vi an toàn (giữ nguyên + cảnh báo) thay
vì liều lĩnh trả 1 câu trả lời có thể vẫn sai.
🆕 (2026-07-25, bản 2) PHÂN LOẠI SỐ THEO NGỮ CẢNH DÒNG thay vì đếm
tỷ lệ phần trăm (bản đầu dùng ngưỡng 10% không đủ tốt — với danh
sách quét dài như bài Euler, số dòng lặp chiếm >90% tổng số, khiến
"10%" vẫn từ chối oan dù Pass 2 tóm tắt đúng). Cách đúng hơn: các
dòng có CÙNG KHUÔN MẪU LẶP LẠI (VD "n = X -> A = Y" xuất hiện ≥3
lần liên tiếp) được coi là 1 DANH SÁCH MINH HỌA — Cohere được phép
chỉ nhắc phần tử ĐẦU TIÊN của danh sách đó (thường là kết quả quan
trọng nhất, VD phản ví dụ nhỏ nhất n=40) mà không cần chép hết. Số
KHÔNG nằm trong danh sách lặp (VD "Tổng số tiền: 187,713,747") vẫn
phải khớp TUYỆT ĐỐI — đây là số kết quả chính, không phải minh họa.
Trả về (final_text, is_trustworthy).
"""
_essential_numbers, _illustrative_lines = _split_essential_vs_illustrative(sandbox_output)
if not _essential_numbers and not _illustrative_lines:
return refined_text, True
refined_numbers = set(_extract_significant_numbers(refined_text))
# Số THIẾT YẾU (ngoài danh sách lặp) — PHẢI khớp tuyệt đối
missing_essential = _essential_numbers - refined_numbers
if missing_essential:
print(f"[CODE-VERIFIER] ⚠️ Pass 2 THIẾU số THIẾT YẾU (không phải danh sách minh họa): "
f"{list(missing_essential)[:5]} — từ chối dùng bản refine, rơi về chế độ an toàn")
return refined_text, False
# Với mỗi danh sách lặp — chỉ cần phần tử ĐẦU TIÊN có mặt (đại diện
# kết quả quan trọng nhất, VD phản ví dụ nhỏ nhất)
for _line_group in _illustrative_lines:
_first_num_in_group = _line_group[0] if _line_group else None
if _first_num_in_group and _first_num_in_group not in refined_numbers:
print(f"[CODE-VERIFIER] ⚠️ Pass 2 THIẾU cả phần tử ĐẦU TIÊN của danh sách minh họa "
f"({_first_num_in_group}) — từ chối dùng bản refine, rơi về chế độ an toàn")
return refined_text, False
print(f"[CODE-VERIFIER] ✅ Pass 2 khớp đủ số thiết yếu + đại diện danh sách minh họa (nếu có)")
return refined_text, True
def _split_essential_vs_illustrative(sandbox_output: str) -> Tuple[set, list]:
"""
Tách sandbox_output thành:
- essential_numbers: số nằm trong dòng ĐỘC LẬP (không lặp khuôn
mẫu với dòng khác) — đây là số kết quả chính, PHẢI khớp tuyệt đối.
- illustrative_lines: list các NHÓM dòng có CÙNG KHUÔN MẪU lặp lại
≥3 lần liên tiếp (VD danh sách quét n) — mỗi nhóm là 1 list số
theo thứ tự xuất hiện, chỉ cần khớp phần tử ĐẦU của mỗi nhóm.
Khuôn mẫu được nhận diện bằng cách thay mọi số trong dòng bằng "#"
rồi so sánh — 2 dòng "n = 40 -> A = 1681" và "n = 41 -> A = 1763"
có cùng khuôn mẫu "n = # -> A = #".
"""
lines = sandbox_output.split("\n")
line_numbers = [_extract_significant_numbers(l) for l in lines]
line_templates = [_NUMBER_IN_LINE.sub("#", l) for l in lines]
essential_numbers = set()
illustrative_lines = []
i = 0
while i < len(lines):
if not line_numbers[i]:
i += 1
continue
# Đếm xem có bao nhiêu dòng liên tiếp cùng khuôn mẫu với dòng này
j = i
while j < len(lines) and line_templates[j] == line_templates[i] and line_numbers[j]:
j += 1
run_length = j - i
if run_length >= 3:
# Đây là 1 danh sách lặp — gộp thành 1 nhóm minh họa
group_numbers = []
for k in range(i, j):
group_numbers.extend(line_numbers[k])
illustrative_lines.append(group_numbers)
else:
# Dòng độc lập (không lặp) — số ở đây là thiết yếu
for k in range(i, j):
essential_numbers.update(line_numbers[k])
i = j
return essential_numbers, illustrative_lines
def _ensure_balanced_fences(text: str) -> str:
"""
🆕 (2026-07-23) FIX BUG THẬT: bot.py (_extract_code_files) coi MỌI THỨ
sau 1 dấu ```lang MỞ (chưa gặp dấu ``` ĐÓNG) là code — kể cả khi đó
thực ra là văn bản cảnh báo mình vừa append vào cuối. Hậu quả: nếu
model gốc lỡ không đóng fence đúng (rất dễ xảy ra với code dài), toàn
bộ cảnh báo bị nuốt vào file .py đính kèm, biến mất khỏi tin nhắn
Discord mà KHÔNG có lỗi/exception nào — im lặng mất cảnh báo.
Hàm này đếm số dấu ``` trong text — nếu LẺ (fence đang mở dở), tự
đóng nó lại (thêm ``` ở cuối) TRƯỚC KHI text được dùng cho bất cứ
việc gì khác (append cảnh báo, gửi đi...). Đảm bảo mọi văn bản rời
khỏi hàm verify luôn có cấu trúc fence cân bằng.
"""
if text.count("```") % 2 != 0:
return text.rstrip() + "\n```"
return text
# 🆕 (2026-07-28) Số hằng số trong CODE (VD area = 2095, "Ha Noi": 3358) —
# khác _SMALL_RESULT_NUMBER (yêu cầu từ khóa "n=", "kết quả"...) và khác
# _FORMATTED_NUMBER (yêu cầu dấu phân cách nghìn). Số liệu input hardcode
# trong code thường KHÔNG có dấu phân cách (Python không cho phép dấu phẩy
# trong số) và KHÔNG đứng gần từ khóa tiếng Việt nào — nên cả 2 pattern trên
# đều bỏ sót hoàn toàn. Bắt riêng: số (≥3 chữ số, tránh bắt index/thứ tự nhỏ
# như "0", "1") đứng NGAY SAU dấu "=" hoặc ":" — đây là ngữ cảnh gán biến/
# dict-value rất đặc trưng trong code, an toàn để không lẫn số dòng/thứ tự.
_CODE_CONSTANT_NUMBER = re.compile(r"[:=]\s*(\d{3,}(?:\.\d+)?)\s*(?:[,\}\)\]\n]|$)")
import ast as _ast_for_scanner
class _HardcodeASTScanner(_ast_for_scanner.NodeVisitor):
"""
🆕 (2026-07-31, theo đề xuất Gemini "AST Code Inspection") — thay vì
regex (dễ bắt nhầm số nằm trong comment/string/range() không phải
hằng số input thật), dùng AST để "soi" đúng cấu trúc cú pháp: chỉ bắt
số Constant xuất hiện trong ngữ cảnh GÁN BIẾN/DICT-VALUE/LIST-ELEMENT
thật — bỏ qua hoàn toàn comment, string literal, và argument của hàm
như range()/len() (thường là tham số thuật toán, không phải dữ liệu
input từ search)."""
def __init__(self):
self.numbers = []
def _extract_from_value(self, value):
if isinstance(value, _ast_for_scanner.Constant) and isinstance(value.value, (int, float)) \
and not isinstance(value.value, bool):
self.numbers.append(value.value)
elif isinstance(value, _ast_for_scanner.Dict):
for v in value.values:
self._extract_from_value(v)
elif isinstance(value, (_ast_for_scanner.List, _ast_for_scanner.Tuple)):
for elt in value.elts:
self._extract_from_value(elt)
def visit_Assign(self, node):
self._extract_from_value(node.value)
self.generic_visit(node)
def visit_AnnAssign(self, node):
if node.value is not None:
self._extract_from_value(node.value)
self.generic_visit(node)
def _extract_code_constant_numbers(code: str) -> list:
"""Trích số hằng số hardcode trong code (input đưa vào từ search/model tự
nhớ, KHÔNG phải kết quả tính toán) — bổ sung cho _extract_significant_numbers
vốn bỏ sót các số này (xem comment ở _CODE_CONSTANT_NUMBER).
🐛→✅ (2026-07-31) Ưu tiên quét bằng AST (chính xác hơn — hiểu đúng cú
pháp, không lẫn số trong comment/string/range()) — chỉ fallback về
regex cũ khi code không parse được (VD đoạn code không hoàn chỉnh,
bị cắt giữa chừng) để pipeline không bao giờ crash vì bước quét này."""
try:
tree = _ast_for_scanner.parse(code)
scanner = _HardcodeASTScanner()
scanner.visit(tree)
# Chuẩn hóa về string để khớp kiểu trả về cũ (dùng cho so sánh
# với set số đã chuẩn hóa ở nơi khác) — bỏ số quá nhỏ (<100,
# thường là index/thứ tự, không phải dữ liệu input thật).
return list(dict.fromkeys(
str(int(n)) if float(n).is_integer() else str(n)
for n in scanner.numbers if abs(n) >= 100
))
except SyntaxError:
# Fallback an toàn: code không parse được (đoạn cắt/không hoàn
# chỉnh) — dùng lại regex cũ, thà bắt kém chính xác hơn còn hơn
# crash toàn bộ pipeline verify.
return list(dict.fromkeys(_CODE_CONSTANT_NUMBER.findall(code)))
def _check_numbers_grounded_in_search(reply_text: str, block_results: list, search_context: str) -> list:
"""
🆕 (2026-07-28) FIX BUG THẬT (theo yêu cầu Boss): verify_code_backed_numbers()
trước giờ CHỈ kiểm tra số liệu ĐẦU RA của code (kết quả tính toán, VD lãi
kép, mật độ) — nhưng với câu hỏi kiểu "Phần 1" (tra cứu diện tích/dân số
RỒI mới tính mật độ), các số ĐẦU VÀO (diện tích, dân số) không do sandbox
sinh ra — chúng do model TỰ ĐỌC từ kết quả search rồi gõ thành hằng số
trong code/văn bản. Sandbox chỉ xác nhận PHÉP CHIA đúng, không xác nhận
được model có đọc đúng số từ nguồn hay không (model có thể nhớ nhầm/bịa
một con số "trông hợp lý" mà không hề có trong search_context thật).
Hàm này KHÔNG chạy sandbox — chỉ đối chiếu văn bản: với mỗi số "đáng kể"
xuất hiện trong reply_text (văn xuôi) HOẶC hằng số hardcode trong code mà
KHÔNG NẰM trong bất kỳ output sandbox nào (tức là số này không phải kết
quả TÍNH TOÁN — nó phải đến từ search hoặc từ trí nhớ model), kiểm tra số
đó có xuất hiện NGUYÊN VĂN trong search_context (nội dung Tavily/web thật
đã đưa cho model) hay không.
Trả về list các số "mồ côi" — có mặt trong câu trả lời, không phải kết
quả tính toán, và KHÔNG tìm thấy trong nguồn search thật → dấu hiệu rõ
ràng của việc model tự bịa/nhớ nhầm số liệu tra cứu.
Cố ý KHÔNG tự động sửa (không biết số đúng là gì) — chỉ cảnh báo, để
Boss/Yui tự quyết định tra lại. An toàn hơn tự đoán số thay thế.
"""
if not search_context:
return [] # Không có search_context để đối chiếu — không thể verify, bỏ qua
# Số đến từ TÍNH TOÁN (đã được sandbox xác nhận) — loại trừ khỏi diện nghi ngờ
_computed_numbers = set()
for b in block_results:
_computed_numbers.update(_extract_significant_numbers(b["output"]))
# Số trong search_context thật — chuẩn hóa để so sánh đúng giá trị
_search_numbers = set(_extract_significant_numbers(search_context))
# Số trong văn bản trả lời (loại bỏ phần code, chỉ xét văn xuôi) — dùng
# bộ trích chung (bắt số có dấu phân cách/ngữ cảnh từ khóa).
_text_only = _PY_CODE_BLOCK.sub("", reply_text)
_reply_numbers = _extract_significant_numbers(_text_only)
# Số hằng số HARDCODE TRONG CODE (VD area = 2095, "Ha Noi": 3358) — bộ
# trích chung bỏ sót loại này (không dấu phân cách, không từ khóa) nên
# cần bộ trích riêng cho ngữ cảnh gán biến/dict-value (xem comment ở
# _CODE_CONSTANT_NUMBER phía trên).
_code_only = "\n".join(b["code"] for b in block_results)
_code_numbers = _extract_code_constant_numbers(_code_only)
orphaned = []
for _num in _reply_numbers + _code_numbers:
if _num in _computed_numbers:
continue # Đây là kết quả tính toán, đã được sandbox xác nhận — bỏ qua
if _num in _search_numbers:
continue # Có trong nguồn search thật — có căn cứ, bỏ qua
orphaned.append(_num)
return list(dict.fromkeys(orphaned)) # loại trùng, giữ thứ tự
def verify_code_backed_numbers(reply_text: str, user_query: str = "", search_context: str = "") -> Tuple[str, bool]:
"""
Entry point chính — gọi trong _finalize_reply() cho task_type liên
quan code. Trả về (text_đã_xử_lý, đã_can_thiệp: bool).
🆕 (2026-07-23) FIX BUG THẬT: bản trước chỉ trích + verify KHỐI CODE
ĐẦU TIÊN trong câu trả lời (dùng re.search, không phải findall). Với
câu trả lời nhiều phần (VD 3 bài toán riêng biệt, 3 khối code), chỉ
phần đầu được sandbox kiểm tra — các phần sau (nơi có thể có sai số
thật) hoàn toàn không được xác minh, dù log không hề báo lỗi gì. Đây
là nguyên nhân thật của case "13.605.000 VNĐ" sai (đáng lẽ phải là
187.713.747) mà không có cảnh báo nào kèm theo.
Giờ verify TỪNG khối code độc lập, mỗi khối tự chạy sandbox riêng —
không còn khối nào "lọt lưới" chỉ vì đứng sau khối khác trong cùng
1 câu trả lời.
🆕 (2026-07-28) search_context: nội dung search thật (Tavily/web) đã
đưa cho model, nếu có — dùng để phát hiện số liệu ĐẦU VÀO (VD diện
tích, dân số) mà model tự bịa/nhớ nhầm thay vì đọc đúng từ nguồn. Xem
_check_numbers_grounded_in_search(). Tham số tùy chọn — không truyền
gì thì bỏ qua bước này (giữ hành vi cũ với các câu hỏi thuần tính toán
không cần search).
LUỒNG (áp dụng cho MỖI khối code tìm thấy):
1. Không có code Python nào / không có số liệu kết quả → bỏ qua,
0 chi phí (giữ nguyên hành vi cũ cho câu trả lời không cần verify).
2. Mỗi khối code → chạy sandbox thật:
a. Sandbox LỖI → cảnh báo rõ ràng cho khối đó, KHÔNG gọi Pass 2.
b. Sandbox OK → gọi Pass 2 (Cohere) viết lại ĐOẠN liên quan tới
khối đó, cross-check bằng Python — khớp thì thay, không khớp
thì fallback cảnh báo cho riêng khối đó.
3. Ghép lại: nếu TẤT CẢ khối đều refine thành công & đáng tin →
dùng bản refine (Pass 2 cuối, gọi 1 lần cho toàn văn bản, xem
_call_cohere_refine_multi) để câu trả lời liền mạch, không vá
từng đoạn rời rạc. Nếu có ít nhất 1 khối cần cảnh báo → giữ
nguyên toàn bộ reply_text gốc + PHỤ LỤC liệt kê rõ khối nào đã
xác minh đúng, khối nào cần cảnh báo kèm số liệu sandbox thật.
"""
if not reply_text or "```python" not in reply_text:
return reply_text, False
# 🆕 Đảm bảo fence cân bằng NGAY TỪ ĐẦU — nếu model gốc lỡ không đóng
# dấu ``` (rất dễ xảy ra với code dài), tự đóng lại trước khi tiếp
# tục xử lý, để mọi thứ append SAU NÀY (cảnh báo, refine...) không bị
# hiểu nhầm là "vẫn còn trong code" bởi bot.py.
reply_text = _ensure_balanced_fences(reply_text)
code_blocks = _PY_CODE_BLOCK.findall(reply_text)
if not code_blocks:
return reply_text, False
if not _has_result_looking_numbers(reply_text) and not _has_unverified_admission(reply_text):
return reply_text, False
if not SANDBOX_OK:
return reply_text, False
# Verify từng khối độc lập
block_results = [] # list of dict: {code, ok, output, trustworthy}
for idx, code in enumerate(code_blocks):
ok, output = run_sandboxed(code, timeout_sec=8, allowed_modules=_pick_module_tier(code))
# 🆕 (2026-07-30, theo đề xuất Gemini + yêu cầu Boss) — MathVerifier:
# sandbox KHÔNG crash KHÔNG có nghĩa là kết quả nó in ra ĐÚNG VỀ MẶT
# TOÁN HỌC — chỉ có nghĩa là code thực thi được. Bug thật đã gặp:
# code tìm ước số đúng logic nhưng gán nhầm biến khi in, sandbox chạy
# "OK" (không crash) nhưng in ra phát biểu sai "1681 = 1681 × 1681".
# Đây là lớp bug mà cross-check SỐ LIỆU (so số với số) không bắt
# được — số "sai" ở đây CHÍNH LÀ số sandbox tự in ra, không phải số
# bị narration bịa thêm. MathVerifier tự GIẢI LẠI từng "A = B" trong
# chính output để xác nhận độc lập, không tin suông vào sandbox
# "không crash" = "đúng logic".
if ok:
_math_ok, _math_reasons = verify_math_statements_in_output(output)
if not _math_ok:
print(f"[CODE-VERIFIER] ⚠️ Khối #{idx+1}: MathVerifier phát hiện phát biểu toán học SAI "
f"trong chính output sandbox (dù không crash): {_math_reasons}")
# Hạ cấp thành "không đáng tin" — chảy vào nhánh cảnh báo
# (not all_ok) thay vì được tin dùng thẳng cho narration/
# template, dù về mặt kỹ thuật sandbox không hề báo lỗi.
ok = False
output = output + (f"\n[⚠️ CẢNH BÁO TOÁN HỌC] " + " | ".join(_math_reasons))
block_results.append({"idx": idx, "code": code, "ok": ok, "output": output})
status = "✅ OK" if ok else "❌ LỖI"
print(f"[CODE-VERIFIER] Khối code #{idx+1}/{len(code_blocks)}: {status}")
all_ok = all(b["ok"] for b in block_results)
# 🆕 (2026-08-01, phát hiện qua bug thật, áp dụng cho MỌI bài toán có
# bước kiểm chứng nghiệm/thay-số — không riêng 3 câu test) —
# EquationConclusionVerifier: đối chiếu nghiệm được SANDBOX XÁC NHẬN
# True với nghiệm được VĂN XUÔI KẾT LUẬN — bắt đúng loại bug "sandbox
# đúng nhưng kết luận nói ngược lại" (xem comment đầy đủ ở
# verify_equation_conclusion()). Chạy trên TOÀN VĂN BẢN + gộp output
# mọi khối (không chỉ khối riêng lẻ, vì câu kết luận thường nằm ngoài
# code block). An toàn: hàm tự bỏ qua nếu không có format kỳ vọng,
# không đoán mò.
_combined_output_for_eq_check = "\n".join(b["output"] for b in block_results if b["ok"])
_eq_ok, _eq_reason = verify_equation_conclusion(_combined_output_for_eq_check, reply_text)
if not _eq_ok:
print(f"[CODE-VERIFIER] ⚠️ EquationConclusionVerifier phát hiện mâu thuẫn: {_eq_reason}")
all_ok = False # Hạ cấp — buộc rơi vào nhánh cảnh báo dù mọi khối sandbox đều chạy "OK"
# 🆕 (2026-07-28) FIX BUG THẬT (Fix B theo yêu cầu Boss): kiểm tra số liệu
# ĐẦU VÀO (hardcode trong code, VD area=2095/population=9521800) có căn cứ
# trong search_context thật hay không — xem _check_numbers_grounded_in_search().
# Chạy TRƯỚC khi rẽ nhánh all_ok, vì đây là vấn đề độc lập với sandbox chạy
# đúng/lỗi: sandbox chỉ xác nhận PHÉP TÍNH đúng, không xác nhận model đọc
# đúng số từ nguồn search. Chỉ áp dụng khi CÓ search_context truyền vào —
# câu hỏi thuần tính toán (không cần search, VD lãi kép/Euler) không bị
# ảnh hưởng gì (search_context rỗng → hàm tự bỏ qua, xem code hàm).
_orphaned_numbers = []
if search_context:
_orphaned_numbers = _check_numbers_grounded_in_search(reply_text, block_results, search_context)
if _orphaned_numbers:
print(f"[CODE-VERIFIER] ⚠️ Số liệu ĐẦU VÀO không tìm thấy trong search_context thật "
f"(có thể model tự bịa/nhớ nhầm khi đọc nguồn): {_orphaned_numbers[:5]}")
if not all_ok:
# 🐛 FIX (2026-07-28): TRƯỚC ĐÂY khi có ≥1 khối LỖI, nhánh này chỉ
# append phụ lục cảnh báo mà KHÔNG bao giờ gọi
# _try_direct_patch_wrong_numbers() cho các khối vẫn CHẠY OK (hàm
# đó trước chỉ được gọi ở nhánh all_ok=True bên dưới). Hậu quả:
# nếu Cohere generate số liệu bịa (hallucinate) cho 1 phần OK
# (VD lãi kép, quét Euler) NGAY CẢ KHI sandbox phần đó chạy đúng,
# số bịa vẫn nằm nguyên trong thân bài — phụ lục chỉ thêm số đúng
# ở CUỐI mà không xoá/thay số sai ở trên, khiến Boss thấy 2 số
# khác nhau cho cùng 1 kết quả ("trả sai tùm lum"). Giờ vá trực
# tiếp các khối OK trước, bất kể có khối khác lỗi hay không —
# _try_direct_patch_wrong_numbers() tự bỏ qua khối lỗi (xem
# `if not b["ok"]: continue` bên trong), nên gọi ở đây an toàn.
_patched_text, _patch_count = _try_direct_patch_wrong_numbers(reply_text, block_results)
if _patch_count > 0:
print(f"[CODE-VERIFIER] ✅ Đã tự động thay {_patch_count} số sai trực tiếp (dù có khối khác lỗi)")
reply_text = _patched_text
# Có ít nhất 1 khối lỗi — liệt kê rõ khối nào lỗi, khối nào OK
lines = ["\n\n---", "⚠️ **CẢNH BÁO: một số phần số liệu CHƯA được xác minh bằng code thật.**"]
for b in block_results:
if b["ok"]:
lines.append(f" • Phần {b['idx']+1}: ✅ đã chạy thật, kết quả: `{b['output'][:150].strip()}`")
else:
lines.append(f" • Phần {b['idx']+1}: ❌ code lỗi ({b['output'][:150]}) — số liệu phần này CÓ THỂ SAI, Boss nên yêu cầu Yui sửa lại.")
if _orphaned_numbers:
lines.append(f" • ⚠️ Số liệu ĐẦU VÀO sau đây KHÔNG tìm thấy trong kết quả tra cứu thật — "
f"CÓ THỂ model đã tự nhớ nhầm/bịa khi đọc nguồn: {', '.join(_orphaned_numbers[:5])}. "
f"Boss nên yêu cầu Yui tra cứu lại và đối chiếu trực tiếp.")
print(f"[CODE-VERIFIER] ⚠️ {sum(1 for b in block_results if not b['ok'])}/{len(block_results)} khối lỗi")
return reply_text + "\n".join(lines), True
# 🐛→✅ REDESIGN (2026-07-28, "Cách A" theo yêu cầu Boss): TRƯỚC ĐÂY khi
# tất cả khối OK, hệ thống vẫn để Cohere (Pass 2 / _call_cohere_refine)
# "viết lại" câu trả lời bằng lời — rồi cross-check bằng Python. Nhưng
# MỌI ca sai số liệu Boss gặp (983 triệu thay vì 938 triệu; n=41 thay vì
# n=40; số lai "938,568,732.64,55") đều bắt nguồn từ CÙNG 1 chỗ: có một
# model đang "nhai lại" số bằng lời TRƯỚC KHI hoặc THAY VÌ dùng nguyên
# văn kết quả sandbox — dù có cross-check sau đó, cross-check chỉ bắt
# được số THIẾU hẳn, không bắt được số bị diễn giải/làm tròn/gõ nhầm 1
# chữ số mà vẫn "đủ dài tương tự". Cách sửa triệt để nhất: KHÔNG CÒN
# LLM NÀO được phép viết lại các con số nữa — chỉ ghép template CỐ ĐỊNH
# (không qua model) + in NGUYÊN VĂN output sandbox thật. Không có bước
# "review lại bằng lời" thì không có gì để bịa.
#
# Giữ lại phần dẫn nhập (câu mở đầu, không chứa số) từ reply_text gốc
# nếu có — để câu trả lời không bị cụt ngủn, chỉ CẮT BỎ phần thân có số
# liệu (đoạn từ khối code Python đầu tiên trở đi) và thay bằng kết quả
# sandbox thật, nguyên văn.
# 🐛 FIX (2026-07-28): cắt tại "```python" ĐẦU TIÊN là chưa đủ — văn bản
# nguồn có thể xen kẽ (code #1 → văn xuôi có số bịa Phần 2 → code #2 →
# văn xuôi có số bịa Phần 3 → code #3), nên phần "intro" tính theo cách
# cũ vẫn kéo theo các đoạn văn xuôi CÓ SỐ nằm sau khối code đầu tiên.
# Cách an toàn tuyệt đối: chỉ giữ các DÒNG ĐẦU liên tiếp không chứa bất
# kỳ số "đáng kể" nào (dùng đúng bộ dò đã có, không đoán riêng) — dừng
# ngay khi gặp dòng đầu tiên có số hoặc gặp "```python", tùy cái nào
# trước. Đảm bảo intro không bao giờ chứa số để tái diễn giải sai.
_first_code_pos = reply_text.find("```python")
_search_region = reply_text[:_first_code_pos] if _first_code_pos != -1 else reply_text
_intro_lines = []
for _line in _search_region.split("\n"):
if _extract_significant_numbers(_line):
break
_intro_lines.append(_line)
_intro = "\n".join(_intro_lines).strip()
_lines = [_intro] if _intro else []
_lines.append("\n**Kết quả chạy thật (sandbox, không qua diễn giải lại):**")
# 🆕 (2026-07-29, theo yêu cầu Boss) — THỬ diễn giải bằng LLM với NGỮ
# CẢNH TỐI GIẢN trước (chỉ câu hỏi + kết quả sandbox, không code/lịch
# sử) để câu trả lời tự nhiên hơn template cứng thuần túy — nhưng BẮT
# BUỘC cross-check bằng Python ngay sau đó (_numbers_match_sandbox_
# exactly). Nếu LLM bịa/làm tròn/tính sai DÙ CHỈ 1 SỐ, từ chối toàn bộ
# và rơi về template cứng bên dưới — không đánh đổi độ an toàn để lấy
# văn phong tự nhiên hơn.
_narrated = _narrate_results_minimal_context(user_query, block_results)
if _narrated and _numbers_match_sandbox_exactly(_narrated, block_results):
_final_lines = [_narrated]
if _orphaned_numbers:
_final_lines.append(f"\n⚠️ **CẢNH BÁO SỐ LIỆU ĐẦU VÀO:** các số sau đây được dùng làm hằng số trong "
f"code nhưng KHÔNG tìm thấy trong kết quả tra cứu thật — CÓ THỂ Yui đã tự nhớ "
f"nhầm/bịa khi đọc nguồn search: {', '.join(_orphaned_numbers[:5])}. Nên yêu cầu "
f"tra cứu lại và đối chiếu trực tiếp với nguồn trước khi tin số này.")
print(f"[CODE-VERIFIER] ✅ Narration (ngữ cảnh tối giản, đã cross-check khớp 100% sandbox) — "
f"dùng bản văn tự nhiên thay template thô.")
return "\n".join(_final_lines), True
# Narration thất bại/không khớp — rơi về template cứng (an toàn tuyệt
# đối, không qua LLM nào, không thể bịa số) như thiết kế gốc.
for b in block_results:
# 🐛 FIX (2026-07-28): TRƯỚC ĐÂY bọc output bằng ```...``` (fence
# KHÔNG có tag ngôn ngữ) — nhưng bot.py._extract_code_files() dùng
# regex _CODE_RE bắt MỌI khối ```...``` (không quan tâm có tag hay
# không) để TÁCH RA FILE ĐÍNH KÈM, rồi _strip_code() XÓA SẠCH nội
# dung đó khỏi text hiển thị trên Discord (giả định code đã có
# trong file, không cần hiện lại trong tin nhắn). Hậu quả: toàn bộ
# số liệu sandbox thật bị nuốt mất khỏi tin nhắn — Boss chỉ thấy
# "Phần 1:" / "Phần 2:" / "Phần 3:" TRỐNG TRƠN, dù dữ liệu vẫn đúng
# (chỉ là bị đẩy vào file đính kèm rời, không có trong text). Giờ
# dùng Discord blockquote ("> " mỗi dòng) — vẫn hiển thị rõ ràng,
# tách biệt với văn xuôi, nhưng KHÔNG bị _CODE_RE nhận nhầm là code
# nên không còn bị strip khỏi tin nhắn.
_quoted_output = "\n".join(f"> {_ol}" for _ol in b['output'].strip().split("\n"))
_lines.append(f"\n*Phần {b['idx']+1}:*\n{_quoted_output}")
if _orphaned_numbers:
_lines.append(f"\n⚠️ **CẢNH BÁO SỐ LIỆU ĐẦU VÀO:** các số sau đây được dùng làm hằng số trong "
f"code nhưng KHÔNG tìm thấy trong kết quả tra cứu thật — CÓ THỂ Yui đã tự nhớ "
f"nhầm/bịa khi đọc nguồn search: {', '.join(_orphaned_numbers[:5])}. Nên yêu cầu "
f"tra cứu lại và đối chiếu trực tiếp với nguồn trước khi tin số này.")
print(f"[CODE-VERIFIER] ✅ Template cố định (không qua LLM) — {len(code_blocks)} khối đều khớp sandbox thật, không có bước diễn giải lại nào có thể bịa số.")
return "\n".join(_lines), True
def _narrate_results_minimal_context(user_query: str, block_results: list) -> Optional[str]:
"""
🆕 (2026-07-29, theo yêu cầu Boss) — bước lọc kết quả TRƯỚC khi gửi cho
LLM diễn giải thành văn, dùng NGỮ CẢNH TỐI GIẢN thay vì gộp cả code +
output + lịch sử chat thành 1 cục dài như thiết kế cũ (_call_cohere_
refine) — Boss nghi ngờ đúng: khi output thật nằm GIỮA một đống code
dài + lịch sử hội thoại, model dễ "bỏ qua khúc giữa" (hiện tượng có
thật ở LLM, gọi là "lost in the middle") và tự bịa số thay vì đọc kết
quả thật đang có sẵn ngay trước mắt.
Ngữ cảnh gửi cho LLM ở đây CHỈ GỒM:
- Câu hỏi gốc rút gọn (để LLM biết đang trả lời cái gì)
- CHỈ output sandbox thật của từng phần (KHÔNG kèm code, KHÔNG kèm
lịch sử chat, KHÔNG kèm toàn văn bản trả lời gốc)
→ prompt ngắn, kết quả thật luôn nằm ở vị trí dễ thấy nhất (không bị
chôn giữa hàng nghìn ký tự code), giảm tối đa nguy cơ hallucination.
Dùng nhóm "fast_light" (Gemini/SambaNova/Mistral/Cohere/OpenRouter/
GitHub/NVIDIA tự xoay vòng theo sức khỏe runtime) thay vì khóa cứng
vào 1 provider — TỐI ƯU QUOTA nhất vì tự chuyển provider khác khi 1
cái hết hạn ngạch, và việc diễn giải văn ngắn từ số liệu có sẵn là
task NHẸ, đúng mục đích thiết kế của "fast_light".
Trả về text đã diễn giải (CHƯA qua cross-check — caller phải tự
verify bằng _numbers_match_sandbox_exactly trước khi dùng), hoặc None
nếu lỗi/không có provider nào — caller fallback về template cứng.
"""
_results_block = "\n\n".join(
f"[Phần {b['idx']+1}] Kết quả chạy code THẬT (đã xác minh, không phải ước lượng):\n{b['output'].strip()}"
for b in block_results
)
prompt = (
f"Câu hỏi gốc (rút gọn): {user_query[:200]}\n\n"
f"{_results_block}\n\n"
"Nhiệm vụ: viết lại các kết quả trên thành văn xuôi TỰ NHIÊN, ngắn "
"gọn, dễ đọc — CHỈ dùng ĐÚNG các con số đã cho ở trên, COPY CHÍNH "
"XÁC từng chữ số, không làm tròn, không tự tính lại, không thêm "
"bất kỳ con số nào không có trong dữ liệu trên. Không giải thích "
"bạn đang làm gì, chỉ đưa thẳng văn bản kết quả."
)
try:
from yui_model_router import call_task_group
text = _run_sync(call_task_group("fast_light", prompt=prompt, system="", max_tokens=500, timeout_sec=10))
return text.strip() if text else None
except Exception as e:
print(f"[CODE-VERIFIER] ⚠️ Narration (fast_light) lỗi: {type(e).__name__}: {str(e)[:150]}")
return None
def _extract_all_numbers_from_trusted_output(text: str) -> set:
"""
🆕 (2026-07-29) Trích MỌI số (≥2 chữ số) từ một văn bản ĐÃ ĐƯỢC TIN
CẬY TUYỆT ĐỐI (output sandbox thật, do chính Python in ra) — dùng
riêng cho _numbers_match_sandbox_exactly(), KHÁC với
_extract_significant_numbers() (dùng cho văn xuôi tự do, cố ý giới
hạn ngặt — chỉ số có định dạng phân cách hoặc kèm từ khóa rõ ràng —
để tránh bắt nhầm số không liên quan như "10 năm", "3 kịch bản").
Sandbox output không có rủi ro đó: mọi số trong đó ĐỀU là kết quả
tính toán thật, nên trích rộng rãi hơn là an toàn và cần thiết — nếu
không, số như "A = 1681" (không có từ khóa "kết quả"/"n=" ngay trước)
sẽ vô hình với bộ trích cũ, khiến narration ĐÚNG bị từ chối OAN chỉ vì
bộ trích không đủ nhạy, không phải vì narration sai.
"""
out = set()
for m in re.finditer(r"\d[\d.,]*\d|\d", text):
norm = _normalize_number(m.group(0))
if len(norm) >= 2:
out.add(norm)
return out
def _numbers_match_sandbox_exactly(narrated_text: str, block_results: list) -> bool:
"""
Cross-check BẮT BUỘC sau bước narrate ở trên — KHÔNG tin lời hứa của
LLM "đã copy chính xác", tự kiểm tra bằng Python: mọi số "đáng kể"
xuất hiện trong văn bản đã narrate PHẢI khớp với 1 số nào đó có thật
trong output sandbox (của bất kỳ khối nào) — nếu có dù chỉ 1 số lạ
(không tìm thấy ở bất kỳ đâu trong sandbox_output), coi là LLM đã bịa/
làm tròn/tính sai trong lúc diễn giải → từ chối toàn bộ, để caller
fallback về template cứng (an toàn tuyệt đối, dù kém tự nhiên hơn).
"""
if not narrated_text:
return False
_sandbox_numbers = set()
for b in block_results:
# 🐛 FIX (2026-07-29): TRƯỚC ĐÂY dùng _extract_significant_numbers()
# (dành cho văn xuôi, giới hạn ngặt) để trích số từ sandbox_output —
# khiến số như "A = 1681" (không kèm từ khóa nhận diện) bị bỏ sót,
# gây FALSE REJECTION cho narration ĐÚNG (test xác nhận: narration
# khớp 100% vẫn bị từ chối vì "1681" "không tìm thấy" dù nó CÓ
# trong sandbox_output). Output sandbox đáng tin tuyệt đối nên trích
# rộng rãi hơn bằng _extract_all_numbers_from_trusted_output().
_sandbox_numbers.update(_extract_all_numbers_from_trusted_output(b["output"]))
_narrated_numbers = _extract_significant_numbers(narrated_text)
for _num in _narrated_numbers:
if _num not in _sandbox_numbers:
print(f"[CODE-VERIFIER] ⚠️ Narration bị từ chối — số '{_num}' không khớp bất kỳ kết quả "
f"sandbox nào (LLM có thể đã bịa/làm tròn/tính sai khi diễn giải).")
return False
return True
def _try_direct_patch_wrong_numbers(reply_text: str, block_results: list) -> Tuple[str, int]:
"""
🆕 (2026-07-25) Tìm-và-thay TRỰC TIẾP số sai trong văn bản gốc bằng
số đúng từ sandbox — KHÔNG gọi LLM, hoạt động độc lập với Pass 2.
Chiến lược AN TOÀN (thà bỏ qua còn hơn thay nhầm): với mỗi khối,
lấy số "kết quả chính" (số formatted dài nhất trong output — thường
là số tiền/kết quả cuối, VD "187,713,747") — nếu văn bản GỐC có
ĐÚNG 1 số formatted khác với số này (không phải chính nó), coi đó
là "số sai tương ứng" và thay thế. Nếu văn bản có 0 hoặc >1 số khả
nghi, KHÔNG đoán — bỏ qua, để phụ lục làm phương án dự phòng.
"""
patched = reply_text
patch_count = 0
# 🐛 FIX (2026-07-29) — BUG THẬT phát hiện qua case thực tế: câu trả lời
# nhiều phần (VD 3 bài toán độc lập: Phần 1 có bảng dân số/diện tích,
# Phần 2 là lãi kép, Phần 3 là Euler) khiến việc tìm "candidate" trên
# TOÀN VĂN BẢN dễ vấp phải số liệu của CÁC PHẦN KHÁC tình cờ cùng độ dài
# (±2 chữ số) với số đúng của phần đang xét — VD số đúng Phần 2 có 9 chữ
# số ("938568733"), nhưng bảng Phần 1 có nhiều số 7 chữ số ("9732068",
# "2455865"...) đều lọt vào ngưỡng ±2, tạo RA NHIỀU HƠN 1 candidate →
# patch bị từ chối OAN (an toàn nhưng bỏ lỡ), để số bịa "2.886.688.240"
# ở Phần 2 nguyên vẹn trong bài dài nhiều phần.
#
# Giờ tách văn bản thành các "cửa sổ" theo vị trí khối code (mỗi khối
# code + đoạn văn xuôi NGAY SAU nó, trước khối code tiếp theo, được coi
# là 1 cửa sổ riêng) — tìm candidate CHỈ trong cửa sổ tương ứng với
# đúng khối đang xét, không còn bị nhiễu bởi số liệu của phần khác.
_code_fence_positions = [m.span() for m in _PY_CODE_BLOCK.finditer(reply_text)]
for b in block_results:
if not b["ok"]:
continue
sandbox_numbers = _extract_significant_numbers(b["output"])
if not sandbox_numbers:
continue
# Số kết quả chính — ưu tiên số đứng GẦN từ khóa kết quả/tổng
# (VD "Tổng số tiền", "kết quả", "sau X năm"), vì chỉ dựa vào độ
# dài chuỗi (như bản đầu dùng max(key=len)) dễ chọn NHẦM số đầu
# vào/số gốc khi nó tình cờ dài bằng số kết quả thật (VD "Số tiền
# gốc: 100,000,000" và "Tổng số tiền: 187,713,747" cùng 11 ký tự
# — max() trả về phần tử ĐẦU TIÊN khi tie, luôn sai trong ca này).
_result_keyword_pattern = re.compile(
r"(?:tổng|kết quả|sau\s+\d+|đáp số|answer|result|total)[^\n]{0,40}?"
r"(" + _FORMATTED_NUMBER.pattern + r")", re.I
)
_keyword_match = _result_keyword_pattern.search(b["output"])
if _keyword_match:
_correct_number_raw = _keyword_match.group(1)
else:
# Không có từ khóa rõ ràng — fallback về số CUỐI CÙNG trong
# output (thường là dòng kết luận, đáng tin hơn số đầu tiên
# thường là input/tham số đầu vào).
_all_formatted = _FORMATTED_NUMBER.findall(b["output"])
_correct_number_raw = _all_formatted[-1] if _all_formatted else None
if not _correct_number_raw:
continue
_correct_normalized = _normalize_number(_correct_number_raw)
# 🆕 (2026-07-29) Xác định CỬA SỔ văn bản riêng cho khối này: từ
# ngay sau khối code #idx (trong bản gốc reply_text, chưa patch)
# tới trước khối code #idx+1, hoặc hết văn bản nếu là khối cuối.
# Dùng vị trí trong reply_text GỐC (không phải `patched` đang bị
# sửa dần) để tránh lệch offset khi patch trước đó đã đổi độ dài
# chuỗi — nhưng nội dung SO SÁNH/THAY THẾ vẫn thao tác trên
# `patched` bằng cách match lại theo NỘI DUNG (không theo offset).
if b["idx"] < len(_code_fence_positions):
_window_start = _code_fence_positions[b["idx"]][1] # ngay sau khối code này
else:
_window_start = 0
_window_end = (_code_fence_positions[b["idx"] + 1][0]
if b["idx"] + 1 < len(_code_fence_positions) else len(reply_text))
_local_window_text = reply_text[_window_start:_window_end]
# 🐛 FIX (2026-07-29) BUG THẬT phát hiện qua case thực tế tiếp theo:
# cửa sổ cục bộ ở trên (đúng cho việc tránh nhiễu GIỮA các phần) lại
# bỏ sót đúng trường hợp phổ biến khác — đoạn "Tổng kết"/"Tóm tắt" Ở
# CUỐI TOÀN BÀI thường nhắc lại kết quả của MỌI phần (không chỉ phần
# cuối). Đoạn tóm tắt này nằm SAU khối code CUỐI CÙNG, nên nó chỉ
# thuộc "cửa sổ" của khối cuối theo logic cũ — số sai của Phần 2 nằm
# trong tóm tắt cuối bài (chứ không lặp lại ngay sau code Phần 2)
# hoàn toàn vô hình với cửa sổ của khối #1 (Phần 2) → patch bị bỏ
# sót (count=0), để "983.065.693" (số bịa) nguyên vẹn trong tóm tắt.
#
# Sửa: LUÔN cộng thêm phần "đuôi văn bản" (mọi thứ sau khối code
# CUỐI CÙNG) vào cửa sổ của MỌI khối — vì đoạn tóm tắt cuối bài về
# bản chất có thể nhắc tới kết quả của bất kỳ phần nào, không riêng
# phần cuối. Không mở rộng thêm gì khác để giữ nguyên lợi ích chống
# nhiễu giữa-các-phần của fix trước.
if _code_fence_positions:
_tail_start = _code_fence_positions[-1][1]
if _tail_start > _window_end: # tránh nối trùng nếu khối này đã là khối cuối
_local_window_text += "\n" + reply_text[_tail_start:]
# Tìm ứng viên CHỈ trong cửa sổ cục bộ này (không phải toàn văn
# bản) — loại bỏ hoàn toàn nhiễu từ số liệu của các phần khác.
# 🐛 FIX (2026-07-29): TRƯỚC ĐÂY so sánh len(_normalize_number(...))
# trực tiếp — khi số đúng có đuôi thập phân dị dạng dài (VD Decimal
# với precision=20 sinh ra "938,568,732.63468005390", 11 chữ số sau
# dấu chấm), _normalize_number() nối liền cả đuôi đó vào, khiến độ
# dài bị lệch nghiêm trọng (20 thay vì 9) — số sai thật (cùng 9 chữ
# số phần nguyên) bị coi là "quá khác biệt" nên KHÔNG được nhận
# diện là candidate, patch bị bỏ sót hoàn toàn. Dùng
# _integer_part_length() để so đúng phần nguyên có ý nghĩa.
_correct_int_len = _integer_part_length(_correct_number_raw)
_candidates_in_text = [
m for m in _FORMATTED_NUMBER.findall(_local_window_text)
if _normalize_number(m) != _correct_normalized
and abs(_integer_part_length(m) - _correct_int_len) <= 2
]
_unique_candidates = list(dict.fromkeys(_candidates_in_text))
if len(_unique_candidates) != 1:
# 0 ứng viên (không tìm thấy gì để thay) hoặc >1 ứng viên
# (không chắc chắn cái nào đúng là số sai) — KHÔNG đoán mò
continue
_wrong_number_raw = _unique_candidates[0]
patched = patched.replace(_wrong_number_raw, _correct_number_raw)
patch_count += 1
print(f"[CODE-VERIFIER] 🔧 Patch trực tiếp: '{_wrong_number_raw}' → '{_correct_number_raw}'")
return patched, patch_count
print("✅ [CODE-VERIFIER] Code-Backed Numbers Verifier v2.0 loaded — Two-Pass Refinement")
print(f" Pass 2 model: {_REFINE_MODEL} (Cohere free — tính theo request, tách quota khỏi model chính)")
print(" Cross-check bằng Python (không chỉ tin lời hứa LLM) → chính xác THẬT 100%")
print(" verify_code_backed_numbers(reply_text, user_query) → (text, đã_can_thiệp)")
# ════════════════════════════════════════════════════════════════
# [v2.1 — 2026-07-22] PUZZLE VERIFIER — mở rộng sang task "chat"
# ════════════════════════════════════════════════════════════════
# Boss yêu cầu: câu đố mẹo/toán trong chat bình thường (không phải
# task code/debug/math) hiện KHÔNG được verify gì cả — model có thể
# tự tin đưa đáp số sai mà không ai kiểm tra, vì verify_code_backed_
# numbers() ở trên CHỈ chạy khi model đã TỰ NGUYỆN viết code Python
# (dòng "if not reply_text or '```python' not in reply_text").
#
# Khác biệt cốt lõi so với verify_code_backed_numbers():
# - Không đợi model tự viết code — TỰ SINH code Python để tính,
# dùng model rẻ (Cohere, cùng model Pass 2 cũ) làm việc này.
# - Chỉ kích hoạt khi câu hỏi CÓ DẤU HIỆU là bài toán/đố có đáp số
# khách quan (regex nhẹ, 0 LLM call để lọc — tối ưu quota đúng
# tinh thần Boss yêu cầu) — không chạy tràn lan cho mọi câu chat.
# - Tái dùng NGUYÊN VẸN run_sandboxed() + pattern Two-Pass refine +
# cross-check đã có ở trên — không viết lại logic an toàn.
# ════════════════════════════════════════════════════════════════
# Dấu hiệu câu hỏi CÓ THỂ là bài toán/đố có đáp số khách quan — quét
# NHẸ bằng từ khóa, không LLM, để quyết định có đáng thử sinh code
# hay không.
#
# 🐛 FIX (2026-08-20, cùng đợt sửa _looks_like_puzzle ở dưới): pattern
# "xác suất|tỷ lệ|phần trăm" quá RỘNG — khớp cả câu hỏi thời sự bình
# thường (VD "tỷ lệ thất nghiệp hiện nay bao nhiêu", "phần trăm giảm
# giá đợt này") không hề là bài toán cần giải bằng code, chỉ là hỏi 1
# con số thực tế cần TRA CỨU (search), không phải TÍNH TOÁN. Thêm điều
# kiện phải có TỪ NGỮ YÊU CẦU TÍNH TOÁN đi kèm (tính/giải/bao nhiêu nếu
# ... thì) mới coi là câu đố — một mình "xác suất/tỷ lệ/phần trăm" đứng
# riêng KHÔNG còn đủ để kích hoạt.
_PUZZLE_SIGNAL_PATTERNS = [
re.compile(r"\d+\s*(?:\+|-|\*|x|×|/|÷)\s*\d+"), # phép tính thô "12 x 8"
re.compile(r"tính (?:giúp|xem|ra|thử)|giải (?:giúp|xem)?\s*(?:mình\s*)?(?:bài|đố)", re.I),
re.compile(r"câu đố|đố vui|đố mẹo", re.I),
# "bao nhiêu" CHỈ tính là dấu hiệu câu đố khi đi kèm đơn vị đếm vật cụ
# thể (con/cái/quả/người/đồng...) — kiểu câu đố đếm chân gà/vịt kinh
# điển. KHÔNG khớp "bao nhiêu" đứng riêng (VD "dân số bao nhiêu",
# "giá bao nhiêu" — đây là câu hỏi thời sự cần TRA CỨU, không phải
# TÍNH TOÁN, xem comment ở _looks_like_puzzle).
re.compile(r"bao nhiêu\s*(?:người|con|cái|quả|đồng|chân|cây|quyển|chiếc)", re.I),
# Chỉ khớp "xác suất/tỷ lệ/phần trăm" khi ĐI KÈM yêu cầu tính toán rõ
# ràng (tính/là bao nhiêu nếu/của việc) — không khớp khi đứng riêng
# trong câu hỏi tin tức/thời sự thông thường.
re.compile(r"(?:tính|là bao nhiêu|của việc).{0,20}(?:xác suất|tỷ lệ|phần trăm)", re.I),
re.compile(r"(?:xác suất|tỷ lệ|phần trăm).{0,20}(?:là bao nhiêu|tính)", re.I),
]
_PUZZLE_CODE_MODEL = _REFINE_MODEL # dùng chung model rẻ với Pass 2
def _looks_like_puzzle(user_query: str, reply_text: str) -> bool:
"""Quét nhẹ, 0 LLM call — chỉ để quyết định có ĐÁNG THỬ hay không.
🐛 FIX (2026-08-20, Boss báo lỗi thật): bản trước quét CẢ reply_text
(combined = user_query + reply_text) — gây DƯƠNG TÍNH GIẢ nghiêm
trọng: bất kỳ câu trả lời chat bình thường nào (VD tổng hợp tin tức
qua MRAC) chỉ cần tình cờ chứa 1 từ như "tỷ lệ"/"phần trăm"/"xác
suất" (rất phổ biến trong tin tức thời sự/kinh tế) là bị hiểu nhầm
thành "câu đố cần giải lại bằng sandbox" — dù USER chỉ hỏi phiếm
("hôm nay có gì thú vị"), không hề đặt câu đố nào. Log thật: Yui trả
lời tin tức bình thường, verifier tự sinh code random.choice() không
liên quan, rồi BÁO CÁO KẾT QUẢ SANDBOX ĐÓ THAY CHO CÂU TRẢ LỜI THẬT —
làm mất hoàn toàn nội dung tin tức đã tổng hợp.
Đây là lỗi LOGIC PHÁT HIỆN (regex quét sai phạm vi), KHÔNG PHẢI lỗi
hiểu tiếng Việt của model — model/MRAC/search đều đã chạy đúng (xem
log: MRAC summary=243c/facts=180c/reasoning=881c là dữ liệu thật).
Giờ CHỈ quét user_query — đúng tinh thần "câu đố phải đến từ câu hỏi
của người dùng", không phải suy ra ngược từ nội dung Yui vừa trả lời.
"""
return any(p.search(user_query) for p in _PUZZLE_SIGNAL_PATTERNS)
def _generate_verifier_code(user_query: str, reply_text: str) -> Optional[str]:
"""
Yêu cầu model để sinh code Python tính lại đáp số của câu hỏi.
🆕 (2026-07-22, theo yêu cầu Boss): dùng nhóm "code" của
yui_model_router (Mistral Codestral ưu tiên đầu, xem
yui_task_group_adapters) thay vì gọi thẳng Cohere như bản đầu —
2 lý do:
1. Tiết kiệm quota Cohere — bản cũ tốn 2 lần gọi Cohere/câu đố
(sinh code + refine); giờ chỉ còn 1 lần (refine).
2. Chất lượng cao hơn — model chuyên sinh code (Codestral) cho
code chính xác hơn model tổng quát (Cohere command-a) vốn
không chuyên code.
Nếu router lỗi/không có provider (hiếm — đã có cơ chế tự chờ boot
ở call_task_group), fallback về gọi thẳng Cohere như bản cũ để
không mất tính năng.
"""
prompt = (
"Đây là câu hỏi của người dùng:\n"
f"{user_query[:400]}\n\n"
"Nếu câu hỏi này có ĐÁP SỐ KHÁCH QUAN có thể tính bằng code Python "
"(toán học, xác suất, đếm, logic có công thức...), hãy viết ĐÚNG 1 "
"đoạn code Python ngắn gọn để tính ra đáp số đó, kết thúc bằng "
"print(kết_quả). CHỈ trả về code trong khối ```python, không giải "
"thích gì thêm.\n\n"
"Nếu câu hỏi KHÔNG có đáp số khách quan tính được bằng code (câu "
"đố chơi chữ, câu hỏi ý kiến, kiến thức không tính toán được...), "
"trả về ĐÚNG 1 dòng duy nhất: KHÔNG_THỂ_TÍNH"
)
text = None
try:
from yui_model_router import call_task_group
text = _run_sync(call_task_group("code", prompt=prompt, system="", max_tokens=600))
except Exception as e:
print(f"[PUZZLE-VERIFIER] ⚠️ yui_model_router lỗi ({type(e).__name__}), fallback Cohere trực tiếp")
if not text:
text = _generate_verifier_code_cohere_fallback(prompt)
if not text or "KHÔNG_THỂ_TÍNH" in text:
return None
m = _PY_CODE_BLOCK.search(text)
return m.group(1).strip() if m else None
def _run_sync(coro):
"""
Chạy 1 coroutine an toàn từ context sync, độc lập với app.py (module
này không import app.py để tránh circular import) — bản rút gọn của
cùng ý tưởng _run_async_safe() đã có bên app.py.
"""
import asyncio as _aio
try:
loop = _aio.get_event_loop()
if loop.is_running():
import concurrent.futures as _cf
with _cf.ThreadPoolExecutor(max_workers=1) as pool:
return pool.submit(lambda: _aio.new_event_loop().run_until_complete(coro)).result(timeout=20)
return loop.run_until_complete(coro)
except RuntimeError:
return _aio.run(coro)
def _generate_verifier_code_cohere_fallback(prompt: str) -> Optional[str]:
"""Fallback nếu yui_model_router lỗi — giữ tính năng hoạt động
bằng cách gọi thẳng Cohere như bản cũ, thay vì mất tính năng hoàn toàn."""
cohere_keys = [os.environ.get(k) for k in os.environ
if "COHERE_KEY" in k.upper() and os.environ.get(k)]
if not cohere_keys:
return None
try:
import cohere
key = random.choice(cohere_keys)
client = cohere.ClientV2(api_key=key)
resp = client.chat(
model=_PUZZLE_CODE_MODEL,
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
max_tokens=600,
)
content = resp.message.content or []
text = ""
for item in content:
if getattr(item, "type", None) == "text" and hasattr(item, "text"):
text = item.text or ""
break
if not text or "KHÔNG_THỂ_TÍNH" in text:
return None
m = _PY_CODE_BLOCK.search(text)
return m.group(1).strip() if m else None
except Exception as e:
print(f"[PUZZLE-VERIFIER] Sinh code lỗi: {type(e).__name__}: {str(e)[:150]}")
return None
def verify_puzzle_answer(reply_text: str, user_query: str = "") -> Tuple[str, bool]:
"""
Entry point cho task "chat"/"knowledge" — verify đáp số câu đố/toán
ngay cả khi model KHÔNG tự viết code. Trả về (text, đã_can_thiệp).
TỐI ƯU QUOTA (đúng yêu cầu Boss):
1. Quét nhẹ 0-LLM trước (_looks_like_puzzle) — hầu hết câu chat
thường (chào hỏi, tâm sự...) dừng ở đây, 0 chi phí thêm.
2. Chỉ khi có dấu hiệu mới gọi model rẻ sinh code (1 lần).
3. Model rẻ tự "từ chối" (KHÔNG_THỂ_TÍNH) nếu câu hỏi không tính
được — không ép sandbox chạy vô nghĩa.
4. Sandbox lỗi → bỏ qua lặng lẽ, giữ nguyên câu trả lời gốc
(KHÔNG cảnh báo ồn ào cho câu chat thường — khác với task
code/debug ở verify_code_backed_numbers, vì ở đây phần lớn
câu chat sẽ "sandbox lỗi" một cách bình thường do không phải
bài toán, không phải dấu hiệu Yui làm gì sai).
5. Sandbox chạy được → refine + cross-check y hệt pattern cũ.
"""
if not SANDBOX_OK or not reply_text or not user_query:
return reply_text, False
if not _looks_like_puzzle(user_query, reply_text):
return reply_text, False
code = _generate_verifier_code(user_query, reply_text)
if not code:
return reply_text, False
ok, output = run_sandboxed(code, timeout_sec=6, allowed_modules=_pick_module_tier(code))
if not ok:
# Khác verify_code_backed_numbers: KHÔNG cảnh báo ồn ào — phần
# lớn câu chat thường sẽ rơi vào đây một cách bình thường.
print(f"[PUZZLE-VERIFIER] ℹ️ Sandbox không chạy được (bỏ qua lặng lẽ): {output[:100]}")
return reply_text, False
refined = _call_cohere_refine(user_query, code, output)
if refined:
final_text, trustworthy = _cross_check_and_fix(refined, output)
if trustworthy:
print("[PUZZLE-VERIFIER] ✅ Đáp số đã xác minh bằng sandbox thật")
return final_text, True
# Pass 2 lỗi/không khớp — vẫn muốn báo đáp số ĐÚNG cho Boss thay vì
# im lặng giữ đáp số có thể sai của model gốc, nhưng KHÔNG tự ý thay
# cả câu trả lời (khác task code — ở đây model gốc có thể đã giải
# thích dài, không muốn xóa hết). Chèn khối xác minh ngắn gọn.
verified_block = (
f"\n\n---\n✅ *Đã kiểm tra lại bằng tính toán thật: {output.strip()[:200]}*"
)
print("[PUZZLE-VERIFIER] ℹ️ Dùng fallback an toàn (append khối xác minh ngắn)")
return reply_text + verified_block, True
# ═══════════════════════════════════════════════════════════════════
# 🆕 (2026-07-30, theo đề xuất Gemini + yêu cầu Boss) — MathVerifier
# ═══════════════════════════════════════════════════════════════════
# Trả lời câu hỏi cốt lõi Boss đặt ra: "sandbox chạy không lỗi thì có nghĩa
# là tính đúng 100% không?" — KHÔNG. Sandbox chỉ đảm bảo code THỰC THI
# không crash và số nó in ra đúng là số Python tính ra từ CHÍNH đoạn code
# đó — nó KHÔNG đảm bảo đoạn code đó viết ĐÚNG LOGIC TOÁN HỌC. Bug thật đã
# gặp: code tìm ước số của 1681 đúng (tìm ra i=41), nhưng khi IN kết quả
# lại gán nhầm biến, in ra "1681 = 1681 × 1681" — sandbox không hề crash
# (chuỗi in ra đúng là chuỗi code tạo ra), nhưng phát biểu toán học đó SAI
# (41×41=1681 mới đúng). Đây là lớp bug mà mọi cross-check SỐ LIỆU (so số
# này với số kia có khớp không) đều bỏ lọt, vì "số sai" ở đây CHÍNH LÀ số
# sandbox tự in ra — không phải số bị narration bịa thêm.
#
# MathVerifier tự GIẢI LẠI từng biểu thức "A = B" xuất hiện trong output
# sandbox (Lớp 1: tách LHS/RHS, eval an toàn, so sánh số học thật — không
# chỉ so sánh chuỗi ký tự), và chặn các chuỗi lặp dấu "=" vô nghĩa (Lớp 4).
# Đây là lớp kiểm tra ĐỘC LẬP, chạy TRƯỚC khi output được đưa vào narration
# hay template — nếu chính sandbox output đã chứa 1 phát biểu sai, việc
# verify narration sau đó (dù đúng 100% so với sandbox) vẫn vô nghĩa vì
# đang xác minh đúng với 1 nguồn đã sai.
#
# CỐ Ý BỎ QUA Lớp 2 (đã có sẵn qua _check_numbers_grounded_in_search) và
# Lớp 3 (AST/operator alignment — độ phức tạp/rủi ro false-positive cao,
# không tương xứng lợi ích ngay lúc này, xem đánh giá đã trao đổi).
# Các hằng số/hàm toán học AN TOÀN được phép dùng trong eval — KHÔNG bao
# gồm bất kỳ builtin nguy hiểm nào (open, exec, __import__...).
_MATH_EVAL_SAFE_NAMES = {
"__builtins__": {},
"abs": abs, "round": round, "min": min, "max": max, "pow": pow,
"sum": sum, "int": int, "float": float,
}
try:
import math as _math_for_eval
for _mname in ("sqrt", "floor", "ceil", "log", "log10", "log2", "exp",
"sin", "cos", "tan", "pi", "e", "factorial"):
if hasattr(_math_for_eval, _mname):
_MATH_EVAL_SAFE_NAMES[_mname] = getattr(_math_for_eval, _mname)
except ImportError:
pass
# Biểu thức "A = B" hoặc "A ≈ B" — bắt cả ký hiệu Unicode (≈, ×, ÷) lẫn
# ASCII (~, *, /) vì model có thể viết theo 1 trong 2 kiểu tùy ngữ cảnh.
_MATH_STATEMENT_RE = re.compile(
r"([0-9+\-*/^().,\s×÷²³√]+?)\s*(?:=|≈)\s*([0-9+\-*/^().,\s×÷²³√]+)"
)
# Chuỗi lặp dấu "=" liên tiếp VÔ NGHĨA (VD "1681 = 1681 = 1681 = 1681") —
# 3 dấu "=" trở lên quanh CÙNG 1 giá trị số lặp lại trong 1 mệnh đề ngắn
# gần như luôn là dấu hiệu lỗi gán biến (không phải hệ phương trình thật,
# thứ hiếm khi viết dồn 1 dòng, và không lặp lại NGUYÊN VẸN cùng 1 số).
# 🐛 FIX: bản đầu yêu cầu các dấu "=" liền kề chỉ cách nhau khoảng trắng —
# không khớp được case thật có số xen giữa ("1681 = 1681 = 1681"). Giờ cho
# phép 1 cụm số/thập phân xen giữa mỗi dấu "=".
_REPEATED_EQUALS_RE = re.compile(r"(?:=\s*[0-9.,]+\s*){2,}=")
def _prepare_math_expr_for_eval(expr: str) -> str:
"""Chuẩn hóa 1 vế biểu thức toán học từ văn bản tự nhiên (Unicode ×÷²³√,
dấu phẩy phân cách nghìn kiểu VN) sang cú pháp Python eval() được."""
e = expr.strip()
e = e.replace(",", "") # bỏ dấu phẩy phân cách nghìn (983,065,693 → 983065693)
e = e.replace("×", "*").replace("÷", "/").replace("^", "**")
e = e.replace("√", "sqrt")
e = re.sub(r"(\d)\s*²", r"(\1)**2", e)
e = re.sub(r"(\d)\s*³", r"(\1)**3", e)
e = re.sub(r"\)\s*²", r")**2", e)
e = re.sub(r"\)\s*³", r")**3", e)
return e
def _safe_eval_math(expr: str) -> Optional[float]:
"""Eval 1 biểu thức số học ĐÃ CHUẨN HÓA trong sandbox tên-an-toàn
(không builtin nguy hiểm). Trả về None nếu không parse/eval được (VD
không phải biểu thức số học thật, chỉ là văn bản trùng dấu '=' tình cờ)
— an toàn: bỏ qua thay vì đoán mò khi không chắc chắn."""
try:
prepared = _prepare_math_expr_for_eval(expr)
if not re.search(r"\d", prepared):
return None # Không chứa số nào — không phải biểu thức toán
return float(eval(prepared, _MATH_EVAL_SAFE_NAMES, {}))
except Exception:
return None
class MathVerifier:
"""
🆕 Kiểm duyệt độc lập MỌI phát biểu toán học "A = B" xuất hiện trong 1
đoạn văn bản (thường là output sandbox thật, nhưng dùng được cho bất kỳ
văn bản nào) — TỰ GIẢI LẠI cả 2 vế bằng eval() an toàn, so sánh giá trị
số học thật thay vì chỉ so sánh chuỗi ký tự hay tin vào chính output.
Dùng: verifier = MathVerifier(); ok, reasons = verifier.check(text)
"""
def __init__(self, tolerance: float = 1e-6):
self.tolerance = tolerance
def check(self, text: str) -> Tuple[bool, list]:
"""Trả về (pass: bool, reasons: list[str]) — reasons rỗng nếu pass,
nếu không sẽ liệt kê CHÍNH XÁC câu nào sai và tại sao."""
reasons = []
# Lớp 4 trước (rẻ, nhanh) — chuỗi lặp "=" vô nghĩa, dấu hiệu rõ
# ràng của lỗi gán biến (VD "1681 = 1681 = 1681 = 1681").
for m in _REPEATED_EQUALS_RE.finditer(text):
snippet = text[max(0, m.start() - 25):m.end() + 15].strip()
reasons.append(f"Chuỗi lặp dấu '=' vô nghĩa (khả năng cao là lỗi gán biến): \"...{snippet}...\"")
# Lớp 1 — tách từng "A = B", tự giải lại, so sánh giá trị thật.
for m in _MATH_STATEMENT_RE.finditer(text):
lhs_raw, rhs_raw = m.group(1), m.group(2)
lhs_val = _safe_eval_math(lhs_raw)
rhs_val = _safe_eval_math(rhs_raw)
if lhs_val is None or rhs_val is None:
continue # Không phải biểu thức toán thật — bỏ qua, không đoán mò
if abs(lhs_val - rhs_val) > self.tolerance * max(1, abs(rhs_val)):
reasons.append(
f"Phát biểu toán học SAI: \"{lhs_raw.strip()} = {rhs_raw.strip()}\" "
f"— thực tế {lhs_raw.strip()} = {lhs_val:g}, còn {rhs_raw.strip()} = {rhs_val:g} "
f"(2 vế KHÔNG bằng nhau)."
)
return (len(reasons) == 0), reasons
def verify_math_statements_in_output(sandbox_output: str) -> Tuple[bool, list]:
"""Entry point tiện dụng — gọi trực tiếp trên 1 khối output sandbox
TRƯỚC KHI dùng nó cho narration/template, để bắt các bug logic kiểu
"1681 = 1681 × 1681" ngay tại nguồn, không phụ thuộc bước nào sau đó."""
return MathVerifier().check(sandbox_output)
# ═══════════════════════════════════════════════════════════════════
# 🆕 (2026-08-01, phát hiện qua bug thật) — EquationConclusionVerifier
# ═══════════════════════════════════════════════════════════════════
# Bug thật: sandbox đã CHẠY ĐÚNG code kiểm chứng nghiệm (in ra "x=6: thỏa
# mãn = True", "x=3: thỏa mãn = False" — theo đúng rule [EQUATION-CHECK]
# vừa thêm vào ural_code_engine.py) NHƯNG phần văn xuôi kết luận lại nói
# NGƯỢC LẠI ("nghiệm đúng là x=3"). Đây là loại lỗi KHÁC với mọi lớp
# trước: không phải công thức tự mâu thuẫn (MathVerifier), không phải số
# bịa từ search (Fix B), không phải sandbox tự in sai (MathVerifier Lớp
# 1) — đây là SANDBOX ĐÚNG, nhưng KẾT LUẬN BẰNG LỜI mâu thuẫn với chính
# bằng chứng sandbox đã xác nhận. Áp dụng CHUNG cho MỌI bài toán có bước
# "kiểm chứng bằng cách thay số/thử nghiệm rồi in True/False" (không chỉ
# 3 câu test) — bất kỳ output nào có đúng format "x=N: ... = True/False"
# đều được đối chiếu.
_ROOT_CHECK_RESULT_RE = re.compile(
r"x\s*=\s*(-?\d+(?:\.\d+)?).{0,40}?(?:thỏa mãn|thoa man|hợp lệ|hop le)"
r".{0,30}?=\s*(True|False|Đúng|Sai|đúng|sai)", re.I
)
_CONCLUSION_ROOT_RE = re.compile(
r"nghiệm (?:đúng|hợp lệ|thỏa mãn)(?:\s+(?:duy nhất|là))?\s*:?\s*(?:là\s*)?"
r"x\s*=\s*(-?\d+(?:\.\d+)?)", re.I
)
def verify_equation_conclusion(sandbox_output: str, reply_text: str) -> Tuple[bool, Optional[str]]:
"""
Đối chiếu: nghiệm nào được sandbox XÁC NHẬN True (qua rule
[EQUATION-CHECK] — output có dòng "x=N: thỏa mãn ... = True/False") với
nghiệm được VĂN XUÔI KẾT LUẬN là "nghiệm đúng" — nếu 2 cái KHÁC NHAU,
đây là bằng chứng RÕ RÀNG kết luận mâu thuẫn với chính sandbox đã chạy.
Trả về (ok: bool, ly_do: Optional[str]).
An toàn: nếu không trích được rõ ràng cả 2 phía (VD output không theo
đúng format rule đã dặn, hoặc văn xuôi không có câu kết luận rõ ràng),
trả (True, None) — KHÔNG đoán mò, bỏ qua kiểm tra này.
"""
_true_roots = set()
for m in _ROOT_CHECK_RESULT_RE.finditer(sandbox_output):
_root, _result = m.group(1), m.group(2).lower()
if _result in ("true", "đúng", "dung"):
_true_roots.add(_root)
if not _true_roots:
return True, None # Sandbox không theo format kỳ vọng — bỏ qua, không đoán mò
_concl_match = _CONCLUSION_ROOT_RE.search(reply_text)
if not _concl_match:
return True, None # Không tìm thấy câu kết luận rõ ràng — bỏ qua
_stated_root = _concl_match.group(1)
if _stated_root not in _true_roots:
return False, (
f"Kết luận nói nghiệm đúng là x={_stated_root}, nhưng chính sandbox đã xác nhận "
f"x={'/'.join(sorted(_true_roots))} mới thỏa mãn phương trình gốc (True) — MÂU THUẪN "
f"với bằng chứng sandbox đã chạy."
)
return True, None
# ═══════════════════════════════════════════════════════════════════
# 🆕 (2026-07-31, theo đề xuất Gemini "NLI Statement Verification" +
# yêu cầu Boss) — KnowledgeFactChecker cho "kiến thức chuẩn hóa"
# ═══════════════════════════════════════════════════════════════════
# Bug thật đã gặp: Yui tự soạn đề trắc nghiệm Vật lý, tự chọn đáp án
# "lực ma sát TỈ LỆ THUẬN VỚI DIỆN TÍCH TIẾP XÚC" là ĐÚNG — trong khi kiến
# thức phổ thông chuẩn (F_mst = μN, đã xác nhận qua nhiều nguồn SGK) khẳng
# định NGƯỢC LẠI: ma sát KHÔNG phụ thuộc diện tích tiếp xúc. Đây là bug
# nằm NGOÀI phạm vi MathVerifier (không có phép tính nào để tự giải lại —
# đây là tri thức, không phải công thức số học).
#
# THIẾT KẾ 2 TẦNG (để KHÔNG tốn quota tràn lan cho mọi câu hỏi kiến thức,
# đúng yêu cầu "tối ưu quota, gói free"):
# Tầng 1 (MIỄN PHÍ, TỨC THÌ) — "Closed Knowledge Schema": 1 bảng tra cứu
# TĨNH các quy luật phụ thuộc/không phụ thuộc CƠ BẢN NHẤT, KHÔNG TRANH
# CÃI của chương trình phổ thông VN (định luật ma sát, định luật Ohm,
# định luật khí lý tưởng...) — so khớp bằng regex, không gọi LLM/search
# nào, chính xác 100% cho đúng những gì đã liệt kê. Đây LÀ tầng chính,
# xử lý được phần lớn case thường gặp (như bug thật đã xảy ra) mà không
# tốn quota.
# Tầng 2 (CÓ THỂ MỞ RỘNG SAU, CHƯA BẬT MẶC ĐỊNH) — search thật khi câu
# hỏi không khớp bảng tĩnh — CỐ Ý CHƯA kích hoạt tự động, vì search MỌI
# câu hỏi kiến thức sẽ tốn quota nặng nếu áp dụng tràn lan; bảng tĩnh
# Tầng 1 đã đủ xử lý đúng loại bug đã xảy ra.
#
# Phạm vi CỐ Ý hẹp: chỉ các quy luật PHỔ THÔNG, KHÔNG TRANH CÃI, có trong
# SGK chuẩn — không mở rộng sang kiến thức có thể thay đổi theo nghiên cứu
# mới hoặc có nhiều trường phái (tránh false-positive nguy hiểm hơn im lặng
# bỏ qua).
_KNOWLEDGE_SCHEMAS = {
# Mỗi entry: (regex nhận diện chủ đề trong câu, dict {biến: {phụ_thuộc: [...], không_phụ_thuộc: [...]}})
"ma_sat_truot": {
"topic_signal": re.compile(r"lực ma sát|luc ma sat|hệ số ma sát|he so ma sat", re.I),
"phu_thuoc": ["áp lực", "ap luc", "lực pháp tuyến", "luc phap tuyen",
"vật liệu", "vat lieu", "bề mặt", "be mat", "hệ số ma sát", "he so ma sat"],
"khong_phu_thuoc": ["diện tích tiếp xúc", "dien tich tiep xuc", "diện tích", "dien tich"],
"source_note": "Định luật ma sát Coulomb: F_mst = μN — không phụ thuộc diện tích tiếp xúc (SGK Vật lý 10).",
},
"dinh_luat_om": {
"topic_signal": re.compile(r"định luật ôm|dinh luat om|cường độ dòng điện|cuong do dong dien", re.I),
"phu_thuoc": ["hiệu điện thế", "hieu dien the", "điện trở", "dien tro"],
"khong_phu_thuoc": [], # Chưa đủ chắc chắn để liệt kê "không phụ thuộc" — an toàn: để trống
"source_note": "Định luật Ohm: I = U/R (SGK Vật lý).",
},
"khi_ly_tuong": {
"topic_signal": re.compile(r"khí lý tưởng|khi ly tuong|phương trình trạng thái|phuong trinh trang thai", re.I),
"phu_thuoc": ["áp suất", "ap suat", "thể tích", "the tich", "nhiệt độ", "nhiet do"],
"khong_phu_thuoc": [],
"source_note": "Phương trình trạng thái khí lý tưởng: pV = nRT (SGK Vật lý/Hóa học).",
},
}
# 🆕 (2026-07-31, phát hiện qua bug thật: Yui chọn "gia tốc rơi tự do =
# −9,8 m/s²" là đáp án đúng cho 1 câu hỏi KHÔNG hề nêu trước hệ quy chiếu
# — theo SGK Vật lý 10 chuẩn, giá trị g được nêu LUÔN LÀ DƯƠNG (g ≈ 9,8
# m/s²); dấu âm CHỈ xuất hiện khi người giải TỰ CHỌN chiều dương hướng
# lên và ghi rõ điều đó — đây là lựa chọn quy ước, không phải "giá trị
# đúng duy nhất". Đây là loại lỗi KHÁC với "phụ thuộc/không phụ thuộc"
# (_KNOWLEDGE_SCHEMAS ở trên) — là sai DẤU/GIÁ TRỊ của 1 hằng số vật lý cụ
# thể, cần bảng tra riêng.
#
# Mỗi entry: (regex nhận diện hằng số, giá trị chuẩn SGK, có cho phép dấu
# âm khi có ngữ cảnh "chọn chiều dương hướng lên" hay không).
_PHYSICAL_CONSTANTS = {
"gia_toc_roi_tu_do": {
"topic_signal": re.compile(r"gia tốc.{0,15}rơi tự do|gia toc.{0,15}roi tu do|"
r"gia tốc trọng trường|gia toc trong truong", re.I),
"standard_value_note": "SGK Vật lý 10: gia tốc rơi tự do g ≈ 9,8 m/s² (DƯƠNG) — dấu âm chỉ dùng khi "
"người giải TỰ CHỌN và NÊU RÕ chiều dương hướng lên, không phải giá trị mặc định.",
# Regex bắt "gia tốc ... là ... −9,8" hoặc "= -9,8" hoặc "âm ...9,8"
# trong ngữ cảnh KHÔNG có cụm "chiều dương hướng lên"/"chọn chiều
# dương" gần đó (an toàn: nếu có nêu rõ hệ quy chiếu, bỏ qua — không
# báo oan).
"negative_value_re": re.compile(r"[-–−]\s*9[.,]8|âm\s+9[.,]8|am\s+9[.,]8", re.I),
"reference_frame_exempt_re": re.compile(
r"chiều dương hướng lên|chieu duong huong len|chọn chiều dương|chon chieu duong", re.I),
},
}
# Cụm từ khẳng định phụ thuộc/không phụ thuộc — dùng để lấy CẢ CÂU chứa
# nó, giao việc "hiểu câu nói gì" cho bước so khớp bên dưới thay vì cố
# regex tách chủ ngữ/vị ngữ (câu tiếng Việt tự nhiên quá đa dạng để làm
# chính xác bằng regex thuần).
_DEPENDENCY_KEYWORD_RE = re.compile(
r"tỉ lệ thuận với|ti le thuan voi|tỉ lệ nghịch với|ti le nghich voi|"
r"không phụ thuộc vào|khong phu thuoc vao|phụ thuộc vào|phu thuoc vao|"
r"tăng khi.{0,20}tăng|tang khi.{0,20}tang|giảm khi.{0,20}tăng|giam khi.{0,20}tang",
re.I,
)
# Tách câu trắc nghiệm nhiều đáp án dồn 1 dòng (A) ... B) ... C) ... D) ...)
# thành từng đáp án riêng để fact-check độc lập từng cái.
_OPTION_SPLIT_RE = re.compile(r"(?=[A-D]\))")
class KnowledgeFactChecker:
"""Tầng 1 (miễn phí, tức thì) của cơ chế fact-check kiến thức chuẩn
hóa — gồm 2 loại kiểm tra: (1) phát biểu "X phụ thuộc/không phụ thuộc
vào Y" đối chiếu _KNOWLEDGE_SCHEMAS, và (2) giá trị/dấu của hằng số vật
lý chuẩn đối chiếu _PHYSICAL_CONSTANTS (VD gia tốc rơi tự do phải
dương trừ khi có nêu rõ hệ quy chiếu khác)."""
def _check_physical_constants(self, text: str) -> list:
"""Kiểm tra RIÊNG cho hằng số vật lý có dấu/giá trị chuẩn — chạy
trên TOÀN VĂN BẢN (không tách theo mệnh đề như _KNOWLEDGE_SCHEMAS)
vì 1 phát biểu "đáp án đúng là B) −9,8" thường tham chiếu ngược lại
1 lựa chọn đã liệt kê ở nơi khác trong câu, không nằm gọn trong 1
mệnh đề. An toàn: bỏ qua HOÀN TOÀN nếu văn bản có nêu rõ hệ quy
chiếu tùy chọn (VD "chọn chiều dương hướng lên") — tránh báo oan
cho bài toán CỐ Ý dùng dấu âm có căn cứ."""
reasons = []
for _const_name, _const in _PHYSICAL_CONSTANTS.items():
if not _const["topic_signal"].search(text):
continue
if _const["reference_frame_exempt_re"].search(text):
continue # Có nêu rõ hệ quy chiếu — không phải mặc định sai, bỏ qua an toàn
if _const["negative_value_re"].search(text):
reasons.append(
f"[{_const_name}] Phát biểu SAI: văn bản dùng giá trị ÂM cho hằng số này mà "
f"KHÔNG nêu rõ hệ quy chiếu (chiều dương hướng lên) — {_const['standard_value_note']}"
)
return reasons
def check(self, text: str) -> Tuple[bool, list]:
"""Trả về (pass: bool, reasons: list[str])."""
reasons = []
reasons.extend(self._check_physical_constants(text))
# Tách theo câu / theo đáp án trắc nghiệm để fact-check từng mệnh
# đề độc lập — tránh 1 câu dài gộp nhiều đáp án đúng+sai lẫn lộn
# khiến không xác định được chính xác phần nào sai.
_segments = []
for _sentence in re.split(r"(?<=[.!?])\s+|\n", text):
if "A)" in _sentence and "B)" in _sentence:
_segments.extend(p for p in _OPTION_SPLIT_RE.split(_sentence) if p.strip())
else:
_segments.append(_sentence)
for _schema_name, _schema in _KNOWLEDGE_SCHEMAS.items():
if not _schema["topic_signal"].search(text):
continue # Văn bản không nhắc tới chủ đề này — bỏ qua
for _seg in _segments:
_keyword_matches = list(_DEPENDENCY_KEYWORD_RE.finditer(_seg))
if not _keyword_matches:
continue
if len(_keyword_matches) > 1:
# 🐛 FIX (2026-07-31) BUG THẬT (phát hiện qua test — false
# positive nghiêm trọng): 1 câu ghép có NHIỀU cụm phụ
# thuộc/không-phụ-thuộc về CÁC BIẾN KHÁC NHAU (VD "phụ
# thuộc vào vật liệu... KHÔNG phụ thuộc vào diện tích" —
# CẢ 2 vế đều ĐÚNG) bị coi là 1 phát biểu duy nhất, dẫn
# tới báo sai oan cả 2 vế dù chúng không hề mâu thuẫn.
# Regex không thể tách an toàn các mệnh đề ghép tiếng
# Việt phức tạp — thà BỎ QUA (không kiểm tra) câu ghép
# còn hơn buộc tội oan. Câu trắc nghiệm dạng "A) ... B)
# ..." (đã tách riêng từng đáp án ở bước trên) không bị
# ảnh hưởng vì mỗi đáp án thường chỉ có 1 cụm.
continue
_seg_lower = _seg.lower()
# 🐛 FIX (2026-07-31): TRƯỚC ĐÂY mỗi biến khớp trong danh
# sách (VD "diện tích tiếp xúc" VÀ "diện tích" đều khớp
# cùng 1 câu vì 1 cái là substring của cái kia) tạo ra 1
# dòng reason RIÊNG, dẫn tới báo trùng ý 2 lần cho cùng 1
# lỗi thật. Giờ chỉ giữ biến khớp DÀI NHẤT (cụ thể nhất)
# cho mỗi (segment, hướng kiểm tra) — loại trùng lặp.
# Phát biểu SAI kiểu 1: nói "KHÔNG phụ thuộc" vào 1 biến
# thực ra CÓ phụ thuộc (theo bảng tri thức).
if re.search(r"không phụ thuộc vào|khong phu thuoc vao", _seg_lower):
_matched = [v for v in _schema["phu_thuoc"] if v in _seg_lower]
if _matched:
_best = max(_matched, key=len)
reasons.append(
f"[{_schema_name}] Phát biểu SAI: \"{_seg.strip()}\" — "
f"thực tế CÓ phụ thuộc vào '{_best}'. {_schema['source_note']}"
)
# Phát biểu SAI kiểu 2: nói "tỉ lệ thuận/phụ thuộc" vào 1
# biến thực ra KHÔNG phụ thuộc (theo bảng tri thức) — đúng
# loại bug thật đã xảy ra ("tỉ lệ thuận với diện tích tiếp
# xúc" trong khi ma sát không phụ thuộc diện tích).
if re.search(r"tỉ lệ thuận với|ti le thuan voi|tỉ lệ nghịch với|"
r"ti le nghich voi|(?<!không )(?<!khong )phụ thuộc vào|"
r"(?<!không )(?<!khong )phu thuoc vao", _seg_lower):
_matched = [v for v in _schema["khong_phu_thuoc"] if v in _seg_lower]
if _matched:
_best = max(_matched, key=len)
reasons.append(
f"[{_schema_name}] Phát biểu SAI: \"{_seg.strip()}\" — "
f"thực tế KHÔNG phụ thuộc vào '{_best}'. {_schema['source_note']}"
)
return (len(reasons) == 0), reasons
def verify_knowledge_claims(text: str) -> Tuple[bool, list]:
"""Entry point tiện dụng — quét văn bản (VD câu trả lời/đề trắc
nghiệm Yui soạn) tìm phát biểu kiến thức mâu thuẫn với bảng tri thức
chuẩn hóa đã biết chắc chắn. KHÔNG gọi search/LLM nào (Tầng 1 thuần
tra bảng) — miễn phí, tức thì, phạm vi cố ý hẹp (chỉ các quy luật phổ
thông không tranh cãi) để tránh false-positive."""
return KnowledgeFactChecker().check(text)
|