# -*- coding: utf-8 -*-
"""把「上海中考真题」批次（batch_exams_final / dataset_version 2026.07.31.2）
拍平成「组卷台」归一化 schema，作为**一个新教材「上海中考真题」并入同一个组卷台**。

由 gen_组卷台.py 调用（两处 hook，与 zujuan_v2 同款）：
  data_recs, course_sub, fig_dict = zujuan_exam.load(HERE)
  data += data_recs
  course['上海中考真题'] = course_sub
  FIG.update(fig_dict)

## 轴怎么摆（教材 → 年级 → 节 这三级下拉是既有 UI，不改）
  tb 教材 = 上海中考真题
  gg 年级 = **年份**（2026年…2008年，倒序）—— 老师最常按「近三年」找
  cs 节号 = 年内序号（中考 < 一模 < 二模，再按区名）
  cd 节名 = 「二模·浦东新」—— 同年内撞名时追加原文件名里的辨识词
  s/src   = 中考 / 一模 / 二模  —— 「来源」下拉做横切（选 2026年 + 二模）
  t 题型  = 选择题/填空题/解答题（原书大题头给的，可信）
  d 难度  = 0（这批没有难度标注，前端会把难度筛选隐掉）
  f/mf    = 空；**配图走题干内联**（见下）
  im      = 内联图 key 列表，只用于「仅有图」筛选和「带图题不出类似题」的判定

## ⚠ 为什么配图必须内联，不能像别的教材那样堆在题干下面
这批 8156 题挂着 `formula_image_only`：**公式本身就是一张图**，而且长在句子中间。
挪到题干下方会得到「求 的值」这种读不懂的题面。所以这里走 `![](asset://…)` 内联，
由前端 renderStem 就地换成 <img class=inl>。几何图同理，位置就是原卷的位置。

## 零答案红线
只取 `stem_markdown`。`answer_markdown` 早已摘出（answers_withheld.jsonl），
`solution_markdown` **一个字都不碰** —— 那里面写着答案。load() 结束会自查一次。

## 去重：两层，且**只删证得动的**
语料里有「同一场考试录了两遍」（两份来源 .doc）。**不能按 id 后缀判**：
`sh-2019-m1-xuhui-math-176` 是徐汇**实验中学**，真的是另一份卷。

第一层 · 题面哈希（`content_sha256`）精确去重 —— 组卷台是选题器，同一道题露一次就够。

第二层 · 整卷判重。同 (年份, 考次, 区) 现实里只有一场考试，但两个录入版本可能
**一个公式已落成 LaTeX、另一个公式还是图**，题面哈希完全对不上（2025 嘉定一模就是）。
所以用「只留汉字 + 去『（4分）』『本题满分N分』『图』+ 联结→连接」的归一指纹比对。
判据 **≥0.85 才并**：实测重合率在 0.94 和 0.65 之间有个真实断层，卡在断层里。

⚠ **低于 0.85 的一律不删**，只写进报告。原因是这个指纹**天生偏向漏判**：
版本越是「公式全是图」，能拿来比的汉字越少，分数越低 —— 而那恰恰是最该并的一类。
拿一个会系统性低估的分数去删卷，删掉的会是真卷。见 [[feedback_checker_lags_behind]]。
load(report_path=...) 会把待人工裁决的组导出成 json。
"""
import io
import json
import os
import re
from collections import OrderedDict, defaultdict

from PIL import Image

TB = '上海中考真题'
# 题干被解析区污染的判据。**只认两种铁证**，不用「∵∴」这类宽标记：
#   ① 出现「故选：X」「故答案为」——题目不会这么写，只有答案会
#   ② 题干**以** ∵/∴/解得/证明: 开头——没有题面引子，整段就是解题过程
# ⚠ 宽标记会虚警：「因式分解：」里有「解：」，「求证：△ABC∽…」本身就是题目要求，
#   「以下是小明证明本题的过程，请补全」是阅读理解题的题面。实测宽判据 19 条里 11 条是虚警。
ANS_MARK = re.compile(r'故选\s*[：:]\s*[A-D]|故答案为|答案为\s*[：:]')
ANS_HEAD = re.compile(r'^\s*(?:[∵∴]|解得|证明\s*[：:])')
# 2026-09-06 补录批起有 mock_3（三模）：之前 8 份三模卷被 paper_extract 记成 mock_2、挂在「二模·区」下，
# 旧 id 不动；新录的三模从此单列「三模·区」，年内排在二模之后。
TYPE_NAME = {'zhongkao': '中考', 'mock_1': '一模', 'mock_2': '二模', 'mock_3': '三模'}
TYPE_ORDER = {'zhongkao': 0, 'mock_1': 1, 'mock_2': 2, 'mock_3': 3}
DIST_SHOW = {'浦东新': '浦东'}      # 节名显示用；数据层区名不改
IMG_RE = re.compile(r'!\[[^\]]*\]\(asset://([A-Za-z0-9\-]+)\)')
# 整卷判重阈值。
# 指纹归一化的顺序 bug 修掉之后（见 _norm_sig），重合率分布重新拉开：
#   同卷 0.50~1.00（76 对） ／ 断层 ／ 0.33、0.19 ／ 不同卷 ≤0.05
# Codex 用**另一套方法**（一对一最大相似度，阈值 0.78）独立判了其中 27 对，
# 判「同卷」的 23 对我这边全部 ≥0.50，判「不同卷」的 4 对全部 ≤0.05 —— 两套方法在同一个断层上收敛。
# 所以卡在 0.45（0.33 与 0.50 之间的空档）。
DUP_RATIO = 0.45

