#!/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。
    容忍带单位（"6 只"）、带分数线（"3/4"）、小数、全角负号。"""
    s = str(s or '').strip().replace('－', '-').replace('，', '').replace(',', '')
    m = _NUM.search(s)
    if not m:
        return None
    t = m.group(0).replace(' ', '')
    try:
        if '/' in t:
            a, b = t.split('/')
            return Fraction(int(a), int(b)) if int(b) else None
        return Fraction(t) if '.' not in t else Fraction(t).limit_denominator(10 ** 6)
    except (ValueError, ZeroDivisionError):
        return None


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

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


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:
        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:
            ok.append({'type': it.get('type', ''), 'stem': it.get('stem', ''),
                       'verified': verified})
        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} 条不符')
