#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
用智谱 GLM 从飞书活动记录里提取「客户特殊要求」→ 数据/customer_special_requirements.json
================================================================================
两段式，可断点续跑：
  1) --filter   : 清洗 5,149 条活动 → 关键词预筛 → 数据/_sr_input.json（候选）
  2) --run      : 按批送 GLM 提取 → 追加写 数据/_sr_extracted.json（按活动 id 去重，可反复跑）
     --limit N  : 只跑前 N 条候选（先做小样本验证）

GLM 凭证直读 ~/.hermes/config.yaml 的 providers.zhipu（复用 ai_chat._call_glm），
授权码/API key 绝不打印、绝不外发。
"""
import argparse
import html
import json
import os
import re
import sys
import time

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, os.path.join(ROOT, '脚本'))
import ai_chat  # noqa: E402

DA = os.path.join(ROOT, '数据')
SRC = os.path.join(DA, 'daily_activities_mcp.json')
INP = os.path.join(DA, '_sr_input.json')
OUT = os.path.join(DA, '_sr_extracted.json')
BATCH = 12

CATEGORIES = ['搭配规则', '配方/体系', '包装/标签', '交付/物流', '质检/文件', '价格/账期', '其他']

# 预筛：出现这些词的活动更可能藏着特殊要求（宁宽勿窄，第二道由 GLM 判）
KW = ('要求', '指定', '必须', '不得', '不能含', '不含', '定制', '标签', '包装', '配方', '体系', '搭配',
      '保护剂', '缓冲', '浓度', '避光', '冻干', '效期', '规格', '装量', '替换', '替代', '专供',
      'OEM', '贴牌', '打印', '说明书', '质检', 'COA', '出厂', '批号', '留样', '稳定性', '灵敏度',
      '背景', '无核酸酶', '无 RNase', 'RNase-free', '内标', '标准品', '对照', '线性', '重复性',
      '上机', '适配', '兼容', '稀释', '复溶', '分装', '冷藏', '冰袋', '冷链', '运费', '账期', '开票',
      '含税', '单价', '折扣', '涨价', '降价', '报备', '授权', '独家', '试用', '测试装', '小样')


def strip_html(s):
    s = html.unescape(s or '')
    s = re.sub(r'<[^>]+>', ' ', s)
    s = re.sub(r'&nbsp;|\u00a0', ' ', s)
    s = re.sub(r'\s+', ' ', s)
    return s.strip()


def load_activities():
    d = json.load(open(SRC, encoding='utf-8'))
    recs = d if isinstance(d, list) else (d.get('activities') or d.get('items') or [])
    out = []
    for r in recs:
        txt = strip_html(r.get('field_b99055'))
        if len(txt) < 12:
            continue
        cust = ''
        m = re.search(r"'label':\s*'([^']+)'", str(r.get('field_5f20fc') or ''))
        if m:
            cust = m.group(1).strip()
        owner = ''
        m2 = re.search(r"'email':\s*'([^']+)'", str(r.get('owner') or ''))
        if m2:
            owner = m2.group(1)
        out.append({
            'id': str(r.get('work_item_id') or ''),
            'date': str(r.get('start_time') or '')[:10],
            'type': str(r.get('name') or ''),
            'customer': cust,
            'owner': owner,
            'text': txt,
        })
    return out


def load_xlsx(path, txtcol='活动描述', custcol='活动关联客户', owncol='客户-所属人', datecol='创建时间'):
    """飞书活动导出 xlsx（H1/月度）→ 同构记录"""
    import pandas as pd
    if not os.path.exists(path):
        return []
    try:
        d = pd.read_excel(path)
    except Exception:
        return []
    out = []
    for idx, (_, r) in enumerate(d.iterrows()):
        txt = strip_html(str(r.get(txtcol) or ''))
        if len(txt) < 12:
            continue
        cust = str(r.get(custcol) or '').strip()
        # 🔴 空单元格经 pandas 变成 float('nan')，`nan or ''` 仍是 nan（真值！），str() 后得到字符串 'nan'
        #    → 必须显式归一，否则脏客户名会一路流到候选库/复核页（2026-09-20 实测 3 条活动 6 行）
        if cust.lower() in ('nan', 'none', 'null', 'nat'):
            cust = ''
        out.append({
            # xlsx 导出没有工作项ID；用「文件短名#行号」保证可回溯到导出文件的哪一行（别用 hash，飞书查不到）
            'id': '%s#%d' % (os.path.basename(path).replace('.xlsx', '')[:22], idx + 1),
            'date': str(r.get(datecol) or '')[:10],
            'type': str(r.get('名称') or r.get('活动类型') or ''),
            'customer': cust,
            'owner': str(r.get(owncol) or ''),
            'text': txt,
        })
    return out


XLSX_SOURCES = [
    os.path.expanduser('~/Desktop/hermas输入-工作台数据库/飞书活动/2026上半年活动导出-销售管理.xlsx'),
    os.path.expanduser('~/Desktop/hermas输入-工作台数据库/飞书活动/活动导出-销售管理-20260601-0706.xlsx'),
    os.path.expanduser('~/Desktop/hermas输入-工作台数据库/飞书活动/活动导出-销售管理-2026年7月1日～20日.xlsx'),
    os.path.expanduser('~/Desktop/hermas输入-工作台数据库/飞书活动/活动导出-销售管理20260701-0709.xlsx'),
    os.path.expanduser('~/Desktop/hermas输入-工作台数据库/2026年7月月报/活动导出-销售管理-2026年7月.xlsx'),
]


def load_all():
    """MCP 近期 + 历史 xlsx 导出，按（客户+日期+正文前 120 字）去重"""
    acts = load_activities()
    for p in XLSX_SOURCES:
        acts += load_xlsx(p)
    seen, uniq = set(), []
    for a in acts:
        k = (a['customer'], a['date'], a['text'][:120])
        if k in seen:
            continue
        seen.add(k)
        uniq.append(a)
    return uniq


def do_filter():
    acts = load_all()
    cand = [a for a in acts if a['customer'] and any(k.lower() in a['text'].lower() for k in KW)]
    json.dump({'total': len(acts), 'candidates': cand}, open(INP, 'w', encoding='utf-8'),
              ensure_ascii=False, indent=1)
    print('  活动总数（去重后） %d ｜ 有客户名 %d ｜ 关键词候选 %d' % (
        len(acts), sum(1 for a in acts if a['customer']), len(cand)))
    if acts:
        ds = sorted(a['date'] for a in acts if a['date'])
        print('  日期范围: %s ~ %s' % (ds[0], ds[-1]))
    from collections import Counter
    c = Counter(a['customer'] for a in cand)
    print('  候选客户数 %d ｜ 候选最多的客户: %s' % (len(c), '、'.join('%s(%d)' % (k, v) for k, v in c.most_common(6))))
    return cand


SYS = """你是宝锐生物（分子诊断原料供应商）的销售数据提炼助手。你的任务：从飞书活动（拜访/沟通）记录中，识别出**客户对宝锐的"特殊要求"**——即区别于默认/标准做法、需要特别记住并遵守的事项。