# Codex 独立复核确认「同年同区同考次但确实不是同一场考试」的，**永不合并**。
# 光靠阈值挡不住这类：它们元数据完全一样，只有题面能区分。
# 结论出处：初中考评分析平台/docs/跨版本补公式_独立判重结论_20260730.md
NEVER_MERGE = {
    frozenset(('sh-2022-m2-huangpu-math-351', 'sh-2022-m2-huangpu-math')),      # 格致中学卷 vs 黄浦区统卷
    frozenset(('sh-2022-m2-pudong-math', 'sh-2022-m2-pudong-math-340')),        # 浦东6月线下 vs 浦东二模
    frozenset(('sh-2023-m1-xuhui-math-364', 'sh-2023-m1-xuhui-math')),          # 同区两份不同的一模
    frozenset(('sh-2025-m2-yangpu-math-533', 'sh-2025-m2-yangpu-math-531')),    # 杨浦二模 vs 质量调研(二)
}
CJK_RE = re.compile(r'[一-鿿]')
# 归一时要抹掉的排版噪声 —— 两个录入版本这些各写各的。
# ⚠ **顺序要紧**：带括号/数字的噪声必须在**汉字过滤之前**去掉。
#   先过滤汉字的话，「（4分）」会只剩一个孤零零的「分」，正则再也匹配不上，
#   于是「分下列根式中…」和「下列根式中…」被判成不同卷。
#   这个 bug 让判重系统性漏判（只自动并掉 26 张，34 对卡在灰区）—— Codex 独立复核时暴露的。
PRE_RE = re.compile(r'!\[[^\]]*\]\(asset://[^)]*\)'
                    r'|[（(]\s*\d+(?:\.\d+)?\s*分\s*[）)]'
                    r'|本题满分\s*\d*\s*分?|其中第\s*\d*\s*小题.{0,12}?分|第\s*\d+\s*小题')
POST_RE = re.compile(r'如图|如右图|如下图|图')
# 标题里点名了具体学校 → 是校级卷，哪怕和区卷重合也不是同一场考试
SCHOOL_RE = re.compile(r'上海市?[一-鿿]{2,8}(?:中学|学校|附中|实验|附属)')


def _norm_sig(stem):
    """整卷判重用的题面指纹：抹掉排版噪声后只留汉字，统一「联结/连接」。
    这样才能让「公式已落 LaTeX 的版本」和「公式还是图的版本」对得上。"""
    s = PRE_RE.sub('', (stem or '').replace('联结', '连接'))
    return POST_RE.sub('', ''.join(CJK_RE.findall(s)))


def _overlap(A, B):
    """两卷题面指纹的重合率（对短的那卷取比例）。前缀匹配容忍小问拆分方式不同。"""
    if not A or not B:
        return 0.0
    B = sorted(B, key=len, reverse=True)
    used = [False] * len(B)
    hit = 0
    for a in A:
        for i, b in enumerate(B):
            if used[i]:
                continue
            if a == b or a[:16] == b[:16] or a.startswith(b[:24]) or b.startswith(a[:24]):
                used[i] = True
                hit += 1
                break
    return hit / min(len(A), len(B))


def _find_batch(HERE):
    """从 HERE 向上找 数学学习系统/data/drafts/batch_exams_final。"""
    for up in ('.', '..', '../..', '../../..'):
        cand = os.path.normpath(os.path.join(
            HERE, up, '数学学习系统', 'data', 'drafts', 'batch_exams_final'))
        if os.path.exists(os.path.join(cand, 'questions.jsonl')):
            return cand
    return None


