# -*- coding: utf-8 -*-
"""TRA_LOI.PY — hàm lõi của bot. CHỮ KÝ CỐ ĐỊNH theo giao_uoc.py mục 4:

    tra_loi(cau_hoi: str, ngu_canh: NguCanh) -> TraLoi

VÌ SAO mọi phụ thuộc bên dưới đều bọc try/except: các module anh em
(`tim/lai.py`, `congcu/*.py`, `llm/*.py`, `bao_ve/*.py`) đang được viết
song song, có thể chưa tồn tại hoặc chưa hoàn thiện lúc tệp này chạy. Thiếu
một mảnh không được làm cả cổng sập lúc khởi động — nhánh phụ thuộc vào
mảnh thiếu đó chỉ đơn giản là luôn trả về câu CHUYỂN NGƯỜI, và bot vẫn sống
với các nhánh còn lại (đặc biệt là 'cam_ket' và 'chao', không phụ thuộc gì
bên ngoài).
"""
from __future__ import annotations

import logging
import re
import os
import sys
import threading
import time
from collections import OrderedDict
from typing import Any, Dict, List, Optional, Tuple

# ---------------------------------------------------------------------
# Nhập giao ước — chịu được cả hai cách chạy (xem giải thích trong
# nao/dinh_tuyen.py, áp dụng y hệt ở đây).
# ---------------------------------------------------------------------
try:
    from giao_uoc import (
        NguCanh, TraLoi, Ydinh, GIU_LICH_SU, LocTim, SO_DOAN_MAC_DINH,
        CAU_CHUYEN_NGUOI, CAU_KHAI_LA_MAY, ZALO, TELEGRAM,
    )
except ImportError:
    _THU_MUC_CHA = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    if _THU_MUC_CHA not in sys.path:
        sys.path.insert(0, _THU_MUC_CHA)
    from giao_uoc import (
        NguCanh, TraLoi, Ydinh, GIU_LICH_SU, LocTim, SO_DOAN_MAC_DINH,
        CAU_CHUYEN_NGUOI, CAU_KHAI_LA_MAY, ZALO, TELEGRAM,
    )

try:
    from .dinh_tuyen import phan_loai, doan_san_pham
except ImportError:
    from dinh_tuyen import phan_loai, doan_san_pham  # type: ignore

_log = logging.getLogger('chatbot.nao.tra_loi')

# ---------------------------------------------------------------------
# TẦNG 1 (mới) — khớp câu trả lời do chủ website tự soạn/tự duyệt, và
# hàng đợi ghi lại câu chưa khớp được. Cùng kiểu try/except như mọi phụ
# thuộc khác trong tệp này: thiếu mảnh nào thì mảnh đó thành None, bot
# chạy y như trước khi có tầng 1 (rơi thẳng xuống tầng 2/3).
# ---------------------------------------------------------------------
try:
    from .tang_mot import khop, tra_loi_tu_muc, tang_so_lan_dung, do_phu_cau_khach
except ImportError:
    try:
        from tang_mot import khop, tra_loi_tu_muc, tang_so_lan_dung, do_phu_cau_khach  # type: ignore
    except Exception as _loi_t1:  # noqa: BLE001 - TẦNG 1 có thể chưa xong/hỏng, không được làm sập cổng
        khop = None  # type: ignore
        tra_loi_tu_muc = None  # type: ignore
        tang_so_lan_dung = None  # type: ignore
        do_phu_cau_khach = None  # type: ignore
        _log.error('Chưa dùng được nao.tang_mot — TẦNG 1 sẽ bị bỏ qua, mọi câu rơi thẳng xuống tầng 2/3. Lỗi: %s', _loi_t1)

try:
    from congcu.hang_doi import ghi_nhan as _hang_doi_ghi_nhan
except Exception as _loi_hd:  # noqa: BLE001
    _hang_doi_ghi_nhan = None  # type: ignore
    _log.error('Chưa dùng được congcu.hang_doi.ghi_nhan() — câu hỏi thiếu sẽ không được ghi nhận. Lỗi: %s', _loi_hd)

# NHẬT KÝ HỘI THOẠI — ghi lại MỌI lượt đã trả lời (kể cả tầng 1), khác
# hang_doi ở trên (chỉ nhận câu KHÔNG trả lời được). Cùng kiểu try/except:
# thiếu mảnh thì thành None, bot chạy y như trước khi có nhật ký.
try:
    from congcu.nhat_ky import ghi as _nhat_ky_ghi
except Exception as _loi_nk:  # noqa: BLE001
    _nhat_ky_ghi = None  # type: ignore
    _log.error('Chưa dùng được congcu.nhat_ky.ghi() — sẽ không ghi được nhật ký hội thoại. Lỗi: %s', _loi_nk)

# PHIÊN + HỒ SƠ KHÁCH (23/09/2026) — lưu phiên xuống CSDL thay vì chỉ RAM,
# sổ tay trích bằng luật, hồ sơ các phiên trước. Thiếu mảnh nào thì bot
# chạy y như trước: nhớ 3 lượt trong RAM, không sổ tay, không hồ sơ.
try:
    from congcu.phien import (
        tai_phien, luu_phien, tai_ho_so, nhan_ve_tai_khoan, them_luot,
    )
except Exception as _loi_ph:  # noqa: BLE001
    tai_phien = luu_phien = tai_ho_so = nhan_ve_tai_khoan = them_luot = None  # type: ignore
    _log.error('Chưa dùng được congcu.phien — phiên chỉ nhớ trong RAM như cũ. Lỗi: %s', _loi_ph)

try:
    from nao.compact import lich_su_cho_prompt as _lich_su_cho_prompt, kich_hoat_nen as _kich_hoat_tom_gon
except Exception as _loi_cp:  # noqa: BLE001
    _lich_su_cho_prompt = _kich_hoat_tom_gon = None  # type: ignore
    _log.error('Chưa dùng được nao.compact — không tóm gọn giữa phiên. Lỗi: %s', _loi_cp)

try:
    from congcu.so_tay import cap_nhat as _cap_nhat_so_tay, thanh_van_ban as _so_tay_thanh_chu
except Exception as _loi_st:  # noqa: BLE001
    _cap_nhat_so_tay = _so_tay_thanh_chu = None  # type: ignore
    _log.error('Chưa dùng được congcu.so_tay — không có sổ tay phiên. Lỗi: %s', _loi_st)


# =====================================================================
# PHỤ THUỘC TỪ CÁC MODULE ANH EM — có thể CHƯA TỒN TẠI, bọc từng cái một
# =====================================================================

try:
    from tim.lai import lay_bo_tim
except Exception as _loi_tim:  # noqa: BLE001 - thiếu mảnh song song, không phải lỗi lập trình ở đây
    lay_bo_tim = None
    _log.error('Chưa dùng được tim.lai.lay_bo_tim() — nhánh tìm-diễn giải sẽ luôn chuyển người. Lỗi: %s', _loi_tim)

try:
    from congcu.san_pham import tim_san_pham, danh_sach_san_pham
except Exception as _loi_sp:  # noqa: BLE001
    tim_san_pham = None
    danh_sach_san_pham = None
    _log.error('Chưa dùng được congcu.san_pham — nhánh hỏi giá sẽ luôn chuyển người. Lỗi: %s', _loi_sp)

try:
    from congcu.licence import key_cua_khach
except Exception as _loi_lic:  # noqa: BLE001
    key_cua_khach = None
    _log.error('Chưa dùng được congcu.licence.key_cua_khach() — nhánh tra key sẽ luôn chuyển người. Lỗi: %s', _loi_lic)

try:
    from congcu.khuon_mau import (
        cau_gia, cau_bang_gia_tat_ca, cau_key, cau_khong_ro, cau_hoi_san_pham, cau_gia_them,
    )
except Exception as _loi_km:  # noqa: BLE001
    cau_gia = None
    cau_gia_them = None
    cau_bang_gia_tat_ca = None
    cau_key = None
    cau_khong_ro = None
    cau_hoi_san_pham = None
    _log.error(
        'Chưa dùng được congcu.khuon_mau — nhánh hỏi giá và tra key sẽ luôn chuyển người. Lỗi: %s', _loi_km,
    )

try:
    from llm.giao_dien import LoiNhaCungCap
except Exception as _loi_gd:  # noqa: BLE001
    class LoiNhaCungCap(Exception):  # type: ignore
        """Bản thay thế tối giản khi llm.giao_dien chưa có — chỉ để except
        không vỡ; không nhà cung cấp thật nào ném được lỗi kiểu này khi
        chính llm.giao_dien còn chưa tồn tại."""
        pass
    _log.error('Chưa dùng được llm.giao_dien.LoiNhaCungCap — dùng lớp lỗi thay thế tối giản. Lỗi: %s', _loi_gd)

try:
    from bao_ve.ngan_sach import con_ngan_sach
except Exception as _loi_ns:  # noqa: BLE001
    def con_ngan_sach() -> bool:  # type: ignore
        # Không rõ ngân sách thì coi như CÒN — đừng tự ý khoá chat của
        # toàn bộ khách chỉ vì module đếm chi phí chưa sẵn sàng.
        return True
    _log.error('Chưa dùng được bao_ve.ngan_sach.con_ngan_sach() — mặc định coi như còn ngân sách. Lỗi: %s', _loi_ns)

try:
    import cau_hinh
except Exception as _loi_ch:  # noqa: BLE001
    cau_hinh = None  # type: ignore
    _log.error('Chưa dùng được cau_hinh — nhánh tìm-diễn giải sẽ luôn chuyển người. Lỗi: %s', _loi_ch)


# =====================================================================
# CÂU CHỮ TĨNH (giao_uoc.py không định nghĩa các câu này, chỉ có
# CAU_CHUYEN_NGUOI/CAU_KHAI_LA_MAY — phần còn lại là câu chữ của LÕI)
# =====================================================================

_CAU_RONG = {
    'vi': 'Dạ anh/chị chưa nhập nội dung câu hỏi, gõ giúp em với ạ.',
    'en': 'Looks like your message is empty — please type your question.',
}
_CAU_QUA_DAI = {
    'vi': 'Câu hỏi hơi dài, anh/chị rút gọn giúp em còn dưới 500 ký tự nhé.',
    'en': 'Your message is a bit long — please keep it under 500 characters.',
}
_CAU_CHAO = {
    'vi': 'Dạ em chào anh/chị ạ!',
    'en': 'Hello!',
}
_CAU_CHAO_LAI = {
    'vi': 'Dạ em đây ạ. Anh/chị đang cần em hỗ trợ việc gì ạ?',
    'en': 'I am here. What can I help you with?',
}
# Ba viec nay do CHU DU AN tu viet (y kien 22/09/2026, cau hoi #5 trong
# bang chat_hoi_thoai). Khac ban cu o viec thu ba: truoc ghi "kiem tra han
# dung license key", nay doi thanh "tiep nhan y kien phan hoi / nhu cau gui
# den admin" — vi tra key thi khach tu xem duoc o trang /orders, con duong
# phan hoi ve chu thi truoc do bot khong he moi.
_GOI_Y_3_VIEC = {
    'vi': ('Em có thể giúp: (1) tra giá các gói sản phẩm, (2) hướng dẫn cài '
           'đặt và xử lý lỗi thường gặp, (3) tiếp nhận ý kiến phản hồi hoặc '
           'nhu cầu của anh/chị để gửi đến admin. Anh/chị cứ hỏi em nhé.'),
    'en': ('I can help with: (1) product pricing, (2) installation and '
           'troubleshooting guides, and (3) passing your feedback or '
           'requests on to the admin. Feel free to ask.'),
}


def _ho_so_shop(khong_gian: Optional[str]):
    """Hồ sơ shop (shop/) của lượt này — liên hệ, câu chào, vai, tính năng."""
    import shop
    return shop.lay(khong_gian)


def _ngon_ngu_hop_le(ngon_ngu: str) -> str:
    return ngon_ngu if ngon_ngu in CAU_CHUYEN_NGUOI else 'vi'


def _tra_loi_chuyen_nguoi(ngon_ngu: str, y_dinh: Ydinh, do_tin_cay: float,
                           da_goi_llm: bool = False, khong_gian: Optional[str] = None) -> TraLoi:
    ngon_ngu = _ngon_ngu_hop_le(ngon_ngu)
    lh = _ho_so_shop(khong_gian).lien_he
    van_ban = CAU_CHUYEN_NGUOI[ngon_ngu].format(zalo=lh.get('zalo') or ZALO, tele=lh.get('telegram') or TELEGRAM)
    return TraLoi(
        tra_loi=van_ban, nguon=[], y_dinh=y_dinh, chuyen_nguoi=True,
        do_tin_cay=do_tin_cay, da_goi_llm=da_goi_llm,
    )


# =====================================================================
# QUẢN LÝ PHIÊN — lịch sử hội thoại giữ TRONG BỘ NHỚ, giai đoạn 1 CỐ Ý
# KHÔNG lưu xuống đĩa (xem đề bài). Cần bộ nhớ này vì hợp đồng dây
# (giao_uoc.py mục 6) không mang lich_su đi/về giữa chat.php và cổng —
# chat.php chỉ gửi session_id + message, nên lịch sử phải sống Ở ĐÂY,
# khoá theo `phien`, giữa các lượt gọi HTTP kế tiếp.
# =====================================================================

_PHIEN_TOI_DA = 500
_PHIEN_HET_HAN_GIAY = 60 * 60  # 60 phút

_khoa_phien = threading.Lock()
# OrderedDict để dọn kiểu LRU đơn giản: mỗi lần đọc/ghi một phiên thì đẩy
# nó lên cuối (mới dùng gần đây nhất); khi vượt PHIEN_TOI_DA thì bỏ phần
# tử ở ĐẦU (cũ nhất/ít dùng nhất).
_kho_phien: "OrderedDict[str, Dict[str, Any]]" = OrderedDict()


