#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""组卷台 v2 · 本地 AI 助手代理（功能A：讲解/出答案）。
服务组卷台.html + 转发 /api/chat 到 DeepSeek（key 只在服务端，不进前端/代码/git）。
跑法：python3 组卷台AI助手.py  → 浏览器开 http://localhost:8848/组卷台.html
纯 stdlib，无第三方依赖。"""
import http.server, socketserver, json, os, re, urllib.request, urllib.error
import hashlib, tempfile, subprocess, shutil   # /api/source 源文件预览下载用
import concurrent.futures
import verify_gen                         # AI 出题的确定性数值核验（算式跑一遍对答案）
try:
    import gen_router                     # 确定性生成器路由（现已停用，见 USE_GENERATOR）
except Exception:
    gen_router = None

HERE = os.path.dirname(os.path.abspath(__file__))
KEYFILE = os.path.expanduser('~/.deepseek_pro_key')
KEY = (open(KEYFILE).read().strip() if os.path.exists(KEYFILE) else os.environ.get('DEEPSEEK_API_KEY', '')).strip()
MODEL = 'deepseek-v4-flash'   # 出题用 flash：推理开销小、够快够好（pro 会烧大量 reasoning token 致截断）
PORT = 8849
BUILD = '2026-07-17.2'   # 前后端版本握手：改前端 NEED_BUILD 时同步改这里；不一致页面会横幅提醒重启


# ══════════ difrobot 上线补丁（由 tools/zaojuan_server_patch.py 注入）══════════
import base64 as _b64, hmac as _hmac, hashlib as _hl, time as _time, threading as _thr
from urllib.parse import unquote as _unq, quote as _q, urlparse as _up, parse_qs as _pq

_JWT_SECRET = os.environ.get('JWT_SECRET', '')
_COS_BASE = os.environ.get('ZAOJUAN_COS_BASE', 'https://difrobot-storage-1330434723.cos.ap-shanghai.myqcloud.com').rstrip('/')

def _load_keymap(name):
    p = os.path.join(HERE, name)
    try:
        with open(p, encoding='utf-8') as f:
            return json.load(f)
    except Exception:
        return {}

_PAGES_MAP = _load_keymap('_cos_pages.json')     # rel(相对组卷系统/) → cos key
_TOPICS_MAP = _load_keymap('_cos_topics.json')   # rel(相对_专题拆分/) → cos key

def _b64url_decode(s):
    s += '=' * (-len(s) % 4)
    return _b64.urlsafe_b64decode(s.encode())

def _verify_jwt(token):
    """校验 difrobot 老师 JWT（HS256）。返回 payload dict 或 None。"""
    if not token or not _JWT_SECRET:
        return None
    try:
        h, p, sig = token.split('.')
        expect = _b64.urlsafe_b64encode(
            _hmac.new(_JWT_SECRET.encode(), (h + '.' + p).encode(), _hl.sha256).digest()
        ).rstrip(b'=').decode()
        if not _hmac.compare_digest(expect, sig):
            return None
        payload = json.loads(_b64url_decode(p))
        if payload.get('exp') and payload['exp'] < _time.time():
            return None
        return payload
    except Exception:
        return None

# 简易内存限流：AI 出题类接口，每用户每窗口 N 次
_RL_LOCK = _thr.Lock()
_RL = {}
_RL_MAX, _RL_WIN = 30, 60.0

def _rate_ok(uid):
    now = _time.time()
    with _RL_LOCK:
        rec = _RL.get(uid)
        if not rec or now >= rec[1]:
            _RL[uid] = [1, now + _RL_WIN]
            return True, 0
        if rec[0] >= _RL_MAX:
            return False, int(rec[1] - now) + 1
        rec[0] += 1
        return True, 0

_OPEN_PATHS = ('/api/version',)   # 版本握手不需要登录（前端开页即查）
# ══════════ 补丁代码结束 ══════════


SIM_PROMPT = r'''你是资深小学奥数老师。老师选中了一道题，请你出 N 道和这道题【同一类型、同一考法、同一解题套路】的练习题，供学生针对这一类题做专项练习。
- **紧扣原题**：原题是什么类型（如"比较大小"就出比较大小、"鸡兔同笼"就出鸡兔同笼、"求周长"就出求周长），你就出什么类型；不要换成别的题型、不要发散成"这个专题下的各种题"。
- **换数字/换情境**：每道都要换一套新的数字、或换个生活情境，是真正的新题；既不许把原题原样抄一遍，也不许只改一两个数字（要有变化、有新意），N 道之间也别雷同。
- 难度贴合原题，符合该年级学生认知；条件完整、自洽、可解、答案唯一、能算出干净答案。
- 数学写法用标准 LaTeX：行内数学（算式、分数、根号、指数、循环小数）一律用 $...$ 包裹，例如 $28\div3\times54$、$\frac{3}{4}$、$\sqrt{16}$、$2^3$、$0.\dot{2}\dot{7}$；乘写 \times、除写 \div、分数写 \frac{分子}{分母}；纯叙述文字、“5个苹果”这类量词不要包 $。

**每道题必须自己先算一遍，并交出可核对的算式**（服务端会执行它来验你有没有真算，对不上的题直接丢弃）：
- `answer`：最终答案，**一个数**（如 "6"、"3/4"、"2.5"）；比较大小题填 ">"、"<" 或 "="。
- `check`：一行 **Python 算式**，只用题干里出现的数字，算出来必须**正好等于** answer。只能用 + - * / // % ** 和括号，不许出现函数、字母、变量。
- `check_kind`：算式怎么核对 —— "eq"（check 的值 = answer）；"sign"（比较大小题，check 写「左式-右式」，正负号对应 > < =）；""（数图形、逻辑推理、开放题这类写不出算式的，三个字段都留空）。
- **每道题只问一个问题**（不要分小问），答案要算得出干净的数：整数，或分母是 2 3 4 5 8 10 100 这类常见分母；**除不尽、答案是 17/23 这种脏数的题不要出**；小学题不要出现负数答案。问「几只/几个/多少人」的，答案必须是整数。
- **绝对不要出需要看图的题**（不许出现「如图」「下图」「图中」，也不许用文字描述图形来顶替）—— 你没法配图，学生看不到图就做不了。这类题型请改出纯文字叙述的同类题。

- 严格只输出 JSON：{"items":[{"type":"题型名","stem":"题干","answer":"6","check":"(32-10*2)/(4-2)","check_kind":"eq"}, ...]}；注意 JSON 字符串里的反斜杠要转义（如把 \frac 写成 \\frac）；无任何多余文字、不要解析、不要代码块围栏。**answer 和 check 只给服务端核对用，绝不会展示给学生。**'''


# JSON 里的合法转义：\" \\ \/ \uXXXX，以及 \b \f \n \r \t
# ⚠ 但 LaTeX 命令正好也以这些字母开头：\frac \times \begin \neq \rightarrow …
#   老写法 `re.sub(r'\\(?!["\\/bfnrtu])', ...)` 把 `\frac` 当成 `\f`(换页符)+`rac`，
#   而且这样的 JSON **能正常解析**，于是悄悄产出 `$rac{1}{4}$` —— 分数全废且不报错。
#   判据改成：`\` + [bfnrt] 只有在**后面不是字母**时才算 JSON 转义，否则是 LaTeX。
_ESC = re.compile(r'\\(?:(["\\/]|u[0-9a-fA-F]{4}|[bfnrt](?![a-zA-Z]))|(.))', re.S)


def _fix_latex_json(t):
    """把 LaTeX 里的裸反斜杠补成 `\\\\`，合法 JSON 转义原样保留。
    整对消费(`\\\\` 一次吃掉两个字符)，所以已正确转义的内容不会被二次破坏。"""
    return _ESC.sub(lambda m: m.group(0) if m.group(1) else '\\\\' + m.group(2), t)


def parse_items(content):
    # 剥 ```json 围栏，容错抽取 items 列表（含 LaTeX 孤立反斜杠修复）
    t = content.strip()
    if t.startswith('```'):
        t = re.sub(r'^```[a-zA-Z]*\n?', '', t); t = re.sub(r'\n?```$', '', t).strip()
    m = re.search(r'\{.*\}', t, re.S)
    if m:
        t = m.group(0)
    fixed = _fix_latex_json(t)
    obj = None
    for cand in (fixed, t):                             # ⚠ 修过的**优先**，见 _fix_latex_json
        try:
            obj = json.loads(cand); break
        except Exception:
            continue
    if obj is None:
        return []
    items = obj.get('items') if isinstance(obj, dict) else (obj if isinstance(obj, list) else [])
    out = []
    for it in (items or []):
        if isinstance(it, dict) and (it.get('stem') or '').strip():
            # answer/check 带出来给核验层用，**不进最终返回前端的题面**（零答案红线）
            out.append({'type': str(it.get('type', '')).strip(), 'stem': str(it['stem']).strip(),
                        'answer': str(it.get('answer', '') or '').strip(),
                        'check': str(it.get('check', '') or '').strip(),
                        'check_kind': str(it.get('check_kind', '') or '').strip()})
    return out


def gen_similar(stem, grade, diff, topic, cnt):
    """照一道原题调 DeepSeek 出 cnt 道同类练习；返回 items 列表；失败抛异常。线程安全，供单题/整页批量共用。"""
    stars = '★' * int(diff) if str(diff).isdigit() else str(diff)
    user = ('老师选中的原题（%s，难度 %s%s）：\n「%s」\n\n请照着这道题，出 %d 道【同一类型、同一考法】的练习题，每道换不同的数字/情境，别抄原题、也别散成别的题型。'
            % (grade or '小学', stars, ('，专题：' + topic) if topic else '', stem, cnt))
    # max_tokens 随道数放大：固定 4000 出 10 道会被截断，末尾几道残缺
    payload = {'model': MODEL, 'temperature': 0.8,
               'max_tokens': min(8000, max(4000, int(cnt) * 800)), 'stream': False,
               'thinking': {'type': 'disabled'},   # 关隐藏思考:实测输出83%是思考token,关掉后3道题7.9s→2.0s,题目质量/LaTeX不受影响
               'messages': [{'role': 'system', 'content': SIM_PROMPT}, {'role': 'user', 'content': user}]}
    req = urllib.request.Request('https://api.deepseek.com/chat/completions',
                                 data=json.dumps(payload).encode('utf-8'),
                                 headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + KEY})
    with urllib.request.urlopen(req, timeout=180) as r:
        out = json.load(r)
    return parse_items(out['choices'][0]['message']['content'])


# ⚠ 用户 2026-07-26 决定：**出类似题回到大模型，不走确定性生成器**（生成器题型覆盖有限、
#   出来的题太板）。改成「大模型出题 + verify_gen 确定性数值核验」。
#   生成器代码和 gen_router 都保留，想切回来把这个开关改 True 即可。
USE_GENERATOR = False


def gen_verified(stem, grade, diff, topic, want, rounds=3):
    """出题 → **确定性数值核验** → 不够就再补一轮。返回 (通过的题, 统计)。

    多要一些再筛（核验会毙掉一部分），比"要几道出几道"更容易一次凑够。
    """
    keep, stat = [], {'kept': 0, 'dropped': 0, 'verified': 0, 'reasons': [], 'rounds': 0}
    for r in range(rounds):
        need = want - len(keep)
        if need <= 0:
            break
        # 冗余出题给核验留淘汰空间。裂项相消这类模型算不明白的题型淘汰率能到 70%，
        # 只多要 50% 会凑不够 → 直接按 2 倍要（上限 20，成本只是输出 token）。
        ask = min(20, max(need * 2, need + 3))
        raw = gen_similar(stem, grade, diff, topic, ask)
        ok, st = verify_gen.filter_items(raw, grade)
        keep += ok
        stat['rounds'] = r + 1
        stat['dropped'] += st['dropped']
        stat['reasons'] += st['reasons']
    keep = keep[:want]
    stat['kept'] = len(keep)
    stat['verified'] = sum(1 for x in keep if x.get('verified'))
    stat['reasons'] = stat['reasons'][:8]
    return keep, stat


class Handler(http.server.SimpleHTTPRequestHandler):
    def end_headers(self):
        # 组卷台.html 常更新（并入新教材/修图等），禁掉浏览器缓存，避免打开的是旧版
        # （踩过：并入初中题后浏览器仍用缓存的旧 89MB，看不到初中题）
        p = self.path.split('?')[0]
        if p.endswith('.html') or p == '/':
            self.send_header('Cache-Control', 'no-cache, no-store, must-revalidate')
        super().end_headers()

    def do_GET(self):
        # difrobot：/api/* 需老师 JWT（/api/version 除外）
        _p = self.path.split('?')[0]
        if _p.startswith('/api/') and _p not in _OPEN_PATHS:
            _tok = ''
            _h = self.headers.get('Authorization') or ''
            if _h.startswith('Bearer '):
                _tok = _h[7:]
            if not _tok:
                _tok = (_pq(_up(self.path).query).get('t') or [''])[0]
            _u = _verify_jwt(_tok)
            if not _u:
                return self._json({'ok': False, 'error': '未登录或登录已过期，请回工作台重新登录'}, 401)
            self._uid = _u.get('id') or _u.get('username') or 'anon'
            if _p.startswith('/api/similar'):
                _ok, _wait = _rate_ok(self._uid)
                if not _ok:
                    return self._json({'ok': False, 'error': '出题请求过于频繁，请 %d 秒后再试' % _wait}, 429)

        if self.path == '/api/version':   # 版本握手：前端开页即查，旧后端(404)或版本不符都会触发页面横幅
            return self._json({'ok': True, 'build': BUILD})
        if self.path.startswith('/api/topic/pdf'):
            return self._topic_pdf()
        if self.path.startswith('/api/topic'):
            return self._topic_preview()
        if self.path.startswith('/api/page'):
            return self._serve_page_png()
        if self.path.startswith('/api/source'):
            return self._serve_unit_source()
        if self.path.startswith('/api/page'):
            return self._serve_page_png()
        # 「对照原书」页图：随站 原书页/ 缺失时，回退到仓库原始 成果/*/pages/*.png（offset已对齐，无需PDF）
        from urllib.parse import unquote
        rel = unquote(self.path.lstrip('/'))
        if rel.startswith('原书页/') and not os.path.exists(os.path.join(HERE, rel)):
            if self._serve_source_page(rel):
                return
        return super().do_GET()

    # ── 初中培优讲义：源 docx 预览/下载 ────────────────────────────
    _units_cache = [None]

    def _units(self):
        """{专题标题: {key, docx, book}}，来自 数学学习系统/data/v2/units.json。"""
        if self._units_cache[0] is None:
            idx = {}
            for up in ('..', '../..', '.'):
                p = os.path.normpath(os.path.join(
                    HERE, up, '数学学习系统', 'data', 'v2', 'units.json'))
                if os.path.exists(p):
                    try:
                        for u in json.load(open(p, encoding='utf-8')):
                            idx[(u.get('title') or '').strip()] = u
                    except Exception:
                        pass
                    break
            self._units_cache[0] = idx
        return self._units_cache[0]

    def _serve_unit_source(self):
        """/api/source?title=<专题标题>[&fmt=pdf]
        fmt 省略 → 原 docx（浏览器直接下载）；fmt=pdf → 转 PDF 内联预览（转换结果缓存）。"""
        from urllib.parse import urlparse, parse_qs, unquote, quote
        qs = parse_qs(urlparse(self.path).query)
        title = unquote((qs.get('title') or [''])[0]).strip()
        fmt = (qs.get('fmt') or [''])[0]
        u = self._units().get(title)
        if not u or not os.path.exists(u.get('docx') or ''):
            return self._json({'ok': False, 'error': '未找到该专题的源文件'}, 404)
        src = u['docx']
        if fmt != 'pdf':
            data = open(src, 'rb').read()
            self.send_response(200)
            self.send_header('Content-Type', 'application/vnd.openxmlformats-'
                                             'officedocument.wordprocessingml.document')
            self.send_header('Content-Disposition',
                             "attachment; filename*=UTF-8''" + quote(os.path.basename(src)))
            self.send_header('Content-Length', str(len(data)))
            self.end_headers()
            return self.wfile.write(data)
        # 预览：**直接用已渲染好的页图**，不再调 LibreOffice。
        # v2 流水线早就把 381 个单元全渲染成了 data/v2/pages/<key>/pNNN.png（16518 张），
        # 再去 soffice 转一次 PDF 是白等 7 秒还起个 LibreOffice 进程 —— 纯属绕远路。
        import glob as _glob
        from urllib.parse import quote as _q
        V2 = os.path.dirname(os.path.dirname(u['docx'])) if False else None
        for up in ('..', '../..', '.'):
            cand = os.path.normpath(os.path.join(HERE, up, '数学学习系统', 'data', 'v2'))
            if os.path.exists(os.path.join(cand, 'units.json')):
                V2 = cand; break
        pngs = sorted(_glob.glob(os.path.join(V2 or '', 'pages', u['key'], 'p*.png'))) if V2 else []
        if not pngs:
            return self._json({'ok': False, 'error': '该专题没有已渲染的原书页图'}, 404)
        imgs = ''.join(
            '<figure><img loading="lazy" src="/api/page?u=%s&p=%s"><figcaption>%s / %d</figcaption></figure>'
            % (_q(u['key']), _q(os.path.basename(f)), os.path.basename(f)[1:-4].lstrip('0') or '1', len(pngs))
            for f in pngs)
        html = ('<!doctype html><meta charset="utf-8"><title>%s</title>'
                '<style>body{margin:0;background:#1a1c1f;color:#c8cdd4;font:14px/1.6 -apple-system,sans-serif}'
                'header{position:sticky;top:0;background:#23262b;padding:10px 16px;border-bottom:1px solid #333;z-index:9}'
                'header a{color:#8ab4f8;text-decoration:none;margin-left:14px}'
                'figure{margin:14px auto;max-width:1000px;text-align:center}'
                'img{width:100%%;border-radius:6px;box-shadow:0 2px 12px #0007;background:#fff}'
                'figcaption{color:#7b8794;font-size:12px;padding:4px}</style>'
                '<header><b>%s</b> · 共 %d 页<a href="/api/source?title=%s">⬇ 下载原始 docx</a></header>%s'
                % (u['title'], u['title'], len(pngs), _q(title), imgs)).encode('utf-8')
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Content-Length', str(len(html)))
        self.end_headers()
        return self.wfile.write(html)

    # ── 专题级源文件：预览页图 / 下载切好的专题 PDF ──────────────
    def _topic_args(self):
        from urllib.parse import urlparse, parse_qs, unquote
        q = parse_qs(urlparse(self.path).query)
        return (unquote((q.get('tb') or [''])[0]), unquote((q.get('g') or [''])[0]),
                unquote((q.get('cd') or [''])[0]),
                [x for x in unquote((q.get('pg') or [''])[0]).split('|') if x],
                unquote((q.get('src') or [''])[0]))

    def _topic_preview(self):
        """/api/topic?tb=&g=&cd=&pg=a|b|c → 该专题所有页图的滚动预览页。"""
        from urllib.parse import quote
        try:
            import topic_source as TS
        except Exception:
            return self._json({'ok': False, 'error': 'topic_source 未就绪'}, 500)
        tb, g, cd, pgs, sh = self._topic_args()
        loc = TS.locate(tb, g, cd, sh)                  # 教材专属（初中=整单元页图）
        files = None
        # ① 优先：已拆好的专题成品（源题文件/_专题拆分/）——预览的就是下载会拿到的那个文件
        pc = TS.precut(tb, g, cd, sh)
        if pc and pc['path'].lower().endswith('.pdf'):
            files = TS.precut_pages(pc, pgs, loc)
        if not files and loc and loc.get('pages'):
            files = loc['pages']
        if not files:                                    # 兜底：用前端传来的题目 pg 并集
            r = TS.locate_by_questions(pgs)
            files = r['pages'] if r else []
        if not files and loc and loc.get('pdf') and loc['pdf'][0]:
            # 没有预渲染页图（如胡小群课后题）→ 用 pdftoppm 现渲（0.15秒/页，非 LibreOffice），
            # 渲的**正是下载会给的那几页**，保证预览与下载同源。
            src0, lo0, hi0 = loc['pdf']
            files = TS.render_pdf_pages(src0, lo0 or 1, hi0 or 0)
        if not files:
            # 实在渲不出 → 给下载入口，别 404 让用户以为坏了
            if loc and loc.get('pdf'):
                dl = ('<a href="/api/topic/pdf?tb=%s&g=%s&cd=%s&src=%s&pg=%s">⬇ 下载本专题 PDF</a>'
                      % (quote(tb), quote(g), quote(cd), quote(sh), quote('|'.join(pgs))))
                html = ('<!doctype html><meta charset="utf-8"><title>%s</title>'
                        '<style>body{margin:0;background:#1a1c1f;color:#c8cdd4;'
                        'font:15px/1.8 -apple-system,PingFang SC,sans-serif;padding:60px 24px;'
                        'text-align:center}a{color:#8ab4f8;font-size:16px}</style>'
                        '<h2>%s</h2><p style="color:#7b8794">这个专题的源文件是 PDF，没有预渲染页图</p>'
                        '<p>%s</p>' % (cd, cd, dl)).encode('utf-8')
                self.send_response(200)
                self.send_header('Content-Type', 'text/html; charset=utf-8')
                self.send_header('Content-Length', str(len(html)))
                self.end_headers()
                return self.wfile.write(html)
            return self._json({'ok': False, 'error': '该专题没有可用的原书页图'}, 404)
        dl = ''
        alts = TS.precut_all(tb, g, cd)
        if len(alts) > 1:
            # 一个课程节下同时有讲义和课后（胡小群）→ 两个都给出来，别只给一个
            dl = ''.join('<a href="/api/topic/pdf?tb=%s&g=%s&cd=%s&src=%s&pg=%s">⬇ 下载%s</a>'
                         % (quote(tb), quote(g), quote(cd), quote(a['label']),
                            quote('|'.join(pgs)), a['label'] or '本专题') for a in alts)
        elif (loc and loc.get('docx')) or pgs or (loc and loc.get('pdf')) or alts:
            dl = ('<a href="/api/topic/pdf?tb=%s&g=%s&cd=%s&src=%s&pg=%s">⬇ 下载本专题</a>'
                  % (quote(tb), quote(g), quote(cd), quote(sh), quote('|'.join(pgs))))
        figs = ''.join(
            '<figure><img loading="lazy" src="/api/page?f=%s"><figcaption>%s</figcaption></figure>'
            % (quote(os.path.relpath(f, HERE)), os.path.basename(f)) for f in files)
        html = ('<!doctype html><meta charset="utf-8"><title>%s</title>'
                '<style>body{margin:0;background:#1a1c1f;color:#c8cdd4;'
                'font:14px/1.6 -apple-system,PingFang SC,sans-serif}'
                'header{position:sticky;top:0;background:#23262b;padding:10px 16px;'
                'border-bottom:1px solid #333;z-index:9}header a{color:#8ab4f8;'
                'text-decoration:none;margin-left:16px}'
                'figure{margin:14px auto;max-width:1040px;text-align:center}'
                'img{width:100%%;border-radius:6px;box-shadow:0 2px 12px #0007;background:#fff}'
                'figcaption{color:#7b8794;font-size:12px;padding:3px}</style>'
                '<header><b>%s</b> · %s · 共 %d 页%s</header>%s'
                % (cd, cd, g, len(files), dl, figs)).encode('utf-8')
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Content-Length', str(len(html)))
        self.end_headers()
        return self.wfile.write(html)

    def _topic_pdf(self):
        """/api/topic/pdf?... → 初中给 docx；其余用 qpdf 从整本切出该专题页范围（缓存）。"""
        from urllib.parse import quote
        try:
            import topic_source as TS
        except Exception:
            return self._json({'ok': False, 'error': 'topic_source 未就绪'}, 500)
        tb, g, cd, pgs, sh = self._topic_args()
        # ① 优先：已拆好的专题成品，直接送（免去每次现切 + 缓存目录）
        pc = TS.precut(tb, g, cd, sh)
        if pc:
            # difrobot：成品文件走 COS 直下（含答案文件的不可猜路径）
            # 注：TS.SRCDIR 指向 源题文件/，算出的 rel 带 _专题拆分/ 前缀；
            #     keymap 以 _专题拆分/ 为根，故需剥掉该前缀再查。
            _rel = os.path.relpath(pc['path'], TS.SRCDIR).replace(os.sep, '/')
            if _rel.startswith('_专题拆分/'):
                _rel = _rel[len('_专题拆分/'):]
            _k = _TOPICS_MAP.get(_rel)
            if _k:
                # 注：COS 匿名 GET 不允许带 response-content-disposition（会 400 InvalidRequest），
                #     所以不改下载文件名，用对象自身的文件名（即原始专题文件名，语义正确）。
                self.send_response(302)
                self.send_header('Location', _COS_BASE + '/' + _q(_k))
                self.end_headers()
                return
            ext = os.path.splitext(pc['path'])[1].lower()
            ctype = ('application/vnd.openxmlformats-officedocument.wordprocessingml.document'
                     if ext == '.docx' else 'application/pdf')
            name = re.sub(r'[/\\:*?"<>|]', '_', cd)[:60] + ext
            return self._send_file(pc['path'], ctype, name)
        loc = TS.locate(tb, g, cd, sh)
        if loc and loc.get('docx') and os.path.exists(loc['docx']):
            return self._send_file(loc['docx'], 'application/vnd.openxmlformats-'
                                   'officedocument.wordprocessingml.document',
                                   os.path.basename(loc['docx']))
        # 找整本 PDF + 页范围
        src = lo = hi = None
        if loc and loc.get('pdf'):
            src, lo, hi = loc['pdf']
        if not src:                                  # 兜底：从题目页图路径反推整本 PDF+页范围
            fb = TS.fallback_pdf(pgs)
            if fb:
                src, lo, hi = fb
        if not src:
            return self._json({'ok': False, 'error': '该教材暂无可切分的整本 PDF'}, 404)
        if not hi:                                   # hi=0 → 源本身就是该专题的完整文件，直接送
            return self._send_file(src, 'application/pdf', os.path.basename(src))
        cache = os.path.join(HERE, '.cache_topic')
        os.makedirs(cache, exist_ok=True)
        sig = hashlib.md5(f'{src}|{lo}|{hi}'.encode()).hexdigest()[:16]
        out = os.path.join(cache, sig + '.pdf')
        if not os.path.exists(out) and not TS.cut_pdf(src, lo, hi, out):
            return self._json({'ok': False, 'error': 'PDF 切分失败'}, 500)
        name = re.sub(r'[/\\:*?"<>|]', '_', cd)[:60] + '.pdf'
        return self._send_file(out, 'application/pdf', name)

    def _send_file(self, path, ctype, name):
        from urllib.parse import quote
        data = open(path, 'rb').read()
        self.send_response(200)
        self.send_header('Content-Type', ctype)
        self.send_header('Content-Disposition',
                         "attachment; filename*=UTF-8''" + quote(name))
        self.send_header('Content-Length', str(len(data)))
        self.end_headers()
        return self.wfile.write(data)

    def _serve_page_png(self):
        """/api/page?f=<相对 组卷系统/ 的页图路径> —— 供专题预览内联页图。
        只允许指向已知页图目录，防目录穿越。"""
        from urllib.parse import urlparse, parse_qs, unquote
        q = parse_qs(urlparse(self.path).query)
        rel = unquote((q.get('f') or [''])[0])
        # difrobot：页图走 COS，服务器不存不读（顺带彻底消除目录穿越风险）
        _k = _PAGES_MAP.get(rel)
        if _k:
            self.send_response(302)
            self.send_header('Location', _COS_BASE + '/' + _q(_k))
            self.send_header('Cache-Control', 'public, max-age=86400')
            self.end_headers()
            return
        fp = os.path.normpath(os.path.join(HERE, rel))
        ok_roots = [os.path.normpath(os.path.join(HERE, '原书页')),
                    # ⚠ pdftoppm 现渲的预览页落在这里，漏了它现渲的预览图会全部 404
                    os.path.normpath(os.path.join(HERE, '.cache_pages')),
                    os.path.normpath(os.path.join(HERE, '..', '成果')),
                    os.path.normpath(os.path.join(HERE, '..', '数学学习系统'))]
        if not (fp.lower().endswith(('.png', '.jpg', '.jpeg'))
                and any(fp.startswith(r) for r in ok_roots) and os.path.exists(fp)):
            return self._json({'ok': False, 'error': 'not found'}, 404)
        data = open(fp, 'rb').read()
        self.send_response(200)
        self.send_header('Content-Type', 'image/png' if fp.endswith('.png') else 'image/jpeg')
        self.send_header('Cache-Control', 'max-age=86400')
        self.send_header('Content-Length', str(len(data)))
        self.end_headers()
        return self.wfile.write(data)

    def _serve_source_page(self, rel):
        # 一课一练: 原书页/ykl/{路由key}/pNNN.png -> 成果/一课一练题库/<书目录>/pages/
        m = re.match(r'原书页/ykl/(1sz|1s|2sz|2s|3s|3sz|3x|3xz|4s|4sz|4x|4xz|5s|5sz|5x|5xz|6s)/p0*(\d+)\.png$', rel)
        if m:
            YKLD = {'1s': '一年级上普通2024秋', '1sz': '一年级上增强2024秋',
                    '2s': '二年级上普通', '2sz': '二年级上增强',
                    '3s': '三年级上', '3sz': '三年级上增强2022秋',
                    '3x': '三年级下普通2022秋', '3xz': '三年级下增强2022秋',
                    '4s': '四年级上普通2026秋', '4sz': '四年级上增强2026秋',
                    '4x': '四年级下普通2023春', '4xz': '四年级下增强2023春',
                    '5s': '五年级上普通2026秋', '5sz': '五年级上增强2026秋',
                    '5x': '五年级下普通2023春', '5xz': '五年级下增强2023春',
                    '6s': '六年级上普通2024秋'}
            png = os.path.join(HERE, '..', '成果', '一课一练题库', YKLD[m.group(1)],
                               'pages', 'p%03d.png' % int(m.group(2)))
            return self._send_png(png)
        # rel = 原书页/{dnj|jyf}/{1-6}/pNNN.jpg
        m = re.match(r'原书页/(dnj|jyf)/([1-6])/p0*(\d+)\.jpg$', rel)
        if not m:
            return False
        TBM = {'dnj': '动脑筋题库', 'jyf': '举一反三题库'}
        GC = {'1': '一', '2': '二', '3': '三', '4': '四', '5': '五', '6': '六'}
        png = os.path.join(HERE, '..', '成果', TBM[m.group(1)], GC[m.group(2)] + '年级',
                           'pages', 'p%03d.png' % int(m.group(3)))
        return self._send_png(png)

    def _send_png(self, png):
        if not os.path.exists(png):
            return False
        data = open(png, 'rb').read()
        self.send_response(200)
        self.send_header('Content-Type', 'image/png')
        self.send_header('Content-Length', str(len(data)))
        self.send_header('Cache-Control', 'max-age=86400')
        self.end_headers()
        self.wfile.write(data)
        return True

    def do_POST(self):
        # difrobot：/api/* 需老师 JWT（/api/version 除外）
        _p = self.path.split('?')[0]
        if _p.startswith('/api/') and _p not in _OPEN_PATHS:
            _tok = ''
            _h = self.headers.get('Authorization') or ''
            if _h.startswith('Bearer '):
                _tok = _h[7:]
            if not _tok:
                _tok = (_pq(_up(self.path).query).get('t') or [''])[0]
            _u = _verify_jwt(_tok)
            if not _u:
                return self._json({'ok': False, 'error': '未登录或登录已过期，请回工作台重新登录'}, 401)
            self._uid = _u.get('id') or _u.get('username') or 'anon'
            if _p.startswith('/api/similar'):
                _ok, _wait = _rate_ok(self._uid)
                if not _ok:
                    return self._json({'ok': False, 'error': '出题请求过于频繁，请 %d 秒后再试' % _wait}, 429)

        if self.path == '/api/similar':
            return self._do_similar()
        if self.path == '/api/similar_batch':
            return self._do_similar_batch()
        if self.path == '/api/export_docx':
            return self._do_export_docx()
        if self.path == '/api/report':
            return self._do_report()
        self.send_error(404)

    def _do_report(self):
        # 一键报告问题：把题目 id + 上下文追加到 问题报告.jsonl（服务端盖时间戳），供事后核对修图/改题
        try:
            import datetime
            body = self._read_body()
            if not body.get('id'):
                return self._json({'ok': False, 'error': '缺 id'}, 400)
            body['ts'] = datetime.datetime.now().isoformat(timespec='seconds')
            with open(os.path.join(HERE, '问题报告.jsonl'), 'a', encoding='utf-8') as f:
                f.write(json.dumps(body, ensure_ascii=False) + '\n')
            self._json({'ok': True})
        except Exception as e:
            self._json({'ok': False, 'error': str(e)[:300]}, 500)

    def _do_export_docx(self):
        # 试卷篮 -> pandoc 真公式 Word（LaTeX 转 Word 原生公式）。失败返回 JSON,前端回退纯文本。
        try:
            import export_lib, urllib.parse
            body = self._read_body()
            if not (body.get('items')):
                return self._json({'ok': False, 'error': '试卷篮为空'}, 400)
            data = export_lib.build_paper_docx(body)
            fn = ((body.get('title') or '专题') + '专题练习.docx')
            self.send_response(200)
            self.send_header('Content-Type', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
            self.send_header('Content-Disposition', "attachment; filename*=UTF-8''" + urllib.parse.quote(fn))
            self.send_header('Content-Length', str(len(data)))
            self.end_headers()
            self.wfile.write(data)
        except Exception as e:
            self._json({'ok': False, 'error': str(e)[:300]}, 500)

    def _read_body(self):
        n = int(self.headers.get('Content-Length', 0))
        return json.loads(self.rfile.read(n) or b'{}')

    def _do_similar(self):
        try:
            body = self._read_body()
            stem = (body.get('stem') or '').strip()
            if not stem:
                return self._json({'ok': False, 'error': '缺少原题'}, 400)
            cnt = max(1, min(20, int(body.get('n', 10))))
            if USE_GENERATOR and gen_router:                 # 已关：见 USE_GENERATOR 处说明
                hit = gen_router.route((body.get('topic') or '').strip(),
                                       body.get('difficulty', ''), cnt,
                                       body.get('grade', ''))
                if hit:
                    return self._json({'ok': True, 'items': hit, 'by': 'generator'})
            items, st = gen_verified(stem, body.get('grade', ''), body.get('difficulty', ''),
                                     (body.get('topic') or '').strip(), cnt)
            if items:
                self._json({'ok': True, 'items': items, 'by': 'deepseek', 'stat': st})
            elif st.get('dropped'):
                # 出了题但一道都没过核验 —— 说明这类题模型算不明白，别硬塞给老师
                self._json({'ok': False, 'error': '生成的题都没通过数值核验（%s）'
                            % '；'.join(r['why'] for r in st.get('reasons', [])[:3]), 'stat': st}, 502)
            else:
                self._json({'ok': False, 'error': '解析生成结果失败'}, 502)
        except urllib.error.HTTPError as e:
            self._json({'ok': False, 'error': 'DeepSeek HTTP %d: %s' % (e.code, e.read().decode('utf-8', 'ignore')[:300])}, 502)
        except Exception as e:
            self._json({'ok': False, 'error': str(e)}, 500)

    def _do_similar_batch(self):
        # 整页批量：每道题各出 1 道类似题，并发调用（限流用小线程池），保序返回。
        try:
            body = self._read_body()
            reqs = body.get('items') or []
            if not isinstance(reqs, list) or not reqs:
                return self._json({'ok': False, 'error': '缺少题目列表'}, 400)
            reqs = reqs[:40]   # 上限保护
            cnt = max(1, min(3, int(body.get('n', 1))))

            def work(it):
                stem = (it.get('stem') or '').strip()
                rec = {'id': it.get('id', ''), 'stem': stem}
                if not stem:
                    return dict(rec, ok=False, error='空题', items=[])
                if USE_GENERATOR and gen_router:             # 已关：见 USE_GENERATOR 处说明
                    hit = gen_router.route((it.get('topic') or '').strip(),
                                           it.get('difficulty', ''), cnt,
                                           it.get('grade', ''))
                    if hit:
                        return dict(rec, ok=True, items=hit, error='', by='generator')
                try:
                    items, st = gen_verified(stem, it.get('grade', ''), it.get('difficulty', ''),
                                             (it.get('topic') or '').strip(), cnt)
                    return dict(rec, ok=bool(items), items=items, stat=st,
                                error='' if items else '没通过数值核验')
                except urllib.error.HTTPError as e:
                    return dict(rec, ok=False, error='HTTP %d' % e.code, items=[])
                except Exception as e:
                    return dict(rec, ok=False, error=str(e)[:120], items=[])

            with concurrent.futures.ThreadPoolExecutor(max_workers=10) as ex:   # 实测10路并发1.2s全返无限流(线程只在等HTTP,不占算力)
                results = list(ex.map(work, reqs))
            ok_n = sum(1 for r in results if r['ok'])
            self._json({'ok': True, 'results': results, 'ok_n': ok_n, 'total': len(results)})
        except Exception as e:
            self._json({'ok': False, 'error': str(e)}, 500)

    def _json(self, obj, code=200):
        b = json.dumps(obj, ensure_ascii=False).encode('utf-8')
        self.send_response(code)
        self.send_header('Content-Type', 'application/json; charset=utf-8')
        self.send_header('Content-Length', str(len(b)))
        self.end_headers()
        self.wfile.write(b)

    def log_message(self, *a):
        pass  # 静默（key 不在 URL/请求行，但仍不打印请求日志）


class Server(socketserver.ThreadingTCPServer):
    allow_reuse_address = True
    daemon_threads = True

    def handle_error(self, request, client_address):
        # 浏览器中断连接(刷新/关标签页时大文件传一半)是常态,不打吓人的traceback
        import sys
        exc = sys.exc_info()[1]
        if isinstance(exc, (BrokenPipeError, ConnectionResetError)):
            return
        super().handle_error(request, client_address)


if __name__ == '__main__':
    os.chdir(HERE)
    print('=' * 56)
    print(' 组卷台 AI 助手（出类似题 · 基于选中题生成练习）')
    print(' 模型：%s | key：%s' % (MODEL, '已载入' if KEY else '⚠ 缺失，请检查 ~/.deepseek_pro_key'))
    print(' 打开浏览器访问： http://localhost:%d/组卷台.html' % PORT)
    print(' 停止：按 Ctrl+C')
    print('=' * 56)
    try:
        Server((os.environ.get('ZAOJUAN_BIND', '127.0.0.1'), PORT), Handler).serve_forever()
    except KeyboardInterrupt:
        print('\n已停止。')