# ── 裸上下标 → Unicode ────────────────────────────────────────────────
# 15% 的题（2834 道）题干里有**数学分隔符之外**的 `x^{2}`、`a_{1}`：这批源文件里
# 这部分公式没被包进 \(…\)，于是 MathJax 不管它，屏幕上就是字面的 `y=2x^{2}-3`。
# 换成 Unicode 上下标，**HTML 预览 / 打印 PDF / pandoc 导 Word / 纯文本兜底**
# 四条路径一次全对；换成 <sup> 只有网页对，导出仍然是字面量。
# 只映射数字和正负号（占 89%）。`S_{△ABC}`、`x^{m}` 这类 Unicode 没有完整字形，
# **原样留着** —— 半套字形混排比字面量更难读。
_SUP = {'0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴', '5': '⁵', '6': '⁶',
        '7': '⁷', '8': '⁸', '9': '⁹', '+': '⁺', '-': '⁻', '−': '⁻', '﹣': '⁻'}
_SUB = {'0': '₀', '1': '₁', '2': '₂', '3': '₃', '4': '₄', '5': '₅', '6': '₆',
        '7': '₇', '8': '₈', '9': '₉', '+': '₊', '-': '₋', '−': '₋', '﹣': '₋'}
_SCRIPT_RE = re.compile(r'([\^_])\{([^{}]{1,6})\}|([\^_])([0-9])(?![0-9{])')
_MATH_SPAN = re.compile(r'\\\(.*?\\\)', re.S)


def _unicode_scripts(text):
    """把数学分隔符**之外**的 `^{2}` / `_{1}` 换成 Unicode 上下标。返回 (新串, 换了几处)。"""
    n = [0]

    def one(m):
        mark = m.group(1) or m.group(3)
        body = m.group(2) if m.group(1) else m.group(4)
        table = _SUP if mark == '^' else _SUB
        if not all(c in table for c in body):
            return m.group(0)                 # 有一个字符没字形就整体不换
        n[0] += 1
        return ''.join(table[c] for c in body)

    out, last = [], 0
    for sp in _MATH_SPAN.finditer(text):      # \(...\) 里交给 MathJax，一个字不动
        out.append(_SCRIPT_RE.sub(one, text[last:sp.start()]))
        out.append(sp.group(0))
        last = sp.end()
    out.append(_SCRIPT_RE.sub(one, text[last:]))
    return ''.join(out), n[0]


# ---- 压平表 → 规范 markdown 表格 ----
# 库里表格是压平成一行的 `国家 | 挪威 | 德国 | …`。前端 splitBlocks 只把
# **以 | 开头**的行当表格行，这种首格是文字的压平串就掉进段落分支、按原文显示，
# 老师看到的就是一串竖线。
#
# 不去放宽「必须以 | 开头」那条规则：绝对值写法 |a| 的竖线会被当成
# 列分隔符（实测 35 道纯向量选择题会被误判成表格题）。改成用 questions.jsonl 里
# 已校验过的 `tables` 字段（结构直接来自 docx 的 <w:tbl>，194 张已渲染成图逐张核过）
# 重写成**规范 markdown 表**：前端 splitBlocks 和导出侧的 pandoc 都已经认这一种，
# 两条路一次改到位，不会出现「预览和下载不是一回事」。
_TBL_MATH = re.compile(r'\\\(.*?\\\)', re.S)
_TBL_MATH_INNER = re.compile(r'\\\((.*?)\\\)', re.S)


def _cells_of_line(ln):
    r"""一行压平串切出的格子。数竖线前先把 \(...\) 整段抠掉，理由同上。"""
    if _TBL_MATH.sub('¤', ln).count('|') < 2:
        return None
    return [c.strip() for c in ln.split('|')]


def _cell_md(cell):
    """单元格 → markdown 文本。图保持 `![](asset://…)`，前端和导出各自会再处理。"""
    out = []
    for part in cell.get('content') or []:
        if part['type'] == 'asset':
            out.append('![](asset://%s)' % part['value'])
        else:
            out.append(str(part['value']))
    # 表格行是按 | 切的，格里再有 | 会把列数撑乱；换行同理（实测这批 0 例，仍然兜住）
    return ''.join(out).replace('|', '\\|').replace('\n', ' ').strip()


def _md_table(tbl):
    """结构化表 → markdown。

    ⚠ colspan 表达不了（markdown pipe table 没有跨列语法）：内容落在**起始格**，
      被跨掉的位置补空格。93 个跨列格分布在 30 张表里，全是「阅读概述 | 正文」
      那种情境题，内容一个字不丢，只是横线会多画几道。vmerge 这批是 0 例。
    """
    rows = []
    for r in tbl['rows']:
        cs = []
        for c in r:
            if c.get('vmerge') == 'continue':
                # ⚠ 被纵向合并掉的格要占位成空格，**不能直接丢**：
                #   丢了后面的格会整体左移，列就对不上表头了
                #   （普陀2024二模q22：「每单扣款」本该在第4列，丢格后跑到第1列）。
                cs.append('')
                continue
            cs.append(_cell_md(c))
            cs.extend([''] * (int(c.get('colspan') or 1) - 1))
        rows.append(cs)
    if not rows:
        return None
    n = max(len(r) for r in rows)
    rows = [r + [''] * (n - len(r)) for r in rows]
    body = ['| ' + ' | '.join(r) + ' |' for r in rows]
    # 第一行当表头 + 分隔行：pandoc 的 pipe_tables 必须有分隔行才认；
    # 前端 splitBlocks 会用 isSep() 把分隔行滤掉，所以预览不会多出一行。
    body.insert(1, '|' + '|'.join([' --- '] * n) + '|')
    return '\n'.join(body)