def _don_dep_phien_khong_khoa(bay_gio: float) -> None:
    """Phải gọi bên trong `with _khoa_phien:` — không tự khoá ở đây để
    tránh khoá lồng khoá khi hàm gọi đã giữ khoá sẵn."""
    het_han = [k for k, v in _kho_phien.items() if bay_gio - v['luc'] > _PHIEN_HET_HAN_GIAY]
    for k in het_han:
        del _kho_phien[k]
    while len(_kho_phien) > _PHIEN_TOI_DA:
        _kho_phien.popitem(last=False)  # bỏ phần tử CŨ NHẤT (đầu OrderedDict)


def _lay_lich_su_phien(phien: str) -> List[Dict[str, str]]:
    if not phien:
        return []
    bay_gio = time.time()
    with _khoa_phien:
        _don_dep_phien_khong_khoa(bay_gio)
        muc = _kho_phien.get(phien)
        if muc is None:
            return []
        _kho_phien.move_to_end(phien)
        return list(muc['lich_su'])


def _luu_lich_su_phien(phien: str, noi_dung_khach: str, noi_dung_bot: str,
                       so_tay: Optional[Dict[str, Any]] = None) -> None:
    if not phien:
        return
    bay_gio = time.time()
    with _khoa_phien:
        _don_dep_phien_khong_khoa(bay_gio)
        muc = _kho_phien.get(phien)
        if muc is None:
            muc = {'luc': bay_gio, 'lich_su': []}
        muc['lich_su'].append({'vai': 'khach', 'noi_dung': noi_dung_khach})
        muc['lich_su'].append({'vai': 'bot', 'noi_dung': noi_dung_bot})
        # Một "lượt" = 1 cặp khách+bot -> giữ tối đa GIU_LICH_SU*2 dòng.
        muc['lich_su'] = muc['lich_su'][-(GIU_LICH_SU * 2):]
        if so_tay is not None:
            muc['so_tay'] = so_tay
        muc['luc'] = bay_gio
        _kho_phien[phien] = muc
        _kho_phien.move_to_end(phien)
        while len(_kho_phien) > _PHIEN_TOI_DA:
            _kho_phien.popitem(last=False)


def _lay_so_tay_ram(phien: str) -> Dict[str, Any]:
    with _khoa_phien:
        muc = _kho_phien.get(phien)
        return dict(muc.get('so_tay') or {}) if muc else {}


# =====================================================================
# PHIÊN TRONG CSDL + HỒ SƠ KHÁCH (23/09/2026)
#
# RAM ở trên KHÔNG bị bỏ: nó vẫn được ghi mỗi lượt và là đường DỰ PHÒNG
# khi CSDL hỏng. Khi CSDL chạy, CSDL thắng — vì nó sống qua khởi động lại.
# =====================================================================

def _nap_ngu_canh(ngu_canh: NguCanh) -> Dict[str, Any]:
    """Nạp lịch sử + sổ tay + bản tóm gọn vào ngu_canh. Trả về TRẠNG THÁI
    phiên để cuối lượt ghi lại và quyết định có tóm gọn không:
      lich_su           — lịch sử ĐẦY ĐỦ đã lưu (tới congcu.phien.GIU_LUOT_LUU)
      so_luot           — số lượt đã lưu TRƯỚC lượt này (None = không có CSDL)
      compact_den_luot  — mốc của bản tóm gọn hiện có
    ngu_canh.lich_su được thay bằng phần dành cho prompt (nao/compact.py)."""
    du_lieu = None
    if tai_phien is not None:
        du_lieu = tai_phien(ngu_canh.phien)
    if du_lieu:
        tt = {
            'lich_su': du_lieu['lich_su'],
            'so_luot': du_lieu.get('so_luot', 0),
            'compact_den_luot': du_lieu.get('compact_den_luot', 0),
        }
        if not ngu_canh.so_tay:
            ngu_canh.so_tay = du_lieu['so_tay']
        if not ngu_canh.may:
            ngu_canh.may = du_lieu['may']
        if not ngu_canh.compact:
            ngu_canh.compact = du_lieu.get('compact') or ''
    else:
        tt = {'lich_su': _lay_lich_su_phien(ngu_canh.phien), 'so_luot': None, 'compact_den_luot': 0}
        if not ngu_canh.so_tay:
            ngu_canh.so_tay = _lay_so_tay_ram(ngu_canh.phien)
    if not ngu_canh.lich_su:
        if _lich_su_cho_prompt is not None:
            ngu_canh.lich_su = _lich_su_cho_prompt(
                tt['lich_su'], tt['so_luot'] or 0, tt['compact_den_luot'], ngu_canh.compact)
        else:
            ngu_canh.lich_su = list(tt['lich_su'])

    if nhan_ve_tai_khoan is not None and ngu_canh.nguoi_dung != 'khach' and ngu_canh.may:
        nhan_ve_tai_khoan(ngu_canh.phien, ngu_canh.may, ngu_canh.nguoi_dung)
    return tt


def _dung_khoi_ho_so(ngu_canh: NguCanh) -> str:
    """Sổ tay phiên + hồ sơ các phiên trước -> vài dòng cho prompt tầng 2
    (llm/giao_dien.py::_thong_tin_khach). Giới hạn cứng độ dài: khối này đi
    vào MỌI lượt gọi mô hình của khách, phình ra là tốn tiền mỗi lượt."""
    dong: List[str] = []
    if _so_tay_thanh_chu is not None:
        st = _so_tay_thanh_chu(ngu_canh.so_tay)
        if st:
            dong.append(st)
    ho_so = tai_ho_so(ngu_canh.nguoi_dung, ngu_canh.may) if tai_ho_so is not None else None
    if ho_so:
        if ho_so.get('ghi_chu_admin'):
            # 02/10/2026: ghi chú chủ shop (Admin -> Hồ sơ khách) là sự thật ưu tiên nhất về khách — vd bot nhận diện
            # sai nhu cầu thì chủ shop ghi đúng ở đây. Trước đây ô này KHÔNG vào câu dẫn.
            dong.insert(0, 'GHI CHÚ CỦA CHỦ SHOP về khách này (ĐÚNG NHẤT, trái với thông tin khác thì theo ghi chú): '
                        + str(ho_so['ghi_chu_admin']))
        if not (ngu_canh.so_tay or {}).get('nen_tang') and _so_tay_thanh_chu is not None:
            # Phiên này chưa nêu nền tảng/sàn mà hồ sơ có -> lấy từ hồ sơ.
            cu = dict(ho_so.get('so_tay') or {})
            cu['dang_hoi'] = None
            cu['loi'] = []
            cu_chu = _so_tay_thanh_chu(cu)
            if cu_chu:
                dong.append(cu_chu)
        if ho_so.get('tom_tat'):
            dong.append('Tóm tắt các lần trước: ' + str(ho_so['tom_tat']))
        if ho_so.get('bang_khoan'):
            dong.append('Còn băn khoăn: ' + str(ho_so['bang_khoan']))
        if ho_so.get('luu_y'):
            dong.append('Lưu ý khi tư vấn: ' + str(ho_so['luu_y']))
    return '\n'.join(dong)[:2500]


def _cap_nhat_so_tay_luot(ngu_canh: NguCanh, cau_hoi: str, y_dinh: Optional[str]) -> None:
    if _cap_nhat_so_tay is None:
        return
    try:
        ngu_canh.so_tay = _cap_nhat_so_tay(ngu_canh.so_tay, cau_hoi, y_dinh,
                                           nganh=_ho_so_shop(ngu_canh.khong_gian).nganh)
    except Exception:  # noqa: BLE001 - sổ tay hỏng không được chặn câu trả lời
        _log.exception('Lỗi khi cập nhật sổ tay phiên.')


def _ket_thuc_luot(ngu_canh: NguCanh, cau_hoi: str, ket_qua: TraLoi, tt: Dict[str, Any]) -> None:
    """Ghi lượt vừa xong vào RAM (dự phòng) và CSDL, rồi bật tóm gọn nền nếu
    đã đủ lượt mới (nao/compact.py). Không bao giờ ném lỗi."""
    try:
        _luu_lich_su_phien(ngu_canh.phien, cau_hoi, ket_qua.tra_loi, ngu_canh.so_tay)
        if luu_phien is None or them_luot is None:
            return
        lich_su_moi = them_luot(tt.get('lich_su') or [], cau_hoi, ket_qua.tra_loi)
        da_luu = luu_phien(
            ngu_canh.phien,
            khong_gian=ngu_canh.khong_gian, may=ngu_canh.may,
            nguoi_dung=ngu_canh.nguoi_dung, ngon_ngu=ngu_canh.ngon_ngu,
            so_tay=ngu_canh.so_tay or {},
            lich_su=lich_su_moi,
            da_goi_llm=ket_qua.da_goi_llm,
        )
        # Tóm gọn chỉ khi có CSDL (so_luot biết được) và vừa ghi được lượt này.
        if da_luu and tt.get('so_luot') is not None and _kich_hoat_tom_gon is not None:
            _kich_hoat_tom_gon(
                ngu_canh.phien, ngu_canh.compact, lich_su_moi, ngu_canh.so_tay,
                int(tt['so_luot']) + 1, int(tt.get('compact_den_luot') or 0),
                khong_gian=ngu_canh.khong_gian,
            )
    except Exception:  # noqa: BLE001
        _log.exception('Lỗi khi lưu phiên sau lượt trả lời.')


NGUON_TOI_DA = 2
# Tầng 1: điểm khớp từ mức này trở lên mới trả NGUYÊN VĂN mục kho tư vấn.
KHOP_NGUYEN_VAN = 0.9
# ... VÀ mục phải phủ ít nhất chừng này phần câu khách (nao/tang_mot.py::
# do_phu_cau_khach) — câu hỏi rộng hơn mục nhiều thì để mô hình trả lời đủ.
PHU_KHACH_NGUYEN_VAN = 0.6
# Dưới mức này thì bỏ hẳn mục, không dùng cả làm tài liệu ưu tiên.
PHU_KHACH_TOI_THIEU = 0.25
NGUON_TY_LE_DIEM = 0.6


def _chon_nguon(ket_qua: List[Any], san_pham: Optional[str]) -> List[Dict[str, str]]:
    """Chọn link "Nguồn tham khảo" gắn cuối câu trả lời.

    Trước 23/09/2026 lấy 3 trang ĐẦU TIÊN trong rổ tìm kiếm, bất kể có liên
    quan hay không. Chủ dự án chụp một câu trả lời về Copytrade kèm nguồn
    "Câu hỏi thường gặp: /codebot/custom#cauhoi" (dịch vụ code bot — chẳng
    dính gì) và "/update/copytrade#master-tang-lot" (nhật ký phiên bản):
    "câu trả lời cho khách bị trích dẫn thừa thãi".

    Rổ tìm kiếm cần RỘNG (mô hình đọc nhiều đoạn để hiểu đủ), nhưng link đưa
    cho khách phải HẸP — chỉ trang thật sự nên mở. Nên lọc riêng ở đây:
      - tối đa NGUON_TOI_DA link;
      - điểm >= NGUON_TY_LE_DIEM x điểm đoạn tốt nhất (bỏ đoạn "vớt vát");
      - đã biết sản phẩm thì chỉ trang của ĐÚNG sản phẩm đó — đoạn "dùng
        chung" vẫn đọc được nhưng không đem ra làm link (có đoạn bị gắn
        nhãn thiếu, vd một đoạn /codebot/custom#cauhoi mang san_pham=NULL);
      - trang nhật ký phiên bản (loai 'cap_nhat') chỉ trích khi nó là đoạn
        khớp NHẤT — khách hỏi cách dùng thì không cần đọc changelog;
      - cùng một trang (khác neo #) chỉ lấy một lần.
    """
    if not ket_qua:
        return []
    diem_top = max(getattr(ket_qua[0], 'diem', 0.0), 0.0)
    nguon: List[Dict[str, str]] = []
    da_thay = set()
    for thu_tu, kq in enumerate(ket_qua):
        doan = kq.doan
        if thu_tu > 0 and diem_top > 0 and kq.diem < NGUON_TY_LE_DIEM * diem_top:
            continue
        if san_pham and doan.san_pham != san_pham:
            continue
        # Changelog không làm link (kể cả khi khớp nhất) — đo thật: trang
        # /update/copytrade#ban-dau-tien bị gắn cho câu hỏi cách dùng.
        if doan.loai == 'cap_nhat':
            continue
        trang = (doan.duong_dan or '').split('#', 1)[0]
        if not trang or trang in da_thay:
            continue
        da_thay.add(trang)
        nguon.append({'tieu_de': doan.tieu_de, 'duong_dan': doan.duong_dan, '_sp': doan.san_pham})
        if len(nguon) >= NGUON_TOI_DA:
            break
    # Hàm này chỉ chạy khi mô hình KHÔNG khai nguồn, tức không biết nó dựa
    # vào đoạn nào — nên thận trọng: lọc hết thì thôi không gắn (trước đây
    # vẫn giữ đoạn khớp nhất), và chưa biết sản phẩm mà các link thuộc >= 2
    # sản phẩm khác nhau thì câu trả lời mang tính chung, không gắn link.
    # Đo thật: "bot của em không chạy" -> bot hỏi lại, nhưng bị gắn
    # /codebot/custom và /sudung/signalbroadcaster.
    if not san_pham and len({n['_sp'] for n in nguon if n['_sp']}) >= 2:
        return []
    return [{'tieu_de': n['tieu_de'], 'duong_dan': n['duong_dan']} for n in nguon]


_MAU_KHAI_NGUON = re.compile(r'^\W*NGUON_DA_DUNG\W*[:：]?\s*(.*)$', re.IGNORECASE)
_MAU_KHAI_NGUON_DINH = re.compile(r'\s*\(?\bNGUON_DA_DUNG\b\W*[:：]?\s*([\d,\s]*)\)?\s*$', re.IGNORECASE)


