Spaces:
Running
Running
Download yui_code_verifier.py from BachDaThan/Ural-AI: direct link, hf CLI and curl.
- Browser
- Download file 102 kB
-
https://huggingface.co/spaces/BachDaThan/Ural-AI/resolve/main/yui_code_verifier.py
- Command line
-
hf download hf://spaces/BachDaThan/Ural-AI/yui_code_verifier.py
-
curl -L -o yui_code_verifier.py https://huggingface.co/spaces/BachDaThan/Ural-AI/resolve/main/yui_code_verifier.py
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) | |