#!/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, '数据')
# 生成文件存到 8765 静态服务 ROOT 之外的私有目录，杜绝裸静态 URL 直连
PRIVATE_BASE = os.path.expanduser('~/Library/Application Support/宝锐工作台/生成文件')
FILE_DIR = os.path.join(PRIVATE_BASE, 'files')
FILE_INDEX = os.path.join(PRIVATE_BASE, 'index.json')
HISTORY_FILE = os.path.join(DATA_DIR, 'chat_history.json')
TOKEN_USAGE_FILE = os.path.join(DATA_DIR, 'ai_token_usage.json')
DEEPSEEK_DAILY_LIMIT = 3          # DeepSeek V4 Pro 每人每天限流次数（默认值，可按人覆盖）
DEEPSEEK_QUOTA_FILE = os.path.join(DATA_DIR, 'ai_deepseek_quota.json')
PERMISSIONS_FILE = os.path.join(DATA_DIR, 'user_permissions.json')  # 按人权限覆盖（admin/gm 面板维护）
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 _record_token_usage(name, usage, question, model=''):
    """记录一次 AI 调用的 token 消耗（按 name 分桶，保留最近 500 条）"""
    if not name or not usage:
        return
    try:
        from datetime import datetime
        data = {}
        if os.path.exists(TOKEN_USAGE_FILE):
            with open(TOKEN_USAGE_FILE, encoding='utf-8') as f:
                _d = json.load(f)
            if isinstance(_d, dict):
                data = _d
        rec = {
            'ts': int(datetime.now().timestamp()),
            'date': datetime.now().strftime('%Y-%m-%d'),
            'model': model or '',
            'prompt': int(usage.get('prompt_tokens') or 0),
            'completion': int(usage.get('completion_tokens') or 0),
            'total': int(usage.get('total_tokens') or 0),
            'q': (question or '')[:80],
        }
        data.setdefault(name, []).append(rec)
        data[name] = data[name][-500:]
        os.makedirs(DATA_DIR, exist_ok=True)
        tmp = TOKEN_USAGE_FILE + '.tmp'
        with open(tmp, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=1)
        os.replace(tmp, TOKEN_USAGE_FILE)
    except Exception:
        pass


def _load_deepseek_quota():
    """读取 DeepSeek 每日限流计数 {name: {date: count}}"""
    try:
        if os.path.exists(DEEPSEEK_QUOTA_FILE):
            with open(DEEPSEEK_QUOTA_FILE, encoding='utf-8') as f:
                d = json.load(f)
            if isinstance(d, dict):
                return d
    except Exception:
        pass
    return {}


def _save_deepseek_quota(data):
    """原子写回 DeepSeek 限流计数"""
    try:
        os.makedirs(DATA_DIR, exist_ok=True)
        tmp = DEEPSEEK_QUOTA_FILE + '.tmp'
        with open(tmp, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=1)
        os.replace(tmp, DEEPSEEK_QUOTA_FILE)
    except Exception:
        pass


def _deepseek_used_today(name):
    """返回某用户今天已用的 DeepSeek 次数（跨天自动归零）"""
    from datetime import datetime
    today = datetime.now().strftime('%Y-%m-%d')
    bucket = _load_deepseek_quota().get(name, {})
    return int(bucket.get('date') == today and bucket.get('count') or 0)


def _deepseek_quota_available(name):
    """DeepSeek 是否还有今日额度（limit=-1 不限；否则按 per-user limit 计）"""
    limit = _get_ds_limit(name)
    if limit < 0:
        return True
    return _deepseek_used_today(name) < limit


def _consume_deepseek_quota(name):
    """消耗一次 DeepSeek 今日额度（返回消耗后的已用次数）"""
    from datetime import datetime
    today = datetime.now().strftime('%Y-%m-%d')
    data = _load_deepseek_quota()
    bucket = data.get(name, {})
    if bucket.get('date') != today:
        bucket = {'date': today, 'count': 0}
    bucket['count'] = int(bucket.get('count') or 0) + 1
    data[name] = bucket
    _save_deepseek_quota(data)
    return bucket['count']


def _load_user_permissions():
    """读取按人权限覆盖 {name: {ds_limit, tabs, extra_customers, blocked_customers}}"""
    try:
        if os.path.exists(PERMISSIONS_FILE):
            with open(PERMISSIONS_FILE, encoding='utf-8') as f:
                d = json.load(f)
            if isinstance(d, dict):
                return d
    except Exception:
        pass
    return {}


def _save_user_permissions(data):
    """原子写回权限覆盖文件"""
    try:
        os.makedirs(DATA_DIR, exist_ok=True)
        tmp = PERMISSIONS_FILE + '.tmp'
        with open(tmp, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=1)
        os.replace(tmp, PERMISSIONS_FILE)
        return True
    except Exception:
        return False


def _get_user_perm(name):
    """返回某用户的权限覆盖 dict（可能为空 {}）"""
    return _load_user_permissions().get(name, {})


def _get_ds_limit(name):
    """返回某用户 DeepSeek 每日限流次数。-1=不限；<=0 或缺省用全局默认 DEEPSEEK_DAILY_LIMIT"""
    p = _get_user_perm(name)
    v = p.get('ds_limit')
    if v is None:
        return DEEPSEEK_DAILY_LIMIT
    try:
        v = int(v)
    except Exception:
        return DEEPSEEK_DAILY_LIMIT
    if v < 0:
        return -1
    return v


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_glm_cfg():
    """从 ~/.hermes/config.yaml 读取智谱 GLM 凭证（providers.zhipu）"""
    try:
        import yaml
        cfg = yaml.safe_load(open(os.path.expanduser('~/.hermes/config.yaml'), encoding='utf-8'))
        z = cfg.get('providers', {}).get('zhipu', {})
        return {
            'api_key': z.get('api_key', ''),
            'base_url': (z.get('base_url') or 'https://open.bigmodel.cn/api/paas/v4').rstrip('/'),
            'model': z.get('default_model') or 'glm-5.3-flash',
        }
    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 _longest_common_substring(a, b):
    """返回 a 与 b 的最长公共连续子串（动态规划）。用于精确简称匹配。"""
    if not a or not b:
        return ''
    la, lb = len(a), len(b)
    dp = [[0] * (lb + 1) for _ in range(la + 1)]
    max_len, end_a = 0, 0
    for i in range(1, la + 1):
        for j in range(1, lb + 1):
            if a[i - 1] == b[j - 1]:
                dp[i][j] = dp[i - 1][j - 1] + 1
                if dp[i][j] > max_len:
                    max_len = dp[i][j]
                    end_a = i
    return a[end_a - max_len:end_a]


# ── 主题竞品分析（业务主题 → 竞品品牌 + 货号）──
_TOPIC_KEYWORDS = {
    '动物疫病': ['非瘟', '非洲猪瘟', '猪瘟', '口蹄疫', '禽流感', '新城疫', '蓝耳', '圆环',
                '伪狂犬', '猪腹泻', '流行性腹泻', '细小', '犬瘟', '猫瘟', '马立克', '法氏囊',
                '鸭瘟', '动保', '动物疫病', '兽医', '兽用', '兽药', '畜牧', '养殖', '水产', '宠物'],
}

# 竞品品牌（主名 → 别名列表，命中任一别名归并到主名）
_COMPETITOR_BRANDS = {
    '诺唯赞': ['诺唯赞', 'Vazyme'],
    '翌圣': ['翌圣', '羿圣', 'Yeasen'],
    '全式金': ['全式金', 'TransGen'],
    '生工': ['生工', 'Sangon'],
    'Takara': ['Takara', '宝日医', '宝生物'],
    '天根': ['天根', 'TIANGEN'],
    '康为世纪': ['康为', '康为世纪', 'CWBIO'],
    '近岸蛋白': ['近岸', '近岸蛋白', 'Novoprotein'],
    '义翘神州': ['义翘', 'SinoBiological'],
    '爱博泰克': ['爱博泰克', '艾博泰克', 'Abclonal'],
    '罗氏': ['罗氏', 'Roche'],
    '菲鹏': ['菲鹏'],
    '迈迪安': ['迈迪安', 'Meridian'],
    '华峰': ['华峰'],
    '白垩纪': ['白垩纪'],
    '东盛': ['东盛'],
    '碧云天': ['碧云天', 'Beyotime'],
    '索莱宝': ['索莱宝', 'Solarbio'],
    '中科唐碳': ['中科唐碳'],
}

