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)