#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
宝锐销售工作台 · AI 智能问答模块

被 销售工作台服务.py 的 /api/chat 端点调用。
职责：
  1. 只答工作问题（system prompt 铁律）
  2. 客户问题权限隔离（按角色过滤可见客户，只注入权限范围内的客户摘要）
  3. 行业/市场/竞品问题自由回答（不注入客户数据）

仅依赖 Python 标准库 + yaml（读取 Hermes config）。
"""
import base64
import io
import json
import os
import subprocess
import tempfile
import urllib.request

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(ROOT, '数据')
HISTORY_FILE = os.path.join(DATA_DIR, 'chat_history.json')
MAX_HISTORY = 60  # 每用户最多保留的历史条数

LINE_LABEL = {'da': '诊断原料', 'ls': '生命科学', 'both': '诊断原料+生命科学'}


def _load_history():
    """读取历史文件，返回 {name: [messages]}"""
    try:
        if os.path.exists(HISTORY_FILE):
            with open(HISTORY_FILE, encoding='utf-8') as f:
                d = json.load(f)
            if isinstance(d, dict):
                return d
    except Exception:
        pass
    return {}


def _save_history(data):
    """写回历史文件（原子写）"""
    try:
        os.makedirs(DATA_DIR, exist_ok=True)
        tmp = HISTORY_FILE + '.tmp'
        with open(tmp, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=1)
        os.replace(tmp, HISTORY_FILE)
        return True
    except Exception:
        return False


def _get_history(name):
    """返回某用户的历史（可能为空列表）"""
    return _load_history().get(name, [])


def _set_history(name, messages):
    """设置某用户的历史（截断）"""
    h = _load_history()
    if messages:
        h[name] = list(messages)[-MAX_HISTORY:]
    else:
        h.pop(name, None)
    _save_history(h)


def _load_deepseek_cfg():
    """从 ~/.hermes/config.yaml 读取 DeepSeek 凭证"""
    try:
        import yaml
        cfg = yaml.safe_load(open(os.path.expanduser('~/.hermes/config.yaml'), encoding='utf-8'))
        ds = cfg.get('providers', {}).get('deepseek', {})
        return {
            'api_key': ds.get('api_key', ''),
            'base_url': (ds.get('base_url') or 'https://api.deepseek.com').rstrip('/'),
            'model': ds.get('default_model') or 'deepseek-v4-pro',
        }
    except Exception as e:
        return {'error': str(e)}


def _load_customer_data():
    """加载客户数据：诊断原料重点客户 + 生命科学课题组"""
    custs = []
    try:
        p = os.path.join(DATA_DIR, 'pricing', '_key_customers.json')
        if os.path.exists(p):
            with open(p, encoding='utf-8') as f:
                custs = json.load(f)
    except Exception:
        custs = []
    groups = []
    try:
        p = os.path.join(DATA_DIR, 'ls_groups.json')
        if os.path.exists(p):
            with open(p, encoding='utf-8') as f:
                g = json.load(f)
            if isinstance(g, dict):
                groups = g.get('groups', [])
            elif isinstance(g, list):
                groups = g
    except Exception:
        groups = []
    return custs, groups


# ── 背景检索（活动 + 产品）──
_BG_CACHE = {}


def _load_activities():
    """加载飞书活动数据（daily_activities_mcp.json），返回规范化列表"""
    if 'activities' in _BG_CACHE:
        return _BG_CACHE['activities']
    try:
        p = os.path.join(DATA_DIR, 'daily_activities_mcp.json')
        with open(p, encoding='utf-8') as f:
            d = json.load(f)
        items = d.get('items', []) if isinstance(d, dict) else d
        acts = []
        for it in items:
            fc = it.get('field_5f20fc')
            cust = fc.get('label', '') if isinstance(fc, dict) else (str(fc) if fc else '')
            ow = it.get('owner')
            owner = ow.get('name_cn', '') if isinstance(ow, dict) else (str(ow) if ow else '')
            acts.append({
                'name': it.get('name') or '',
                'desc': it.get('field_b99055') or '',
                'customer': cust,
                'owner': owner,
                'date': it.get('start_time') or '',
            })
        _BG_CACHE['activities'] = acts
    except Exception:
        _BG_CACHE['activities'] = []
    return _BG_CACHE['activities']


def _load_products():
    """加载产品库（_all_products.json），构建 sku→名称 映射"""
    if 'products' in _BG_CACHE:
        return _BG_CACHE['products']
    try:
        p = os.path.join(DATA_DIR, 'pricing', '_all_products.json')
        with open(p, encoding='utf-8') as f:
            d = json.load(f)
        pmap = {}
        for prod in (d if isinstance(d, list) else []):
            sku = prod.get('sku') or ''
            name = prod.get('name') or ''
            if sku and sku != 'nan' and sku not in pmap:
                pmap[sku] = name
        _BG_CACHE['products'] = pmap
    except Exception:
        _BG_CACHE['products'] = {}
    return _BG_CACHE['products']


def _extract_skus(text):
    """提取货号（M2181、M2331、FM2181-4-NA 等）"""
    import re
    return set(re.findall(r'(?:[A-Z]{0,3})M\d{3,4}(?:-\d+)*(?:-[A-Z]{1,3})?', text or ''))


_COMMON_BIGRAM = {'公司', '有限', '股份', '生物', '科技', '技术', '医疗', '医学', '工程',
                  '检测', '诊断', '制药', '医药', '药业', '集团', '控股', '健康', '生命',
                  '科学', '研究', '实验', '检验', '中心', '产业', '发展', '实业', '国际',
                  '客户', '厂家', '问题', '性能', '项目', '测试', '反馈', '无法', '确认',
                  '哪里', '出来', '应该', '怎么', '继续', '推动', '资源', '内部', '一样'}


def _has_common_bigram(a, b):
    """a 是否含某个非通用 2 字片段，且该片段出现在 b 中"""
    for i in range(len(a) - 1):
        sub = a[i:i + 2]
        if sub in _COMMON_BIGRAM:
            continue
        if sub in b:
            return True
    return False


def _search_background(question, vcust):
    """按问题检索相关背景（飞书活动 + 产品），返回背景文本"""
    if not question:
        return ''
    skus = _extract_skus(question)
    parts = []
    # 1. 相关活动（按货号 + 客户名/活动名公共子串检索）
    acts = _load_activities()
    matched = []  # (priority, activity)
    for a in acts:
        hay = a['customer'] + ' ' + a['name'] + ' ' + a['desc']
        prio = 0
        if a['customer'] and _has_common_bigram(a['customer'], question):
            prio = 3
        elif skus and any(s in hay for s in skus):
            prio = 2
        elif a['name'] and _has_common_bigram(a['name'], question):
            prio = 1
        if prio:
            matched.append((prio, a))
    if matched:
        seen = set()
        uniq = []
        for prio, a in matched:
            key = a['date'] + a['customer'] + a['name'] + a['desc'][:20]
            if key not in seen:
                seen.add(key)
                uniq.append((prio, a))
        uniq.sort(key=lambda x: (x[1]['date'] or '').replace('-', ''), reverse=True)
        uniq.sort(key=lambda x: x[0], reverse=True)
        uniq = [a for _, a in uniq][:8]
        lines = ['【飞书活动记录（最近 %d 条）】' % len(uniq)]
        for a in uniq:
            lines.append('- [%s] %s｜客户：%s｜跟进人：%s｜%s' % (
                a['date'], a['name'], a['customer'], a['owner'], a['desc'][:200]))
        parts.append('\n'.join(lines))
    # 2. 相关产品
    if skus:
        pmap = _load_products()
        plines = []
        for s in skus:
            base = s.split('-')[0]
            for k, v in pmap.items():
                if k == s or k.startswith(s + '-') or (base and k.startswith(base + '-')):
                    plines.append('%s：%s' % (k, v))
                    break
        plines = list(dict.fromkeys(plines))[:10]
        if plines:
            parts.append('【相关产品】\n' + '\n'.join(plines))
    return '\n\n'.join(parts) if parts else ''


def _load_ima_cfg():
    """读取 IMA 凭证（client_id + api_key）"""
    try:
        cid = os.environ.get('IMA_OPENAPI_CLIENTID') or open(os.path.expanduser('~/.config/ima/client_id'), encoding='utf-8').read().strip()
        key = os.environ.get('IMA_OPENAPI_APIKEY') or open(os.path.expanduser('~/.config/ima/api_key'), encoding='utf-8').read().strip()
        return cid, key
    except Exception:
        return '', ''


IMA_KB_DIAG = 'fwEO41E0Ef0CfulmtQHYgYQy_jBsbl6tXE5x6VYLIoA='
IMA_KB_LS = 'lofJ7cHGhLIvU_ISjQ0tmdYkgzvfjhvZrfVaoo4s7Jc='


def _search_knowledge_base(query, line='da'):
    """检索 IMA 知识库（索引层标题）+ 本地知识库内容（内容层），返回背景文本"""
    import re as _re
    import glob as _glob
    if not query:
        return ''
    # 0. 提取核心词：去掉虚词后按非中文切分，得到候选专有名词
    _STOP = set('的了和与跟是有在把被让向从对这将那什么怎么哪比更最很都也还再又或并及等而之其')
    cleaned = query
    for c in _STOP:
        cleaned = cleaned.replace(c, ' ')
    cands = [w for w in _re.split(r'[^\u4e00-\u9fffA-Za-z0-9]+', cleaned) if len(w) >= 2]
    core_words = []
    seen_cw = set()
    for w in cands + list(_extract_skus(query)):
        w = w.strip()
        if w and w not in _COMMON_BIGRAM and w not in seen_cw:
            seen_cw.add(w)
            core_words.append(w)
    if not core_words:
        return ''
    # 1. IMA 索引层：用第一个核心词搜索（单一专有名词效果最好）
    titles = []
    cid, key = _load_ima_cfg()
    if cid and key:
        kb = IMA_KB_LS if line == 'ls' else IMA_KB_DIAG
        try:
            req = urllib.request.Request(
                'https://ima.qq.com/openapi/wiki/v1/search_knowledge',
                data=json.dumps({'knowledge_base_id': kb, 'query': core_words[0], 'cursor': ''}).encode('utf-8'),
                headers={'ima-openapi-clientid': cid, 'ima-openapi-apikey': key, 'Content-Type': 'application/json'},
                method='POST')
            with urllib.request.urlopen(req, timeout=8) as r:
                d = json.loads(r.read().decode('utf-8'))
            for it in (d.get('data') or {}).get('info_list') or []:
                t = (it.get('title') or '').strip()
                if t:
                    titles.append(t)
        except Exception:
            pass
    # 2. 本地内容层：按核心词 glob 找文件读内容（前 4 个文件）
    kb_dir = os.path.join(ROOT, '知识库-copilot')
    content_parts = []
    try:
        matched = []
        for kw in core_words[:6]:
            for fp in _glob.glob(os.path.join(kb_dir, '**', '*' + kw + '*'), recursive=True):
                if fp not in matched and not fp.endswith('.DS_Store'):
                    matched.append(fp)
        for fp in matched[:4]:
            try:
                if fp.endswith('.md'):
                    with open(fp, encoding='utf-8') as f:
                        txt = f.read()
                elif fp.endswith('.pdf'):
                    import pymupdf
                    doc = pymupdf.open(fp)
                    txt = '\n'.join(p.get_text() for p in doc)
                else:
                    continue
                content_parts.append('【%s】\n%s' % (os.path.basename(fp), txt[:1500]))
            except Exception:
                continue
    except Exception:
        pass
    parts = []
    if titles:
        parts.append('【IMA 知识库相关材料】\n' + '\n'.join('- ' + t[:80] for t in titles[:6]))
    if content_parts:
        parts.append('【本地知识库内容】\n' + '\n\n'.join(content_parts))
    return '\n\n'.join(parts) if parts else ''





def _cust_core(name):
    """提取客户核心词：去括号 + 去公司/科技/生物等后缀，保留至少 2 字"""
    s = (name or '').strip()
    if not s:
        return ''
    while '（' in s and '）' in s:
        i = s.find('（'); j = s.find('）', i)
        if j > i:
            s = s[:i] + s[j + 1:]
        else:
            break
    while '(' in s and ')' in s:
        i = s.find('('); j = s.find(')', i)
        if j > i:
            s = s[:i] + s[j + 1:]
        else:
            break
    for suf in ('股份有限公司', '有限责任公司', '有限公司', '公司', '集团'):
        if s.endswith(suf) and len(s) - len(suf) >= 2:
            s = s[:-len(suf)]
    for suf in ('生物', '科技', '医疗', '医药', '医学', '技术', '制药', '工程', '诊断', '检测'):
        if s.endswith(suf) and len(s) - len(suf) >= 2:
            s = s[:-len(suf)]
    return s


_MCP_CUST_CORES = None


def _load_mcp_customer_cores():
    """加载 mcp_customers.json 客户名 → 核心词映射（缓存）"""
    global _MCP_CUST_CORES
    if _MCP_CUST_CORES is not None:
        return _MCP_CUST_CORES
    cores = []
    try:
        with open(os.path.join(DATA_DIR, 'mcp_customers.json'), encoding='utf-8') as f:
            d = json.load(f)
        custs = d.get('customers', []) if isinstance(d, dict) else d
        for c in custs:
            name = c.get('name') if isinstance(c, dict) else str(c)
            if name:
                core = _cust_core(name)
                if core and len(core) >= 2:
                    cores.append((name, core))
    except Exception:
        pass
    _MCP_CUST_CORES = cores
    return cores


def _identify_customer(question):
    """从问题中识别客户名，返回客户全名或空串（最长核心词优先，支持简称）"""
    if not question:
        return ''
    cores = _load_mcp_customer_cores()
    best, best_len = '', 0
    # 1. 完整核心词匹配（"上海透景生命科技" in question）
    for name, core in cores:
        if core and core in question and len(core) > best_len:
            best, best_len = name, len(core)
    if best:
        return best
    # 2. 简称匹配：客户全名含某个非通用 2 字片段，且该片段出现在 question 中（如"透景"）
    for name, core in cores:
        if name and _has_common_bigram(name, question) and len(name) > best_len:
            best, best_len = name, len(name)
    return best


def _load_customer360(question):
    """识别问题中的客户名，拉取该客户 6 维全景（订单/客诉/商机/活动/知识库/材料）。每维失败降级。"""
    cust = _identify_customer(question)
    if not cust:
        return ''
    core = _cust_core(cust)
    if not core:
        return ''
    from collections import Counter
    parts = []
    # 1. 订单/物流
    try:
        with open(os.path.join(DATA_DIR, 'shipment_status.json'), encoding='utf-8') as f:
            d = json.load(f)
        orders = [o for o in (d.get('orders') or []) if core in (o.get('customer') or '')]
        if orders:
            tax = sum((o.get('taxTotal') or 0) for o in orders)
            st = Counter((o.get('shipment') or {}).get('statusLabel') or '无物流' for o in orders)
            lines = ['【订单/物流】%d 笔，价税合计 %.1f 万' % (len(orders), tax / 10000)]
            lines.append('状态：' + '、'.join('%s %d' % (k, v) for k, v in st.most_common(5)))
            # 测试/样品单（contractNo 含"测试"/"样品"，或金额为 0）
            test_orders = [o for o in orders if ('测试' in (o.get('contractNo') or '') or '样品' in (o.get('contractNo') or '')
                          or ((o.get('amount') or 0) == 0 and (o.get('taxTotal') or 0) == 0))]
            if test_orders:
                lines.append('【测试/样品订单】%d 笔：' % len(test_orders))
                for o in sorted(test_orders, key=lambda x: (x.get('createDate') or ''), reverse=True)[:8]:
                    ship = o.get('shipment') or {}
                    lines.append('- %s｜%s｜合同 %s｜单号 %s｜收件 %s｜%s' % (
                        o.get('createDate', ''), o.get('orderId', ''),
                        (o.get('contractNo') or '')[:20], o.get('trackingNo', ''),
                        o.get('recipient', ''), ship.get('statusLabel', '无物流')))
            parts.append('\n'.join(lines))
    except Exception:
        pass
    # 2. 客诉
    try:
        with open(os.path.join(DATA_DIR, 'mcp_complaints.json'), encoding='utf-8') as f:
            d = json.load(f)
        items = [i for i in (d.get('items') or []) if core in (i.get('customer') or '')]
        if items:
            lines = ['【客诉】%d 条' % len(items)]
            for i in items[:5]:
                lines.append('- [%s] %s｜紧急度 %s｜状态 %s｜%s' % (
                    i.get('created', ''), i.get('name', ''), i.get('urgency', ''),
                    i.get('status', ''), (i.get('type') or '')[:40]))
            parts.append('\n'.join(lines))
    except Exception:
        pass
    # 3. 商机
    try:
        with open(os.path.join(DATA_DIR, 'opportunity_sandbox.json'), encoding='utf-8') as f:
            d = json.load(f)
        opps = [o for o in (d.get('opportunities') or []) if core in (o.get('customer') or '')]
        if opps:
            lines = ['【商机】%d 个' % len(opps)]
            for o in opps[:5]:
                lines.append('- %s｜%s｜目标 %.1f 万｜Q1-Q4 %.1f/%.1f/%.1f/%.1f' % (
                    o.get('name', ''), o.get('type', ''),
                    (o.get('target') or 0) / 10000, (o.get('q1') or 0) / 10000,
                    (o.get('q2') or 0) / 10000, (o.get('q3') or 0) / 10000, (o.get('q4') or 0) / 10000))
            parts.append('\n'.join(lines))
    except Exception:
        pass
    # 4. 近期活动（复用 _load_activities）
    acts = [a for a in _load_activities() if core in (a.get('customer') or '')]
    if acts:
        acts = sorted(acts, key=lambda a: (a.get('date') or ''), reverse=True)
        lines = ['【近期活动】%d 条' % len(acts)]
        for a in acts[:5]:
            lines.append('- [%s] %s｜%s' % (a.get('date', ''), a.get('name', ''), (a.get('desc') or '')[:150]))
        parts.append('\n'.join(lines))
    # 5. 竞品情报（IMA + 本地知识库）
    try:
        kb_bg = _search_knowledge_base(cust, 'da')
        if kb_bg:
            parts.append(kb_bg)
    except Exception:
        pass
    # 6. 已有材料（file_manifest.json）
    try:
        with open(os.path.join(DATA_DIR, 'file_manifest.json'), encoding='utf-8') as f:
            d = json.load(f)
        files = [v for v in d.values() if core in (v.get('name') or '')]
        if files:
            labels = {'visit': '拜访卡', 'market': '市场调研卡', 'visitor': '来访接待', 'report': '调研报告'}
            lines = ['【已有材料】']
            for f in files[:5]:
                lines.append('- %s：%s' % (labels.get(f.get('type'), '文件'), f.get('name', '')))
            parts.append('\n'.join(lines))
    except Exception:
        pass
    if not parts:
        return ''
    return '【客户 360 全景：%s】\n\n' % cust + '\n\n'.join(parts)

def _norm_dept(dept):
    """部门归一化"""
    d = dept or ''
    if '大客户' in d:
        return '大客户部'
    if '拓展' in d:
        return '销售拓展部'
    if '生命科学' in d:
        return '生命科学销售部'
    return d


def _dept_personnel(my_dept):
    """返回某部门在职人员名单"""
    names = set()
    try:
        p = os.path.join(DATA_DIR, 'pricing', '_personnel.json')
        with open(p, encoding='utf-8') as f:
            pl = json.load(f).get('personnel', [])
        for x in pl:
            if _norm_dept(x.get('dept', '')) == my_dept and x.get('status', '在职') != '离职':
                names.add(x['name'])
    except Exception:
        pass
    return names


def _visible_for_user(user):
    """按角色过滤可见客户/课题组"""
    custs, groups = _load_customer_data()
    name = (user.get('name') or '').strip()
    role = (user.get('role') or 'sales').strip()
    dept = (user.get('dept') or '').strip()

    if role in ('admin', 'gm'):
        return custs, groups

    if role == 'manager':
        # 生命科学区域经理（浙江/广东）
        if ('生命科学' in dept) or ('浙江' in dept) or ('广东' in dept):
            region_kw = '浙江' if '浙江' in dept else ('广东' if '广东' in dept else '')
            if region_kw:
                vgroups = [g for g in groups if region_kw in (g.get('region') or '')]
            else:
                vgroups = groups
            return [], vgroups
        # 诊断原料部门经理：本部门
        dept_names = _dept_personnel(_norm_dept(dept))
        vcust = [c for c in custs if (c.get('salesperson') or c.get('sales')) in dept_names]
        return vcust, []

    # sales：仅自己
    vcust = [c for c in custs if (c.get('salesperson') or c.get('sales')) == name]
    vgroups = [g for g in groups if (g.get('sales') or '') == name]
    return vcust, vgroups


def _build_system_prompt(user, vcust, vgroups):
    """构建 system prompt（三条铁律 + 可见客户名单）"""
    name = user.get('name', '')
    role = user.get('role', 'sales')
    dept = user.get('dept', '')
    line = user.get('line', 'da')
    line_label = LINE_LABEL.get(line, line)

    L = []
    L.append('你是宝锐生物（BIORI BIOTECH）销售工作台的智能助手。')
    L.append('当前用户：%s（角色：%s，部门：%s，业务线：%s）' % (name, role, dept, line_label))
    L.append('')
    L.append('【铁律一：只答工作问题】')
    L.append('你只回答销售工作相关问题（客户、订单、KPI业绩、产品、行业动态、市场竞争、竞品分析、销售方法论、工作流程等）。')
    L.append('严禁回答家庭、个人生活、私人事务、娱乐八卦、政治、宗教信仰等与工作无关的问题。遇到此类问题，统一回复："抱歉，我只能协助销售工作相关的问题～"')
    L.append('')
    L.append('【铁律二：客户数据权限隔离】')
    if role in ('admin', 'gm'):
        try:
            crm_n = len(_load_mcp_customer_cores())
        except Exception:
            crm_n = 0
        L.append('当前用户是超管（%s），拥有全公司客户数据访问权限，不受客户名单限制。' % role)
        L.append('可查询对象包括：ERP 重点客户（%d 家）、CRM 客户库（%d 家），以及飞书活动、订单物流、价格档案、客诉、商机、测试反馈等数据源中出现的所有客户。' % (len(vcust), crm_n))
        L.append('对于任何客户，只要系统检索到其真实数据（见下方背景）即可引用并回答；检索不到则明确说明"暂无该客户的相关数据"，严禁编造客户数据。')
    else:
        L.append('你只能谈论当前用户权限范围内的客户/课题组。以下是该用户可见的客户名单：')
        if vcust:
            for c in vcust:
                sp = c.get('salesperson') or c.get('sales') or ''
                s26 = c.get('sales_2026') or 0
                try:
                    s26w = round(float(s26) / 10000, 1)
                except Exception:
                    s26w = 0
                L.append('- %s（归属：%s，2026销售额：%s万）' % (c.get('name', ''), sp, s26w))
        if vgroups:
            for g in vgroups:
                L.append('- [课题组] %s（归属：%s，状态：%s，区域：%s）' % (g.get('name', ''), g.get('sales', ''), g.get('status', ''), g.get('region', '')))
        if not vcust and not vgroups:
            L.append('（无可见客户记录）')
        L.append('名单之外的客户，一律回复"该客户不在您的权限范围内，无法提供信息"。不得编造客户数据。')
        L.append('用户上传的文件内容同样受此权限约束：文件中若出现名单之外的客户/人员，不得引用其数据。')
    L.append('')
    L.append('【铁律三：行业/市场/竞品自由回答】')
    L.append('行业趋势、市场规模、竞品动态、产品技术等非客户专属问题，可自由回答，不受权限限制。')
    L.append('')
    L.append('【回答要求】')
    L.append('1. 用中文，简洁专业，条理清晰')
    L.append('2. 金额统一用"万元"单位')
    L.append('3. 涉及客户数据时，只引用上面名单中的信息')
    L.append('4. 不确定的数据明确说明，不要编造')
    return '\n'.join(L)


def _call_deepseek(system_prompt, messages):
    """调用 DeepSeek API（thinking 必须 disabled，否则答案在 reasoning_content）"""
    cfg = _load_deepseek_cfg()
    if 'error' in cfg:
        return None, 'DeepSeek 凭证读取失败：' + cfg['error']
    if not cfg.get('api_key'):
        return None, 'DeepSeek 凭证未配置'
    body = {
        'model': cfg['model'],
        'messages': [{'role': 'system', 'content': system_prompt}] + messages,
        'max_tokens': 2000,
        'thinking': {'type': 'disabled'},
    }
    req = urllib.request.Request(
        '%s/chat/completions' % cfg['base_url'],
        data=json.dumps(body).encode('utf-8'),
        headers={'Content-Type': 'application/json', 'Authorization': 'Bearer %s' % cfg['api_key']},
        method='POST',
    )
    with urllib.request.urlopen(req, timeout=90) as r:
        d = json.loads(r.read())
    msg = d.get('choices', [{}])[0].get('message', {})
    content = (msg.get('content') or '').strip()
    if not content:
        content = (msg.get('reasoning_content') or '').strip()
    return content, None


def _parse_excel(raw):
    """xlsx/xlsm → 文本（前 200 行，tab 分隔）"""
    import openpyxl
    wb = openpyxl.load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
    parts = []
    for ws in wb.worksheets:
        rows = []
        for row in ws.iter_rows(values_only=True):
            cells = ['' if c is None else str(c) for c in row]
            if any(cells):
                rows.append('\t'.join(cells))
            if len(rows) >= 200:
                break
        if rows:
            parts.append('[Sheet: %s]\n%s' % (ws.title, '\n'.join(rows)))
    return ('\n\n'.join(parts))[:6000]


def _parse_pdf(raw):
    """PDF → 文本（前 20 页）"""
    import pymupdf
    doc = pymupdf.open(stream=raw, filetype='pdf')
    parts = []
    for page in doc:
        parts.append(page.get_text())
        if len(parts) >= 20:
            break
    return '\n'.join(parts)[:6000]


def _parse_via_textutil(raw, name):
    """docx/doc/rtf/txt → 文本（textutil，macOS 自带）"""
    suffix = os.path.splitext(name)[1] or '.txt'
    fd, tmp = tempfile.mkstemp(suffix=suffix)
    try:
        with os.fdopen(fd, 'wb') as f:
            f.write(raw)
        r = subprocess.run(['textutil', '-convert', 'txt', '-stdout', tmp],
                           capture_output=True, timeout=30)
        return (r.stdout or b'').decode('utf-8', errors='ignore')[:6000]
    except Exception:
        return ''
    finally:
        try:
            os.unlink(tmp)
        except Exception:
            pass


def _parse_ocr(raw, ext):
    """图片 → 文本（tesseract，chi_sim+eng）"""
    fd, tmp = tempfile.mkstemp(suffix='.' + ext)
    try:
        with os.fdopen(fd, 'wb') as f:
            f.write(raw)
        r = subprocess.run(['tesseract', tmp, 'stdout', '-l', 'chi_sim+eng'],
                           capture_output=True, timeout=60)
        return (r.stdout or b'').decode('utf-8', errors='ignore')[:6000]
    except Exception:
        return ''
    finally:
        try:
            os.unlink(tmp)
        except Exception:
            pass


def _parse_upload(name, data):
    """按扩展名分派解析上传文件，返回文本（失败返回空串）"""
    name = name or ''
    ext = name.rsplit('.', 1)[-1].lower() if '.' in name else ''
    raw = None
    if isinstance(data, str):
        try:
            raw = base64.b64decode(data)
        except Exception:
            return data[:6000]  # 可能是纯文本（csv 内容直传）
    elif isinstance(data, (bytes, bytearray)):
        raw = bytes(data)
    if raw is None:
        return ''
    try:
        if ext in ('xlsx', 'xlsm'):
            return _parse_excel(raw)
        if ext == 'csv':
            return raw.decode('utf-8', errors='ignore')[:6000]
        if ext in ('docx', 'doc', 'rtf', 'txt'):
            return _parse_via_textutil(raw, name)
        if ext == 'pdf':
            return _parse_pdf(raw)
        if ext in ('png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'):
            return _parse_ocr(raw, ext)
    except Exception:
        return ''
    return ''


STEP_PROMPTS = {
    'analyze': '\n\n【当前步骤：🔍 分析】你现在的任务是帮销售做"访前分析"：主动给出客户画像、痛点、竞品、机会，并列出"该问客户什么"。',
    'deliver': '\n\n【当前步骤：📢 传递】你现在的任务是帮销售"说对话"：给出场景话术、价值主张、异议处理、竞品对比数据。',
    'follow': '\n\n【当前步骤：✅ 跟进】你现在的任务是帮销售"推落地"：把结论拆解成行动项（负责人/截止日），给出协同推进建议。',
    'review': '\n\n【当前步骤：📖 沉淀】你现在的任务是帮销售"沉淀经验"：提炼成败原因、关键动作、可复用打法，引导回写知识库。',
}



def handle(payload):
    """处理 /api/chat 请求，返回 (status_code, dict)"""
    question = (payload.get('question') or '').strip()
    user = payload.get('user') or {}
    messages = payload.get('messages') or []
    files = payload.get('files') or []
    if not question and not messages and not files:
        return 400, {'ok': False, 'error': 'question required'}
    if not user.get('name'):
        return 401, {'ok': False, 'error': '未登录，无法回答'}
    try:
        vcust, vgroups = _visible_for_user(user)
        sys_prompt = _build_system_prompt(user, vcust, vgroups)
        step = (payload.get('step') or '').strip()
        if step in STEP_PROMPTS:
            sys_prompt += STEP_PROMPTS[step]
        # 检索背景（飞书活动 + 产品），让 AI 知道业务背景
        _q_for_search = question
        if not _q_for_search:
            for m in reversed(messages):
                if isinstance(m, dict) and m.get('role') == 'user' and (m.get('content') or '').strip():
                    _q_for_search = (m.get('content') or '').strip()
                    break
        background = _search_background(_q_for_search, vcust)
        kb_bg = _search_knowledge_base(_q_for_search, user.get('line', 'da'))
        kb_hit = bool(kb_bg)
        if kb_bg:
            background = (background + '\n\n' + kb_bg) if background else kb_bg
        cust360 = _load_customer360(_q_for_search)
        if cust360:
            background = (background + '\n\n' + cust360) if background else cust360
        if background:
            sys_prompt += '\n\n' + background + '\n\n（以上是系统检索到的飞书活动、产品、知识库背景，请结合这些信息回答用户问题，不要回复"无法访问飞书信息"。）'
        # 解析上传文件
        upload_ctx = ''
        upload_names = []
        for f in (files if isinstance(files, list) else []):
            if not isinstance(f, dict):
                continue
            fname = f.get('name') or ''
            parsed = _parse_upload(fname, f.get('data') or '')
            if parsed:
                upload_names.append(fname)
                upload_ctx += '\n\n=== 文件：%s ===\n%s' % (fname, parsed)
        # 组装多轮消息（截断最近 16 条，防超长）
        history = []
        for m in messages[-16:]:
            role = m.get('role') if isinstance(m, dict) else ''
            content = (m.get('content') or '').strip() if isinstance(m, dict) else ''
            if role in ('user', 'assistant') and content:
                history.append({'role': role, 'content': content})
        if upload_ctx:
            up = '我上传了文件，请基于文件内容回答问题（文件中涉及客户数据时遵守权限约束）。' + upload_ctx
            if question:
                history.append({'role': 'user', 'content': question + '\n\n' + up})
            else:
                history.append({'role': 'user', 'content': up})
        elif question:
            history.append({'role': 'user', 'content': question})
        answer, err = _call_deepseek(sys_prompt, history)
        if err:
            return 500, {'ok': False, 'error': err}
        # 组装溯源来源
        sources = []
        for n in upload_names:
            sources.append('上传文件：' + n)
        if background:
            if cust360:
                sources.append('客户360全景')
            sources.append('飞书活动+产品背景')
        if kb_hit:
            sources.append('IMA知识库')
        if vcust:
            sources.append('客户数据（%d 条可见）' % len(vcust))
        if vgroups:
            sources.append('课题组数据（%d 条可见）' % len(vgroups))
        if not sources:
            sources.append('AI 通用知识')
        return 200, {'ok': True, 'answer': answer,
                     'visible_customers': len(vcust), 'visible_groups': len(vgroups),
                     'parsed_files': upload_names, 'sources': sources}
    except Exception as e:
        return 500, {'ok': False, 'error': str(e)}


def archive_experience(messages, user):
    """把一段多轮问答链（含补充追问）提炼成结构化经验条目。

    返回 (title, content) 或 (None, error)。
    content = LLM 提炼的结构化正文 + 附带的原始问答链（可追溯）。
    """
    from datetime import datetime
    chain = []
    for m in (messages if isinstance(messages, list) else []):
        role = m.get('role') if isinstance(m, dict) else ''
        content = (m.get('content') or '').strip() if isinstance(m, dict) else ''
        if role in ('user', 'assistant') and content:
            chain.append({'role': role, 'content': content})
    if not chain:
        return None, '没有可归档的对话内容'

    # 拼对话文本（最近 20 条，防超长）
    dialog = '\n'.join(
        ('用户：' if m['role'] == 'user' else '助手：') + m['content']
        for m in chain[-20:]
    )

    # 拼原始问答链（Q/A 配对，零 LLM 成本，直接可追溯）
    qa, q = [], None
    for m in chain:
        if m['role'] == 'user':
            q = m['content']
        elif q is not None:
            qa.append((q, m['content']))
            q = None
    raw_chain = '\n\n'.join(
        '**Q%d**：%s\n\n**A%d**：%s' % (i, qq, i, aa)
        for i, (qq, aa) in enumerate(qa, 1)
    )

    extract_prompt = (
        '以下是宝锐销售工作台 AI 助手与用户的一段多轮对话（含补充追问）。\n'
        '请把这段对话提炼成一条可复用的销售经验，用 markdown 输出，格式严格如下：\n\n'
        '# <一句话标题，概括这条经验的核心结论>\n\n'
        '**问题背景**：<初始问题、客户或场景>\n\n'
        '**涉及客户**：<对话中提到的客户名，没有就写"无">\n\n'
        '**涉及货号/产品**：<对话中提到的货号或产品，没有就写"无">\n\n'
        '**关键结论**：<多轮追问后沉淀的核心结论，分点列出>\n\n'
        '**建议动作**：<下一步建议或可复用的打法>\n\n'
        '要求：简洁、专业、可复用；不得编造对话中没有的信息；保留具体数字与货号。\n\n'
        '对话内容：\n' + dialog
    )
    extract_sys = '你是销售知识管理助手，负责把销售对话提炼成结构化、可复用的经验条目。'
    extracted, err = _call_deepseek(extract_sys, [{'role': 'user', 'content': extract_prompt}])
    if err:
        return None, err
    if not extracted:
        return None, '经验提炼失败，请稍后重试'

    lines = extracted.strip().split('\n')
    title = ''
    body_lines = []
    for i, ln in enumerate(lines):
        s = ln.strip()
        if i == 0 and s.startswith('#'):
            title = s.lstrip('#').strip()
        else:
            body_lines.append(ln)
    if not title:
        title = '销售经验_' + datetime.now().strftime('%Y%m%d%H%M')
    body = '\n'.join(body_lines).strip()

    full = body
    if raw_chain:
        full += '\n\n---\n\n## 原始问答链\n\n' + raw_chain
    return title, full