_COMPETITOR_INTENT = ['竞品', '竞对', '对手', '品牌', '货号', '友商', '竞争']

# 已知竞品货号（品牌 → 货号，从飞书活动记录确认过）
_COMPETITOR_SKUS = {
    '诺唯赞': ['P526', 'QV110', 'Q811', 'R333-c1', 'Q711'],
    '全式金': ['AS131'],
}

_TOPIC_COMPETITOR_CACHE = {}


def _search_topic_competitors(question, vcust=None, vgroups=None, is_admin=False):
    """主题竞品扫描：问题命中「业务主题 + 竞品意图」时，全量扫描活动提取竞品品牌+货号（非超管过滤客户名）"""
    if not question:
        return ''
    if not any(k in question for k in _COMPETITOR_INTENT):
        return ''
    topic = None
    for t, kws in _TOPIC_KEYWORDS.items():
        if any(k in question for k in kws):
            topic = t
            break
    if not topic:
        return ''
    # 数据新鲜度：daily_activities_mcp.json mtime 变化则重扫（并强制重载活动）
    p = os.path.join(DATA_DIR, 'daily_activities_mcp.json')
    try:
        mtime = os.path.getmtime(p)
    except Exception:
        mtime = 0
    c = _TOPIC_COMPETITOR_CACHE.get(topic)
    if c and c.get('mtime') == mtime:
        return c['summary']
    if mtime and c and c.get('mtime') != mtime:
        _BG_CACHE.pop('activities', None)  # 强制重载最新活动
    kws = _TOPIC_KEYWORDS[topic]
    brand_stat = {}
    topic_acts = 0
    for a in _load_activities():
        text = (a['name'] or '') + ' ' + (a['desc'] or '')
        if not any(k in text for k in kws):
            continue
        topic_acts += 1
        tl = text.lower()
        for bmain, aliases in _COMPETITOR_BRANDS.items():
            if not any(al.lower() in tl for al in aliases):
                continue
            st = brand_stat.setdefault(bmain, {'count': 0, 'customers': set(), 'dates': set(), 'skus': set()})
            st['count'] += 1
            if a['customer'] and (is_admin or _name_visible(a['customer'], vcust, vgroups)):
                st['customers'].add(a['customer'])
            if a['date']:
                st['dates'].add(a['date'][:10])
            # 货号：仅已知货号表精确匹配（启发式正则易误报宝锐自有货号，已弃用）
            for sku in _COMPETITOR_SKUS.get(bmain, []):
                if sku in text:
                    st['skus'].add(sku)
    if not brand_stat:
        summary = '【%s主题竞品扫描】相关活动 %d 条，未发现明确竞品品牌提及。' % (topic, topic_acts)
    else:
        maxdate = ''
        for st in brand_stat.values():
            if st['dates']:
                d = max(st['dates'])
                if d > maxdate:
                    maxdate = d
        lines = ['【%s主题竞品扫描（相关活动 %d 条，最新 %s）】' % (topic, topic_acts, maxdate)]
        for b in sorted(brand_stat, key=lambda x: -brand_stat[x]['count']):
            st = brand_stat[b]
            skus = '、'.join(sorted(st['skus'])) if st['skus'] else '未记录货号'
            custs = '、'.join(sorted(st['customers'])[:6])
            lines.append('- %s：提及 %d 次；货号：%s；客户：%s' % (b, st['count'], skus, custs))
        summary = '\n'.join(lines)
    _TOPIC_COMPETITOR_CACHE[topic] = {'mtime': mtime, 'summary': summary}
    return summary


def _search_background(question, vcust, vgroups=None, is_admin=False):
    """按问题检索相关背景（飞书活动 + 产品），返回背景文本。非超管按可见名单过滤活动。"""
    if not question:
        return ''
    skus = _extract_skus(question)
    parts = []
    # 1. 相关活动（按货号 + 客户名/活动名公共子串检索）
    acts = _load_activities()
    matched = []  # (priority, activity)
    for a in acts:
        if not is_admin and a['customer'] and not _name_visible(a['customer'], vcust, vgroups):
            continue
        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))
    # 3. 产品卖点 + 选型（新数据底座 product_selling_points / pcr_lamp_selection）
    try:
        sp_lines = []
        sp_file = os.path.join(DATA_DIR, 'product_selling_points.json')
        if os.path.exists(sp_file):
            with open(sp_file, encoding='utf-8') as _f:
                _spd = json.load(_f)
            for p in _spd.get('products', []) or []:
                _skus = p.get('skus', []) or []
                _hay = (p.get('product_line', '') or '') + ' ' + (p.get('category', '') or '') + ' ' + ' '.join(_skus) + ' ' + ' '.join(p.get('competitors', []) or [])
                _hit = any((s in question) for s in _skus)
                if _hit or _has_common_bigram(_hay, question):
                    _fb = p.get('fabe', {}) or {}
                    sp_lines.append('【产品卖点】%s｜定位：%s｜优势：%s｜竞品：%s' % (
                        p.get('product_line', ''), p.get('position', ''), _fb.get('advantage', ''), '、'.join(p.get('competitors', []) or [])))
        sel_file = os.path.join(DATA_DIR, 'pcr_lamp_selection.json')
        if os.path.exists(sel_file):
            with open(sel_file, encoding='utf-8') as _f:
                _seld = json.load(_f)
            for it in _seld.get('items', []) or []:
                _proj = it.get('project', '') or ''
                if _proj and _has_common_bigram(_proj, question):
                    _rec = [r.get('sku', '') for r in (it.get('recommended', []) or []) if r.get('sku')]
                    if _rec:
                        sp_lines.append('【选型推荐】%s（%s）→ %s' % (_proj, it.get('method', ''), ' / '.join(_rec[:4])))
        if sp_lines:
            parts.append('【产品卖点/选型】\n' + '\n'.join(sp_lines[:8]))
    except Exception:
        pass
    # 4. 主题竞品扫描（动物疫病/水产/宠物 等主题 → 竞品品牌+货号）
    try:
        _tc = _search_topic_competitors(question, vcust, vgroups, is_admin)
        if _tc:
            parts.append(_tc)
    except Exception:
        pass
    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)]
    # 重复去掉行业后缀（"生物科技" → "生物" → 空），避免残留"生物"尾巴
    # 但"生物"常是品牌名核心（如"迈克生物"），只有去掉后剩余 >= 3 字才去（"迈克生物"→保留，"西安佰奥莱博生物"→"西安佰奥莱博"）
    changed = True
    while changed:
        changed = False
        for suf in ('科技', '医疗', '医药', '医学', '技术', '制药', '工程', '诊断', '检测', '生物'):
            if s.endswith(suf) and len(s) - len(suf) >= 2:
                if suf == '生物' and len(s) - len(suf) < 3:
                    continue
                s = s[:-len(suf)]
                changed = True
    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. 最长公共子串匹配（3字以上连续片段，如"佰奥莱博"精确匹配，排除"佰奥"2字歧义）
    for name, core in cores:
        if not name:
            continue
        lcs = _longest_common_substring(name, question)
        if len(lcs) >= 3 and len(lcs) > best_len:
            best, best_len = name, len(lcs)
    if best:
        return best
    # 3. 简称匹配：客户全名含某个非通用 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 _name_visible(name, vcust, vgroups):
    """判断客户名是否在可见名单内（vcust: 客户, vgroups: 课题组）。用于非超管背景检索的权限过滤。"""
    if not name:
        return False
    core = _cust_core(name)
    for c in (vcust or []):
        cn = (c.get('name') or '') if isinstance(c, dict) else str(c)
        if cn and (cn == name or (core and (core in cn or cn in core))):
            return True
    for g in (vgroups or []):
        gn = (g.get('name') or '') if isinstance(g, dict) else str(g)
        if gn and (gn == name or (core and (core in gn or gn in core))):
            return True
    return False