# ⚠ 必须**段首锚定**：几何题里「交BP于点C.」「垂足为D.」满地都是，
#   不锚定会把 4 张情境题表当成选项行挡掉（实测 2026闵行一模q23）。
_TBL_OPT = re.compile(r'^\s*[（(]?[ABCD][）)]?\s*[．.、]')
_ASSET_RE = re.compile(r'!\[\]\(asset://[^)]*\)')
_WS_RE = re.compile(r'\s+')


def _norm_cell(t):
    r"""格文本归一：只抹写法不抹语义（去空白、剥 \(...\) 外壳、图占位统一成 §）"""
    t = _ASSET_RE.sub('§', str(t or ''))
    t = _TBL_MATH_INNER.sub(r'\1', t)
    return _WS_RE.sub('', t)


def _inject_tables(stem, tables):
    r"""把题干里的压平串换成规范 markdown 表。

    ⚠ 一题可能有两张表（徐汇2020二模q21：原始数据表 + 频数分布表），所以要逐张配。

    配法是**按格数精确对齐**，不按「最长的那行」：合并入库时就是靠格数一一对应
    过的关，这里复用同一个锚点。一张表配到 0 条或 ≥2 条压平串就跳过 ——
    宁可继续显示压平串，也不换错行。

    ⚠ 不要在这里拿「屏蔽数学段后还剩几根竖线」当判据。绝对值写法 |a| 的竖线在
      数学段**外面**，想屏蔽它就得收掉 `|¤|`，而真表格里 `| \(x\) | \(0\) |`
      长得一模一样，一收就把 43 张含公式的表也判没了（实测 391 掉到 348）。
      精确格数 + 短格占比 + 不是选项行，这三条已经足够窄。
    """
    if not tables:
        return stem
    lines = stem.split('\n')
    plans = []
    for tbl in tables:
        want = sum(1 for r in tbl['rows'] for c in r if c.get('vmerge') != 'continue')
        md = _md_table(tbl)
        if not md or want < 4:
            continue
        flat = [_norm_cell(_cell_md(c)) for r in tbl['rows'] for c in r
                if c.get('vmerge') != 'continue']
        hit, hit2 = [], []
        for i, ln in enumerate(lines):
            # ⚠ 不能跳过「以 | 开头」的行：首格为空的压平串就长这样
            #   （`| 甲 | 乙 | 丙 | 丁 | 平均数 | 70 |…`，实测 40 张）。
            #   前端 splitBlocks 会把这种行当**一整行表**渲染 —— 形状是错的，
            #   3×4 的表被摊成 1×12。库里没有真正的多行 markdown 表，不会误伤。
            segs = ln.split('|')
            if len(segs) != want:
                continue
            if sum(1 for c in segs if _TBL_OPT.match(c)) >= 3:   # 选项行不是数据表
                continue
            # ⚠ 别拿「短格占比」当护栏：情境题（阅读概述/发现原理/任务一）整格就是
            #   一大段话，实测 18 张被这条误杀。改成**拿表格自己的格内容去比**：
            #   一半以上的格能对上才认，这比长度精确得多。
            src = {_norm_cell(c) for c in segs if _norm_cell(c)}
            if not src:
                continue
            hitn = sum(1 for c in flat if c and c in src)
            if hitn / max(1, len([c for c in flat if c])) < 0.5:
                continue
            hit.append(i)
        # 内容配：这条压平串的格有 ≥70% 能在这张表里找到就算它。
        # ⚠ 压平串是有损的，格数不一定等于原表格数（浦东2023二模q22 压平 17 / 原表 15）。
        tset = {c for c in flat if c}
        for k, ln in enumerate(lines):
            segs = [_norm_cell(c) for c in ln.split('|')]
            ne = [c for c in segs if c]
            if len(ne) < 4 or sum(1 for c in segs if _TBL_OPT.match(c)) >= 3:
                continue
            if sum(1 for c in ne if c in tset) / len(ne) >= 0.7:
                hit2.append(k)

        need = [c for c in flat if c]

        def cover(idxs):
            u = set()
            for k in idxs:
                u |= {_norm_cell(c) for c in lines[k].split('|') if _norm_cell(c)}
            return sum(1 for c in need if c in u) / max(1, len(need))

        pick = None
        # ⚠ 一张表在题干里可能**横跨好几行**（宝山2026二模q22 的成绩表占 4 行、
        #   黄浦2025二模q22 的得分表甲一行乙一行）。这时候不是「有歧义」，
        #   而是要把**整段**换成一张表 —— 判据是这些行合起来才盖得住原表。
        if len(hit2) > 1:
            lo, hi = min(hit2), max(hit2)
            mid_ok = all(k in hit2 or len(lines[k].strip()) < 20 for k in range(lo, hi + 1))
            if mid_ok and hi - lo <= 6 and cover(range(lo, hi + 1)) >= 0.8 \
               and cover(hit2) > cover(hit[:1]) + 1e-9:
                pick = (lo, hi)
        if pick is None and len(hit) == 1:
            pick = (hit[0], hit[0])
        if pick is None and len(hit2) == 1:
            pick = (hit2[0], hit2[0])
        if pick:
            plans.append((pick, md))
    if not plans:
        return stem
    seen = set()
    for (lo, hi), md in sorted(plans, key=lambda x: -x[0][0]):   # 从后往前替，行号不串位
        if any(i in seen for i in range(lo, hi + 1)):
            continue
        seen |= set(range(lo, hi + 1))
        # 表格前后各留一个空行：pandoc 靠空行断块，前端 splitBlocks 也更稳
        lines[lo:hi + 1] = ['', md, '']
    return '\n'.join(lines)