# Từ khoá phải đứng MỘT MÌNH làm tiêu đề, rồi dấu hai chấm hoặc hết dòng —
# "Nguồn kênh Telegram: đăng nhập…" (một bước hướng dẫn thật) KHÔNG khớp.
_MAU_DAU_KHOI_NGUON = re.compile(
    r'^[\s*_#>]*(nguồn tham khảo|nguồn|tham khảo thêm|tài liệu tham khảo|sources?|references?)'
    r'[\s*_]*(?:[:：].*)?$',
    re.IGNORECASE,
)
_MAU_DONG_LIET_KE = re.compile(r'^\s*([-*•]|\d{1,2}[.)])\s+')


def cat_khoi_nguon(van_ban: str) -> str:
    """Cắt khối "Nguồn tham khảo: ..." mà MÔ HÌNH tự viết trong câu trả lời.

    Chủ dự án 23/09/2026: "hãy viết thêm 1 phần tự động cắt đoạn nguồn tham
    khảo khỏi câu trả lời". Cổng (app.py) đã thôi tự gắn khối này; hàm đây
    lo nốt trường hợp mô hình tự viết nó dù câu dẫn đã cấm.

    Cắt: dòng tiêu đề ("Nguồn tham khảo:", "**Nguồn:**", "Sources:") cùng các
    dòng liệt kê ngay sau nó. Dòng tiêu đề có nội dung cùng dòng ("Nguồn:
    /guilde/x") cũng cắt. KHÔNG đụng câu thường có chữ "nguồn" ở giữa câu
    (vd "nguồn tín hiệu", "kênh nguồn") — chỉ bắt khi nó đứng đầu dòng làm
    tiêu đề và theo sau là dấu hai chấm hoặc hết dòng."""
    if not van_ban:
        return van_ban
    ra: List[str] = []
    dang_cat = False
    for dong in van_ban.splitlines():
        t = dong.strip()
        if dang_cat:
            if t and _MAU_DONG_LIET_KE.match(dong):
                continue
            dang_cat = False
        if t and _MAU_DAU_KHOI_NGUON.match(t):
            dang_cat = True
            continue
        ra.append(dong)
    return '\n'.join(ra).rstrip()


def _la_nhan_khai_cut(dong: str) -> bool:
    """'NGU', '**NGUON_D' ... — dòng chỉ gồm chữ HOA/gạch dưới (bỏ dấu định
    dạng) và là tiền tố của nhãn khai nguồn. 'Ngu' viết thường không tính."""
    lo = re.sub(r'[\s*`_:]+$', '', re.sub(r'^[\s*`]+', '', dong))
    return len(lo) >= 2 and re.fullmatch(r'[A-Z_]+', lo) is not None and 'NGUON_DA_DUNG'.startswith(lo)


_MAU_KHAI_ANH = re.compile(r'^[ \t>*_`-]*(LOAI_ANH|MO_TA_ANH)[*_`]*\s*[:：][*_`]*\s*(.*)$', re.M)
_LOAI_ANH_HOP_LE = ('loi', 'cau_hinh', 'chart', 'chuyen_khoan', 'khac')


def tach_khai_anh(van_ban: str) -> Tuple[str, str, str]:
    """Tách hai dòng "LOAI_ANH: …" / "MO_TA_ANH: …" (lượt có ảnh, llm/giao_dien.py::
    huong_dan_anh). Trả (câu trả lời đã xoá hai dòng, loại, mô tả). Khách không bao giờ thấy chúng."""
    loai, mo_ta = '', ''
    for m in _MAU_KHAI_ANH.finditer(van_ban or ''):
        if m.group(1) == 'LOAI_ANH':
            loai = m.group(2).strip().strip('<>*_` ').lower()
        else:
            mo_ta = m.group(2).strip()
    if loai not in _LOAI_ANH_HOP_LE:
        loai = 'khac' if (loai or mo_ta) else ''
    try:
        from congcu.so_tay import che_du_lieu_nhay_cam
        mo_ta = che_du_lieu_nhay_cam(mo_ta)
    except Exception:  # noqa: BLE001
        pass
    sach = re.sub(r'\n{3,}', '\n\n', _MAU_KHAI_ANH.sub('', van_ban or '')).strip()
    return sach, loai, mo_ta[:500]


def tach_khai_nguon(van_ban: str) -> Tuple[str, Optional[List[int]]]:
    """Tách dòng "NGUON_DA_DUNG: 1,3" mô hình viết ở cuối (llm/giao_dien.py).

    Trả (câu trả lời đã XOÁ dòng khai, danh sách số đoạn). Danh sách None =
    mô hình không khai (quên / sai định dạng) -> nơi gọi tự đoán nguồn. Mọi
    dòng có nhãn đó đều bị xoá dù nằm đâu — khách không bao giờ được thấy nó.
    """
    if not van_ban:
        return van_ban, None
    so: Optional[List[int]] = None
    giu = []
    for dong in van_ban.splitlines():
        m = _MAU_KHAI_NGUON.match(dong.strip())
        if m:
            so = [int(x) for x in re.findall(r'\d+', m.group(1)) if int(x) > 0]
            continue
        # Nhãn DÍNH cuối câu ("... nhé? NGUON_DA_DUNG: 1,3,4") — chấm mù 29/09/2026: GPT-6 Sol lộ 5 lượt.
        m = _MAU_KHAI_NGUON_DINH.search(dong)
        if m:
            so = [int(x) for x in re.findall(r'\d+', m.group(1)) if int(x) > 0]
            dong = dong[:m.start()].rstrip()
            if not dong.strip():
                continue
        giu.append(dong)
    # Câu trả lời bị cắt ở trần token ngay giữa dòng khai ("NGU", "NGUON_D")
    # — đo thật 23/09/2026, chữ "NGU" lộ ra cho khách. Dòng cuối là một tiền
    # tố của nhãn thì bỏ.
    while giu and _la_nhan_khai_cut(giu[-1]):
        giu.pop()
    return '\n'.join(giu).rstrip(), so


def _nguon_theo_khai(ket_qua: List[Any], so_doan: List[int],
                     san_pham: Optional[str] = None) -> List[Dict[str, str]]:
    """Đoạn mô hình khai đã dùng -> link, mỗi trang một lần, tối đa NGUON_TOI_DA.
    Đoạn không có đường dẫn công khai (vd mục kho tư vấn chưa gắn nguồn) bỏ qua.

    Mô hình hay khai "cho có" cả khi chỉ hỏi làm rõ hoặc trả lời bằng kiến
    thức chung (đo thật: "bot của em không chạy" -> nó hỏi lại 3 câu nhưng
    khai /codebot/custom và /sudung/signalbroadcaster). Nên:
      - đã biết sản phẩm: bỏ đoạn của sản phẩm KHÁC;
      - chưa biết sản phẩm mà các đoạn khai thuộc >= 2 sản phẩm khác nhau:
        câu trả lời mang tính chung -> không gắn link nào."""
    nguon: List[Dict[str, str]] = []
    da_thay = set()
    hop_le = [i for i in so_doan if 1 <= i <= len(ket_qua)]
    if san_pham:
        hop_le = [i for i in hop_le if ket_qua[i - 1].doan.san_pham in (san_pham, None)]
    else:
        cac_sp = {ket_qua[i - 1].doan.san_pham for i in hop_le if ket_qua[i - 1].doan.san_pham}
        if len(cac_sp) >= 2:
            return []
    # Trang nhật ký phiên bản chỉ đem ra làm link khi mô hình không dựa vào
    # trang nào khác — khách hỏi cách dùng thì đọc trang hướng dẫn, không
    # đọc changelog (đo thật 23/09/2026: mô hình khai /update/copytrade).
    khong_changelog = [i for i in hop_le if ket_qua[i - 1].doan.loai != 'cap_nhat']
    for i in (khong_changelog or hop_le):
        doan = ket_qua[i - 1].doan
        trang = (doan.duong_dan or '').split('#', 1)[0]
        if not trang or trang in da_thay:
            continue
        da_thay.add(trang)
        nguon.append({'tieu_de': doan.tieu_de, 'duong_dan': doan.duong_dan})
        if len(nguon) >= NGUON_TOI_DA:
            break
    return nguon


def _doan_tu_muc(muc: dict, van_ban: str) -> Any:
    """Mục kho tư vấn (tầng 1 khớp LỎNG) -> một đoạn tài liệu ưu tiên cho mô
    hình. loai='tuvan' làm llm/giao_dien.py đánh dấu "LỜI CỦA CHỦ WEBSITE"."""
    from giao_uoc import Doan, KetQuaTim
    return KetQuaTim(
        doan=Doan(
            ma=f"tuvan/{muc.get('id')}", van_ban_vi=van_ban, van_ban_en=van_ban,
            san_pham=muc.get('san_pham') or None, loai='tuvan',
            duong_dan=str(muc.get('duong_dan_nguon') or ''),
            tieu_de=str(muc.get('cau_hoi_chuan') or 'Kho tư vấn'), nguon_tep='chat_muc',
        ),
        diem=1.0,
    )


def _cau_kem_san_pham(cau_hoi: str, ngu_canh: NguCanh) -> Optional[str]:
    """Câu ngắn không nêu sản phẩm ("còn MT4 thì sao") mà phiên này đang bàn
    về một sản phẩm -> ghép tên sản phẩm vào để TẦNG 1 khớp được, thay vì
    rơi xuống tầng 2 gọi mô hình. None nếu không cần/không ghép được."""
    dang_hoi = (ngu_canh.so_tay or {}).get('dang_hoi')
    if not dang_hoi or len((cau_hoi or '').split()) > 8:
        return None
    try:
        if doan_san_pham(cau_hoi):
            return None
    except Exception:  # noqa: BLE001
        return None
    return f'{cau_hoi} {dang_hoi}'


# =====================================================================
# NHÀ CUNG CẤP AI DÙNG CHUNG CHO TIẾN TRÌNH — dựng qua cau_hinh, một lần
# =====================================================================

_nha_cung_cap_dem: Dict[str, Any] = {'doi_tuong': None}


def _lay_nha_cung_cap_dung_chung():
    if cau_hinh is None:
        return None
    if _nha_cung_cap_dem['doi_tuong'] is None:
        try:
            _nha_cung_cap_dem['doi_tuong'] = cau_hinh.lay_nha_cung_cap()
        except Exception:  # noqa: BLE001 - cau_hinh.lay_nha_cung_cap() đã tự rơi về rỗng,
            # nhưng phòng thêm một lớp nữa ở đây cho chắc.
            _log.exception('Không dựng được nhà cung cấp AI qua cau_hinh.lay_nha_cung_cap().')
            return None
    return _nha_cung_cap_dem['doi_tuong']


# =====================================================================
# CÁC NHÁNH XỬ LÝ THEO Ý ĐỊNH
# =====================================================================

def _xu_ly_tra_key(cau_hoi: str, ngu_canh: NguCanh, do_tin_cay: float) -> TraLoi:
    """TRA KEY ĐÃ TẮT — bot KHÔNG đọc và KHÔNG đọc ra key của khách nữa.

    Chủ dự án quyết định 23/09/2026, nguyên văn:
        "tắt phần tra key, tốt nhất ko nên lòi dữ liệu key của khách ra
         bảng tư vấn"

    VÌ SAO ĐÂY LÀ QUYẾT ĐỊNH ĐÚNG, ghi lại để đừng ai bật lại cho "tiện":

    Khung chat là chỗ RÒ RỈ DỄ NHẤT trong cả hệ. Danh tính ở đó chỉ dựa vào
    phiên đăng nhập của web, mà một khung chat thì có quá nhiều đường đi
    sai: khách gửi ảnh chụp màn hình cho người khác, máy dùng chung không
    đăng xuất, hoặc chỉ đơn giản là nhật ký hội thoại lưu lại nguyên mã key
    rồi nằm trong CSDL mãi mãi.

    Đổi lại được gì: KHÔNG NHIỀU. Khách tự xem được đầy đủ key và hạn dùng
    tại /orders, nơi đã có xác thực tử tế. Bot chỉ việc chỉ đường.

    Nên nhánh này giờ KHÔNG chạm tới `congcu.licence` nữa — không gọi máy
    chủ key, không đọc CSDL key, không có gì để mà lỡ đọc ra. Muốn chắc hơn
    nữa thì xem `thu/kiem_ghep.py`, ở đó có ca canh đúng chuyện này.
    """
    if cau_key is None:
        _log.error('Thiếu congcu.khuon_mau — chuyển người cho câu tra_key.')
        return _tra_loi_chuyen_nguoi(ngu_canh.ngon_ngu, 'tra_key', do_tin_cay, khong_gian=ngu_canh.khong_gian)
    # Truyền danh sách RỖNG: `cau_key([])` chính là câu chỉ khách sang
    # /orders. Không tra, không đọc, không có đường nào lộ ra.
    return TraLoi(
        tra_loi=cau_key([], ngu_canh.ngon_ngu), nguon=[], y_dinh='tra_key',
        chuyen_nguoi=False, do_tin_cay=do_tin_cay, da_goi_llm=False,
    )