def _load_customer360(question, vcust=None, vgroups=None, is_admin=False):
    """识别问题中的客户名，拉取该客户 6 维全景（订单/客诉/商机/活动/知识库/材料）。每维失败降级。非超管按可见名单隔离。"""
    cust = _identify_customer(question)
    if not cust:
        return ''
    core = _cust_core(cust)
    if not core:
        return ''
    if not is_admin and not _name_visible(cust, vcust, vgroups):
        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):
    """按角色过滤可见客户/课题组（支持按人 extra_customers 追加 / blocked_customers 屏蔽）"""
    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

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

    # 按人权限覆盖：extra_customers 追加、blocked_customers 屏蔽（admin/gm 面板维护）
    perm = _get_user_perm(name)
    extra = set(perm.get('extra_customers') or [])
    blocked = set(perm.get('blocked_customers') or [])
    if extra:
        vcust = vcust + [c for c in custs if (c.get('name') or '') in extra and c not in vcust]
        vgroups = vgroups + [g for g in groups if (g.get('name') or '') in extra and g not in vgroups]
    if blocked:
        vcust = [c for c in vcust if (c.get('name') or '') not in blocked]
        vgroups = [g for g in vgroups if (g.get('name') or '') not in blocked]
    return vcust, vgroups


# 入职拆分配置（与前端 app.html/index.html 的 JOIN_SPLIT 保持一致）
JOIN_SPLIT = {
    '刘子研': {'join_month': 8, 'full_year_target': 13000000, 'pre_join_target': 5489000, 'post_join_target': 7511000, 'pre_join_actual': 5500000},
}


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('行业趋势、市场规模、竞品动态、产品技术等非客户专属问题，可自由回答，不受权限限制。')
    _js = JOIN_SPLIT.get(name)
    if _js:
        L.append('')
        L.append('【当前员工的 KPI 口径（入职拆分，重要）】')
        L.append('%s 于 2026 年 %d 月入职。全年（1-12月）目标 %.1f 万。' % (name, _js['join_month'], _js['full_year_target'] / 10000))
        L.append('其中：入职前（1-7月）目标 %.1f 万，已达成 %.1f 万（这是前任业绩，不计入 %s 本人的达成）；入职后（8-12月）目标 %.1f 万，由本人承担。' % (_js['pre_join_target'] / 10000, _js['pre_join_actual'] / 10000, name, _js['post_join_target'] / 10000))
    L.append('')
    L.append('【场景路由与回答框架（务必遵守）】')
    L.append('根据用户问题类型，采用对应数据源和回答框架：')
    L.append('1. 产品选型（问"XX检测/项目推什么体系/货号"）→ 检索 数据/pcr_lamp_selection.json 和 数据/product_selling_points.json，命中具体货号 + 一线反馈，不要泛泛而谈。')
    L.append('2. 产品竞争力（问"XX产品优势/有竞争力吗"）→ 用 FABE 四段式（特征→优势→客户利益→证据）回答，引用 数据/product_selling_points.json 的定量证据；无数据明说"需补测"，严禁编造参数。')
    L.append('3. 竞品对比（问"和XX比/国产替代"）→ 引用 数据/market_intel/ 最新竞品情报 + selling_points 的 competitors/evidence，给出差异化打法和胜率判断。')
    L.append('4. 客户咨询（问"XX客户怎么样"）→ 聚合订单+客诉+商机+活动+竞品五维，一次答全。')
    L.append('5. 市场/行业（问"市场规模/趋势"）→ 引用 selling_points 的 market_intel 字段 + 行业报告数据。')
    L.append('6. 数据查询（问"订单/回款/KPI"）→ 引用 shipment_status / kpi_progress / kpi_dashboard 的真实数字，注明口径。')
    L.append('7. 销售打法（问"怎么谈/话术/异议"）→ 引用话术库 + LTC 方法论，给可落地话术和下一步动作。')
    L.append('通用思考顺序：①识别场景 → ②提取实体（产品/货号/客户/竞品）→ ③权限过滤 → ④检索对应数据源 → ⑤结构化输出。')
    L.append('涉及产品/竞品问题时，可用工具读取上述 JSON 文件获取卖点、选型、竞品情报数据。')
    L.append('')
    L.append('【回答要求】')
    L.append('1. 用中文，简洁专业，条理清晰')
    L.append('2. 金额统一用"万元"单位')
    L.append('3. 涉及客户数据时，只引用上面名单中的信息')
    L.append('4. 不确定的数据明确说明，不要编造')
    L.append('')
    L.append('【高效原则（务必遵守）】')
    L.append('优先基于上面的客户名单和背景数据直接回答，不要反复调用工具检索。')
    L.append('数据查不到就明确说"暂无相关数据"，严禁为了凑答案反复尝试多个工具。')
    L.append('整个回答尽量在 3 步以内完成，避免拖长。')
    L.append('')
    L.append('【文件输出规则（用户要下载文件时遵守）】')
    L.append('当用户明确要求生成文件、文档、HTML页面、报告、知识卡、表格等可下载内容时：')
    L.append('1. 【重要】不要调用 write_file 等工具把文件写到磁盘，也不要提及任何本地文件路径——系统会自动把你输出的内容保存为文件。')
    L.append('2. 把完整文件内容（如完整 HTML 代码，不要省略、不要截断）用以下标记包裹输出：')
    L.append('   <<<FILE:文件名.html>>>')
    L.append('   （完整的文件内容；HTML 用纯文字+样式即可，【不要】内嵌 base64 图片——配图位置用文字占位说明，图片由系统/人工后续补充）')
    L.append('   <<<END_FILE>>>')
    L.append('3. 标记之外，用 1-2 句简短文字说明生成结果即可。')
    L.append('4. 文件名用有意义的中文名，扩展名用 .html/.md/.txt/.csv 等。')
    return '\n'.join(L)


def _load_file_index():
    """读生成文件索引，返回 {'files': [...]}"""
    try:
        if os.path.exists(FILE_INDEX):
            with open(FILE_INDEX, encoding='utf-8') as f:
                d = json.load(f)
            if isinstance(d, dict):
                return d
    except Exception:
        pass
    return {'files': []}


def _save_file_index(idx):
    try:
        os.makedirs(PRIVATE_BASE, exist_ok=True)
        tmp = FILE_INDEX + '.tmp'
        with open(tmp, 'w', encoding='utf-8') as f:
            json.dump(idx, f, ensure_ascii=False, indent=1)
        os.replace(tmp, FILE_INDEX)
    except Exception:
        pass


_ALLOWED_EXT = ('.html', '.htm', '.md', '.txt', '.csv', '.json', '.pdf')


# ── 拜访卡自动生成（销售说"需要拜访XX"时触发）──
_VISIT_WORDS = ('拜访', '访前', '拜访卡', '准备见', '去见', '要见')


def _detect_visit_intent(question):
    """检测"拜访"意图，返回客户名或空串。"""
    if not question:
        return ''
    if not any(w in question for w in _VISIT_WORDS):
        return ''
    return _identify_customer(question)


def _cust_short(core):
    """客户简称：去掉常见地名前缀，保留品牌词（如"上海伯杰"→"伯杰"）"""
    s = core or ''
    for pref in ('上海', '北京', '广州', '深圳', '珠海', '杭州', '苏州', '成都', '武汉', '南京',
                 '天津', '重庆', '长沙', '西安', '郑州', '青岛', '厦门', '宁波', '无锡', '佛山'):
        if s.startswith(pref) and len(s) - len(pref) >= 2:
            s = s[len(pref):]
            break
    return s or core