def _strip_lead_no(stem, qno):
    """剥掉题干开头的书本题号（`1．` `3. ` `12、`），题号本身走 `n` 字段由前端画成角标。

    ⚠ 比对时要拿 question_no 的**主号**（`35(1)` → `35`）：小问的 qno 带括号后缀，
    直接和开头的 `35` 比会对不上 —— 那样同一道大题的四个小问会各自顶着一个 `35.`，
    而正常题却剥干净了，列表里就成了「有的有题号有的没有」。踩过。
    对不上仍然原样留着：宁可多一个数字，也不能把题面第一个数字吃掉。"""
    m = re.match(r'\s*(\d{1,2})\s*[．.、)）]\s*', stem)
    if not m:
        return stem
    main = re.match(r'\s*(\d{1,3})', str(qno) or '')
    if main and main.group(1) == m.group(1):
        return stem[m.end():]
    return stem


def _fig_b64(fp, maxdim=600, colors=32):
    with Image.open(fp) as im:
        if im.mode == 'P':
            im = im.convert('RGBA')
        if im.mode == 'RGBA':
            bg = Image.new('RGB', im.size, (255, 255, 255))
            bg.paste(im, mask=im.split()[-1])
            im = bg
        elif im.mode != 'RGB':
            im = im.convert('RGB')
        W, H = im.size
        sc = min(1.0, maxdim / max(W, H))
        im2 = im.resize((max(1, round(W * sc)), max(1, round(H * sc))),
                        Image.LANCZOS) if sc < 1 else im
        W2, H2 = im2.size
        try:
            qz = im2.quantize(colors=colors, method=Image.FASTOCTREE)
        except Exception:
            qz = im2
        buf = io.BytesIO()
        qz.save(buf, 'PNG', optimize=True)
    return 'data:image/png;base64,' + base64_b64(buf.getvalue()), W2, H2


def base64_b64(b):
    import base64 as _b
    return _b.b64encode(b).decode()


def _rescue_wmf(paths, cache):
    """PIL 读不了的 WMF 交给 LibreOffice 转 PNG，转好的落在 cache 里。

    ⚠ 不这么做的后果是**静默错题**：这些图挂着 `asset_render_failed`，
    而它们多数是行内公式图 —— 嵌不进去，题面就从「求 √(x+1) 的值」变成「求 的值」，
    看起来是一道完整的题，其实少了核心条件。宁可多花两分钟转图。
    ⚠ 转换在**纯 ASCII 临时目录**里做：soffice 的 file:// URL 碰到中文/空格路径会崩。
    """
    import shutil
    import subprocess
    import tempfile
    soffice = shutil.which('soffice')
    todo = [(a, p) for a, p in paths if not os.path.exists(os.path.join(cache, a + '.png'))]
    if not todo or not soffice:
        return
    os.makedirs(cache, exist_ok=True)
    tmp = tempfile.mkdtemp(prefix='wmf2png_')
    try:
        name = {}
        for i, (a, p) in enumerate(todo):
            fn = 'w%05d.wmf' % i
            shutil.copyfile(p, os.path.join(tmp, fn))
            name[fn[:-4]] = a
        subprocess.run([soffice, '--headless', '--convert-to', 'png', '--outdir', tmp]
                       + [os.path.join(tmp, f + '.wmf') for f in name],
                       capture_output=True, timeout=900)
        for stem, a in name.items():
            src = os.path.join(tmp, stem + '.png')
            if os.path.exists(src):
                shutil.move(src, os.path.join(cache, a + '.png'))
    except Exception:
        pass
    finally:
        shutil.rmtree(tmp, ignore_errors=True)