def _xu_ly_gia(cau_hoi: str, ngu_canh: NguCanh, do_tin_cay: float) -> TraLoi:
    if tim_san_pham is None or danh_sach_san_pham is None or cau_gia is None or cau_bang_gia_tat_ca is None:
        _log.error('Thiếu congcu.san_pham hoặc congcu.khuon_mau — chuyển người cho câu hỏi giá.')
        return _tra_loi_chuyen_nguoi(ngu_canh.ngon_ngu, 'gia', do_tin_cay, khong_gian=ngu_canh.khong_gian)
    try:
        slug_doan = doan_san_pham(cau_hoi)
        import shop
        them = shop.gia_them(_ho_so_shop(ngu_canh.khong_gian))
        sp = tim_san_pham(slug_doan) if slug_doan else None
        if slug_doan in them and cau_gia_them is not None:
            # Sản phẩm ngoài bảng `product` (TradingAuto: VipTrend) — giá chủ khai trong shop/<ma>.py.
            ten, cac_dong, _ = them[slug_doan]
            van_ban = cau_gia_them(ten, cac_dong, ngu_canh.ngon_ngu)
        elif sp is not None:
            van_ban = cau_gia(sp, ngu_canh.ngon_ngu)
        else:
            van_ban = cau_bang_gia_tat_ca(danh_sach_san_pham(), ngu_canh.ngon_ngu,
                                          [g for _, _, g in them.values()])
    except Exception:  # noqa: BLE001
        _log.exception('Lỗi khi soạn câu trả lời giá cho câu hỏi %r.', cau_hoi)
        return _tra_loi_chuyen_nguoi(ngu_canh.ngon_ngu, 'gia', do_tin_cay, khong_gian=ngu_canh.khong_gian)
    return TraLoi(
        tra_loi=van_ban, nguon=[], y_dinh='gia', chuyen_nguoi=False,
        do_tin_cay=do_tin_cay, da_goi_llm=False,
    )


# =====================================================================
# HỎI GIÁ NỐI TIẾP — hiểu "giá của cái gì" theo chủ đề đang bàn
# =====================================================================
#
# Messenger 24/09/2026: khách bàn đặt code 2 bot (Pine Script + Python) qua
# 5 lượt, rồi hỏi "Chi phí như nào". Câu không nêu sản phẩm nên nhánh giá
# đổ nguyên bảng giá 7 sản phẩm có sẵn — đúng số, sai câu hỏi. Nhánh giá cũ
# chỉ đọc CÂU HIỆN TẠI; ngữ cảnh chỉ tới được mô hình ở tầng 2.
#
# Nay câu hỏi giá KHÔNG nêu sản phẩm thì nhìn sổ tay phiên:
#   - đang bàn đặt code  -> mô hình trả lời theo mục "chi phí code" của chủ
#     (tài liệu ưu tiên) + lịch sử, để báo giá đúng việc khách đang tả;
#   - đang bàn một sản phẩm -> giá ĐÚNG sản phẩm đó (khuôn mẫu, từ CSDL);
#   - chưa rõ gì         -> bảng giá tất cả như cũ.
# Giá sản phẩm vẫn không bao giờ do mô hình sinh ra.

# VAI "CHUYÊN GIA" (chủ dự án 24/09/2026: "tùy vào ngữ cảnh như tư vấn về code
# bot thì nên dùng mô hình trọng số tư duy cao"). Chỉ khi ĐANG BÀN ĐẶT CODE và
# câu này có chất kỹ thuật (tả logic, chỉ báo, quản lý vốn) hoặc hỏi giá cho
# chiến lược — "ok", "bao lâu xong" vẫn để mô hình rẻ trả lời.
def _can_chuyen_gia(cau_hoi: str, ngu_canh: NguCanh, y_dinh: str) -> bool:
    try:
        from congcu.cau_hinh_ai import bat_chuyen_gia
        if not bat_chuyen_gia(ngu_canh.khong_gian):
            return False
    except Exception:  # noqa: BLE001
        return False
    if not _ho_so_shop(ngu_canh.khong_gian).co_dat_code or (ngu_canh.so_tay or {}).get('chu_de') != 'code':
        return False
    if y_dinh == 'gia':
        return True
    try:
        from nao.dinh_tuyen import chuan_hoa
        from congcu.so_tay import so_tu_ky_thuat
        vb = chuan_hoa(cau_hoi or '')
        so = so_tu_ky_thuat(vb)
    except Exception:  # noqa: BLE001
        return False
    return so >= 2 or (so >= 1 and len(vb.split()) >= 25)


CAU_MUC_GIA_CODE = 'chi phí code chiến lược thành bot bao nhiêu'
GHI_CHU_GIA_CODE = (
    'Khách đang hỏi CHI PHÍ ĐẶT CODE cho đúng việc đã bàn trong hội thoại (không phải giá sản phẩm có sẵn '
    'và KHÔNG liệt kê bảng giá sản phẩm). Dùng các mức giá trong LỜI CỦA CHỦ WEBSITE, xếp việc khách đã tả '
    'vào mức phù hợp và nói rõ vì sao; nhiều bot thì báo theo từng bot. Thông tin khách đã cho thì không hỏi '
    'lại; chỉ hỏi thêm đúng thứ còn thiếu để chốt giá. Không tự nghĩ ra mức giá ngoài tài liệu. '
    'Nêu ĐỦ các mức và dòng hình thức bàn giao như tài liệu (EA MT4/MT5 file .ex4/.ex5, Pine Script, '
    'Python) — khách đang bàn một nền tảng vẫn nêu đủ để khách biết lựa chọn.'
)


def _doan_muc_gia_code(ngu_canh: NguCanh) -> Optional[Any]:
    """Mục kho tư vấn "chi phí code" đã duyệt -> tài liệu ưu tiên. None nếu chưa có."""
    if khop is None:
        return None
    try:
        kq = khop(CAU_MUC_GIA_CODE, ngu_canh)
        if kq is None:
            return None
        muc = kq[0]
        return _doan_tu_muc(muc, str(muc.get('tra_loi_vi') or ''))
    except Exception:  # noqa: BLE001
        _log.exception('Lỗi khi lấy mục giá code làm tài liệu ưu tiên.')
        return None


def _xu_ly_gia_theo_ngu_canh(cau_hoi: str, ngu_canh: NguCanh, do_tin_cay: float) -> TraLoi:
    st = ngu_canh.so_tay or {}
    try:
        co_san_pham = bool(_san_pham_co_gia(cau_hoi))
    except Exception:  # noqa: BLE001
        co_san_pham = True
    if not co_san_pham:
        try:
            from nao.dinh_tuyen import chuan_hoa
            vb = chuan_hoa(cau_hoi or '')
        except Exception:  # noqa: BLE001
            vb = (cau_hoi or '').lower()
        if _MAU_HOI_GIA_CODE.search(vb) and st.get('chu_de') != 'code':
            # "Cho xin giá cod ạ" (Messenger 25/09/2026) — khách đã nói là giá code, đừng hỏi
            # "thuê hay code" nữa. Không đổi sổ tay: chỉ lượt này đi nhánh giá code.
            _log.info('Hỏi giá có chữ code: %r -> tư vấn giá code.', cau_hoi)
            ngu_canh.ghi_chu = GHI_CHU_GIA_CODE
            try:
                return _xu_ly_rag(cau_hoi, ngu_canh, 'gia', do_tin_cay, _doan_muc_gia_code(ngu_canh))
            finally:
                ngu_canh.ghi_chu = ''
        if st.get('chu_de') == 'code':
            _log.info('Hỏi giá nối tiếp khi đang bàn đặt code: %r -> tư vấn giá code.', cau_hoi)
            ngu_canh.ghi_chu = GHI_CHU_GIA_CODE
            try:
                return _xu_ly_rag(cau_hoi, ngu_canh, 'gia', do_tin_cay, _doan_muc_gia_code(ngu_canh))
            finally:
                ngu_canh.ghi_chu = ''
        if st.get('dang_hoi'):
            _log.info('Hỏi giá nối tiếp: %r -> giá sản phẩm phiên %s.', cau_hoi, st['dang_hoi'])
            return _xu_ly_gia(f"{cau_hoi} {st['dang_hoi']}", ngu_canh, do_tin_cay)
        if _MAU_HOI_GIA_THUE.search(vb) or _bot_vua_hoi_lai_gia(ngu_canh):
            # "Thuê thì giá bao nhiêu 1 tháng" / khách đã trả lời câu hỏi lại mà vẫn chưa rõ
            # sản phẩm: đưa bảng giá đầy đủ (có VipTrend) thay vì hỏi lại lần hai — 26/09/2026
            # bot hỏi y nguyên một câu hai lượt liền.
            return _xu_ly_gia(cau_hoi, ngu_canh, do_tin_cay)
        # Chưa biết hỏi giá CÁI GÌ: hỏi lại một câu thay vì đổ bảng giá 7 sản phẩm
        # (chủ dự án 24/09/2026: "tống luôn câu trả lời combo"). Khách hỏi đúng
        # "bảng giá / giá các sản phẩm" thì vẫn đưa cả bảng.
        try:
            from nao.dinh_tuyen import chuan_hoa
            muon_ca_bang = bool(_MAU_MUON_BANG_GIA.search(chuan_hoa(cau_hoi or '')))
        except Exception:  # noqa: BLE001
            muon_ca_bang = True
        if not muon_ca_bang:
            return TraLoi(tra_loi=CAU_HOI_LAI_GIA, nguon=[], y_dinh='gia', chuyen_nguoi=False,
                          do_tin_cay=do_tin_cay, da_goi_llm=False)
    return _xu_ly_gia(cau_hoi, ngu_canh, do_tin_cay)


_MAU_MUON_BANG_GIA = re.compile(r'\b(bang gia|cac san pham|tat ca|tung san pham|san pham ben|pricing|price list)\b')
_MAU_HOI_GIA_CODE = re.compile(r'\b(code|cod|lap trinh|viet (ea|bot)|lam bot)\b')
_MAU_HOI_GIA_THUE = re.compile(r'\b(thue|mot thang|1 thang|theo thang|theo nam|mot nam|1 nam)\b')


def _luot_bot_truoc(ngu_canh: NguCanh) -> str:
    for luot in reversed(ngu_canh.lich_su or []):
        if luot.get('vai') != 'khach':
            return str(luot.get('noi_dung') or '')
    return ''


def _chuan_nut(van_ban: str) -> str:
    """Chữ của nút gợi ý, bỏ khác biệt vụn: hoa/thường, khoảng trắng, khoảng trắng trước dấu phẩy."""
    s = re.sub(r'\s+', ' ', (van_ban or '').strip().lower())
    return re.sub(r'\s+([,.!?])', r'\1', s).strip(' .!?')


def _tra_loi_nut_bam(cau_hoi: str, ngu_canh: NguCanh, y_dinh: str, do_tin_cay: float) -> Optional[TraLoi]:
    """Khách bấm nút gợi ý (chữ trùng khít một nút của shop) -> câu trả lời chuẩn = nguyên văn mục kho
    gắn với nút, không gọi mô hình, ở mọi chế độ tư vấn. Chủ dự án 29/09/2026 sau chấm mù: "khi họ nhấn
    vào thì trả lời luôn thay vì gọi". None = không phải nút / mục hỏng / trùng câu bot vừa gửi."""
    nut = {_chuan_nut(k): v for k, v in (_ho_so_shop(ngu_canh.khong_gian).nut_bam or {}).items()}
    muc_id = nut.get(_chuan_nut(cau_hoi))
    if not muc_id or tra_loi_tu_muc is None:
        return None
    try:
        from .tang_mot import nap_muc
    except ImportError:
        from tang_mot import nap_muc  # type: ignore
    muc = next((m for m in nap_muc(ngu_canh.khong_gian or 'tradingauto') if int(m.get('id') or 0) == muc_id), None)
    if muc is None:
        _log.warning('Nút gợi ý trỏ tới mục id=%s nhưng mục không có / chưa duyệt — trả lời như câu thường.', muc_id)
        return None
    kq = tra_loi_tu_muc(muc, ngu_canh, 1.0)
    if kq is None or kq.tra_loi.strip() == _luot_bot_truoc(ngu_canh).strip():
        return None   # bấm lại nút vừa trả lời: khách cần thêm, để bộ não trả lời
    kq.y_dinh, kq.do_tin_cay = y_dinh, max(do_tin_cay, 0.9)
    if tang_so_lan_dung is not None:
        try:
            tang_so_lan_dung(muc_id)
        except Exception:  # noqa: BLE001 - đếm thống kê hỏng không được làm hỏng câu trả lời
            _log.exception('Lỗi khi tăng so_lan_dung cho mục id=%s.', muc_id)
    return kq


def _bot_vua_hoi_lai_gia(ngu_canh: NguCanh) -> bool:
    return _luot_bot_truoc(ngu_canh).strip() == CAU_HOI_LAI_GIA.strip()


def _la_tra_loi_lua_chon(cau_hoi: str, ngu_canh: NguCanh) -> bool:
    """Câu ngắn chỉ là CHỌN một phương án bot vừa đưa ra ("Trả phí" khi bot hỏi gói TradingView
    miễn phí hay trả phí) — không phải câu hỏi mới. Messenger 24/09/2026: "Trả phí" bị xếp 'gia'
    vì chữ "phí", bot bỏ mạch tư vấn TradingView để hỏi "thuê hay code"."""
    try:
        from nao.dinh_tuyen import chuan_hoa
        vb = chuan_hoa(cau_hoi or '').strip()
        truoc = chuan_hoa(_luot_bot_truoc(ngu_canh))
    except Exception:  # noqa: BLE001
        return False
    if not vb or not truoc or len(vb.split()) > 4 or re.search(r'\b(gia|bao nhieu|chi phi|price|cost)\b', vb):
        return False
    return re.search(r'\b' + re.escape(vb) + r'\b', truoc) is not None
CAU_HOI_LAI_GIA = ('Dạ anh/chị đang hỏi giá **thuê bot, tool có sẵn** hay **chi phí code bot theo chiến lược riêng** ạ? '
                   'Bảng giá các tool có sẵn anh/chị xem tại /Pricing.')