def _build_visit_card(cust_name, user):
    """生成基础版拜访卡 HTML（后端自动收集本地数据：分级/决策链链接/价格档案/客诉）。
    返回 html 字符串，失败返回 None。"""
    import time
    tpl_path = os.path.join(ROOT, '工具', '拜访卡模板.html')
    if not os.path.exists(tpl_path):
        return None
    try:
        with open(tpl_path, encoding='utf-8') as f:
            tpl = f.read()
    except Exception:
        return None
    # 决策链链接
    dcl = {}
    try:
        with open(os.path.join(DATA_DIR, 'decision_chain_links.json'), encoding='utf-8') as f:
            dcl = json.load(f)
    except Exception:
        pass
    link = dcl.get(cust_name, {}) or {}
    # 价格档案
    pa = {}
    try:
        with open(os.path.join(DATA_DIR, '_archive', 'price_archive.json'), encoding='utf-8') as f:
            pa = json.load(f)
    except Exception:
        pass
    prices = pa.get(cust_name, {}) if isinstance(pa, dict) else {}
    total_amt = 0
    top = []
    if isinstance(prices, dict):
        for k, v in prices.items():
            if not isinstance(v, list):
                continue
            amt = sum(x.get('amt', 0) for x in v if isinstance(x, dict))
            total_amt += amt
            top.append((k, amt))
    top.sort(key=lambda x: -x[1])
    # 客诉
    complaints = []
    for cpath in (os.path.join(DATA_DIR, 'mcp_complaints.json'),
                  os.path.join(DATA_DIR, '_backup_erp_20260821_111646', 'mcp_complaints.json')):
        try:
            with open(cpath, encoding='utf-8') as f:
                comp = json.load(f)
            citems = comp if isinstance(comp, list) else comp.get('items', comp.get('data', []))
            _core = _cust_core(cust_name)
            _short = _cust_short(_core)  # 客诉名用简称（无地名前缀），须用 short 匹配而非 core
            for x in citems:
                nm = str(x.get('name', '')) if isinstance(x, dict) else str(x)
                if _short and _short in nm:
                    complaints.append(nm)
            break
        except Exception:
            continue
    # 级别 + 销售
    _raw_level = str(link.get('level', '') or '').strip()
    level_short = _raw_level.replace('类', '级') or '重点客户'  # "大客户"→大客户，"A类"→A级
    owner = link.get('owner', '') or user.get('name', '')
    today = time.strftime('%Y-%m-%d')
    core = _cust_core(cust_name)
    short = _cust_short(core) or cust_name[:2]
    top3 = '、'.join([t[0].split('|')[0].strip()[:24] for t in top[:3]]) or '待补充'
    ph = {
        '{{客户简称}}': short,
        '{{客户全称}}': cust_name,
        '{{客户级别}}': level_short,
        '{{销售员}}': owner,
        '{{日期}}': today,
        '{{CRM活动数}}': '待补充',
        '{{CRM范围}}': '待补充',
        '{{今日拜访人}}': owner,
        '{{storage_key}}': 'bp_' + (short or 'cust') + '_visit_v1',
        '{{决策链URL}}': link.get('decision_chain_url', ''),
        '{{客户链接}}': link.get('customer_link', ''),
        '{{KPI1值}}': '%d条' % len(complaints), '{{KPI1说明}}': '历史客诉记录',
        '{{KPI2值}}': '%d个' % len(top), '{{KPI2说明}}': '在采/在测产品',
        '{{KPI3值}}': '%d个' % (len(prices) if isinstance(prices, dict) else 0), '{{KPI3说明}}': '价格档案SKU',
        '{{KPI4值}}': ('%.1f万' % (total_amt / 10000)) if total_amt else '待补充', '{{KPI4说明}}': '累计采购额(档案)',
        '{{KR角色名}}': '待补充(研发决策)',
        '{{SR1角色名}}': '待补充(技术对接)',
        '{{SR2角色名}}': '待补充(采购)',
        '{{项目1}}': '待补充', '{{项目2}}': '待补充', '{{项目3}}': '待补充',
        '{{技术员1}}': '待补充', '{{线1}}': '待补充',
        '{{技术员2}}': '待补充', '{{线2}}': '待补充',
        '{{技术员3}}': '待补充', '{{线3}}': '待补充',
        '{{拜访目标占位}}': '待补充（建议：确认在测项目进展 + 新项目立项时间点 + 客诉闭环）',
    }
    html = tpl
    for k, v in ph.items():
        html = html.replace(k, v)
    # 数据数组（基础版：主采产品/客诉闭环/新项目）
    topics = []
    if top:
        topics.append({"priority": "p0", "topic": "主采产品：" + top[0][0].split('|')[0].strip()[:24],
                       "context": "累计采购 %.1f 万，确认在测/在采状态与后续用量" % (top[0][1] / 10000),
                       "impact": 5, "feasibility": 5, "strategy": "确认当前用量、性能反馈、后续需求"})
    if complaints:
        topics.append({"priority": "p0", "topic": "客诉闭环",
                       "context": "；".join([c[-40:] for c in complaints[:3]]),
                       "impact": 5, "feasibility": 4, "strategy": "主动汇报处理方案，重建信任"})
    topics.append({"priority": "p1", "topic": "新项目立项", "context": "待补充",
                   "impact": 4, "feasibility": 3, "strategy": "摸清产品管线，提前卡位"})
    risks = []
    if complaints:
        risks.append({"title": "客诉影响信任", "prob": "中", "counter": "主动闭环历史客诉"})
    risks.append({"title": "测试周期拉长", "prob": "中", "counter": "设定里程碑，每周同步进度"})
    prepChecks = [
        {"id": "c1", "text": "带最新测试/采购数据", "done": False},
        {"id": "c2", "text": "带价格档案(累计 %.1f 万)" % (total_amt / 10000) if total_amt else "带价格档案", "done": False},
        {"id": "c3", "text": "确认决策链人物名单", "done": False},
        {"id": "c4", "text": "准备客诉闭环方案" if complaints else "准备新项目推荐方案", "done": False},
    ]
    # competitors（项目×供应商态势：从商机数据填充，供应商份额默认 50/50 待确认）
    competitors = []
    try:
        with open(os.path.join(DATA_DIR, 'opps_by_customer.json'), encoding='utf-8') as f:
            _opp_data = json.load(f)
        _olist = _opp_data.get(cust_name, [])
        for _i, _o in enumerate(_olist[:5]):
            if not isinstance(_o, dict):
                continue
            competitors.append({
                'id': 'c%d' % (_i + 1),
                'name': _o.get('name', '商机'),
                'sub': _o.get('status', ''),
                'phase': _o.get('status', '待确认'),
                'phaseCls': 'testing',
                'status': '待确认',
                'statusLabel': '待补充',
                'suppliers': [{'name': '宝锐', 'pct': 50, 'bar': '50%'}, {'name': '竞品(待确认)', 'pct': 50, 'bar': '50%'}],
            })
    except Exception:
        pass
    if not competitors and top:
        # 商机数据空，用主采产品兜底一个项目
        competitors.append({
            'id': 'c1', 'name': '主采产品', 'sub': top[0][0].split('|')[0].strip()[:24],
            'phase': '在采', 'phaseCls': 'testing', 'status': '在采', 'statusLabel': '在采',
            'suppliers': [{'name': '宝锐', 'pct': 100, 'bar': '100%'}],
        })
    for name, data in (('competitors', competitors), ('topics', topics), ('risks', risks), ('prepChecks', prepChecks)):
        html = html.replace('%s: [],' % name, '%s: %s,' % (name, json.dumps(data, ensure_ascii=False)), 1)
    return html


# ── 竞对情报库 + 活动录入下拉选项 ──
COMPETITOR_INTEL_FILE = os.path.join(DATA_DIR, 'competitor_intel.json')


def _normalize_competitor_brand(brand):
    """竞对品牌归一化：别名 → 标准名。未知品牌原样返回。"""
    b = (brand or '').strip()
    if not b:
        return ''
    for main, aliases in _COMPETITOR_BRANDS.items():
        for a in aliases:
            if a.lower() in b.lower():
                return main
    return b


def _load_competitor_intel():
    try:
        with open(COMPETITOR_INTEL_FILE, encoding='utf-8') as f:
            d = json.load(f)
        if isinstance(d, dict):
            return d
    except Exception:
        pass
    return {'entries': []}


