#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""AI 出题的**确定性数值核验**——让大模型出题，但不让它自己说了算。

## 为什么这么做
用户要回到大模型出题（生成器题型覆盖有限、出来的题太板），
但大模型的老毛病是**数字凑不出来**：鸡兔同笼给了奇数条腿、除不尽、答案是 17/23 这种脏数、
小学题算出负数。这些不是"表达不好"，是题本身废了。

## 思路
让模型出题时**顺带交出答案和一段可执行的算式**，服务端跑一遍：
    算式的值 ≠ 它自己说的答案  → 说明它没真算过 → 毙掉
    答案是脏数/负数/超范围        → 学生做不出干净结果 → 毙掉
**核验这一侧完全是代码，不依赖模型自觉**；模型只负责出题，判卷权在我们手里。

答案只在服务端当对数用，**不进返回给前端的题面**（零答案红线）。

## 三种核验方式（模型自己标 check_kind）
    eq    check 求值 == answer         "10 只鸡兔共 32 条腿" → check="(32-10*2)/(4-2)" answer="6"
    sign  check 的正负号对应 answer     "比较 23+95 ○ 87+19" → check="(23+95)-(87+19)" answer=">"
    ""    不可用算式表达（数图形/逻辑推理/开放题）→ 放行但标 verified=False