def _disambig(title):
    """同年同区同考次撞名时，从原标题里挑一个辨识词（学校名 / 「精品解析」这类来源标记）。"""
    m = re.search(r'上海市?([一-龥]{2,10}(?:中学|学校|附中|实验))', title or '')
    if m:
        return m.group(1)
    if (title or '').startswith('精品'):
        return '精品解析本'
    return ''


def load(HERE, verbose=True):
    """返回 (data_records, course_sublist_by_year, fig_base64_dict)。"""
    B = _find_batch(HERE)
    if not B:
        if verbose:
            print('  [中考] 未找到 batch_exams_final/questions.jsonl，跳过')
        return [], OrderedDict(), {}

    papers = {p['external_id']: p for p in
              map(json.loads, open(os.path.join(B, 'exam_papers.jsonl'), encoding='utf-8'))
              if p.get('record_status') == 'draft'}
    qs = [q for q in map(json.loads, open(os.path.join(B, 'questions.jsonl'), encoding='utf-8'))
          if q.get('paper_external_id') in papers]

    # ── 小问不单独成卡 ──────────────────────────────────────────────
    # 批次里一道多小问的题存了 **1 条父题 + 每小问 1 条**，子记录 = 共同引子 + 单个小问。
    # 父题题干已经含全部小问，所以组卷台只收父题：否则一道题裂成 4 张几乎一样的卡，
    # 老师挑题时要在重复卡里翻找（陆老师 2026-07-30 指出：一模·静安#2 第24、25题）。
    # 实证：5925 条子记录里 5870 条（99.07%）剥掉公共引子后内容全被父题包含，丢了不损失题面；
    # 余下 55 条「多出来」的内容**全是解题过程**（小问切分器在那几卷抓错了区域），更不该留。
    n_sub = sum(1 for q in qs if q.get('parent_external_id'))
    qs = [q for q in qs if not q.get('parent_external_id')]

    # ── 题干被解析区污染的，不进选题器 ─────────────────────────────
    # 这些记录的题面整个变成了答案（「∴﹣8的立方根是﹣2．故选：B．」），
    # 既违零答案红线，本身也不是一道能用的题。id 写进报告，回头修批次。
    polluted = [q['external_id'] for q in qs
                if ANS_MARK.search(q.get('stem_markdown') or '')
                or ANS_HEAD.match(q.get('stem_markdown') or '')]
    _pol = set(polluted)
    qs = [q for q in qs if q['external_id'] not in _pol]

    n_by_paper = defaultdict(int)
    for q in qs:
        n_by_paper[q['paper_external_id']] += 1
    groups = defaultdict(list)
    for pid, p in papers.items():
        groups[(p['year'], p['exam_type'], p.get('district') or '市统一')].append(pid)

    # ── 第二层去重（先做）：同(年份,考次,区)里重合 ≥0.85 的，是同一场考试录了两遍 ──
    # 留谁：公式已落成 LaTeX 的那版优先（组卷台里公式图既搜不到也出不了类似题），
    #       再比题数，再比 id 短。
    sig_of = defaultdict(list)
    latex_of = defaultdict(int)
    for q in qs:
        t = _norm_sig(q.get('stem_markdown'))
        if len(t) >= 8:
            sig_of[q['paper_external_id']].append(t)
        if '\\(' in (q.get('stem_markdown') or ''):
            latex_of[q['paper_external_id']] += 1

    def _rank(pid):
        return (-latex_of[pid], -n_by_paper[pid], len(pid), pid)

    dead, pending = set(), []
    for key, pids in groups.items():
        if len(pids) < 2:
            continue
        for i in range(len(pids)):
            for j in range(i + 1, len(pids)):
                a, b = pids[i], pids[j]
                if a in dead or b in dead:
                    continue
                if frozenset((a, b)) in NEVER_MERGE:     # 已人工/独立复核确认非同卷
                    continue
                ov = _overlap(sig_of[a], sig_of[b])
                school = (SCHOOL_RE.search(papers[a].get('title') or '') or
                          SCHOOL_RE.search(papers[b].get('title') or ''))
                if ov >= DUP_RATIO and not school:
                    dead.add(max(a, b, key=_rank))       # _rank 越小越该留 → 淘汰 max
                elif ov >= 0.2:
                    pending.append({'overlap': round(ov, 2), 'group': '%s %s %s' % key,
                                    'a': a, 'a_title': papers[a].get('title'),
                                    'b': b, 'b_title': papers[b].get('title'),
                                    'school_named': bool(school)})
    for pid in dead:
        papers.pop(pid, None)
    qs = [q for q in qs if q['paper_external_id'] in papers]

    # ── 卷名：同(年份,考次,区)撞名时补辨识词，保证 cd 在同一年内唯一（match() 按 cd 过滤）──
    label = {}
    for key in list(groups):
        groups[key] = [p for p in groups[key] if p in papers]
    for (yr, et, dist), pids in groups.items():
        if not pids:
            continue
        # 区名显示映射：数据层从第一批起就写「浦东新」（避免与「浦东」歧义匹配），
        # 只在节名这一处改成老师叫的「浦东」；id / 数据 / 分组键都不动（2026-09-07）。
        base = f'{TYPE_NAME.get(et, et)}·{DIST_SHOW.get(dist, dist)}'
        if len(pids) == 1:
            label[pids[0]] = base
            continue
        for pid in sorted(pids):
            d = _disambig(papers[pid].get('title'))
            label[pid] = f'{base}({d})' if d else base
        # 补完还撞的（两份都没辨识词）→ 加序号，绝不让两张卷共用一个 cd
        seen = defaultdict(int)
        for pid in sorted(pids, key=lambda x: (-n_by_paper[x], len(x), x)):
            seen[label[pid]] += 1
            if seen[label[pid]] > 1:
                label[pid] = '%s#%d' % (label[pid], seen[label[pid]])

    # ── 第一层去重：题面哈希精确去重，同一道题在组卷台里只露一次 ──
    qs.sort(key=lambda q: (_rank(q['paper_external_id']), q.get('sort_order') or 0))
    seen_sha, kept, n_dup = set(), [], 0
    for q in qs:
        h = q.get('content_sha256')
        if h and h in seen_sha:
            n_dup += 1
            continue
        if h:
            seen_sha.add(h)
        kept.append(q)

    # ── 年内节序号：中考 < 一模 < 二模，再按区名 ──
    live = sorted({q['paper_external_id'] for q in kept},
                  key=lambda p: (TYPE_ORDER.get(papers[p]['exam_type'], 9),
                                 papers[p].get('district') or '', p))
    cs_of, per_year = {}, defaultdict(int)
    for pid in live:
        y = papers[pid]['year']
        per_year[y] += 1
        cs_of[pid] = per_year[y]

    data, fig_refs, leak, n_scr = [], {}, 0, 0
    asset_dir = os.path.join(B, 'assets')
    index = {}
    with os.scandir(asset_dir) as it:
        for e in it:
            index[e.name.rsplit('.', 1)[0]] = e.name
    for q in kept:
        pid = q['paper_external_id']
        p = papers[pid]
        stem = _inject_tables(q.get('stem_markdown') or '', q.get('tables'))
        stem = _strip_lead_no(stem, q.get('question_no'))
        # 2026-09-09 视觉转录卷：几何图只留占位（stem 里是裸 ⟦IMG⟧，没有资产），卡片上显示成「（图略）」，别把内部标记露给老师
        stem = stem.replace('⟦IMG⟧', '（图略）')
        stem, _n = _unicode_scripts(stem)
        n_scr += _n
        ims = []
        for aid in IMG_RE.findall(stem):
            fn = index.get(aid)
            if not fn:
                continue
            key = 'ex/' + aid
            fig_refs[key] = os.path.join(asset_dir, fn)
            ims.append(key)
        # 自查零答案：答案原文不该出现在题面里。
        # ⚠ 门槛必须 ≥4 字：选择题答案就是「B」一个字母，而题面里必然有选项标号 B，
        #   按「包含即泄漏」判会虚报一片（实测虚报 23 条，全是这个原因）。
        ans = str(q.get('answer_markdown') or '').strip()
        if len(ans) >= 4 and ans in stem:
            leak += 1
        data.append({
            'id': q['external_id'], 'tb': TB,
            'gg': '%d年' % p['year'], 'lv': 'ZK%d' % p['year'],
            'cs': cs_of[pid], 'cd': label[pid],
            'src': TYPE_NAME.get(p['exam_type'], ''), 's': TYPE_NAME.get(p['exam_type'], ''),
            'n': q.get('question_no') or '', 'q': stem,
            't': q.get('question_type_raw') or '其他', 'd': 0,
            'f': '', 'mf': [], 'p': [], 'k': [], 'im': ims,
            # pid = 原卷 id。前端「📄 原卷」按钮拿它调本地助手 /api/openword，
            # 由服务端把 id 解析成源文件路径再用系统默认程序打开。
            # ⚠ 只传 id 不传路径：路径来自前端 = 任意文件都能被打开。
            'pid': pid,
        })

    # 课程树节点给 **4 元**：第 4 位是分组名，前端会拿它渲成 <optgroup>，
    # 于是「节/专题」下拉在每个年份里自动分成 中考真题 / 一模 / 二模 三组。
    years = sorted({d['gg'] for d in data}, key=lambda s: -int(s[:-1]))
    course_sub = OrderedDict()
    for y in years:
        nodes = OrderedDict()
        for x in data:
            if x['gg'] != y:
                continue
            nodes.setdefault(x['cs'], [x['cd'], 0, x['s'] or '其他'])
            nodes[x['cs']][1] += 1
        course_sub[y] = [[s, nd[0], nd[1], nd[2]] for s, nd in sorted(nodes.items())]

    # 先把 PIL 读不了的（清一色是 .wmf）挑出来，交给 LibreOffice 转成 PNG 缓存。
    cache = os.path.join(HERE, '_wmf_cache')
    broken = []
    for key, fp in sorted(fig_refs.items()):
        if not fp.lower().endswith('.wmf'):
            continue
        try:
            with Image.open(fp) as im:
                im.load()
        except Exception:
            broken.append((key[3:], fp))
    if broken:
        if verbose:
            print('  [中考] PIL 读不了的图 %d 张（全是 wmf），交给 LibreOffice 转…' % len(broken))
        _rescue_wmf(broken, cache)

    FIG, skipped, rescued = {}, 0, 0
    for i, (key, fp) in enumerate(sorted(fig_refs.items()), 1):
        alt = os.path.join(cache, key[3:] + '.png')
        try:
            d64, W, H = _fig_b64(fp)
            FIG[key] = {'d': d64, 'w': W, 'h': H}
        except Exception:
            try:
                d64, W, H = _fig_b64(alt)
                FIG[key] = {'d': d64, 'w': W, 'h': H}
                rescued += 1
            except Exception:
                skipped += 1
        if verbose and i % 3000 == 0:
            print('  [中考] 配图 %d/%d' % (i, len(fig_refs)))

    # 仍然嵌不进去的图：**不能悄悄消失**。留个显式记号，让老师一眼看出这里缺东西，
    # 而不是拿到一道「求 的值」这种看着完整、其实少了条件的题。
    n_hole = 0
    for x in data:
        miss = [k for k in x['im'] if k not in FIG]
        if not miss:
            continue
        for k in miss:
            x['q'] = re.sub(r'!\[[^\]]*\]\(asset://%s\)' % re.escape(k[3:]), '⟦图缺失⟧', x['q'])
        x['im'] = [k for k in x['im'] if k in FIG]
        n_hole += 1

    report = {'questions_with_missing_figure': n_hole,
              'answer_polluted_stems_excluded': sorted(polluted),
              'dropped_duplicate_papers': sorted(dead),
              'pending_human_call': sorted(pending, key=lambda x: -x['overlap'])}
    rp = os.path.join(HERE, '_中考重复卷待裁决.json')
    with open(rp, 'w', encoding='utf-8') as f:
        json.dump(report, f, ensure_ascii=False, indent=1)

    if verbose:
        print('  [中考] 交付卷 %d 张，去重后 %d 张 / %d 题'
              '（整卷判重 -%d 张，同题面去重 -%d 题）'
              % (len(papers) + len(dead), len(live), len(data), len(dead), n_dup))
        print('  [中考] 年份 %s；配图 %d 张（LibreOffice 救回 %d，仍失败 %d → %d 道题标⟦图缺失⟧），'
              'FIG %.1f MB；答案泄漏自查 %d 条'
              % (years[0] + '~' + years[-1], len(FIG), rescued, skipped, n_hole,
                 len(json.dumps(FIG)) / 1e6, leak))
        print('  [中考] 裸上下标转 Unicode：%d 处；小问不单独成卡 -%d 条（父题已含全部小问）；'
              '题干被解析污染剔除 -%d 条' % (n_scr, n_sub, len(polluted)))
        if pending:
            print('  [中考] ⚠ 另有 %d 对「疑似同卷但重合率不到 %.2f」**没删**，'
                  '待人工裁决 → %s' % (len(pending), DUP_RATIO, os.path.basename(rp)))
    return data, course_sub, FIG


if __name__ == '__main__':
    d, c, f = load(os.path.dirname(os.path.abspath(__file__)))
    print('自测：', len(d), '题', len(c), '个年份', len(f), '图')
    if d:
        print('样例：', json.dumps({k: v for k, v in d[0].items() if k != 'q'},
                                   ensure_ascii=False))