def _add_competitor_intel(payload, user):
    """写入一条竞对情报（品牌归一化 + 去重 + 落库）。返回 (ok, msg)。"""
    import time
    brand = _normalize_competitor_brand(payload.get('brand') or '')
    sku = (payload.get('sku') or '').strip()
    performance = (payload.get('performance') or '').strip()
    feedback = (payload.get('feedback') or '').strip()
    customer = (payload.get('customer') or '').strip()
    if not brand or (not sku and not performance and not feedback):
        return False, '竞对信息不完整：至少要有品牌 + 货号/性能/评价之一'
    idx = _load_competitor_intel()
    entries = idx.get('entries', [])
    dup_key = (brand, sku, customer)
    for e in entries:
        if (e.get('brand'), e.get('sku'), e.get('customer')) == dup_key:
            return True, '已存在相同竞对情报，跳过'
    entries.append({
        'brand': brand, 'sku': sku, 'performance': performance, 'feedback': feedback,
        'customer': customer, 'owner': user.get('name', ''), 'time': time.strftime('%Y-%m-%d %H:%M:%S'),
    })
    idx['entries'] = entries
    try:
        with open(COMPETITOR_INTEL_FILE, 'w', encoding='utf-8') as f:
            json.dump(idx, f, ensure_ascii=False, indent=1)
        return True, '竞对情报已沉淀（%d 条）' % len(entries)
    except Exception as e:
        return False, str(e)


def _activity_options(user):
    """活动录入下拉选项（权限过滤）：客户/商机/联系人/货号。"""
    vcust, vgroups = _visible_for_user(user)
    is_admin = (user.get('role') or 'sales') in ('admin', 'gm')
    if is_admin:
        try:
            with open(os.path.join(DATA_DIR, 'mcp_customers.json'), encoding='utf-8') as f:
                mc = json.load(f)
            visible_names = [c.get('name', '') for c in mc.get('customers', []) if c.get('name')]
        except Exception:
            visible_names = []
    else:
        visible_names = [c.get('name', '') for c in vcust if c.get('name')]
    visible_names = [n for n in visible_names if n]
    # 商机（按可见客户）
    opps = {}
    try:
        with open(os.path.join(DATA_DIR, 'opps_by_customer.json'), encoding='utf-8') as f:
            opp_data = json.load(f)
        for cn, olist in opp_data.items():
            if cn in visible_names and isinstance(olist, list):
                opps[cn] = [{'name': o.get('name', ''), 'status': o.get('status', ''), 'url': o.get('url', '')}
                            for o in olist if isinstance(o, dict)]
    except Exception:
        pass
    # 联系人（按可见客户）
    contacts = {}
    try:
        with open(os.path.join(DATA_DIR, 'customer_contacts.json'), encoding='utf-8') as f:
            cc = json.load(f)
        clist = cc.get('contacts', {})
        if isinstance(clist, dict):
            for cn, plist in clist.items():
                if cn in visible_names:
                    contacts[cn] = [str(p).strip() for p in plist if str(p).strip()] if isinstance(plist, list) else []
    except Exception:
        pass
    # 货号（全量，非敏感）
    skus = []
    try:
        with open(os.path.join(DATA_DIR, 'sku_list.json'), encoding='utf-8') as f:
            skus = json.load(f)
    except Exception:
        pass
    return {'customers': visible_names, 'opps': opps, 'contacts': contacts, 'skus': skus}


def _extract_files(answer, owner=''):
    """从 answer 里提取 <<<FILE:名>>>...<<<END_FILE>>> 标记，保存到私有目录并写索引。

    返回 (clean_text, files)，files = [{'fid': ..., 'name': ...}]
    """
    import re
    import time
    import uuid
    files = []

    def _save(m):
        name = (m.group(1) or '').strip()
        content = (m.group(2) or '').strip()
        if not name or not content:
            return ''
        # 清洗文件名：去路径穿越与非法字符
        name = re.sub(r'[\\/:*?"<>|\r\n]+', '_', name).strip('._')[:80]
        if not name:
            return ''
        ext = os.path.splitext(name)[1].lower()
        if ext not in _ALLOWED_EXT:
            ext = '.html'
        try:
            os.makedirs(FILE_DIR, exist_ok=True)
            fid = time.strftime('%Y%m%d-%H%M%S') + '-' + uuid.uuid4().hex[:6]
            fp = os.path.join(FILE_DIR, fid + ext)
            with open(fp, 'w', encoding='utf-8') as f:
                f.write(content)
            size = os.path.getsize(fp)
            idx = _load_file_index()
            idx.setdefault('files', []).append({
                'fid': fid, 'name': name, 'ext': ext,
                'owner': owner or '未知', 'time': time.strftime('%Y-%m-%d %H:%M:%S'),
                'size': size, 'is_public': False,
            })
            _save_file_index(idx)
            # 文书归档员：即时归档到 归档/<销售员>/<类型>/（软链接，失败静默不影响主流程）
            try:
                from 文书归档员 import archive_one
                archive_one(fp, owner, name)
            except Exception:
                pass
            files.append({'fid': fid, 'name': name})
        except Exception:
            pass
        return ''  # 文件内容从显示文本中移除

    clean = re.sub(r'<<<FILE:([^>]+)>>>\s*(.*?)\s*<<<END_FILE>>>', _save, answer, flags=re.DOTALL)
    clean = re.sub(r'\n{3,}', '\n\n', clean).strip()
    return clean, files


def _list_files(viewer='', vrole='sales'):
    """按权限列出文件：admin/gm 全见，否则仅自己"""
    idx = _load_file_index()
    is_admin = vrole in ('admin', 'gm')
    out = []
    for f in idx.get('files', []):
        if not is_admin and f.get('owner') != viewer:
            continue
        out.append({
            'fid': f.get('fid'), 'name': f.get('name'), 'owner': f.get('owner'),
            'time': f.get('time'), 'size': f.get('size'), 'is_public': bool(f.get('is_public')),
        })
    out.sort(key=lambda x: x.get('time', ''), reverse=True)
    return out


def _get_file_path(fid, viewer='', vrole='sales'):
    """鉴权取文件：返回 (path, name) 或 (None, err)"""
    idx = _load_file_index()
    is_admin = vrole in ('admin', 'gm')
    for f in idx.get('files', []):
        if f.get('fid') != fid:
            continue
        if not is_admin and f.get('owner') != viewer:
            return None, '无权访问该文件'
        fp = os.path.join(FILE_DIR, fid + f.get('ext', '.html'))
        if not os.path.exists(fp):
            return None, '文件不存在'
        return fp, f.get('name', 'file')
    return None, '文件不存在'


def _mark_public(fid, is_public=True):
    """标记/取消公共资源（权限在上层校验）"""
    idx = _load_file_index()
    for f in idx.get('files', []):
        if f.get('fid') == fid:
            f['is_public'] = bool(is_public)
            _save_file_index(idx)
            return True
    return False


def _cleanup_expired(days=30):
    """清理超过 days 天、且未标记公共的文件，返回删除数量"""
    import time as _t
    idx = _load_file_index()
    cutoff = _t.time() - days * 86400
    keep = []
    removed = 0
    for f in idx.get('files', []):
        try:
            ts = _t.mktime(_t.strptime(f.get('time', ''), '%Y-%m-%d %H:%M:%S'))
        except Exception:
            ts = _t.time()  # 解析失败保守保留
        if f.get('is_public') or ts > cutoff:
            keep.append(f)
            continue
        try:
            fp = os.path.join(FILE_DIR, f.get('fid', '') + f.get('ext', '.html'))
            if os.path.exists(fp):
                os.remove(fp)
            removed += 1
        except Exception:
            keep.append(f)
    idx['files'] = keep
    _save_file_index(idx)
    return removed


