Ural-AI / yui_code_verifier.py
BachDaThan's picture
Upload yui_code_verifier.py
4a0cc1d verified
Raw History Blame Contribute Delete
102 kB
# ================================================================
# 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)