# =====================================================================
# CÂU HỎI CHUNG CHUNG VỀ MỘT SẢN PHẨM — hỏi làm rõ TRƯỚC, không đổ bài
# =====================================================================
#
# Chủ dự án 23/09/2026, về câu "quang trade panel dùng như thế nào": "câu
# này thường là phải hỏi 2 bước vì vừa có thể là cách cài đặt vừa có thể là
# cách dùng. Người chuyên nghiệp là thường hỏi cụ thể vấn đề rồi mới tư vấn
# đúng chỗ." Bot đã đổ ra một bài trộn cả cài đặt lẫn thao tác, tự chèn
# link, rồi còn gắn "Nguồn tham khảo".
#
# Quyết định bằng LUẬT trước khi gọi mô hình (như luật hỏi sản phẩm): câu
# dẫn chỉ là lời khuyên, mô hình đứng trước 8 đoạn tài liệu cụ thể thì gần
# như luôn chọn trả lời luôn. Mô hình VẪN được gọi — để đưa ra lựa chọn cụ
# thể lấy từ tài liệu của đúng sản phẩm đó — nhưng bị dặn chỉ được hỏi.

# Từ (đã bỏ dấu) không mang nội dung cụ thể — câu chỉ gồm tên sản phẩm + các
# từ này là câu hỏi chung chung.
_TU_CHUNG_CHUNG = frozenset("""
dung su huong dan xai cach the nao nhu sao ra lam duoc khong ko k vay a ah nhe
nha anh chi em minh toi t cho hoi ve bot ea tool phan mem san pham cua ben voi
la gi oi can muon biet giup chut ti ne thi co tu dau bat
""".split())
# Nền tảng / kênh hay nằm trong tên sản phẩm — không phải nội dung cụ thể.
_TU_NEN_TANG = frozenset('mt4 mt5 metatrader telegram discord tradingview to va and'.split())
# Phải có ít nhất một từ nói về "cách dùng / hướng dẫn" — không thì câu kiểu
# "trade panel?" chỉ là gọi tên, để nhánh thường xử lý.
_MAU_Y_CACH_DUNG = re.compile(r'\b(dung|su dung|huong dan|xai|cach)\b')
GHI_CHU_HOI_RONG = (
    'Câu hỏi này RỘNG — khách chưa nói đang cần phần nào. Lượt này em KHÔNG hướng dẫn gì. '
    'Một câu ngắn đón câu hỏi, rồi hỏi đúng MỘT câu làm rõ, cho khách chọn: (1) cài đặt, '
    '(2) một thao tác cụ thể khi dùng — kể 3 đến 5 thao tác CHÍNH của đúng sản phẩm này lấy '
    'từ tài liệu, mỗi cái vài chữ, hoặc (3) đang gặp lỗi thì báo lỗi gì. Không giải thích '
    'thêm, không chèn link. Dòng khai: NGUON_DA_DUNG: 0.'
)


def _cau_hoi_chung_chung(cau_hoi: str, ngu_canh: NguCanh) -> bool:
    """'quang trade panel dùng như thế nào', 'hướng dẫn sử dụng copytrade',
    'dcabot xài sao' -> True. 'trade panel chốt 50% là sao', 'cài copytrade
    thế nào', 'lỗi 4109' -> False (đã nêu việc cụ thể)."""
    sp = doan_san_pham(cau_hoi) or (ngu_canh.so_tay or {}).get('dang_hoi')
    if not sp:
        return False
    # Chữ số chỉ xét ở PHẦN CÒN LẠI (bên dưới) — "MT5" nằm ngay trong tên
    # sản phẩm; chặn mọi câu có chữ số từng làm hỏng đúng câu cần bắt.
    try:
        from nao.dinh_tuyen import chuan_hoa, _ALIAS_SAN_PHAM
    except Exception:  # noqa: BLE001
        return False
    vb = chuan_hoa(cau_hoi)
    if not _MAU_Y_CACH_DUNG.search(vb):
        return False
    for alias in sorted((a for ds in _ALIAS_SAN_PHAM.values() for a in ds), key=len, reverse=True):
        vb = re.sub(r'\b' + re.escape(chuan_hoa(alias)) + r'\b', ' ', vb)
    # Khách hay dán NGUYÊN tên sản phẩm từ trang web: "Telegram, Discord to
    # MT5 Signal Copier dùng như thế nào" — đo thật 23/09/2026, các chữ
    # telegram/discord/mt5/to/copier bị tưởng là nội dung cụ thể nên câu
    # lọt sang nhánh trả lời thường, bị đổ bài và gắn nguồn. Bỏ luôn mọi từ
    # trong tên đầy đủ (EN + VI, lấy từ CSDL) của ĐÚNG sản phẩm đó.
    tu_ten = set(_TU_NEN_TANG)
    try:
        sp_obj = tim_san_pham(sp) if tim_san_pham is not None else None
        if sp_obj is not None:
            for ten in (getattr(sp_obj, 'ten', ''), getattr(sp_obj, 'ten_vi', '')):
                tu_ten.update(re.findall(r'[a-z0-9]+', chuan_hoa(ten or '')))
    except Exception:  # noqa: BLE001 - không đọc được tên thì chỉ dùng bí danh
        pass
    con_lai = [t for t in re.findall(r'[a-z0-9]+', vb)
               if t not in _TU_CHUNG_CHUNG and t not in tu_ten]
    # 'quang' là phần tên "Quang Trade Panel" khi khách gõ đủ tên.
    con_lai = [t for t in con_lai if t != 'quang']
    return not con_lai


def _khoi_danh_muc(khong_gian: Optional[str] = None) -> str:
    """Danh mục sản phẩm đang bán + mô tả ngắn, từ CSDL (đệm 60 giây trong
    congcu/san_pham.py). Đi vào MỌI lượt tư vấn: đo thật 23/09/2026, câu
    "bên bạn bán những sản phẩm nào" ra danh sách mà SMART DCA EA và MT5
    Manager chỉ có tên — tìm kiếm không kéo được đoạn nào về hai sản phẩm đó."""
    hs = _ho_so_shop(khong_gian)
    try:
        dong = []
        # Bảng `product` là danh mục của TradingAuto — shop khác KHÔNG được thấy nó.
        for sp in (danh_sach_san_pham() if (hs.dung_bang_product and danh_sach_san_pham is not None) else []):
            ten = sp.ten_vi or sp.ten
            dong.append(f'- {ten}' + (f': {sp.mo_ta}' if getattr(sp, 'mo_ta', '') else ''))
        dong.extend(hs.danh_muc_them)           # bán thật nhưng ngoài bảng (TradingAuto: VipTrend)
        # Giá sản phẩm ngoài bảng — nhãn "BẢNG GIÁ HỆ THỐNG" để luật giá của câu dẫn cho phép chép.
        for ten, cac_dong, _ in __import__('shop').gia_them(hs).values():
            dong.append(f'BẢNG GIÁ HỆ THỐNG — {ten}: ' + '; '.join(cac_dong))
        return '\n'.join(dong)
    except Exception:  # noqa: BLE001
        _log.exception('Không dựng được danh mục sản phẩm cho prompt.')
        return ''


CAU_RONG_SO_TU = 8


def _cau_rong(cau_hoi: str) -> bool:
    """Câu hỏi nhiều việc một lúc ("giá sao, cài thế nào, chạy vps được không,
    copy sang tài khoản khác được không") — đếm từ khoá sau khi bỏ hư từ."""
    try:
        from nao.tang_mot import _tu_khoa
        return len(set(_tu_khoa(cau_hoi))) >= CAU_RONG_SO_TU
    except Exception:  # noqa: BLE001
        return len((cau_hoi or '').split()) >= CAU_RONG_SO_TU * 2


def _san_pham_co_gia(cau_hoi: str) -> Optional[str]:
    """Sản phẩm CÓ GIÁ khách nêu trong câu. 'codebot' là trang dịch vụ code (giá
    theo chiến lược), không phải sản phẩm trong bảng giá — coi như không nêu."""
    try:
        sp = doan_san_pham(cau_hoi)
    except Exception:  # noqa: BLE001
        return None
    return None if sp in (None, '', 'codebot') else sp


def _xu_ly_gia_kem_tu_van(cau_hoi: str, ngu_canh: NguCanh, do_tin_cay: float) -> TraLoi:
    """Câu hỏi giá KÈM nhiều việc khác. Trước 23/09/2026 nhánh 'gia' trả mỗi
    bảng giá và bỏ rơi phần còn lại của câu hỏi.

    Mô hình tư vấn phần còn lại (được dặn: phần giá hệ thống tự gắn, đừng
    nhắc giá); bảng giá THẬT từ CSDL (khuôn mẫu, không qua mô hình) gắn bên
    dưới. Luật "số liệu giá không do mô hình sinh ra" vẫn giữ nguyên."""
    gia = _xu_ly_gia(cau_hoi, ngu_canh, do_tin_cay)
    if gia.chuyen_nguoi:
        return _xu_ly_rag(cau_hoi, ngu_canh, 'khac', do_tin_cay)
    ngu_canh.ghi_chu = ('Bảng giá chính xác đã được hệ thống đặt NGAY TRƯỚC câu trả lời của em. '
                        'Em bỏ hẳn ý về giá: không có mục "Giá", không nhắc tới giá hay bảng giá. '
                        'Chỉ trả lời các ý còn lại của khách.')
    try:
        tu_van = _xu_ly_rag(cau_hoi, ngu_canh, 'khac', do_tin_cay)
    finally:
        ngu_canh.ghi_chu = ''
    if tu_van.chuyen_nguoi:
        return gia
    # Giá TRƯỚC: khách hỏi giá đầu tiên, và câu trả lời tư vấn kết bằng
    # một câu hỏi — bảng giá chèn sau câu hỏi đó thì đọc rất cụt.
    tu_van.tra_loi = gia.tra_loi.rstrip() + '\n\n' + tu_van.tra_loi.lstrip()
    tu_van.y_dinh = 'gia'
    return tu_van


def _xu_ly_chao(ngu_canh: NguCanh, do_tin_cay: float) -> TraLoi:
    ngon_ngu = _ngon_ngu_hop_le(ngu_canh.ngon_ngu)
    hs = _ho_so_shop(ngu_canh.khong_gian)
    if _luot_bot_truoc(ngu_canh).startswith(_CAU_CHAO.get(ngon_ngu, _CAU_CHAO['vi'])):
        # Vừa chào xong mà khách chào tiếp ("hi bạn" rồi "alo bạn") — Messenger 24/09/2026 bot
        # gửi nguyên câu chào dài hai lần liền.
        return TraLoi(tra_loi=_CAU_CHAO_LAI.get(ngon_ngu, _CAU_CHAO_LAI['vi']), nguon=[], y_dinh='chao',
                      chuyen_nguoi=False, do_tin_cay=do_tin_cay, da_goi_llm=False)
    van_ban = ' '.join((
        _CAU_CHAO.get(ngon_ngu, _CAU_CHAO['vi']),
        hs.tu_gioi_thieu.get(ngon_ngu) or CAU_KHAI_LA_MAY.get(ngon_ngu, CAU_KHAI_LA_MAY['vi']),
        hs.goi_y_viec.get(ngon_ngu) or _GOI_Y_3_VIEC.get(ngon_ngu, _GOI_Y_3_VIEC['vi']),
    ))
    return TraLoi(
        tra_loi=van_ban, nguon=[], y_dinh='chao', chuyen_nguoi=False,
        do_tin_cay=do_tin_cay, da_goi_llm=False,
    )


def _cau_de_tim(cau_hoi: str, ngu_canh: NguCanh) -> str:
    """Câu dùng để TÌM TÀI LIỆU — có thể khác câu khách vừa gõ.

    VÌ SAO cần hàm này. Bot giờ biết hỏi lại: khách hỏi "cài EA vào MT5 thế
    nào", bot hỏi "anh/chị cài sản phẩm nào?", khách đáp "dcabot". Đúng cách
    người ta nói chuyện. Nhưng nếu đi tìm tài liệu bằng đúng hai chữ
    "dcabot" thì không ra được gì hữu ích — CÂU HỎI THẬT nằm rải ra hai
    lượt, và một mình lượt sau thì vô nghĩa.

    Đo thật trên cổng: hỏi lại xong, khách trả lời tên sản phẩm, bot trả
    "chỗ này em chưa chắc" rồi chuyển người. Hỏi lại mà không nối được mạch
    còn tệ hơn không hỏi — khách đã bỏ công trả lời rồi mà vẫn không được gì.

    Cách nối: câu hiện tại NGẮN (ít từ có nghĩa) và có lượt khách trước đó
    thì ghép hai lượt lại làm câu tìm. Chỉ ghép khi câu hiện tại ngắn —
    câu dài thì tự nó đã đủ nghĩa, ghép thêm chỉ làm loãng truy vấn.
    """
    goc = (cau_hoi or '').strip()
    if len(goc.split()) > 5:
        return goc
    truoc = ''
    for luot in reversed(ngu_canh.lich_su or []):
        if luot.get('vai') == 'khach':
            truoc = str(luot.get('noi_dung') or '').strip()
            break
    if not truoc:
        return goc
    ghep = (truoc + ' ' + goc).strip()
    _log.info('Câu ngắn %r — ghép với lượt trước để tìm: %r', goc, ghep)
    return ghep


def _da_biet_san_pham(cau_hoi: str, ngu_canh: NguCanh) -> bool:
    """Đã biết khách đang hỏi về sản phẩm nào chưa?

    Tìm ở HAI chỗ, và phải tìm ở cả hai: câu hỏi hiện tại, VÀ hội thoại
    trước. Chỉ nhìn câu hiện tại là bot hỏi lại y hệt câu nó vừa hỏi xong —
    khách trả lời "dca bot" rồi hỏi tiếp "thế bước 2 làm sao", câu sau lại
    không chứa tên sản phẩm và bot lại hỏi lại lần nữa. Vòng lặp đó khó chịu
    hơn hẳn việc trả lời tràn lan.
    """
    if doan_san_pham(cau_hoi):
        return True
    # Sổ tay phiên (congcu/so_tay.py) nhớ sản phẩm khách đã nêu kể cả khi
    # lượt đó đã trôi khỏi 3 lượt gần nhất.
    if (ngu_canh.so_tay or {}).get('dang_hoi'):
        return True
    for luot in (ngu_canh.lich_su or []):
        if doan_san_pham(str(luot.get('noi_dung') or '')):
            return True
    return False


