#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""科研 DOCX → 知识库 L1 卡片（一卡一问答）+ L2 原件。

遵循 `baoyu-kb-governance` 铁律：
  ① 一卡一问答，禁百问百答汇集大文件
  ② 文件名即检索键：品类_主题_类型_ls.md
  ③ 头部六字段必填：title/scene/line/owner/updated/confidence(+source)
  ⑦ 可溯源：结论末尾标来源
  图片按 L2 放 原件/，卡内相对路径引用（不 base64 内嵌）

用法:  /usr/bin/python3 脚本/docx_sci_to_kb.py [--dry-run]
"""
import sys, os, io, re, zipfile, shutil
import xml.etree.ElementTree as ET

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DOCS = os.path.expanduser('~/.hermes/profiles/zone-b/cache/documents')
KB = os.path.join(ROOT, '知识库-copilot', '生命科学')
DRY = '--dry-run' in sys.argv

W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
R = '{http://schemas.openxmlformats.org/officeDocument/2006/relationships}'
A = '{http://schemas.openxmlformats.org/drawingml/2006/main}'

SPEC = [
    dict(tag='faq',
         src=os.path.join(DOCS, 'doc_788900c99d22_科研FAQ-2026年9月.docx'),
         srctag='科研FAQ-2026年9月',
         outdir=os.path.join(KB, '技术FAQ'),
         split='2', sub='3', type='FAQ卡片', skip=('1',),
         scene='科研技术支持 · qPCR/RTQ 故障排查'),
    dict(tag='case',
         src=os.path.join(DOCS, 'doc_c34cfa968e6d_科研案例库-2026年9月.docx'),
         srctag='科研案例库-2026年9月',
         outdir=os.path.join(KB, '案例', '成功'),
         split='4', sub=None, type='客户案例', skip=('1', '2', '3'),
         scene='科研技术支持 · 客户问题解决案例'),
]

# ⚠️ 案例卡文件名人工确定，不做隐式推断：原标题「案例1：BR4D421+转膜问题」出现两次
#    （两份标题完全相同、文件名会撞车），且其中一份正文其实是 BR1D303 + BR1E102 熔解曲线。
CASE_NAMES = {
    0: dict(cat='BR3M121', key='PCR长片段扩增失败', org='浙江省人民医院牟晓洲', note=None),
    1: dict(cat='BR1D303', key='qPCR熔解曲线异常', org='国科大杭州研究院',
            note='原标题为「案例1：BR4D421+转膜问题」，但正文讲的是 BR1D303 逆转录 + '
                 'BR1E102 qPCR 熔解曲线，**标题与内容不符，疑似源文件笔误**。'
                 '正文一字未改；卡片文件名与品类按**正文内容**归类，便于检索。'),
    2: dict(cat='BR4D421', key='大分子量转膜转不上', org='浙江大学陆华松', note=None),
}

CATS = [('BR1D303', 'BR1D303'), ('BR3M121', 'BR3M121'), ('BR4D421', 'BR4D421'),
        ('熔解', '熔解曲线'), ('cDNA', 'cDNA'), ('转膜', '转膜'),
        ('气泡', 'qPCR'), ('CT值', 'qPCR'), ('Ct值', 'qPCR'), ('Ct', 'qPCR'),
        ('扩增', 'qPCR'), ('qPCR', 'qPCR')]

def cat_of(text, fallback='qPCR'):
    for kw, c in CATS:
        if kw in text:
            return c
    return fallback

def slug(t, cat=None, n=24):
    """文件名主题段：去标点，并去掉与品类重复的前缀（避免生成 cDNA_cDNA…）"""
    t = re.sub(r'[？?！!，,。.：:；;、\s（）()【】\[\]"“”\'’+\-—/]+', '', t)
    if cat and t.startswith(cat):
        t = t[len(cat):]
    return (t[:n] or '未命名')

def parse(src):
    z = zipfile.ZipFile(src)
    rmap = {}
    for r in ET.fromstring(z.read('word/_rels/document.xml.rels')):
        rmap[r.get('Id')] = r.get('Target')
    body = ET.fromstring(z.read('word/document.xml')).find(W + 'body')
    out = []
    for el in body:
        if el.tag == W + 'p':
            st = ''
            pPr = el.find(W + 'pPr')
            if pPr is not None:
                s = pPr.find(W + 'pStyle')
                if s is not None:
                    st = s.get(W + 'val') or ''
            txt = []
            for n in el.iter():
                if n.tag == W + 't' and n.text:
                    txt.append(n.text)
                elif n.tag in (W + 'tab', W + 'br'):
                    txt.append(' ')
            t = re.sub(r'[ \t]+', ' ', ''.join(txt)).strip()
            im = [rmap[b.get(R + 'embed')].split('/')[-1]
                  for b in el.iter(A + 'blip') if b.get(R + 'embed') in rmap]
            if t or im:
                out.append(dict(kind='p', style=st, text=t, images=im))
        elif el.tag == W + 'tbl':
            # ⚠️ 图片常被放进表格单元格的图表框里，必须递归找 blip，否则漏图（本库漏 4 张）
            im = [rmap[b.get(R + 'embed')].split('/')[-1]
                  for b in el.iter(A + 'blip') if b.get(R + 'embed') in rmap]
            rows = []
            for tr in el.findall(W + 'tr'):
                cells = []
                for tc in tr.findall(W + 'tc'):
                    cells.append(' '.join(
                        re.sub(r'\s+', ' ',
                               ''.join(n.text or '' for n in p.iter()
                                       if n.tag == W + 't')).strip()
                        for p in tc.findall(W + 'p')).strip())
                rows.append(cells)
            out.append(dict(kind='tbl', style='table', text='', images=im, rows=rows))
    return z, out

def block_lines(o):
    if o['kind'] == 'tbl':
        rows = [r for r in (o.get('rows') or []) if any((c or '').strip() for c in r)]
        if not rows:
            return []
        w = max(len(r) for r in rows)
        rows = [r + [''] * (w - len(r)) for r in rows]
        out = ['| ' + ' | '.join(c or ' ' for c in rows[0]) + ' |', '|' + '---|' * w]
        for r in rows[1:]:
            out.append('| ' + ' | '.join((c or ' ').replace('|', '/') for c in r) + ' |')
        return out + ['']
    t = o['text']
    if not t:
        return []
    m = re.match(r'^([^：:]{2,14})[：:]\s*(.+)$', t)
    if m and not t.startswith(('http', '（')):
        return ['**%s**：%s' % (m.group(1), m.group(2)), '']
    return [t, '']

def card_md(title, body_lines, spec, imgs, note=None):
    yml = ['---',
           'title: %s' % title,
           'scene: %s' % spec['scene'],
           'line: ls',
           'owner: 王凡(浙江)/蒋嵘(广州)',
           'updated: 2026-09',
           'confidence: 中',
           'source: %s.docx（科研技术支持整理，2026-09）' % spec['srctag'],
           '---', '']
    parts = ['# %s' % title, ''] + body_lines
    # 图片已内联在正文里（保留原文位置），此处不再重复输出「配图」段落
    if note:
        parts += ['', '> ⚠️ %s' % note]
    parts += ['', '---', '',
              '*来源：%s.docx ｜ 录入 2026-09-30 ｜ 置信度：中（技术支持整理，未经逐条复核）*'
              % spec['srctag']]
    return '\n'.join(yml + parts) + '\n'

def main():
    report, total = [], 0
    for spec in SPEC:
        if not os.path.exists(spec['src']):
            print('✗ 源文件缺失:', spec['src']); continue
        z, blocks = parse(spec['src'])

        # ── L2 原件层 ──
        odir = os.path.join(spec['outdir'], '原件')
        if not DRY:
            os.makedirs(odir, exist_ok=True)
        mapping = {}
        media = [n for n in z.namelist() if n.startswith('word/media/')]
        for n in media:
            base = n.split('/')[-1]
            mapping[base] = '%s_%s' % (spec['srctag'], base)
            if not DRY:
                with z.open(n) as fh, open(os.path.join(odir, mapping[base]), 'wb') as fo:
                    shutil.copyfileobj(fh, fo)

        # ── 切卡：按标题样式分段 ──
        cards, cur, main_t = [], None, None
        for o in blocks:
            st = o.get('style') or ''
            if o['kind'] == 'p' and st == spec['split']:
                main_t = o['text']
                cur = dict(title=o['text'], body=[], parent=None, imgs=[])
                cards.append(cur); continue
            if spec.get('sub') and o['kind'] == 'p' and st == spec['sub']:
                # ⚠️ 父标题必须取「最近一个主问题」main_t，不能取 cards[-1]：
                #    从第二个子卡起 cards[-1] 已是上一个子卡，会把 7 个子卡的
                #    父子关系串成链，索引卡就只列出 1 个子项。
                cur = dict(title=o['text'], body=[], parent=main_t, imgs=[])
                cards.append(cur); continue
            if cur is None:
                continue                    # 产品分类骨架 / 文档标题 → 不成卡
            if st in spec.get('skip', ()):
                # ⚠️ 分类骨架标题（大类/系列/货号）必须显式跳过：案例的切分样式是 4，
                #    骨架是 1/2/3，不跳就会被当正文全量灌进最后一张案例卡（实测污染）。
                continue
            if o['images']:
                cur['imgs'] += [(mapping.get(i, i), '') for i in o['images']]
                # 图片**留在原文位置**：正文常有「上图是…」「左：… 右：…」的指代，
                # 统一挪到卡尾会让指代悬空（案例卡实测最明显）。
                for _i in o['images']:
                    cur['body'] += ['![](原件/%s)' % mapping.get(_i, _i), '']
            if o['kind'] == 'tbl' and o['images'] and not (o.get('rows') or []):
                continue
            cur['body'] += block_lines(o)

        # ── 定文件名（去重 + 案例人工表 + 空正文判为索引卡）──
        seen, out, case_i = {}, [], -1
        for c in cards:
            t = c['title'].strip()
            if not t:
                continue
            is_case = bool(spec['tag'] == 'case' and re.match(r'^案例', t))
            if is_case:
                case_i += 1
            note = None
            if c.get('parent'):
                cat = cat_of(c['parent'] + ' ' + t)
                base = '%s_%s_%s_ls.md' % (cat, slug(t, cat), spec['type'])
                is_index = False
            elif is_case:
                ov = CASE_NAMES.get(case_i, {})
                cat = ov.get('cat') or cat_of(t)
                base = '%s_%s_%s_%s_ls.md' % (cat, ov.get('key') or slug(t, cat),
                                              ov.get('org', ''), spec['type'])
                is_index, note = False, ov.get('note')
            else:
                cat = cat_of(t)
                # 只有当存在以它为父的子卡时才判为索引卡；否则即使正文短也是真卡片
                # （如「实际检测结果的趋势跟预期不符」正文只有「待从经验中找答案」，
                #   属资料缺口，必须保留成卡并标注，不能当索引丢掉）
                if any(x.get('parent') == t for x in cards):
                    base = '%s_%s_FAQ索引_ls.md' % (cat, slug(t, cat))
                    is_index = True
                else:
                    base = '%s_%s_%s_ls.md' % (cat, slug(t, cat), spec['type'])
                    is_index = False
            n = seen.get(base, 0)
            seen[base] = n + 1
            if n:
                base = base[:-3] + '-%d.md' % (n + 1)
            out.append(dict(c=c, title=t, cat=cat, base=base, is_index=is_index, note=note))

        # ── 写盘 ──
        for o in out:
            c, t = o['c'], o['title']
            note = o['note']
            if '待从经验中找答案' in ''.join(c['body']):
                note = ('此条原文仅写「待从经验中找答案」，属**资料缺口**，未作补写；'
                        '建议技术支持补充后更新本卡。')
            if o['is_index']:
                kids = [x for x in out if x['c'].get('parent') == t]
                body = ['本卡为**索引**，不承载答案；下方每条为独立问答卡。', '',
                        '| 子问题 | 卡片文件 |', '|---|---|']
                for k in kids:
                    body.append('| %s | `%s` |' % (k['title'], k['base']))
                body += ['', '> 原文此处仅有一个分组标题，具体原因与解决方案在其下的子问题里。']
            else:
                body = c['body']
            md = card_md(t, body, spec, c['imgs'], note)
            if not DRY:
                io.open(os.path.join(spec['outdir'], o['base']), 'w',
                        encoding='utf-8').write(md)
            report.append((spec['tag'], o['base'], len(md), len(c['imgs']), o['is_index']))
            total += 1
        print('【%s】原件 %d 张 → %s' % (spec['srctag'], len(media),
                                       os.path.relpath(odir, ROOT)))
        print('        卡片 %d 张 → %s' % (
            len([r for r in report if r[0] == spec['tag']]),
            os.path.relpath(spec['outdir'], ROOT)))

    print('\n%-5s %-60s %6s %3s %s' % ('来源', '卡片文件', '字符', '图', '索引'))
    for tag, fn, n, ni, ix in report:
        print('%-5s %-60s %6d %3d %s' % (tag, fn, n, ni, '✓' if ix else ''))
    print('\n合计 %d 张卡片' % total)
    if DRY:
        print('（--dry-run，未写盘）')

if __name__ == '__main__':
    main()