"""
import ast, re
from fractions import Fraction

# 允许出现在 check 里的运算（**没有函数调用、没有名字、没有下标**，所以不存在注入面）
_BIN = {ast.Add: lambda a, b: a + b, ast.Sub: lambda a, b: a - b,
        ast.Mult: lambda a, b: a * b, ast.Div: lambda a, b: a / b,
        ast.FloorDiv: lambda a, b: Fraction(a // b), ast.Mod: lambda a, b: a % b}

MAX_ABS = Fraction(10 ** 9)          # 结果量级上限，防 2**999 之类的炸弹
# 「整齐答案」的分母白名单。
# ⚠ 不能只设个上限了事：我一开始写「分母 > 100 才算脏」，自检就放过了 17/23 ——
#   而教材里真实出现的分母压倒性是 ≤12（几分之几）或 100/1000（小数、百分数）。
#   17/23、13/29 这种除不尽的脏数，正是用户说的「算不出来」。
OK_DEN = set(range(1, 13)) | {14, 15, 16, 18, 20, 24, 25, 30, 32, 36,
                              40, 45, 50, 60, 64, 75, 100, 125, 200, 250, 500, 1000}


class BadCheck(Exception):
    pass


def _ev(node):
    """自己递归求值，**不调用 eval/exec**。只认数字和四则/取余/幂。"""
    if isinstance(node, ast.Expression):
        return _ev(node.body)
    if isinstance(node, ast.Constant):
        if isinstance(node.value, bool) or not isinstance(node.value, (int, float)):
            raise BadCheck('非数字常量')
        return Fraction(node.value).limit_denominator(10 ** 9)
    if isinstance(node, ast.UnaryOp):
        if isinstance(node.op, ast.USub): return -_ev(node.operand)
        if isinstance(node.op, ast.UAdd): return _ev(node.operand)
        raise BadCheck('不支持的一元运算')
    if isinstance(node, ast.BinOp):
        if isinstance(node.op, ast.Pow):
            b, e = _ev(node.left), _ev(node.right)
            if e.denominator != 1 or not (0 <= e <= 8):
                raise BadCheck('幂次超范围')
            return b ** int(e)
        f = _BIN.get(type(node.op))
        if not f:
            raise BadCheck('不支持的运算符')
        a, b = _ev(node.left), _ev(node.right)
        if isinstance(node.op, (ast.Div, ast.FloorDiv, ast.Mod)) and b == 0:
            raise BadCheck('除以零')
        r = f(a, b)
        if abs(r) > MAX_ABS:
            raise BadCheck('数值过大')
        return r
    raise BadCheck('不允许的语法：' + type(node).__name__)


def calc(expr):
    """安全求值一段算式 → Fraction；不合法抛 BadCheck。"""
    expr = (expr or '').strip().replace('×', '*').replace('÷', '/').replace('－', '-').replace('＋', '+')
    if not expr or len(expr) > 200:
        raise BadCheck('算式为空或过长')
    try:
        tree = ast.parse(expr, mode='eval')
    except SyntaxError:
        raise BadCheck('算式语法错误')
    return _ev(tree)


_NUM = re.compile(r'-?\d+(?:\.\d+)?(?:\s*/\s*\d+)?')


def parse_num(s):
    """把模型给的 answer 解析成 Fraction；解析不出返回 None。

    2026-08-28 按业界判等器（NeMo math_grader / dart-math / MARIO_EVAL）的坑单加固：
      百分号        "40%" → 2/5          （模型写百分数、check 算出 0.4，原先直接误毙）
      带分数        "1又1/2"、LaTeX 的 "1\\frac{1}{2}" → 3/2
      LaTeX 分数    "\\frac{3}{4}"、"\\dfrac{3}{4}" → 3/4
      $ 包裹/度数   "$6$"、"30°"、"30度" → 数值本体
    ⚠ 顺序敏感：先剥 $ 再认 \\frac，最后才走裸数字兜底 ——
      裸数字正则会把 "\\frac{3}{4}" 里的 3 单独抓走，得到 3 而不是 3/4。
    """
    s = str(s or '').strip().replace('－', '-').replace('，', '').replace(',', '')
    s = s.replace('$', ' ').strip()                       # $6$ / $\frac..$ 的壳
    pct = '%' in s or '％' in s
    s = s.replace('％', '').replace('%', '')
    s = re.sub(r'(?:°|度)\s*$', '', s)                    # 度数后缀

    val = None
    # 带分数：整数 + \frac 或 整数又a/b
    m = re.search(r'(-?)(\d+)\s*(?:又|\\d?frac)\s*\{?(\d+)\}?\s*[/{]?\s*\{?(\d+)\}?', s)
    if m and ('又' in s or re.search(r'\d\s*\\d?frac', s)):
        try:
            sign = -1 if m.group(1) else 1
            whole, a, b = int(m.group(2)), int(m.group(3)), int(m.group(4))
            val = sign * (Fraction(whole) + Fraction(a, b)) if b else None
        except (ValueError, ZeroDivisionError):
            val = None
    if val is None:
        m = re.search(r'\\d?frac\s*\{\s*(-?\d+)\s*\}\s*\{\s*(\d+)\s*\}', s)
        if m:
            try:
                val = Fraction(int(m.group(1)), int(m.group(2))) if int(m.group(2)) else None
            except (ValueError, ZeroDivisionError):
                val = None
    if val is None:
        m = _NUM.search(s)
        if not m:
            return None
        t = m.group(0).replace(' ', '')
        try:
            if '/' in t:
                a, b = t.split('/')
                val = Fraction(int(a), int(b)) if int(b) else None
            else:
                val = Fraction(t) if '.' not in t else Fraction(t).limit_denominator(10 ** 6)
        except (ValueError, ZeroDivisionError):
            return None
    if val is not None and pct:
        val = val / 100
    return val


# 小学阶段（一~五年级）不该出现负数答案；六年级起学有理数，放开
_PRIMARY = re.compile(r'[一二三四五]年级')

# 问「几只 / 多少人 / 几本」这类**数个数**的题，答案必须是整数。
# ⚠ 这条是自检自己揪出来的：「共 10 个头 33 条腿」算出 6.5 只兔子，前一版竟然放行了 ——
#   而这正是用户说的「算不出来」的典型：算式和答案自洽，题却是废的。
_COUNT_UNIT = ('只|个|人|本|辆|棵|朵|次|名|条|张|支|块|袋|箱|份|盒|排|组|种|位|头|匹|架|艘|层|页|题|道|间|扇|把|件|颗|粒|枚')
_ASK_COUNT = re.compile(r'[几多]\s*少?\s*(?:' + _COUNT_UNIT + r')')


# 「分数求和」题干形态：至少两个分数相加（\frac 或 a/b 写法都认）。
_FRAC_SUM = re.compile(r'(?:\\d?frac\{[^{}]+\}\{[^{}]+\}|\d+\s*/\s*\d+)\s*[+＋]')


def _frac_sum_stem(stem):
    return len(_FRAC_SUM.findall(stem or '')) >= 2


def _needs_integer(stem):
    return bool(_ASK_COUNT.search(re.sub(r'\$[^$]*\$', ' ', str(stem or ''))))


# AI 出不了图，所以**任何需要看图才能做的题都是废题**。
# ⚠ 实测模型会拿文字描述顶替图：「数一数下图中有几个三角形（图形描述：一个由3条横线和4条竖线…）」——
#   学生看不到图，这题没法做；而它算式自洽，前面所有数值规则都拦不住。
# ⚠ 别用「如图|下图|图中」这种枚举 —— 实测漏掉了「下面**图形中**」「**图形由**一个大三角形…」。
#   改成**只要还剩「图」字就毙**，先把「图书馆」这类正常名词摘掉。
#   宁可多毙（大不了重出一批），也不能把学生做不了的题送到老师面前。
# ⚠ 「图上距离/图上」是比例尺题的固定说法，题目自带数据、**不需要配图**。
#   不放行的话整个比例尺题型会被全毙（对抗审查揪出来的）。
_FIG_OK_NOUN = re.compile(r'图书馆|图书|地图|拼图|图画|图钉|图案|图上距离|图上|作图|画图')
_HAS_FIG = re.compile(r'图')


def _allow_negative(grade):
    g = str(grade or '')
    return not _PRIMARY.search(g) or bool(re.search(r'初|六年级|七|八|九', g))


def verify(item, grade='', src_stem=''):
    """核验一道生成题 → (是否放行, 是否真核验过, 毙掉的原因)

    放行但 verified=False 的情况：模型标了不可算（数图形/逻辑/开放题）。
    """
    stem = (item.get('stem') or '').strip()
    if not stem:
        return False, False, '空题干'
    if _HAS_FIG.search(_FIG_OK_NOUN.sub('', stem)):
        return False, False, '题目涉及图形，但 AI 出不了图（学生做不了）'
    kind = (item.get('check_kind') or '').strip()
    chk = (item.get('check') or '').strip()
    ans = (item.get('answer') or '').strip()

    if kind not in ('eq', 'sign') or not chk:
        # 模型自认算不出算式 → 放行但打上"未核验"，让老师自己过一眼（比硬毙掉更诚实）
        return True, False, ''

    try:
        v = calc(chk)
    except BadCheck as e:
        return False, False, '算式跑不通：%s' % e

    if kind == 'sign':
        want = {'>': 1, '<': -1, '=': 0, '＞': 1, '＜': -1, '＝': 0}.get(ans)
        if want is None:
            return False, False, '比较题的答案不是 > < ='
        got = (v > 0) - (v < 0)
        if got != want:
            return False, False, '算式符号(%d)与答案(%s)不符' % (got, ans)
        return True, True, ''

    a = parse_num(ans)
    if a is None:
        return False, False, '答案不是数值：%r' % ans[:30]
    if v != a:
        return False, False, '算式值 %s ≠ 答案 %s（模型没真算）' % (v, a)
    if a.denominator not in OK_DEN and not _frac_sum_stem(stem):
        # ⚠ 裂项/分数求和题豁免：1/(1×3)+…+1/(19×21) 的标准答案就是 10/21，
        #   分母天然是序列端点（21、31、40…），不在白名单是**这类题的常态**不是脏数。
        #   实测被误杀：Fable 出的 6 道裂项全对，10/21、10/31 两道被这条冤枉毙掉。
        #   豁免判据看题干形态（连续分数相加），不看分母数值 —— 按数值放宽会
        #   把别的题型的 17/23 也放进来，那正是这条闸要拦的东西。
        return False, False, '答案不是整齐的数（%s，分母 %d）' % (a, a.denominator)
    if a < 0 and not _allow_negative(grade):
        return False, False, '小学题算出负数'
    if a.denominator != 1 and _needs_integer(stem):
        return False, False, '问的是「几只/多少个」，算出来却是 %s（题本身凑不出整数解）' % a

    # 模型是否偷换了数字：算式里 ≥3 位的数，题干里必须真的有
    #   （2、4、10、60、100 这类小常数是解题常量，不能要求出现在题干里）
    for n in re.findall(r'\d{3,}', chk):
        if n not in re.sub(r'[\s,，]', '', stem):
            return False, False, '算式用了题干里没有的数字 %s' % n
    return True, True, ''


def filter_items(items, grade='', want=None):
    """批量核验 → (放行的题, 统计)。题面里**不带答案**回前端（零答案红线）。"""
    ok, bad = [], []
    for it in (items or []):
        passed, verified, why = verify(it, grade)
        if passed:
            row = {'type': it.get('type', ''), 'stem': it.get('stem', ''),
                   'verified': verified}
            if it.get('vtype'):              # 变式组的策略标签，展示用
                row['vtype'] = it['vtype']
            ok.append(row)
        else:
            bad.append({'stem': (it.get('stem') or '')[:60], 'why': why})
    if want:
        ok = ok[:want]
    return ok, {'kept': len(ok), 'dropped': len(bad),
                'verified': sum(1 for x in ok if x['verified']), 'reasons': bad[:8]}


if __name__ == '__main__':                      # 自检
    cases = [
        ({'stem': '鸡兔同笼，共 10 个头 32 条腿', 'answer': '6', 'check': '(32-10*2)/(4-2)', 'check_kind': 'eq'}, True),
        # ⚠ 算式和答案自洽，但兔子有 6.5 只 —— 题本身是废的，必须毙
        ({'stem': '鸡兔同笼，共 10 个头 33 条腿，兔有几只？', 'answer': '6.5', 'check': '(33-10*2)/(4-2)', 'check_kind': 'eq'}, False),
        ({'stem': '比较大小 23+95 ○ 87+19', 'answer': '>', 'check': '(23+95)-(87+19)', 'check_kind': 'sign'}, True),
        ({'stem': '比较大小 23+95 ○ 87+19', 'answer': '<', 'check': '(23+95)-(87+19)', 'check_kind': 'sign'}, False),
        ({'stem': '算一算 17÷23', 'answer': '17/23', 'check': '17/23', 'check_kind': 'eq'}, False),
        ({'stem': '小明有 5 元用了 8 元', 'answer': '-3', 'check': '5-8', 'check_kind': 'eq'}, False),
        ({'stem': '共 12 人', 'answer': '999', 'check': '3456-2457', 'check_kind': 'eq'}, False),
        # AI 出不了图 → 需要看图的题一律毙（哪怕它把图"描述"出来）
        ({'stem': '数一数图中有几个三角形', 'answer': '8', 'check': '', 'check_kind': ''}, False),
        ({'stem': '下面图形中共有多少个三角形。图形由一个大三角形分成…', 'answer': '8', 'check': '', 'check_kind': ''}, False),
        ({'stem': '图书馆有 120 本书，借出 45 本，还剩多少本？', 'answer': '75', 'check': '120-45', 'check_kind': 'eq'}, True),
        ({'stem': '一个数列 1,3,5,…,99，共有多少个数？', 'answer': '50', 'check': '(99-1)/2+1', 'check_kind': 'eq'}, True),
        ({'stem': 'x', 'answer': '1', 'check': '__import__("os").system("ls")', 'check_kind': 'eq'}, False),
        ({'stem': 'x', 'answer': '1', 'check': '1/0', 'check_kind': 'eq'}, False),
    ]
    bad = 0
    for it, want in cases:
        got, ver, why = verify(it, '三年级')
        flag = '✓' if got == want else '✗'
        if got != want: bad += 1
        print(f'{flag} 期望{"放行" if want else "毙掉"} 实际{"放行" if got else "毙掉"} '
              f'核验={ver} {why}  ← {it["stem"][:24]}')
    print('\n自检', '全过' if not bad else f'{bad} 条不符')