def _xu_ly_rag(cau_hoi: str, ngu_canh: NguCanh, y_dinh: Ydinh, do_tin_cay: float,
               doan_uu_tien: Optional[Any] = None) -> TraLoi:
    """'huong_dan', 'loi', 'khac' -> tìm kiếm rồi tư vấn.

    ĐỔI 23/09/2026 (giao_uoc.py mục 3): trước đây không tìm được đoạn nào
    thì chuyển người. Nay vẫn gọi mô hình — nó được trả lời bằng kiến thức
    chung về MT4/MT5 và trading, còn thông tin RIÊNG của sản phẩm thì câu
    dẫn cấm nói khi không có tài liệu. Chủ dự án: "một số liên quan đến
    thường thức và kinh nghiệm thì vẫn tư vấn".

    `doan_uu_tien`: mục kho tư vấn khớp LỎNG ở tầng 1 — đưa lên đầu rổ làm
    "lời của chủ website", thay vì trả nguyên văn một mục có thể lệch câu hỏi.
    """

    # ---- HỎI LẠI SẢN PHẨM TRƯỚC, bằng LUẬT CỨNG chứ không nhờ mô hình ----
    #
    # Chủ dự án (ý kiến 22/09/2026, câu #4): "Chỗ này hướng dẫn tuỳ theo từng
    # sản phẩm, phải hỏi họ trước về sản phẩm nào, sau đó mới hướng dẫn cài
    # đặt riêng theo từng sản phẩm."
    #
    # Tôi đã thử làm điều này bằng CÂU DẪN trước ("HỎI LẠI TRƯỚC KHI TRẢ LỜI
    # DÀI..."). ĐO THẬT trên cổng: KHÔNG ăn. Hỏi "cài EA vào MT5 thế nào" —
    # câu không nêu sản phẩm nào — mô hình vẫn đổ ra nguyên bài hướng dẫn.
    # Lý do dễ hiểu: tầng tìm kiếm đã đặt sẵn trước mặt nó 5 đoạn tài liệu
    # rất cụ thể, nên "tôi chưa biết đủ để trả lời" là điều nó khó tự nghĩ ra.
    #
    # Câu dẫn là LỜI KHUYÊN, và mô hình cân nhắc lời khuyên với những thứ
    # khác. Muốn chắc chắn thì phải quyết định TRƯỚC KHI gọi mô hình. Nên
    # chặn ở đây: chưa biết sản phẩm thì hỏi, không gọi mô hình, 0 đồng.
    #
    # THU HẸP 23/09/2026: chỉ còn áp cho câu CÀI ĐẶT không kèm mã lỗi. Đo
    # thật: "lỗi 130 invalid stops là gì" — câu kiến thức chung, trả lời
    # được ngay — bị hỏi lại "anh dùng sản phẩm nào". Câu báo lỗi mơ hồ
    # ("bot không chạy") giờ để mô hình tự hỏi làm rõ theo luật trong câu
    # dẫn (hỏi đúng thứ còn thiếu: sản phẩm, MT4/MT5, dòng lỗi).
    # Lượt có ẢNH: sản phẩm / lỗi nằm ngay trong ảnh — không hỏi lại mù, để mô hình nhìn.
    co_anh = bool(getattr(ngu_canh, 'anh', None))
    if (y_dinh == 'huong_dan' and cau_hoi_san_pham is not None and not co_anh
            and not re.search(r'\b\d{2,5}\b', cau_hoi or '')):
        try:
            if not _da_biet_san_pham(cau_hoi, ngu_canh):
                _log.info('Chưa rõ sản phẩm cho câu %r — hỏi lại, KHÔNG gọi mô hình.', cau_hoi)
                return TraLoi(
                    tra_loi=cau_hoi_san_pham(ngu_canh.ngon_ngu),
                    nguon=[], y_dinh=y_dinh, chuyen_nguoi=False,
                    do_tin_cay=do_tin_cay, da_goi_llm=False, tang=1,
                )
        except Exception:  # noqa: BLE001 - hỏng chỗ này không được chặn câu trả lời
            _log.exception('Lỗi khi kiểm sản phẩm trước khi hỏi lại — bỏ qua, trả lời như cũ.')

    # ---- Câu hỏi CHUNG CHUNG về một sản phẩm -> chỉ hỏi làm rõ (xem
    # _cau_hoi_chung_chung). Không hỏi lại HAI LẦN liền: lượt trước vừa hỏi
    # làm rõ mà khách vẫn đáp chung chung thì trả lời theo khả năng cao nhất.
    hoi_rong = False
    try:
        hoi_rong = (not ngu_canh.ghi_chu and not co_anh
                    and not (ngu_canh.so_tay or {}).get('vua_hoi_lam_ro')
                    and _cau_hoi_chung_chung(cau_hoi, ngu_canh))
    except Exception:  # noqa: BLE001
        _log.exception('Lỗi khi nhận diện câu hỏi chung chung — bỏ qua.')
    if hoi_rong:
        _log.info('Câu hỏi chung chung %r — chỉ hỏi làm rõ, không gắn nguồn.', cau_hoi)
        ngu_canh.ghi_chu = GHI_CHU_HOI_RONG
    if isinstance(ngu_canh.so_tay, dict):
        ngu_canh.so_tay['vua_hoi_lam_ro'] = bool(hoi_rong)

    # Câu không nêu sản phẩm thì lọc theo sản phẩm phiên đang bàn (sổ tay)
    # — "còn MT4 thì sao" sau khi hỏi copytrade phải tìm trong tài liệu
    # copytrade, không phải cả kho.
    san_pham_doan = doan_san_pham(cau_hoi) or (ngu_canh.so_tay or {}).get('dang_hoi')
    ket_qua: List[Any] = []
    try:
        if lay_bo_tim is None:
            raise RuntimeError('thiếu tim.lai.lay_bo_tim()')
        bo_tim = lay_bo_tim(khong_gian=ngu_canh.khong_gian)
        loc = LocTim(san_pham=san_pham_doan, ngon_ngu=ngu_canh.ngon_ngu)
        ket_qua = list(bo_tim.tim(_cau_de_tim(cau_hoi, ngu_canh), loc=loc,
                                  so_luong=SO_DOAN_MAC_DINH) or [])
    except Exception as loi:  # noqa: BLE001 - chỉ mục hỏng thì vẫn tư vấn bằng kiến thức chung
        _log.error('Không tìm được tài liệu cho câu %r — tư vấn không kèm tài liệu: %s', cau_hoi, loi)
    if doan_uu_tien is not None:
        ket_qua = [doan_uu_tien] + ket_qua
    if not ngu_canh.danh_muc:
        ngu_canh.danh_muc = _khoi_danh_muc(ngu_canh.khong_gian)

    nha_cung_cap = _lay_nha_cung_cap_dung_chung()
    if nha_cung_cap is None:
        _log.error('Không có nhà cung cấp AI để diễn giải câu %r — chuyển người.', cau_hoi)
        return _tra_loi_chuyen_nguoi(ngu_canh.ngon_ngu, y_dinh, do_tin_cay, khong_gian=ngu_canh.khong_gian)

    if not ngu_canh.mo_hinh:
        try:
            if _can_chuyen_gia(cau_hoi, ngu_canh, y_dinh):
                from congcu.cau_hinh_ai import mo_hinh
                ngu_canh.mo_hinh = mo_hinh('chuyen_gia', ngu_canh.khong_gian)
                _log.info('Vai CHUYÊN GIA (%s) cho câu %r.', ngu_canh.mo_hinh, cau_hoi[:80])
        except Exception:  # noqa: BLE001 - chọn mô hình hỏng thì dùng mô hình trả lời
            _log.exception('Lỗi khi chọn vai chuyên gia — dùng mô hình trả lời.')

    try:
        van_ban = nha_cung_cap.dien_giai(cau_hoi, ket_qua, ngu_canh)
        # Đọc NGAY tại đây, KHÔNG để tới cuối hàm tra_loi() — nhà cung cấp
        # là singleton DÙNG CHUNG cho cả tiến trình (xem
        # _lay_nha_cung_cap_dung_chung() ở trên), và app.py chạy hàm này
        # trong LUỒNG RIÊNG nên hai khách hỏi cùng lúc là hai luồng cùng
        # chạm vào nó thật. Đọc mấy số này ở một chỗ khác, muộn hơn, là có
        # nguy cơ đọc phải số của MỘT LƯỢT KHÁC.
        #
        # Có HAI lớp chắn, và cần cả hai: đọc ngay tại đây (chỗ này), và
        # llm/tuongthich_openai.py cất số liệu trong kho RIÊNG THEO LUỒNG.
        # Bỏ lớp thứ hai thì lớp thứ nhất KHÔNG đủ — luồng B vẫn kịp đè lên
        # giữa dòng gọi và dòng đọc. Đã dựng thu/thu_song_song.py để canh:
        # với kho dùng chung, 5 trên 6 khách nhận token của khách khác.
        # getattr(..., None): nhà cung cấp nào không có thuộc tính này (vd
        # NhaCungCapRong) thì vẫn không lỗi, chỉ để trống.
        model_gan_nhat = getattr(nha_cung_cap, 'model_thuc_te_gan_nhat', None)
        token_vao_gan_nhat = getattr(nha_cung_cap, 'token_vao_gan_nhat', None)
        token_ra_gan_nhat = getattr(nha_cung_cap, 'token_ra_gan_nhat', None)
    except LoiNhaCungCap as loi:
        _log.error('Nhà cung cấp AI lỗi khi diễn giải câu %r: %s', cau_hoi, loi)
        return _tra_loi_chuyen_nguoi(ngu_canh.ngon_ngu, y_dinh, do_tin_cay, da_goi_llm=True, khong_gian=ngu_canh.khong_gian)
    except Exception:  # noqa: BLE001
        _log.exception('Lỗi không lường trước khi diễn giải câu %r.', cau_hoi)
        return _tra_loi_chuyen_nguoi(ngu_canh.ngon_ngu, y_dinh, do_tin_cay, da_goi_llm=True, khong_gian=ngu_canh.khong_gian)

    # Lượt có ảnh: mô hình khai loại + mô tả ảnh (cắt ra trước khi gửi khách).
    loai_anh, mo_ta_anh = '', ''
    if getattr(ngu_canh, 'anh', None):
        van_ban, loai_anh, mo_ta_anh = tach_khai_anh(van_ban)
    # Mô hình khai những đoạn đã THẬT SỰ dùng; không khai thì đoán theo điểm.
    van_ban, so_da_dung = tach_khai_nguon(van_ban)
    van_ban = cat_khoi_nguon(van_ban)
    if hoi_rong:
        # Lượt chỉ hỏi làm rõ: không có gì để trích nguồn.
        ngu_canh.ghi_chu = ''
        so_da_dung = []
    if so_da_dung is None:
        nguon = _chon_nguon([k for k in ket_qua if k.doan.loai != 'tuvan'], san_pham_doan)
    else:
        nguon = _nguon_theo_khai(ket_qua, so_da_dung, san_pham_doan)

    if not ket_qua:
        return TraLoi(
            tra_loi=van_ban, nguon=nguon, y_dinh=y_dinh, chuyen_nguoi=False,
            do_tin_cay=round(do_tin_cay / 2.0, 2), da_goi_llm=True,
            model=model_gan_nhat, token_vao=token_vao_gan_nhat, token_ra=token_ra_gan_nhat,
            loai_anh=loai_anh, mo_ta_anh=mo_ta_anh,
        )

    # Kết hợp độ tin cậy của định tuyến (luật) với điểm khớp của kết quả tìm
    # kiếm. VÌ SAO không nén điểm tuyệt đối bằng x/(x+1): giao_uoc.py chỉ
    # hứa "điểm sau khi hợp nhất, càng lớn càng khớp" — KHÔNG hứa biên độ cụ
    # thể (tim/lai.py hiện dùng RRF với k=60, nên điểm hạng 1 của MỘT nguồn
    # chỉ ~0.016; ép so với thang 0..1 bằng x/(x+1) làm do_tin_cay luôn xấp
    # xỉ 0 dù khớp rất tốt — một lỗi thực tế đã thấy khi tự kiểm). Thay vào
    # đó dùng một đại lượng KHÔNG PHỤ THUỘC BIÊN ĐỘ: tỉ trọng của đoạn top
    # trong tổng điểm cả rổ kết quả trả về — top càng vượt trội các đoạn
    # còn lại thì tỉ trọng càng gần 1, bất kể đơn vị điểm gốc là gì.
    tong_diem = sum(max(kq.diem, 0.0) for kq in ket_qua)
    diem_top = max(ket_qua[0].diem, 0.0)
    ty_trong_top = (diem_top / tong_diem) if tong_diem > 0 else (1.0 / len(ket_qua))
    do_tin_cay_cuoi = round((do_tin_cay + ty_trong_top) / 2.0, 2)

    return TraLoi(
        tra_loi=van_ban, nguon=nguon, y_dinh=y_dinh, chuyen_nguoi=False,
        do_tin_cay=do_tin_cay_cuoi, da_goi_llm=True,
        model=model_gan_nhat, token_vao=token_vao_gan_nhat, token_ra=token_ra_gan_nhat,
        loai_anh=loai_anh, mo_ta_anh=mo_ta_anh,
    )


# =====================================================================
# HÀM LÕI — CHỮ KÝ KHÔNG ĐƯỢC ĐỔI
# =====================================================================

def tra_loi(cau_hoi: str, ngu_canh: NguCanh) -> TraLoi:
    """Cổng vào của lõi. Đầu ra đi qua một chỗ duy nhất để đổi đường dẫn nội
    bộ ("/toolintroduce/dcabot") thành link đầy đủ — xem congcu/lien_ket.py."""
    kq = _tra_loi_goc(cau_hoi, ngu_canh)
    try:
        from congcu.lien_ket import tuyet_doi_hoa, goc_cho, trang_goc_cho
        kq.tra_loi = tuyet_doi_hoa(kq.tra_loi, goc_cho(ngu_canh.khong_gian), trang_goc_cho(ngu_canh.khong_gian))
    except Exception:  # noqa: BLE001 - đổi link hỏng thì giữ nguyên câu trả lời
        _log.exception('Lỗi khi đổi đường dẫn nội bộ thành link đầy đủ.')
    return kq