def _load_memory(name, exclude_recent=16):
    """读该用户历史对话，返回压缩的『记忆』文本（取超出当前上下文的部分，供跨会话连贯）。

    exclude_recent：排除最近 N 条（这些已在当前 messages 里），注入更早的历史。
    """
    hist = _get_history(name)
    if not hist:
        return ''
    lines = []
    pool = hist[:-exclude_recent] if len(hist) > exclude_recent else []
    for m in pool[-20:]:
        if not isinstance(m, dict):
            continue
        role = '用户' if m.get('role') == 'user' else '助手'
        content = (m.get('content') or '').strip().replace('\n', ' ')
        if not content:
            continue
        lines.append('%s：%s' % (role, content[:200]))
    if not lines:
        return ''
    return '【该用户更早的对话记忆（帮助保持上下文连贯，回答时自然引用关键信息，勿逐字复述历史原文）】\n' + '\n'.join(lines)


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': 4000,
        '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=150) 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 _call_hermes(system_prompt, messages):
    """调用 Hermes API Server（OpenAI 兼容，带持久记忆 + 只读工具）"""
    token = os.environ.get('API_SERVER_KEY') or 'nas-file-share-token-2026'
    body = {
        'model': 'hermes-agent',
        'messages': [{'role': 'system', 'content': system_prompt}] + messages,
        'max_tokens': 16000,
    }
    req = urllib.request.Request(
        'http://127.0.0.1:8642/v1/chat/completions',
        data=json.dumps(body).encode('utf-8'),
        headers={'Content-Type': 'application/json', 'Authorization': 'Bearer %s' % token},
        method='POST',
    )
    try:
        with urllib.request.urlopen(req, timeout=180) as r:
            d = json.loads(r.read())
        usage = d.get('usage') or {}
        msg = d.get('choices', [{}])[0].get('message', {})
        content = (msg.get('content') or '').strip()
        if not content:
            content = (msg.get('reasoning_content') or '').strip()
        if not content:
            return None, 'Hermes 返回空回答', usage
        return content, None, usage
    except Exception as e:
        _m = str(e)
        if 'timed out' in _m.lower() or 'timeout' in _m.lower():
            return '（该问题处理超时了。请换成更具体的问题，比如具体客户名、订单编号，或「XX客户最近的订单/回款情况」，我能更快回答。）', None, None
        return None, 'Hermes 调用失败：%s' % e, None


def _call_hermes_stream(system_prompt, messages, usage_out):
    """流式调用 Hermes API Server（SSE），逐段 yield delta 文本；usage 写入 usage_out dict"""
    token = os.environ.get('API_SERVER_KEY') or 'nas-file-share-token-2026'
    body = {
        'model': 'hermes-agent',
        'messages': [{'role': 'system', 'content': system_prompt}] + messages,
        'max_tokens': 16000,
        'stream': True,
    }
    req = urllib.request.Request(
        'http://127.0.0.1:8642/v1/chat/completions',
        data=json.dumps(body).encode('utf-8'),
        headers={'Content-Type': 'application/json', 'Authorization': 'Bearer %s' % token},
        method='POST',
    )
    with urllib.request.urlopen(req, timeout=300) as r:
        for raw in r:
            line = raw.decode('utf-8', 'ignore').strip()
            if not line.startswith('data:'):
                continue
            data = line[5:].strip()
            if data == '[DONE]':
                break
            try:
                d = json.loads(data)
            except Exception:
                continue
            choices = d.get('choices') or []
            ch = choices[0].get('delta', {}) if choices else {}
            content = (ch.get('content') or '') if isinstance(ch, dict) else ''
            if content:
                yield content
            u = d.get('usage')
            if u and isinstance(usage_out, dict):
                usage_out.update(u)


def _call_glm(system_prompt, messages):
    """调用智谱 GLM 5.3 Flash（直连 open.bigmodel.cn）。

    GLM 5.3 Flash 是 thinking 模型：真实答案在 content，思考过程在 reasoning_content。
    只取 content（reasoning 是英文思考痕迹，不进正文）。
    """
    cfg = _load_glm_cfg()
    if 'error' in cfg:
        return None, 'GLM 凭证读取失败：' + cfg['error']
    if not cfg.get('api_key'):
        return None, 'GLM 凭证未配置'
    body = {
        'model': cfg['model'],
        'messages': [{'role': 'system', 'content': system_prompt}] + messages,
        'max_tokens': 16000,
        'temperature': 0.3,
    }
    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=150) 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 _call_glm_stream(system_prompt, messages, usage_out):
    """流式调用智谱 GLM 5.3 Flash（SSE）。

    只 yield delta.content（真实答案），跳过 delta.reasoning_content（思考 token，不进正文、不占前端显示）。
    配置缺失时抛异常，由 handle_stream 捕获转 error 事件。
    """
    cfg = _load_glm_cfg()
    if 'error' in cfg:
        raise RuntimeError('GLM 凭证读取失败：' + cfg['error'])
    if not cfg.get('api_key'):
        raise RuntimeError('GLM 凭证未配置')
    body = {
        'model': cfg['model'],
        'messages': [{'role': 'system', 'content': system_prompt}] + messages,
        'max_tokens': 16000,
        'temperature': 0.3,
        'stream': True,
    }
    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=300) as r:
        for raw in r:
            line = raw.decode('utf-8', 'ignore').strip()
            if not line.startswith('data:'):
                continue
            data = line[5:].strip()
            if data == '[DONE]':
                break
            try:
                d = json.loads(data)
            except Exception:
                continue
            choices = d.get('choices') or []
            ch = choices[0].get('delta', {}) if choices else {}
            content = (ch.get('content') or '') if isinstance(ch, dict) else ''
            if content:
                yield content
            u = d.get('usage')
            if u and isinstance(usage_out, dict):
                usage_out.update(u)


def _routing_enabled():
    """模型路由总开关：AI_MODEL_ROUTING=off/0 时退回全 DeepSeek（一键回退）"""
    return os.environ.get('AI_MODEL_ROUTING', 'on').strip().lower() not in ('0', 'off', 'false', 'no')


def _is_complex(question, files, step):
    """复杂度判断：文件上传 / 四步闭环模式 / 深度分析意图词 → 复杂（走 DeepSeek）"""
    if files:
        return True
    if step:
        return True
    q = (question or '').strip()
    if not q:
        return False
    deep_words = ('分析', '对比', '报告', '生成', '策略', '方案', '复盘', '竞品', '360', '全景', '趋势', '预测',
                  '订单', '回款', 'kpi', '达成', '业绩', '金额', '账期', '应收')
    if any(w in q.lower() for w in deep_words):
        return True
    return False