只提取明确出自客户（或客户与我方约定的）要求，例如：
- 搭配规则：某货号必须配某个保护剂/缓冲液/体系
- 配方/体系：客户自己的体系对酶/缓冲有特定浓度、组分、替换要求
- 包装/标签：指定标签内容、分装规格、贴牌/OEM、打印要求
- 交付/物流：冷链、冰袋、发货时间、指定快递、分批交付
- 质检/文件：要 COA、要留样、要特定批号、要出厂检测项
- 价格/账期：指定价格、账期、开票方式、独家/报备约定
- 其他

**不要提取**：一般的项目进展、客套寒暄、正在测试/试用但没提要求的、我方单方面的推销话术、笼统的"客户很满意"。没有把握就不提。

输出**严格 JSON 数组**（不要 markdown 代码块、不要解释文字），每项：
{"活动id":"<原样>","客户":"<原样>","类别":"<7类之一>","标题":"≤18字短语","内容":"一句到两句话，具体到货号/规格/数值，不得编造","涉及货号":["..."],"依据":"<引用活动原文里最关键的一句，≤60字>","置信度":"高|中|低"}
没有任何特殊要求的记录一律不输出（可以返回空数组 []）。"""


def do_run(limit=0, batch=BATCH, shard=0, shards=1):
    inp = json.load(open(INP, encoding='utf-8'))
    cand = inp['candidates']
    if shards > 1:
        cand = [a for i, a in enumerate(cand) if i % shards == shard]
    out_path = OUT if shards == 1 else OUT.replace('.json', '_s%d.json' % shard)
    done = {}
    if os.path.exists(out_path):
        prev = json.load(open(out_path, encoding='utf-8'))
        done = {k: v for k, v in (prev.get('by_activity') or {}).items()}
    todo = [a for a in cand if a['id'] not in done]
    if limit:
        todo = todo[:limit]
    print('  分片 %d/%d ｜ 候选 %d ｜ 已处理 %d ｜ 本次处理 %d（每批 %d 条）' % (
        shard + 1, shards, len(cand), len(done), len(todo), batch))
    found = list((json.load(open(out_path, encoding='utf-8')).get('items') or [])) if os.path.exists(out_path) else []
    ok = err = 0
    for i in range(0, len(todo), batch):
        grp = todo[i:i + batch]
        payload = [{'活动id': a['id'], '日期': a['date'], '客户': a['customer'], '销售员': a['owner'], '内容': a['text'][:900]} for a in grp]
        content, e = None, None
        for _try in range(2):                     # 超时/网络异常重试一次，绝不带走整个分片
            try:
                content, e = ai_chat._call_glm(SYS, [{'role': 'user', 'content': '请从以下 %d 条活动记录中提取特殊要求：\n%s' % (len(grp), json.dumps(payload, ensure_ascii=False))}])
                break
            except Exception as ex:
                content, e = None, str(ex)
                time.sleep(3 + _try * 3)
        if e or not content:
            err += 1
            print('   ⚠️ 第 %d 批失败: %s' % (i // batch + 1, str(e)[:90]))
            time.sleep(2)
            continue
        m = re.search(r'\[.*\]', content, re.S)
        items = []
        if m:
            try:
                items = json.loads(m.group(0))
            except Exception:
                try:
                    items = json.loads(m.group(0).replace('\n', ' '))
                except Exception:
                    items = []
        for it in items if isinstance(items, list) else []:
            if isinstance(it, dict) and it.get('内容'):
                found.append(it)
        for a in grp:
            done[a['id']] = 1
        ok += 1
        print('   分片%d 批 %d/%d ✓ 抽出 %d 条（累计 %d）' % (shard + 1, i // batch + 1, (len(todo) + batch - 1) // batch, len(items) if isinstance(items, list) else 0, len(found)))
        json.dump({'updated': time.strftime('%Y-%m-%d %H:%M:%S'), 'by_activity': done, 'items': found},
                  open(out_path, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
    print('  分片 %d 完成：成功 %d 批 ｜ 失败 %d 批 ｜ 累计抽出 %d 条' % (shard + 1, ok, err, len(found)))
    return found


def merge_shards(shards):
    all_items, all_done = [], {}
    for i in range(shards):
        p = OUT.replace('.json', '_s%d.json' % i)
        if not os.path.exists(p):
            continue
        d = json.load(open(p, encoding='utf-8'))
        all_items += (d.get('items') or [])
        all_done.update(d.get('by_activity') or {})
    json.dump({'updated': time.strftime('%Y-%m-%d %H:%M:%S'), 'by_activity': all_done, 'items': all_items},
              open(OUT, 'w', encoding='utf-8'), ensure_ascii=False, indent=1)
    print('  合并 %d 个分片 → %d 条抽出 ｜ 覆盖活动 %d 条' % (shards, len(all_items), len(all_done)))
    return all_items


if __name__ == '__main__':
    ap = argparse.ArgumentParser()
    ap.add_argument('--filter', action='store_true')
    ap.add_argument('--run', action='store_true')
    ap.add_argument('--limit', type=int, default=0)
    ap.add_argument('--batch', type=int, default=BATCH)
    ap.add_argument('--shard', type=int, default=0)
    ap.add_argument('--shards', type=int, default=1)
    ap.add_argument('--merge', type=int, default=0)
    a = ap.parse_args()
    if a.merge:
        merge_shards(a.merge)
    elif a.filter or not (a.filter or a.run):
        do_filter()
    if a.run:
        do_run(a.limit, a.batch, a.shard, a.shards)