def _tra_loi_goc(cau_hoi: str, ngu_canh: NguCanh) -> TraLoi:
    ngon_ngu = _ngon_ngu_hop_le(ngu_canh.ngon_ngu)
    cau_hoi_sach = (cau_hoi or '').strip()

    # ---- 1. Câu rỗng / quá dài — trả lời lịch sự, KHÔNG chuyển người,
    # KHÔNG chạm phiên/ngân sách/mô hình, vì đây chưa phải một câu hỏi thật. ----
    if not cau_hoi_sach:
        return TraLoi(
            tra_loi=_CAU_RONG.get(ngon_ngu, _CAU_RONG['vi']), nguon=[], y_dinh='khac',
            chuyen_nguoi=False, do_tin_cay=1.0, da_goi_llm=False,
        )
    if len(cau_hoi_sach) > 500:
        return TraLoi(
            tra_loi=_CAU_QUA_DAI.get(ngon_ngu, _CAU_QUA_DAI['vi']), nguon=[], y_dinh='khac',
            chuyen_nguoi=False, do_tin_cay=1.0, da_goi_llm=False,
        )

    # ---- Nạp phiên (CSDL trước, RAM dự phòng — xem _nap_ngu_canh), rồi
    # CẮT lịch sử cho prompt còn tối đa GIU_LICH_SU lượt TRƯỚC KHI DÙNG. ----
    try:
        tt = _nap_ngu_canh(ngu_canh)
    except Exception:  # noqa: BLE001 - nạp phiên hỏng thì chạy như phiên mới, không chặn câu trả lời
        _log.exception('Lỗi khi nạp phiên %r — coi như phiên mới.', ngu_canh.phien)
        tt = {'lich_su': list(ngu_canh.lich_su or []), 'so_luot': None, 'compact_den_luot': 0}
        ngu_canh.lich_su = ngu_canh.lich_su[-(GIU_LICH_SU * 2):]

    # ---- 2. Hết ngân sách -> chuyển người, KHÔNG gọi mô hình. ----
    if not con_ngan_sach():
        ket_qua = _tra_loi_chuyen_nguoi(ngon_ngu, 'khac', 1.0, khong_gian=ngu_canh.khong_gian)
        _cap_nhat_so_tay_luot(ngu_canh, cau_hoi_sach, None)
        _ket_thuc_luot(ngu_canh, cau_hoi_sach, ket_qua, tt)
        return ket_qua

    # ---- 3. Phân loại ý định (luật trước, xem nao/dinh_tuyen.py). ----
    y_dinh, do_tin_cay_pl = phan_loai(cau_hoi_sach, ngu_canh)
    if y_dinh == 'gia' and _la_tra_loi_lua_chon(cau_hoi_sach, ngu_canh):
        _log.info('Câu %r là chọn phương án bot vừa hỏi — không phải hỏi giá.', cau_hoi_sach)
        y_dinh = 'khac'

    # ---- 3a. Sổ tay + hồ sơ khách. Cập nhật sổ tay bằng câu NÀY trước
    # khi trả lời, để "em dùng MT4, cài copytrade sao" có ngay MT4 trong
    # khối thông tin gửi mô hình. Hồ sơ chỉ dùng ở tầng 2 (qua prompt). ----
    _cap_nhat_so_tay_luot(ngu_canh, cau_hoi_sach, y_dinh)
    try:
        ngu_canh.ho_so = _dung_khoi_ho_so(ngu_canh)
    except Exception:  # noqa: BLE001
        _log.exception('Lỗi khi dựng khối hồ sơ khách — bỏ qua.')
        ngu_canh.ho_so = ''

    # ---- 3b. TẦNG 1 (mới, xem đầu nao/tang_mot.py) — câu trả lời do CHỦ
    # WEBSITE tự soạn/tự duyệt trong quản trị. Khớp được thì trả NGUYÊN
    # VĂN, KHÔNG gọi mô hình AI: tốn 0 đồng, ~50 ms, và không thể sai vì
    # đó là lời của chủ.
    #
    # 'tra_key' vẫn bị loại hẳn: câu trả lời phụ thuộc TỪNG KHÁCH (key của
    # ai, hạn tới bao giờ), một câu soạn sẵn dùng chung không thể nào đúng.
    #
    # 'cam_ket' thì KHÔNG còn bị loại — đây là thay đổi có chủ ý, 22/09/2026.
    #
    #   Ban đầu tôi loại cả 'cam_ket' vì nhánh 4 có luật "thà chuyển người
    #   nhầm còn hơn để bot lỡ hứa". Nhưng chủ dự án sau đó tự viết câu trả
    #   lời cho đúng hai câu thuộc nhóm này (ý kiến 22/09): chính sách hoàn
    #   tiền, và cách xử lý khi khách xin giảm giá. Loại 'cam_ket' khỏi tầng
    #   1 nghĩa là chủ viết xong mà bot vẫn không bao giờ dùng — tức là luật
    #   an toàn của tôi đang chặn chính người mà nó phục vụ.
    #
    #   Phân biệt cho đúng: luật kia chống việc BOT TỰ NGHĨ RA một lời hứa.
    #   Một mục đã DUYỆT là lời của chủ, không phải bot tự nghĩ. Tính chất
    #   cần giữ là "bot không bao giờ ứng tác một cam kết", và nó vẫn nguyên:
    #   khớp được mục đã duyệt thì trả nguyên văn lời chủ; KHÔNG khớp thì
    #   rơi xuống nhánh 4 và chuyển người y như cũ. Không có đường nào cho
    #   mô hình AI tự soạn một câu cam kết.
    #
    # Toàn bộ khối này bọc try/except: tầng 1 hỏng ở bất kỳ đâu thì bot
    # phải chạy Y HỆT như trước khi có tầng 1 (rơi xuống nhánh 4 bên dưới).
    # ---- 3a-nút. Khách bấm NÚT GỢI Ý của kênh chat -> câu trả lời chuẩn, không gọi mô hình (mọi chế độ).
    if y_dinh != 'tra_key' and not getattr(ngu_canh, 'anh', None):
        try:
            tl_nut = _tra_loi_nut_bam(cau_hoi_sach, ngu_canh, y_dinh, do_tin_cay_pl)
        except Exception:  # noqa: BLE001 - hỏng thì trả lời như câu thường
            _log.exception('Lỗi nhánh nút gợi ý cho câu %r.', cau_hoi_sach)
            tl_nut = None
        if tl_nut is not None:
            _log.info('Nút gợi ý: %r -> câu trả lời chuẩn.', cau_hoi_sach)
            _ket_thuc_luot(ngu_canh, cau_hoi_sach, tl_nut, tt)
            if _nhat_ky_ghi is not None:
                try:
                    _nhat_ky_ghi(cau_hoi_sach, tl_nut, ngu_canh)
                except Exception:  # noqa: BLE001
                    _log.exception('Lỗi khi ghi nhật ký hội thoại (nút gợi ý).')
            return tl_nut

    # ---- 3a'. AGENT TƯ VẤN (29/09/2026, nao/agent.py) — một bộ não đọc cả phiên, tự gọi công cụ, có lượt
    # đọc lại. Bật thì THAY tầng 1 + khúc rẽ nhánh theo từ khoá bên dưới (gốc lỗi lệch ngữ cảnh 28/09).
    # Chưa đưa vào agent: tra_key (dữ liệu riêng từng khách), lượt có ảnh, cam_ket (thà chuyển người).
    # Agent lỗi bất kỳ -> rơi về lõi cũ, khách vẫn có câu trả lời.
    ket_qua_agent = None
    try:
        from congcu.cau_hinh_ai import che_do_tu_van
        che_do = (getattr(ngu_canh, 'che_do', '') or '').strip() or che_do_tu_van(ngu_canh.khong_gian)
        if (che_do == 'agent' and y_dinh not in ('tra_key', 'cam_ket')
                and not getattr(ngu_canh, 'anh', None)):
            from nao.agent import tra_loi_agent
            ket_qua_agent = tra_loi_agent(cau_hoi_sach, ngu_canh, y_dinh, do_tin_cay_pl)
    except Exception:  # noqa: BLE001
        _log.exception('Agent tư vấn lỗi cho câu %r — rơi về lõi cũ.', cau_hoi_sach)
        ket_qua_agent = None

    doan_uu_tien = None
    # Hỏi giá nối tiếp khi đang bàn đặt code: bỏ tầng 1 (mục trả nguyên văn sẽ
    # hỏi lại những gì khách vừa kể) — xem _xu_ly_gia_theo_ngu_canh.
    gia_code_noi_tiep = False
    try:
        gia_code_noi_tiep = (y_dinh == 'gia' and (ngu_canh.so_tay or {}).get('chu_de') == 'code'
                             and bool(ngu_canh.lich_su) and not _san_pham_co_gia(cau_hoi_sach))
    except Exception:  # noqa: BLE001
        gia_code_noi_tiep = False
    # Lượt có ẢNH: kho tư vấn không nhìn được ảnh — trả nguyên văn một mục chỉ
    # khớp phần chữ ("lỗi này là gì?") sẽ bỏ qua đúng thứ khách gửi. Đi thẳng mô hình.
    co_anh = bool(getattr(ngu_canh, 'anh', None))
    if (ket_qua_agent is None and y_dinh != 'tra_key' and not gia_code_noi_tiep and not co_anh
            and khop is not None and tra_loi_tu_muc is not None):
        try:
            cau_da_khop = cau_hoi_sach
            ket_qua_khop = khop(cau_hoi_sach, ngu_canh)
            if ket_qua_khop is None:
                # Câu hỏi nối tiếp không nêu sản phẩm: thử lại một lần với
                # sản phẩm phiên đang bàn. Khớp được là 0 đồng thay vì gọi
                # mô hình — đây là chỗ khách thấy "chưa mượt" trước đây.
                cau_kem = _cau_kem_san_pham(cau_hoi_sach, ngu_canh)
                if cau_kem:
                    ket_qua_khop = khop(cau_kem, ngu_canh)
                    cau_da_khop = cau_kem
                    if ket_qua_khop is not None:
                        _log.info('Tầng 1 khớp nhờ ghép sản phẩm phiên: %r -> %r', cau_hoi_sach, cau_kem)
        except Exception:  # noqa: BLE001 - tầng 1 hỏng không được làm sập cổng
            _log.exception('Lỗi không lường trước ở tầng 1 (khop) cho câu %r.', cau_hoi_sach)
            ket_qua_khop = None

        phu_khach = 1.0
        if ket_qua_khop is not None and do_phu_cau_khach is not None:
            try:
                phu_khach = do_phu_cau_khach(cau_da_khop, ket_qua_khop[0])
            except Exception:  # noqa: BLE001
                phu_khach = 1.0
            if phu_khach < PHU_KHACH_TOI_THIEU:
                # Mục chỉ dính vài từ lẻ của một câu hỏi khác hẳn (đo thật:
                # "nên thuê vps ở đâu, ping bao nhiêu" -> mục TradingView Bot).
                # Không trả nguyên văn, cũng không đưa làm tài liệu ưu tiên.
                _log.info('Tầng 1 bỏ mục id=%s: chỉ phủ %.0f%% câu khách.',
                          ket_qua_khop[0].get('id'), phu_khach * 100)
                ket_qua_khop = None

        if ket_qua_khop is not None:
            muc, diem_khop = ket_qua_khop
            try:
                tra_loi_tang_mot = tra_loi_tu_muc(muc, ngu_canh, diem_khop)
            except Exception:  # noqa: BLE001
                _log.exception('Lỗi không lường trước ở tầng 1 (tra_loi_tu_muc) cho mục id=%s.', muc.get('id'))
                tra_loi_tang_mot = None

            # Khớp LỎNG (điểm < KHOP_NGUYEN_VAN) ở câu hướng dẫn/lỗi/khác: KHÔNG
            # trả nguyên văn — đưa mục cho mô hình làm tài liệu ưu tiên số 1.
            # Lý do: 23/09/2026 câu "master đóng lệnh một phần thì slave có
            # đóng theo không" nhận nguyên văn mục "master tăng lot thì slave
            # có tăng theo không" — trùng từ khoá, khác câu hỏi. Lời của chủ
            # vẫn được tôn trọng (ưu tiên cao nhất trong prompt), nhưng câu
            # trả lời đúng câu khách hỏi. 'gia'/'cam_ket'/'chao' giữ nguyên
            # văn như cũ: không để mô hình tự diễn đạt lại giá hay cam kết.
            if (tra_loi_tang_mot is not None
                    and tra_loi_tang_mot.tra_loi.strip() == _luot_bot_truoc(ngu_canh).strip()):
                # Đúng câu bot VỪA gửi lượt trước — Messenger 26/09/2026: "Cho mịn xin giá" nhận lại
                # nguyên bài TradingView gửi một phút trước. Khách hỏi tiếp tức là câu đó chưa đủ:
                # không lặp; mục vẫn là tài liệu ưu tiên cho mô hình (câu hỏi/lỗi/khác).
                _log.info('Tầng 1: mục id=%s trùng câu bot vừa gửi — không lặp lại.', muc.get('id'))
                if y_dinh in ('huong_dan', 'loi', 'khac'):
                    doan_uu_tien = _doan_tu_muc(muc, tra_loi_tang_mot.tra_loi)
                tra_loi_tang_mot = None
            elif (tra_loi_tang_mot is not None
                    and (diem_khop < KHOP_NGUYEN_VAN or phu_khach < PHU_KHACH_NGUYEN_VAN)
                    and y_dinh in ('huong_dan', 'loi', 'khac')):
                _log.info('Tầng 1 khớp LỎNG (%.2f) mục id=%s — đưa cho mô hình làm tài liệu ưu tiên.',
                          diem_khop, muc.get('id'))
                doan_uu_tien = _doan_tu_muc(muc, tra_loi_tang_mot.tra_loi)
                tra_loi_tang_mot = None
            elif (tra_loi_tang_mot is not None and y_dinh == 'gia'
                    and (diem_khop < KHOP_NGUYEN_VAN or phu_khach < PHU_KHACH_NGUYEN_VAN)):
                # Câu hỏi giá khớp LỎNG một mục: không trả nguyên văn (đo thật:
                # câu hỏi 4 việc có chữ "giá" nhận nguyên bài cài đặt). Rơi
                # xuống nhánh 'gia' bên dưới — bảng giá thật từ CSDL.
                _log.info('Tầng 1 khớp LỎNG (%.2f) ở câu hỏi giá — bỏ, dùng nhánh giá.', diem_khop)
                tra_loi_tang_mot = None

            if tra_loi_tang_mot is not None:
                if tang_so_lan_dung is not None:
                    try:
                        tang_so_lan_dung(muc.get('id'))
                    except Exception:  # noqa: BLE001 - đếm thống kê hỏng KHÔNG được làm hỏng câu trả lời
                        _log.exception('Lỗi không lường trước khi tăng so_lan_dung cho mục id=%s.', muc.get('id'))
                _ket_thuc_luot(ngu_canh, cau_hoi_sach, tra_loi_tang_mot, tt)
                if _nhat_ky_ghi is not None:
                    try:
                        _nhat_ky_ghi(cau_hoi_sach, tra_loi_tang_mot, ngu_canh)
                    except Exception:  # noqa: BLE001 - nhật ký hỏng KHÔNG được làm hỏng câu trả lời đã có
                        _log.exception('Lỗi không lường trước khi ghi nhật ký hội thoại (tầng 1).')
                return tra_loi_tang_mot
            # tra_loi_tu_muc() trả None (điền hụt chỗ trống, hoặc lỗi) —
            # KHÔNG return, rơi tiếp xuống nhánh 4 như một câu chưa khớp.

    # ---- 4. Rẽ nhánh theo ý định. ----
    if ket_qua_agent is not None:
        ket_qua = ket_qua_agent
    elif co_anh:
        # Ảnh đi cùng lượt mô hình (câu dẫn ảnh dặn: chuyển khoản thì không xác nhận
        # tiền; cam kết vẫn theo quy tắc chung). Nhánh khuôn mẫu không đọc được ảnh.
        ket_qua = _xu_ly_rag(cau_hoi_sach, ngu_canh, y_dinh, do_tin_cay_pl, doan_uu_tien)
    elif y_dinh == 'cam_ket':
        # KHÔNG gọi mô hình — thà chuyển người nhầm còn hơn để bot lỡ hứa.
        ket_qua = _tra_loi_chuyen_nguoi(ngon_ngu, y_dinh, do_tin_cay_pl, khong_gian=ngu_canh.khong_gian)
    elif y_dinh in ('tra_key', 'gia') and not (
            _ho_so_shop(ngu_canh.khong_gian).co_tra_key if y_dinh == 'tra_key'
            else _ho_so_shop(ngu_canh.khong_gian).dung_bang_product):
        # Shop không có key / không có bảng giá trong CSDL: không có nhánh khuôn mẫu
        # nào đúng cho nó — mô hình tư vấn theo kho tư vấn + tài liệu của CHÍNH shop đó.
        ket_qua = _xu_ly_rag(cau_hoi_sach, ngu_canh, y_dinh, do_tin_cay_pl, doan_uu_tien)
    elif y_dinh == 'tra_key':
        ket_qua = _xu_ly_tra_key(cau_hoi_sach, ngu_canh, do_tin_cay_pl)
    elif y_dinh == 'gia' and not _san_pham_co_gia(cau_hoi_sach):
        # Hỏi giá mà chưa nêu sản phẩm có giá: theo ngữ cảnh (đặt code / sản phẩm
        # đang bàn) hoặc hỏi lại — kể cả câu nhiều ý, không gắn nguyên bảng giá.
        ket_qua = _xu_ly_gia_theo_ngu_canh(cau_hoi_sach, ngu_canh, do_tin_cay_pl)
    elif y_dinh == 'gia' and _cau_rong(cau_hoi_sach):
        ket_qua = _xu_ly_gia_kem_tu_van(cau_hoi_sach, ngu_canh, do_tin_cay_pl)
    elif y_dinh == 'gia':
        ket_qua = _xu_ly_gia_theo_ngu_canh(cau_hoi_sach, ngu_canh, do_tin_cay_pl)
    elif y_dinh == 'chao':
        ket_qua = _xu_ly_chao(ngu_canh, do_tin_cay_pl)
    else:  # 'huong_dan', 'loi', 'khac'
        ket_qua = _xu_ly_rag(cau_hoi_sach, ngu_canh, y_dinh, do_tin_cay_pl, doan_uu_tien)

    # ---- 4b. Ghi nhận TẦNG NÀO đã trả câu này — một chỗ duy nhất.
    # Tầng 1 tự đặt tang=1 trong nao/tang_mot.py và đã return sớm ở nhánh 3b,
    # nên tới được đây nghĩa là tầng 2 hoặc 3. Phân biệt bằng SỰ THẬT đã có,
    # không thêm cờ mới: có gọi mô hình = tầng 2, còn lại = tầng 3 (câu mẫu
    # hoặc chuyển người). Nhờ trường này mà đếm được tỉ lệ phủ tầng 1.
    if ket_qua.tang == 0:
        ket_qua.tang = 2 if ket_qua.da_goi_llm else 3

    # Lịch sử + nhật ký giữ DÒNG MÔ TẢ ảnh thay cho ảnh — lượt sau không gửi lại
    # ảnh mà mô hình vẫn biết khách đã gửi gì (mạch hội thoại liền).
    cau_luu = cau_hoi_sach
    if co_anh:
        cau_luu += f"\n[Ảnh khách gửi: {ket_qua.mo_ta_anh or 'mô hình không mô tả'}]"
    _ket_thuc_luot(ngu_canh, cau_luu, ket_qua, tt)

    # ---- NHẬT KÝ HỘI THOẠI — ghi MỌI lượt đường chính (tầng 2 hoặc 3;
    # tầng 1 đã ghi và return sớm ở nhánh 3b). Cùng chỗ với bước 5 dưới đây
    # nhưng là HAI bảng khác nhau với MỤC ĐÍCH khác nhau (xem đầu
    # congcu/nhat_ky.py so với đầu congcu/hang_doi.py). ----
    if _nhat_ky_ghi is not None:
        try:
            _nhat_ky_ghi(cau_luu, ket_qua, ngu_canh)
        except Exception:  # noqa: BLE001 - nhật ký hỏng KHÔNG được làm hỏng câu trả lời đã có
            _log.exception('Lỗi không lường trước khi ghi nhật ký hội thoại.')

    # ---- 5. TẦNG 1 không khớp (hoặc khớp mà điền hụt, xem nhánh 3b) —
    # ghi câu hỏi vào hàng đợi cho chủ website xem trong quản trị, kèm
    # câu bot vừa trả làm bản nháp. Đây là vòng lặp làm tầng 1 dày lên
    # theo thời gian (xem đầu congcu/hang_doi.py).
    #
    # ĐỪNG ghi nhận: câu rỗng/quá dài (đã return sớm ở bước 1, không bao
    # giờ chạy tới đây), ý định 'chao' (không phải lỗ hổng tri thức), và
    # ý định 'cam_ket' (không phải lỗ hổng tri thức cần lấp — đó là câu
    # bot CHỦ Ý chuyển người, ghi vào hàng đợi chỉ tạo nhiễu).
    #
    # THÊM 'tra_key' ngày 23/09/2026, cùng lý do với 'cam_ket' nhưng
    # hậu quả đã đo được chứ không phải phòng xa: trong 73 hội thoại đầu
    # tiên có 57 câu hỏi key — tức 78% lưu lượng. Bot TRẢ LỜI ĐÚNG hết
    # (không đọc key ra, chỉ khách sang /orders theo đúng quyết định của
    # chủ dự án), nhưng vì không đi qua đường tìm kiếm nên câu nào cũng
    # rơi vào `chat_thieu`. Kết quả: bảng "câu bot không trả lời được"
    # có 18 mục thì 7 mục đầu là câu key, gom 57/70 lượt — phần thiếu
    # THẬT bị chôn dưới nhiễu, mở bảng ra không đọc được gì.
    # Khớp lỏng một mục có sẵn (doan_uu_tien) thì KHÔNG phải lỗ hổng tri thức.
    # Lượt có ẢNH không phải lỗ hổng kho (kho chỉ có chữ, không viết mục cho "ảnh này là gì").
    if (_hang_doi_ghi_nhan is not None and doan_uu_tien is None and not co_anh
            and y_dinh not in ('chao', 'cam_ket', 'tra_key')):
        try:
            # Danh mục sản phẩm dùng để đoán là của TradingAuto — shop khác không gắn nhầm.
            san_pham_doan = (doan_san_pham(cau_hoi_sach)
                             if _ho_so_shop(ngu_canh.khong_gian).dung_bang_product else None)
        except Exception:  # noqa: BLE001
            san_pham_doan = None
        try:
            _hang_doi_ghi_nhan(
                cau_hoi_sach, y_dinh, san_pham_doan, ket_qua.tra_loi, ngu_canh.khong_gian,
            )
        except Exception:  # noqa: BLE001 - ghi hàng đợi hỏng TUYỆT ĐỐI không được làm hỏng câu trả lời đã có
            _log.exception('Lỗi không lường trước khi ghi nhận câu hỏi thiếu vào hàng đợi.')

    return ket_qua