def _route_model(question, files, step, model_pref, name, role):
    """决定本次用哪个模型，返回 (target_model, fallback_notice)。

    target_model ∈ {'glm', 'deepseek'}；fallback_notice 非空表示发生了降级（DeepSeek 额度用尽）。
    规则：
      - pref='glm'       → 直接 GLM（无限流）
      - pref='deepseek'  → DeepSeek（admin/gm 豁免限流；其余每人每天 DEEPSEEK_DAILY_LIMIT 次，超限降 GLM）
      - pref='auto'      → 复杂走 DeepSeek（同额度约束），简单走 GLM
      - 路由开关 off      → 一律 DeepSeek（一键回退，不限额）
    """
    if not _routing_enabled():
        return 'deepseek', ''
    is_admin = (role or 'sales') in ('admin', 'gm')
    if model_pref == 'glm':
        return 'glm', ''
    if model_pref == 'deepseek':
        if is_admin or _deepseek_quota_available(name):
            return 'deepseek', ''
        return 'glm', 'DeepSeek 今日额度已用完（每人每天 %d 次），已自动改用 GLM 快速模型。' % _get_ds_limit(name)
    # auto（默认）：复杂任务走 DeepSeek，简单任务走 GLM
    if _is_complex(question, files, step):
        if is_admin or _deepseek_quota_available(name):
            return 'deepseek', ''
        return 'glm', ''
    return 'glm', ''


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 页；扫描版无文字层时前 3 页 OCR 回退）"""
    import pymupdf
    doc = pymupdf.open(stream=raw, filetype='pdf')
    parts = []
    for i, page in enumerate(doc):
        if i >= 20:
            break
        txt = (page.get_text() or '').strip()
        if len(txt) < 30 and i < 3:
            # 扫描版（无文字层）→ 渲染为图片 OCR，限前 3 页防超时
            try:
                pix = page.get_pixmap(dpi=120)
                txt = (_parse_ocr(pix.tobytes('png'), 'png') or '').strip()
            except Exception:
                txt = ''
        if txt:
            parts.append(txt)
    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
        # ── 拜访意图检测：销售说"需要拜访XX"时，后端直接生成拜访卡 ──
        visit_cust = _detect_visit_intent(_q_for_search)
        if visit_cust:
            _visit_html = _build_visit_card(visit_cust, user)
            if _visit_html:
                import time as _t
                _fname = '%s_拜访卡_%s.html' % (_cust_short(_cust_core(visit_cust)) or visit_cust[:2], _t.strftime('%Y%m%d'))
                _full = '<<<FILE:%s>>>\n%s\n<<<END_FILE>>>\n\n✅ 已生成拜访卡（含分级、决策链链接、价格档案、客诉）。决策链人物、竞品份额等深度信息标了「待补充」，可继续问我补充。' % (_fname, _visit_html)
                _clean, _files = _extract_files(_full, user.get('name', ''))
                return 200, {'ok': True, 'answer': _clean, 'files': _files,
                             'visible_customers': len(vcust), 'visible_groups': len(vgroups),
                             'parsed_files': [], 'sources': ['拜访卡自动生成']}
        background = _search_background(_q_for_search, vcust, vgroups, (user.get('role') or 'sales') in ('admin', 'gm'))
        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, vcust, vgroups, (user.get('role') or 'sales') in ('admin', 'gm'))
        if cust360:
            background = (background + '\n\n' + cust360) if background else cust360
        if background:
            sys_prompt += '\n\n' + background + '\n\n（以上是系统检索到的飞书活动、产品、知识库背景，请结合这些信息回答用户问题，不要回复"无法访问飞书信息"。）'
        # 注入该用户更早的对话记忆（跨会话连贯）
        _mem = _load_memory(user.get('name', ''))
        if _mem:
            sys_prompt += '\n\n' + _mem
        # 解析上传文件
        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})
        # ── 模型路由（非流式路径，与 handle_stream 一致）──
        _mp = (payload.get('model') or 'auto').strip().lower()
        _tm, _fn = _route_model(_q_for_search, files, step, _mp, user.get('name', ''), user.get('role', ''))
        if _tm == 'deepseek':
            _consume_deepseek_quota(user.get('name', ''))
            answer, err, usage = _call_hermes(sys_prompt, history)
        else:
            answer, err = _call_glm(sys_prompt, history)
            usage = None
        if err:
            return 500, {'ok': False, 'error': err}
        clean_answer, files_out = _extract_files(answer, user.get('name', ''))
        # 记录本次调用的 token 消耗（按业务员 name 分桶，供数据管理 Tab 统计）
        _record_token_usage(user.get('name', ''), usage, question, _tm)
        # 组装溯源来源
        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': clean_answer, 'files': files_out,
                     '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 handle_stream(payload):
    """流式处理 /api/chat：yield 事件 dict，type ∈ {status, delta, done, error}

    与 handle() 逻辑一致（检索背景 → 调 Hermes），区别是：检索前先发 status、
    LLM 回答改走 SSE 逐段 yield delta，避免前端长时间无响应被 240s 超时切断。
    """
    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:
        yield {'type': 'error', 'text': 'question required'}
        return
    if not user.get('name'):
        yield {'type': 'error', 'text': '未登录，无法回答'}
        return
    # 立刻发第一个 status，让前端知道服务已收到（连接保持活跃）
    yield {'type': 'status', 'text': '正在检索业务背景…'}
    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]
        _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
        # ── 拜访意图检测：销售说"需要拜访XX"时，后端直接生成拜访卡 ──
        visit_cust = _detect_visit_intent(_q_for_search)
        if visit_cust:
            _visit_html = _build_visit_card(visit_cust, user)
            if _visit_html:
                import time as _t
                _fname = '%s_拜访卡_%s.html' % (_cust_short(_cust_core(visit_cust)) or visit_cust[:2], _t.strftime('%Y%m%d'))
                _full = '<<<FILE:%s>>>\n%s\n<<<END_FILE>>>\n\n✅ 已生成拜访卡（含分级、决策链链接、价格档案、客诉）。决策链人物、竞品份额等深度信息标了「待补充」，可继续问我补充。' % (_fname, _visit_html)
                _clean, _files = _extract_files(_full, user.get('name', ''))
                yield {'type': 'done',
                       'sources': ['拜访卡自动生成'],
                       'files': _files,
                       'model': 'visit-card',
                       'notice': '',
                       'visible_customers': len(vcust),
                       'visible_groups': len(vgroups),
                       'parsed_files': []}
                return
        background = _search_background(_q_for_search, vcust, vgroups, (user.get('role') or 'sales') in ('admin', 'gm'))
        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, vcust, vgroups, (user.get('role') or 'sales') in ('admin', 'gm'))
        if cust360:
            background = (background + '\n\n' + cust360) if background else cust360
        if background:
            sys_prompt += '\n\n' + background + '\n\n（以上是系统检索到的飞书活动、产品、知识库背景，请结合这些信息回答用户问题，不要回复"无法访问飞书信息"。）'
        # 注入该用户更早的对话记忆（跨会话连贯）
        _mem = _load_memory(user.get('name', ''))
        if _mem:
            sys_prompt += '\n\n' + _mem
        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)
        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})
        # ── 模型路由：auto / glm / deepseek（deepseek 受每日额度约束，admin/gm 豁免）──
        model_pref = (payload.get('model') or 'auto').strip().lower()
        target_model, fallback_notice = _route_model(
            _q_for_search, files, step, model_pref, user.get('name', ''), user.get('role', ''))
        if target_model == 'deepseek':
            _consume_deepseek_quota(user.get('name', ''))
        _model_label = {'glm': 'GLM 快速', 'deepseek': 'DeepSeek 深度'}.get(target_model, target_model)
        yield {'type': 'status', 'text': '已检索到背景，正在用 %s 思考回答…' % _model_label}
        usage = {}
        full_text = ''
        _stream_fn = _call_glm_stream if target_model == 'glm' else _call_hermes_stream
        for chunk in _stream_fn(sys_prompt, history, usage):
            if chunk:
                full_text += chunk
                yield {'type': 'delta', 'text': chunk}
        clean_text, files_out = _extract_files(full_text, user.get('name', ''))
        _record_token_usage(user.get('name', ''), usage, question, target_model)
        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 通用知识')
        yield {'type': 'done',
               'sources': sources,
               'files': files_out,
               'model': target_model,
               'notice': fallback_notice or '',
               'visible_customers': len(vcust),
               'visible_groups': len(vgroups),
               'parsed_files': upload_names}
    except Exception as e:
        yield {'type': 'error', 'text': 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


def _build_market_card(cust_name, user):
    """生成基础版市场调研卡 HTML（本地数据：画像/商机/决策链/竞对情报/价格档案）。
    深度竞品格局需 Hermes 主 Agent Web 搜索，此处标"待补充"。返回 html 或 None。"""
    import time
    def _lj(path):
        try:
            with open(os.path.join(DATA_DIR, path), encoding='utf-8') as f:
                return json.load(f)
        except Exception:
            return None
    dcl = _lj('decision_chain_links.json') or {}
    link = dcl.get(cust_name, {}) or {}
    opp = _lj('opps_by_customer.json') or {}
    olist = opp.get(cust_name, []) if isinstance(opp, dict) else []
    # 竞对情报（competitor_intel.json，匹配该客户的竞品记录）
    ci = _lj('competitor_intel.json') or {}
    ci_items = ci if isinstance(ci, list) else (ci.get('items', ci.get('data', [])) if isinstance(ci, dict) else [])
    core = _cust_core(cust_name)
    short = _cust_short(core) or cust_name[:2]
    comp_matches = []
    for x in (ci_items or []):
        if not isinstance(x, dict):
            continue
        cname = str(x.get('customer', ''))
        if (short and short in cname) or (core and core in cname):
            comp_matches.append(x)
    # 价格档案
    pa = _lj('_archive/price_archive.json') or {}
    prices = pa.get(cust_name, {}) if isinstance(pa, dict) else {}
    total_amt = 0
    top = []
    if isinstance(prices, dict):
        for k, v in prices.items():
            if isinstance(v, list):
                amt = sum(x.get('amt', 0) for x in v if isinstance(x, dict))
                total_amt += amt
                top.append((k, amt))
    top.sort(key=lambda x: -x[1])
    top3 = '、'.join([t[0].split('|')[0].strip()[:24] for t in top[:3]]) or '待补充'
    _raw_level = str(link.get('level', '') or '').strip()
    level_short = _raw_level.replace('类', '级') or '重点客户'
    owner = link.get('owner', '') or user.get('name', '')
    today = time.strftime('%Y-%m-%d')
    # 商机行
    opp_rows = ''
    for i, o in enumerate((olist or [])[:8]):
        if isinstance(o, dict):
            nm = o.get('name', '商机')
            st = o.get('status', '待确认')
            opp_rows += '<tr><td>%d</td><td>%s</td><td><span class="tag b">%s</span></td></tr>' % (i + 1, nm, st)
    if not opp_rows:
        opp_rows = '<tr><td colspan="3" style="color:#9aa0a6">暂无商机记录</td></tr>'
    # 竞对行
    comp_rows = ''
    for x in comp_matches[:8]:
        brand = x.get('brand', x.get('competitor', '待确认'))
        prod = x.get('product', '')
        note = x.get('note', '')
        comp_rows += '<tr><td>%s</td><td>%s</td><td>%s</td></tr>' % (brand, prod, note)
    if not comp_rows:
        comp_rows = '<tr><td colspan="3" style="color:#9aa0a6">暂无竞对情报沉淀，需 Web 搜索补充</td></tr>'
    # 决策链链接
    durl = link.get('decision_chain_url', '')
    clink = link.get('customer_link', '')
    link_btns = ''
    if durl:
        link_btns += '<a class="btn" href="%s" target="_blank">🔗 打开飞书决策链</a>' % durl
    if clink:
        link_btns += '<a class="btn" href="%s" target="_blank">📋 打开飞书客户档案</a>' % clink
    if not link_btns:
        link_btns = '<span style="color:#9aa0a6">暂无飞书决策链链接</span>'
    html = '''<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>%s · 市场调研卡（基础版）</title>
<style>
:root{--pri:#1a73e8;--pri-d:#0d47a1;--bg:#f5f7fa;--card:#fff;--ink:#202124;--sub:#5f6368;--line:#e8eaed;--ok:#10ac84;--warn:#f2994a;--bad:#eb5757}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;background:var(--bg);color:var(--ink);padding:16px;line-height:1.65}
.wrap{max-width:980px;margin:0 auto}
.banner{background:linear-gradient(135deg,#0d47a1,#1565c0,#1a73e8);border-radius:14px;padding:24px 28px;color:#fff;box-shadow:0 4px 16px rgba(13,71,161,.25)}
.banner h1{font-size:21px}.banner .sub{font-size:13px;opacity:.9;margin-top:4px}
.badge{display:inline-block;background:rgba(255,255,255,.22);border-radius:20px;padding:2px 12px;font-size:12px;margin-left:10px;vertical-align:middle}
section{background:var(--card);border-radius:12px;padding:20px 22px;margin-top:16px;box-shadow:0 1px 3px rgba(0,0,0,.05)}
h2{font-size:16px;color:var(--pri-d);margin-bottom:14px;padding-left:10px;border-left:4px solid var(--pri)}
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px}
.kpi{background:var(--bg);border-radius:10px;padding:14px 16px;border:1px solid var(--line)}
.kpi .v{font-size:20px;font-weight:800;color:var(--pri)}.kpi .l{font-size:12px;color:var(--sub);margin-top:2px}
table{width:100%%;border-collapse:collapse;font-size:13px}
th{background:#f1f3f4;color:#5f6368;text-align:left;padding:9px 10px;font-weight:600;border-bottom:2px solid var(--line)}
td{padding:9px 10px;border-bottom:1px solid var(--line);vertical-align:top}
tr:last-child td{border-bottom:none}
.tag{display:inline-block;font-size:11px;border-radius:4px;padding:1px 7px;background:#e8f0fe;color:var(--pri)}
.btn{display:inline-block;background:var(--pri);color:#fff;text-decoration:none;border-radius:8px;padding:8px 16px;font-size:13px;margin:4px 6px 4px 0}
.note{background:#eef4fd;border:1px solid #c9ddf6;border-radius:10px;padding:14px 16px;font-size:13px;margin-top:12px}
</style></head><body><div class="wrap">
<div class="banner"><h1>%s <span class="badge">市场调研卡 · 基础版</span></h1><div class="sub">级别：%s ｜ 对接销售：%s ｜ 编制：%s</div></div>
<section><h2>一、赛道定位</h2><table>
<tr><th style="width:120px">维度</th><th>结论</th></tr>
<tr><td>客户名称</td><td>%s</td></tr>
<tr><td>客户分级</td><td><span class="tag">%s</span></td></tr>
<tr><td>决策链</td><td>%s</td></tr>
<tr><td>终端赛道</td><td><span style="color:var(--warn)">待确认</span>（需 Web 搜索判断：人用IVD / 宠物诊断 / 科研服务 / 食品安全检测）</td></tr>
</table></section>
<section><h2>二、关键数据</h2><div class="kpis">
<div class="kpi"><div class="v">%d</div><div class="l">在跟商机数</div></div>
<div class="kpi"><div class="v">%.1f 万</div><div class="l">累计采购额</div></div>
<div class="kpi"><div class="v">%d</div><div class="l">竞对情报条数</div></div>
<div class="kpi"><div class="v">%s</div><div class="l">主采产品 TOP</div></div>
</div></section>
<section><h2>三、在跟商机</h2><table><tr><th>#</th><th>商机名称</th><th>状态</th></tr>%s</table></section>
<section><h2>四、竞对情报（本地沉淀）</h2><table><tr><th>竞品品牌</th><th>产品/项目</th><th>备注</th></tr>%s</table>
<div class="note">⚠️ 深度竞品格局（终端赛道竞争格局、市场规模、竞品对比）需 Hermes 主 Agent 执行 Web 搜索补全。本基础版仅含本地沉淀数据。</div>
</section>
<section><h2>五、宝锐协同角色</h2><table>
<tr><th style="width:140px">主采产品</th><td>%s</td></tr>
<tr><th>累计采购额</th><td>%.1f 万</td></tr>
</table></section>
</div></body></html>''' % (short, short, level_short, owner, today,
    cust_name, level_short, link_btns,
    len(olist or []), total_amt / 10000.0, len(comp_matches), top3,
    opp_rows, comp_rows, top3, total_amt / 10000.0)
    return html


def _process_visit_request(req, user):
    """处理一个拜访请求：按 types 生成拜访卡 + 市场调研卡。返回 (files, err)。"""
    customer = (req.get('customer') or '').strip()
    if not customer:
        return [], 'customer required'
    types = req.get('types') or []
    if not types:
        types = ['拜访卡']
    core = _cust_core(customer)
    short = _cust_short(core) or customer[:2]
    card_dir = os.path.join(ROOT, '客户管理', '拜访卡')
    mkt_dir = os.path.join(ROOT, '客户管理')
    files = []
    errs = []
    if '拜访卡' in types:
        try:
            html = _build_visit_card(customer, user)
            if html:
                os.makedirs(card_dir, exist_ok=True)
                out = os.path.join(card_dir, '%s_拜访卡.html' % short)
                with open(out, 'w', encoding='utf-8') as f:
                    f.write(html)
                files.append({'type': '拜访卡', 'path': os.path.relpath(out, ROOT), 'customer': customer})
            else:
                errs.append('拜访卡生成失败')
        except Exception as e:
            errs.append('拜访卡异常: %s' % e)
    if '市场调研卡' in types:
        try:
            html = _build_market_card(customer, user)
            if html:
                os.makedirs(mkt_dir, exist_ok=True)
                out = os.path.join(mkt_dir, '%s_市场调研卡.html' % short)
                with open(out, 'w', encoding='utf-8') as f:
                    f.write(html)
                files.append({'type': '市场调研卡', 'path': os.path.relpath(out, ROOT), 'customer': customer})
            else:
                errs.append('市场调研卡生成失败')
        except Exception as e:
            errs.append('市场调研卡异常: %s' % e)
    return files, '; '.join(errs) if errs else ''