if __name__ == '__main__':
    # =================================================================
    # TỰ KIỂM — chạy: python nao/tra_loi.py  (hoặc từ trong nao/:
    # python tra_loi.py). PHẢI CHẠY ĐƯỢC dù chưa có chỉ mục (kho/chi_muc)
    # và chưa có CSDL — mọi nhánh thiếu phụ thuộc phải rơi êm về câu
    # chuyển người, không được ném lỗi ra ngoài.
    # =================================================================
    logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')

    # Ép nhà cung cấp là 'rong' (mặc định của cau_hinh cũng là 'rong', ép
    # lại ở đây cho RÕ RÀNG với đề bài "chạy ~6 câu qua tra_loi() với
    # NhaCungCapRong") và xoá cache singleton để chắc chắn dựng lại.
    os.environ['CHATBOT_NHA_CUNG_CAP'] = 'rong'
    _nha_cung_cap_dem['doi_tuong'] = None

    CAC_CAU_TU_KIEM = [
        'cho em xin giảm giá',
        'giá dcabot bao nhiêu',
        'key của tôi còn hạn không',
        'chào bạn',
        'cài EA vào MT5 sao',
        'bot báo lỗi không vào lệnh',
    ]

    print('=' * 78)
    print(f'THỬ {len(CAC_CAU_TU_KIEM)} CÂU QUA tra_loi() — provider mặc định (rong nếu llm/rong.py có sẵn)')
    print('=' * 78)
    for i, cau in enumerate(CAC_CAU_TU_KIEM, start=1):
        nc = NguCanh(phien=f'tu-kiem-{i}', ngon_ngu='vi', nguoi_dung='khach')
        try:
            kq = tra_loi(cau, nc)
        except Exception as loi:  # noqa: BLE001 - tự kiểm này chính là để bắt trường hợp KHÔNG được phép xảy ra
            print(f"[{i}] {cau!r} -> LỖI KHÔNG MONG MUỐN: {loi!r}")
            raise
        doan_dau = kq.tra_loi[:80].replace('\n', ' ')
        print(
            f"[{i}] cau={cau!r}\n"
            f"    y_dinh={kq.y_dinh:10} chuyen_nguoi={kq.chuyen_nguoi!s:5} "
            f"da_goi_llm={kq.da_goi_llm!s:5} do_tin_cay={kq.do_tin_cay:.2f}\n"
            f"    80 ky tu dau: {doan_dau!r}"
        )

    print('\n' + '=' * 78)
    print('KIỂM lịch sử phiên: hỏi 2 lượt cùng phien, xem lượt 2 có mang lịch sử vào không')
    print('=' * 78)
    nc_phien = NguCanh(phien='tu-kiem-lich-su', ngon_ngu='vi', nguoi_dung='khach')
    tra_loi('chào bạn', nc_phien)
    truoc = _lay_lich_su_phien('tu-kiem-lich-su')
    print(f"Lịch sử lưu được sau lượt 1: {truoc}")
    assert len(truoc) == 2, 'LỖI: phải lưu đúng 1 cặp khách+bot sau 1 lượt'

    print('\n' + '=' * 78)
    print('KIỂM câu rỗng / quá dài — không được chuyển người, không được lỗi')
    print('=' * 78)
    nc_bien = NguCanh(phien='tu-kiem-bien', ngon_ngu='vi')
    kq_rong = tra_loi('', nc_bien)
    kq_dai = tra_loi('a' * 600, nc_bien)
    print(f"Câu rỗng   -> chuyen_nguoi={kq_rong.chuyen_nguoi}, tra_loi={kq_rong.tra_loi[:60]!r}")
    print(f"Câu quá dài -> chuyen_nguoi={kq_dai.chuyen_nguoi}, tra_loi={kq_dai.tra_loi[:60]!r}")
    assert kq_rong.chuyen_nguoi is False and kq_dai.chuyen_nguoi is False, (
        'LỖI: câu rỗng/quá dài không được đánh dấu chuyển người'
    )

    print('\nTỰ KIỂM tra_loi.py HOÀN TẤT — không có lỗi bất ngờ nào.')
