#!/usr/bin/env python3
"""
飞书问题反馈同步脚本（五步完整流程）
功能：
  步骤1：从 MySQL 读取飞书群消息
  步骤2：写入知识库电子表格
  步骤3：反馈对话链排序
  步骤4：问题归纳 → 输出到「用户反馈问题汇总」
  步骤5：优先级判定与排序 → 按 P0>P1>P2>P3 重排输出

用法:
  python3 sync_feishu_feedback.py [--days N] [--dry-run] [--skip-priority]

  --days N          同步最近 N 天（默认 3）
  --dry-run         仅打印操作不写入
  --skip-priority   跳过步骤5优先级判定
"""

import sys, os, json, subprocess, argparse, re, fcntl
from datetime import date, datetime, timedelta
from collections import defaultdict, deque

# 确保能找到同级目录下的 priority_classifier
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from priority_classifier import compute_final_priority, sort_by_priority

# === 配置 ===
MYSQL_HOST = "bj-cdb-8frbdwju.sql.tencentcdb.com"
MYSQL_PORT = 25413
MYSQL_USER = "chatbot"
MYSQL_PASS = "xhuBx7d@uT2gUVv"
MYSQL_DB = "vala_test"

SPREADSHEET_TOKEN = "AHtnsehwShUVyDtjasSciIvgn7b"
CLI = "/root/.nvm/versions/node/v24.14.0/bin/lark-cli"

HEADER = ["消息ID", "发送者", "消息类型", "内容", "媒体URL", "引用消息ID", "消息时间", "消息时间戳"]

# === 分发配置 ===
DISPATCH_CHAT_ID = "oc_4171a2188f2554522a4309f2d7c27753"  # 「小葵小葵」问题反馈群
DISPATCH_CRED_DIR = "/root/.openclaw/credentials/xiaokui"
XIAOKUI_BOT_OPEN_ID = "ou_fdbf5fdafd91670db34b6ac887f30fb7"  # 小葵Bot的open_id，用于校验文档归属

# P0问题默认@的人（user_id）
P0_NOTIFY_USERS = [
    "eggbg21g",  # 毋益飞
]

# 推断引用策略参数
TIME_WINDOW_MIN = 2        # 同发送者聚类时间窗口（分钟）
GAP_THRESHOLD_MIN = 30     # 大时间跨度视为新话题（分钟）
SIMILARITY_FLOOR = 0.08    # 推断引用的最小内容相似度（低于此值视为话题切换）
MAX_CLUSTER_MSGS = 15      # 单簇最大消息数（超过则尝试拆分）


def content_similarity(text1, text2):
    """计算两条消息的内容相似度（中文分词 + Jaccard 系数）
    用于判断推断引用时是否发生话题切换。
    """
    if not text1 or not text2:
        return 0.0
    # 提取中文词（2字以上）和英文词（3字以上），过滤纯数字和标点
    t1 = set(re.findall(r'[\u4e00-\u9fff]{2,}|[a-zA-Z]{3,}', str(text1)))
    t2 = set(re.findall(r'[\u4e00-\u9fff]{2,}|[a-zA-Z]{3,}', str(text2)))
    if not t1 or not t2:
        return 0.0
    # 去掉停用词级别的短词
    stop_words = {'收到', '好的', '明白', '可以', '这个', '那个', '请问', '图片', '文件', '媒体', '报告'}
    t1 = t1 - stop_words
    t2 = t2 - stop_words
    if not t1 or not t2:
        return 0.0
    intersection = t1 & t2
    union = t1 | t2
    return len(intersection) / len(union)


def get_env():
    return {
        "PATH": "/root/.nvm/versions/node/v24.14.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
        "LARKSUITE_CLI_CONFIG_DIR": "/root/.openclaw/credentials/xiaokui",
        "HOME": "/root",
    }


def get_db_connection():
    import pymysql
    return pymysql.connect(
        host=MYSQL_HOST, port=MYSQL_PORT,
        user=MYSQL_USER, password=MYSQL_PASS,
        database=MYSQL_DB, charset="utf8mb4"
    )


def query_messages(start_date, end_date):
    """查询指定日期范围的飞书群消息"""
    conn = get_db_connection()
    cursor = conn.cursor()
    cursor.execute("""
        SELECT message_id, sender_name, msg_type, content, media_url, quote_message_id,
               DATE_FORMAT(msg_time, '%%Y-%%m-%%d %%H:%%i:%%s') as msg_time, msg_timestamp
        FROM lark_group_message
        WHERE msg_time >= %s AND msg_time < %s
        ORDER BY msg_time ASC
    """, (f"{start_date} 00:00:00", f"{end_date} 00:00:00"))
    rows = cursor.fetchall()
    conn.close()
    return rows


def get_existing_sheets():
    """获取当前表格的所有 sheet"""
    env = get_env()
    result = subprocess.run(
        [CLI, "sheets", "+info", "--spreadsheet-token", SPREADSHEET_TOKEN, "--as", "bot"],
        env=env, capture_output=True, text=True, timeout=15
    )
    data = json.loads(result.stdout)
    return {s["title"]: s["sheet_id"] for s in data["data"]["sheets"]["sheets"]}


def create_sheet(title):
    """创建新 sheet 并返回 sheet_id"""
    env = get_env()
    body = json.dumps({"requests": [{"addSheet": {"properties": {"title": title}}}]})
    result = subprocess.run(
        [CLI, "api", "POST",
         f"/open-apis/sheets/v2/spreadsheets/{SPREADSHEET_TOKEN}/sheets_batch_update",
         "--data", body, "--as", "bot"],
        env=env, capture_output=True, text=True, timeout=15
    )
    rj = json.loads(result.stdout)
    if rj.get("code") == 0:
        return rj["data"]["replies"][0]["addSheet"]["properties"]["sheetId"]
    raise Exception(f"Failed to create sheet: {rj}")


def write_sheet(sheet_id, rows):
    """写入数据到指定 sheet（覆盖模式）"""
    env = get_env()
    all_rows = [HEADER] + [[str(c) if c is not None else "" for c in r] for r in rows]
    
    # 分批写入（最多 200 行/批，包含表头）
    batch_size = 200
    for batch_start in range(0, len(all_rows), batch_size):
        batch = all_rows[batch_start:batch_start + batch_size]
        values_json = json.dumps(batch, ensure_ascii=False)
        start_row = batch_start + 1
        end_row = start_row + len(batch) - 1
        result = subprocess.run(
            [CLI, "sheets", "+write", "--spreadsheet-token", SPREADSHEET_TOKEN,
             "--range", f"{sheet_id}!A{start_row}:H{end_row}",
             "--values", values_json, "--as", "bot"],
            env=env, capture_output=True, text=True, timeout=30
        )
        d = json.loads(result.stdout)
        if not d.get("ok"):
            print(f"  Write error at batch {batch_start}: {result.stdout[:200]}")
            return False
    return True


# ============================================================
# 策略2：推断缺失的引用关系
# ============================================================

def parse_time(ts):
    return datetime.strptime(ts, '%Y-%m-%d %H:%M:%S')


def extract_mentions(content):
    """提取消息中的 @用户名"""
    if not content:
        return []
    return re.findall(r'@(\S+)', content)


def infer_missing_references(rows):
    """
    推断没有 quote_message_id 的消息的引用关系。
    
    规则（按优先级）：
    1. @提及匹配：消息中 @了某人，关联到被@者最近一条消息
    2. 同发送者聚类：同一发送者在 2 分钟窗口内连续发多条消息，
       认为这些消息是对同一目标（其前一条不同发送者消息）的回复
    3. 最近不同发送者：关联到最近一条不同发送者的消息（需在 30 分钟内）
    
    返回: [(child_message_id, inferred_parent_message_id), ...]
    """
    added = []
    
    for i, r in enumerate(rows):
        mid = r[0]
        if r[5]:  # 已有 quote_message_id，跳过
            continue
        
        content = r[3] or ''
        sender = r[1]
        ts = parse_time(r[6])
        
        # 策略 1: @提及匹配（最高优先级，最可靠）
        mentioned = extract_mentions(content)
        mention_parent = None
        if mentioned:
            for j in range(i - 1, -1, -1):
                if rows[j][1] in mentioned:
                    mention_parent = rows[j][0]
                    break
        
        # 策略 2: 同发送者聚类
        same_sender_parent = None
        for j in range(i - 1, max(0, i - 5), -1):  # 往前看 5 条
            if rows[j][1] == sender:
                diff = (ts - parse_time(rows[j][6])).total_seconds() / 60
                if diff <= TIME_WINDOW_MIN:
                    # 内容相似度检查：同发送者短时间内的续话应有关联
                    if content_similarity(content, rows[j][3]) < SIMILARITY_FLOOR:
                        break  # 内容差异大，可能是发新话题
                    if rows[j][5]:
                        same_sender_parent = rows[j][5]
                    else:
                        # 同发送者无引用，关联到它的前一条不同发送者（需在 GAP 限制内）
                        for k in range(j - 1, -1, -1):
                            if rows[k][1] != sender:
                                k_diff = (ts - parse_time(rows[k][6])).total_seconds() / 60
                                if k_diff <= GAP_THRESHOLD_MIN:
                                    same_sender_parent = rows[k][0]
                                break
                    break
        
        # 策略 3: 最近不同发送者（gap + 内容相似度双重校验防跨话题）
        prev_other = None
        for j in range(i - 1, -1, -1):
            if rows[j][1] != sender:
                diff = (ts - parse_time(rows[j][6])).total_seconds() / 60
                if diff <= GAP_THRESHOLD_MIN:
                    # 内容相似度检查：当前消息和前一条不同发送者的消息是否话题相关
                    if content_similarity(content, rows[j][3]) >= SIMILARITY_FLOOR:
                        prev_other = rows[j][0]
                break
        
        # 优先级：@提及 > 同发送者聚类 > 最近不同发送者
        parent = mention_parent or same_sender_parent or prev_other
        if parent:
            added.append((mid, parent))
    
    return added


# ============================================================
# 反馈对话链排序
# ============================================================

def sort_threads(rows):
    """
    反馈对话链排序：
    1. 先通过策略2推断缺失的引用关系
    2. 通过引用链（quote_message_id）将消息聚合为问题簇
    3. 每个簇内递归展开：子回复紧跟父消息
    4. 簇间按最早消息时间排序
    
    返回: (ordered_rows, clusters_dict, cluster_order)
    """
    if not rows:
        return rows, {}, []

    # 推断缺失引用
    inferred = infer_missing_references(rows)
    if inferred:
        print(f"   🔗 推断 {len(inferred)} 条缺失引用关系")

    # 注入推断的引用
    enriched_rows = [list(r) for r in rows]
    row_map = {r[0]: r for r in enriched_rows}
    for child_mid, parent_mid in inferred:
        idx = next(i for i, r in enumerate(enriched_rows) if r[0] == child_mid)
        enriched_rows[idx][5] = parent_mid

    msg_map = {r[0]: r for r in enriched_rows}
    msg_idx = {r[0]: i for i, r in enumerate(enriched_rows)}

    # Union-Find 聚类：有引用关系的消息归为同一簇
    parent = {r[0]: r[0] for r in enriched_rows}
    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x
    def union(a, b):
        pa, pb = find(a), find(b)
        if pa != pb:
            if msg_idx.get(pa, 999) < msg_idx.get(pb, 999):
                parent[pb] = pa
            else:
                parent[pa] = pb

    for r in enriched_rows:
        if r[5] and r[5] in parent:
            union(r[0], r[5])

    clusters = defaultdict(list)
    for r in enriched_rows:
        clusters[find(r[0])].append(r)

    # 合并孤立单簇（只有1条消息的簇，尝试合并到相邻时间上最近的簇）
    def try_merge_orphan_clusters(clusters_dict):
        """将只有1-2条消息的孤立簇合并到最近的大簇"""
        orphan_ids = [cid for cid, cmsgs in clusters_dict.items() if len(cmsgs) <= 2]
        big_cluster_ids = [cid for cid, cmsgs in clusters_dict.items() if len(cmsgs) > 2]
        
        if not orphan_ids or not big_cluster_ids:
            return clusters_dict
        
        for orphan_id in orphan_ids:
            orphan_msgs = clusters_dict[orphan_id]
            orphan_time = min(parse_time(m[6]) for m in orphan_msgs)
            orphan_senders = set(m[1] for m in orphan_msgs)
            
            # 找时间上最近的大簇
            best_cid = None
            best_gap = float('inf')
            for bcid in big_cluster_ids:
                b_msgs = clusters_dict[bcid]
                b_time = min(parse_time(m[6]) for m in b_msgs)
                gap = abs((orphan_time - b_time).total_seconds() / 60)
                # 优先：发送者有交集 & 时间间隔 < 60 分钟
                b_senders = set(m[1] for m in b_msgs)
                has_overlap = bool(orphan_senders & b_senders)
                if has_overlap and gap < 60 and gap < best_gap:
                    best_gap = gap
                    best_cid = bcid
                elif not has_overlap and gap < 10 and gap < best_gap:
                    best_gap = gap
                    best_cid = bcid
            
            if best_cid:
                clusters_dict[best_cid].extend(orphan_msgs)
                del clusters_dict[orphan_id]
        
        return clusters_dict
    
    clusters = try_merge_orphan_clusters(dict(clusters))
    clusters = defaultdict(list, clusters)

    # 拆分超大簇：>MAX_CLUSTER_MSGS 条消息的簇按内容相似度切分
    def split_oversized_clusters(clusters_dict):
        """对超过 MAX_CLUSTER_MSGS 条的簇，按内容相似度找边界拆分成子簇"""
        oversized_ids = [cid for cid, cmsgs in clusters_dict.items() if len(cmsgs) > MAX_CLUSTER_MSGS]
        if not oversized_ids:
            return clusters_dict
        
        for oid in oversized_ids:
            msgs = sorted(clusters_dict[oid], key=lambda m: m[6])
            if len(msgs) <= MAX_CLUSTER_MSGS:
                continue
            
            # 计算相邻消息间的相似度，找低相似度边界
            boundaries = []  # (pos, similarity) — 在 pos 之后切分
            for i in range(len(msgs) - 1):
                sim = content_similarity(msgs[i][3], msgs[i+1][3])
                # 在 sender 切换 + 内容相似度极低的位置标记为边界
                if msgs[i][1] != msgs[i+1][1] and sim < 0.05:
                    boundaries.append((i, sim))
            
            if not boundaries:
                continue
            
            # 按相似度最低的边界切分（每条边界产生一个新簇）
            boundaries.sort(key=lambda x: x[1])  # 最低相似度优先
            split_points = set()
            for pos, sim in boundaries:
                if len(split_points) >= 3:  # 最多拆 3 次
                    break
                # 保证拆分后每段至少 3 条消息
                prev_points = sorted(split_points)
                if any(abs(pos - p) < 3 for p in prev_points):
                    continue
                split_points.add(pos)
            
            if not split_points:
                continue
            
            # 执行拆分
            sorted_bps = sorted(split_points)
            prev = 0
            del clusters_dict[oid]
            for bp in sorted_bps:
                sub = msgs[prev:bp+1]
                clusters_dict[sub[0][0]] = sub
                prev = bp + 1
            sub = msgs[prev:]
            if sub:
                clusters_dict[sub[0][0]] = sub
        
        return clusters_dict
    
    clusters = split_oversized_clusters(dict(clusters))
    clusters = defaultdict(list, clusters)

    # 簇间按最早时间排序
    cluster_order = sorted(clusters.keys(),
                           key=lambda cid: min(m[6] for m in clusters[cid]))

    # 簇内递归展开：子回复紧跟父消息（子节点按时间排序）
    def recursive_sort(mid, ids, mdict):
        result = []
        if mid not in mdict:
            return result
        result.append(mdict[mid])
        children = sorted(
            [m for m in mdict.values() if m[5] == mid],
            key=lambda x: x[6]
        )
        for child in children:
            result += recursive_sort(child[0], ids, mdict)
        return result

    ordered = []
    for root_id in cluster_order:
        cluster_msgs = clusters[root_id]
        ids = {m[0] for m in cluster_msgs}
        mdict = {m[0]: m for m in cluster_msgs}

        # 找根：引用的消息不在本簇内，或无引用
        roots = [m for m in cluster_msgs if not m[5] or m[5] not in ids]
        roots.sort(key=lambda x: x[6])

        seen = set()
        for root in roots:
            for m in recursive_sort(root[0], ids, mdict):
                if m[0] not in seen:
                    seen.add(m[0])
                    ordered.append(m)
        # 补上遗漏的
        for m in cluster_msgs:
            if m[0] not in seen:
                ordered.append(m)

    return ordered, clusters, cluster_order


def sort_cluster_msgs(cluster_msgs):
    """
    对单个簇内的消息进行递归排序（根消息时间线 + 子回复递归展开）。
    cluster_msgs: list of message rows
    返回: 排序后的消息列表
    """
    ids = {m[0] for m in cluster_msgs}
    mdict = {m[0]: m for m in cluster_msgs}

    roots = [m for m in cluster_msgs if not m[5] or m[5] not in ids]
    roots.sort(key=lambda x: x[6])

    def rec(mid):
        result = []
        if mid not in mdict:
            return result
        result.append(mdict[mid])
        children = sorted([m for m in mdict.values() if m[5] == mid], key=lambda x: x[6])
        for c in children:
            result += rec(c[0])
        return result

    seen = set()
    ordered = []
    for r in roots:
        for m in rec(r[0]):
            if m[0] not in seen:
                seen.add(m[0])
                ordered.append(m)
    for m in cluster_msgs:
        if m[0] not in seen:
            ordered.append(m)
    return ordered


# ============================================================
# 步骤 4：问题归纳
# ============================================================

def extract_location_elements(msgs):
    """从问题簇的所有消息中提取位置要素"""
    full_text = " ".join(str(m[3]) or "" for m in msgs)
    
    result = {"端": "", "环节": "", "课程": "", "角色/组件": ""}
    
    # 端：按优先级匹配，移动端相关优先
    device_patterns = [
        (r"(?<![a-zA-Z])iOS(?![a-zA-Z])", "iOS"),
        (r"(?<![a-zA-Z])iPad(?![a-zA-Z])", "iPad"),
        (r"\bpad端\b", "pad端"),
        (r"\bAndroid\b|安卓", "Android"),
        (r"\bAPP\b|\bapp\b|手机", "移动端"),
        (r"移动端", "移动端"),
        (r"\bPC\b|电脑", "PC"),
    ]
    for pattern, label in device_patterns:
        if re.search(pattern, full_text, re.IGNORECASE):
            result["端"] = label
            break
    if not result["端"]:
        result["端"] = "未知"
    
    # 环节
    scene_patterns = [
        (r"关卡内|关卡里|关卡中|进关卡|在关卡", "关卡内"),
        (r"关卡外", "关卡外"),
        (r"\bLoading\b|loading|加载|转星星", "关卡内"),
        (r"后台.*加载|后台.*提示|后台.*转圈|加载.*提示.*一直|提示.*一直.*在", "关卡内"),
        (r"知识巩固", "知识巩固"),
        (r"巩固题", "巩固题"),
        (r"单元挑战", "单元挑战"),
        (r"听力挑战", "听力挑战"),
        (r"阅读挑战", "阅读挑战"),
        (r"口语挑战", "口语挑战"),
        (r"写作挑战", "写作挑战"),
        (r"单元强化", "单元强化"),
        (r"瓦拉学院", "瓦拉学院"),
    ]
    for pattern, label in scene_patterns:
        if re.search(pattern, full_text, re.IGNORECASE):
            result["环节"] = label
            break
    if not result["环节"]:
        result["环节"] = "未知"
    
    # 课程编号
    course_m = re.search(r'\bL?\d{1,2}[-–—]\d{1,2}\b', full_text)
    if course_m:
        result["课程"] = course_m.group(0).strip()
    
    # 角色/组件
    comp_patterns = [
        (r"\bNPC\b|\bNpc\b", "NPC"),
        (r"\bHUD\b|\bhud\b|\bHud\b", "HUD"),
        (r"音频|声音|无声|没声音|无音频", "音频"),
        (r"组件", "组件"),
        (r"\bUI\b|界面|显示", "UI"),
        (r"数据.*丢|数据.*没|数据.*消失|数据.*丢失", "数据"),
        (r"加载.*慢|Loading.*长|Loading.*慢|加载.*长|转星星", "Loading"),
        (r"网络", "网络"),
    ]
    comps = []
    for pattern, label in comp_patterns:
        if re.search(pattern, full_text, re.IGNORECASE) and label not in comps:
            comps.append(label)
    result["角色/组件"] = "、".join(comps) if comps else ""
    
    return result


def generate_problem_description(cluster_msgs, location, root_text, ai_placeholder=False, placeholder_idx=0):
    """
    基于簇中消息生成问题描述。
    格式：在{端}端{环节}（{课程}），{具体表现}

    当 ai_placeholder=True 时，返回 [待AI归纳:#N] 占位符，后续由 AI 根据上下文生成。
    """
    if ai_placeholder:
        return f"[待AI归纳:#{placeholder_idx}]"

    all_text = " ".join(str(m[3]) or "" for m in cluster_msgs)
    all_text = re.sub(r'\[Image:[^\]]+\]', '', all_text)
    all_text = re.sub(r'https?://\S+', '', all_text)
    
    device = location.get("端", "") or ""
    scene = location.get("环节", "") or ""
    course = location.get("课程", "") or ""
    
    # 端信息
    platform = ""
    if re.search(r'iOS|ios|苹果|iPhone|iPad', all_text):
        platform = "iOS 端"
    elif re.search(r'移动端|手机|APP|android|安卓', all_text, re.IGNORECASE):
        platform = "移动端"
    elif re.search(r'PC|unity|编辑器', all_text, re.IGNORECASE):
        platform = "PC/编辑器"
    if not platform:
        platform = device if device and device != "未知" else ""
    
    # 场景和关卡（过滤占位值"未知"）
    loc_parts = []
    if platform:
        loc_parts.append(platform)
    if scene and course and scene != "未知":
        loc_parts.append(f"{scene}（{course}）")
    elif scene and scene != "未知":
        loc_parts.append(scene)
    elif course:
        loc_parts.append(f"关卡{course}")
    
    location_str = "".join(loc_parts) if loc_parts else ""
    
    # 具体表现（按优先级从高到低排列，先命中的权重更大）
    symptoms = []
    
    # --- 加载/数据/音频 ---
    has_loading = bool(re.search(r'Loading.*长|加载.*慢|转星星.*10|加载.*10.*秒|loading.*timeout|加载超时', all_text, re.IGNORECASE))
    has_data_loss = bool(re.search(r'数据.*丢|数据.*没|组件.*数据.*没|数据.*不见|数据.*丢失', all_text))
    has_no_audio = bool(re.search(r'无音频|没声音|没有声音|组件无音频', all_text))
    
    if has_loading and has_data_loss:
        normal_match = re.search(r'(正常|一般|通常).{0,3}(\d+)\s*秒', all_text)
        normal_time = f"（正常{normal_match.group(2)}秒）" if normal_match else ""
        loading_desc = f"Loading 耗时约 10 秒{normal_time}"
        if has_no_audio:
            symptoms.append(f"{loading_desc}，导致组件数据丢失、无音频")
        else:
            symptoms.append(f"{loading_desc}，导致组件数据丢失")
    elif has_loading:
        symptoms.append("Loading 时间特别长（约 10 秒）")
        if has_no_audio:
            symptoms.append("组件无音频")
    elif has_no_audio:
        symptoms.append("组件无音频")
    elif has_data_loss:
        symptoms.append("组件数据丢失")
    
    # --- 崩溃/闪退/卡死 ---
    if re.search(r'闪退|崩溃|crash|闪.*退', all_text, re.IGNORECASE):
        symptoms.append("闪退/崩溃")
    
    # --- 无法启动/打不开/卡在界面 ---
    has_cannot_open = bool(re.search(r'(打不开|无法打开|无法进入|进不去|无法启动|启动.*失败|打开.*失败)', all_text))
    has_stuck = bool(re.search(r'(卡在|卡住|卡死|停在.*界面|没反应|无响应)', all_text))
    
    # 版本号/包异常
    version_issue_pat = r'版本.*(不对|错误|没变|没升|还是.*版本|显示.*版本|未更新|版本号|没.*升级)'
    has_version_issue = bool(re.search(version_issue_pat, all_text))
    build_issue_pat = r'(测试包|hotfix|热更.*(失败|问题|包)|包.*打不|打不.*包|打.*132.*包|包.*没法|没法.*打|打包.*(没有|不了|选项))'
    has_build_issue = bool(re.search(build_issue_pat, all_text, re.IGNORECASE))
    
    if has_cannot_open or has_stuck:
        # 提取具体场景：是打不开还是卡住了
        detail = ""
        if has_cannot_open:
            # 尝试提取具体描述
            m = re.search(r'(测试包|安装包|APP|应用|程序|客户端)', all_text)
            target = m.group(1) if m else "应用"
            # 检查是否有版本号异常
            ver_m = re.search(r'(还是|显示|右下角).{0,5}(\d+\.\d+\.\d+)', all_text)
            if ver_m:
                detail = f"{target}无法打开（仍显示旧版本 {ver_m.group(2)}）"
            else:
                detail = f"{target}无法打开/进入"
        elif has_stuck:
            m = re.search(r'卡在(?:了)?(.{2,15}?)(?:界面|页面|屏幕|，|。|$)', all_text)
            if m:
                detail = f"卡在{m.group(1)}界面"
            else:
                detail = "卡在界面上无响应"
        symptoms.append(detail)
    
    if has_version_issue and not has_cannot_open:
        # 版本号相关，但还没被上面的"打不开+旧版本"覆盖
        m = re.search(r'(还是|显示|版本).{0,5}(\d+\.\d+\.\d+)', all_text)
        if m:
            symptoms.append(f"版本号显示异常（仍为{m.group(2)}，未更新到新版本）")
        else:
            symptoms.append("版本号显示异常，未更新到预期版本")
    
    if has_build_issue and not has_cannot_open:
        # 测试包/热更相关问题
        symptoms.append("测试包/热更包无法获取或打包")
    
    # --- HUD/UI ---
    if re.search(r'HUD.*变成.*条|hud.*窄|hud.*变', all_text, re.IGNORECASE):
        symptoms.append("NPC 头上的 HUD 偶尔变成一小条")
    if re.search(r'UI.*显示|界面.*显示|显示.*异常|花屏|白屏|黑屏|闪烁', all_text):
        symptoms.append("UI显示异常")
    
    # --- 版本更新相关 ---
    if re.search(r'版本.*更新|更新.*版本|更新.*后|升级.*后|新版.*上线', all_text):
        if not has_version_issue and not has_build_issue:
            symptoms.append("版本更新后出现")

    # --- 语音识别/判分类 ---
    has_speech_recog = bool(re.search(r'(识别|跟读|判分|打分|评测).*(不准|不对|错误|异常|识别率|识别成|只能.*识别)', all_text))
    has_hotword = bool(re.search(r'热词|大模型.*纠|识别.*干扰|极短句.*识别', all_text))
    if has_speech_recog:
        # 尝试提取具体表现
        recog_detail = ""
        m = re.search(r'(要求.*表达|对话表达|需要.*读|应该.*读).{0,30}([""].+?[""]|\S+)', all_text)
        if m:
            recog_detail = f"语音识别不准确（{m.group(1)}，只识别成错误内容）"
        elif has_hotword:
            recog_detail = "语音识别率低，已尝试大模型热词纠正"
        else:
            recog_detail = "语音识别/判分不准确"
        symptoms.append(recog_detail)

    # --- 内容/命名缺失 ---
    if re.search(r'(未命名|全是数字|显示.*数字|Label.*缺失|名称.*丢失|名称.*没有)', all_text):
        m = re.search(r'([A-Z]+\d+|L\d+).*(未命名|全是数字|显示.*数字)', all_text)
        if m:
            symptoms.append(f"{m.group(1)} 级别单元名称未命名，显示为数字编号")
        else:
            symptoms.append("内容/单元名称显示异常（未命名或显示为数字）")

    # --- 后台/弹窗/提示残留 ---
    has_overlay_stuck = bool(re.search(r'(提示|弹窗|toast|popup|加载中).*(一直|不消失|不.*关|残留|卡)', all_text, re.IGNORECASE))
    has_bg_stuck = bool(re.search(r'(后台|背景).*(加载|提示|转圈).*(一直|不停|没.*停)', all_text))
    if has_overlay_stuck or has_bg_stuck:
        symptoms.append("后台加载提示/弹窗未自动消失，持续显示")

    # --- 网络/VPN ---
    if re.search(r'(VPN|网络.*问题|网络.*异常|断网|连不上|代理).*(导致|造成|影响|是不是)', all_text):
        symptoms.append("疑似网络/VPN导致的功能异常")
    elif re.search(r'挂.*VPN|开了.*VPN|VPN.*关', all_text, re.IGNORECASE):
        symptoms.append("需确认是否因 VPN/网络代理导致")

    # --- 热更/测试包/打包（增强覆盖） ---
    has_package_issue = bool(re.search(r'(打包|测试包|hotfix|热更|构建|build).*(没有|不了|失败|选项|没了|异常)', all_text, re.IGNORECASE))
    has_no_test_build = bool(re.search(r'(没有.*测试包|没有.*包|找不到.*包|打不.*包)', all_text))
    if (has_package_issue or has_no_test_build) and not has_cannot_open and not has_build_issue:
        if has_no_test_build:
            symptoms.append("无法获取测试包/安装包")
        else:
            symptoms.append("测试包打包/热更新异常")

    # --- 频率标签（放在描述前面） ---
    freq_tag = ""
    if re.search(r'偶尔|有时|有时候|随机|没什么规律|不.*规律', all_text):
        freq_tag = "【偶现】"
    elif re.search(r'频繁|每次都|经常|必现|总是|一直', all_text):
        freq_tag = "【频繁】"
    elif re.search(r'高概率|很大概率|极大概率', all_text, re.IGNORECASE):
        freq_tag = "【高概率】"
    
    # --- 兜底：没有匹配到任何症状，从首条消息提取摘要 ---
    if not symptoms:
        # 清洗文本：去除 @ 提及、URL、媒体标记、emoji、多余空白
        summary = root_text if root_text else all_text
        summary = re.sub(r'@\S+', '', summary)          # 去 @ 提及
        summary = re.sub(r'https?://\S+', '', summary)  # 去 URL
        summary = re.sub(r'\[Image:[^\]]+\]', '', summary)  # 去图片标记
        summary = re.sub(r'\[流泪\]|\[笑哭\]|\[捂脸\]|\[旺柴\]|\[加油\]', '', summary)  # 去常见 emoji
        summary = re.sub(r'\s+', ' ', summary).strip()   # 合并空白
        if not summary:
            summary = "未知问题"
        elif len(summary) > 60:
            # 在合适位置截断：优先在标点处断开
            truncated = summary[:60]
            break_point = max(truncated.rfind('，'), truncated.rfind('。'), truncated.rfind('？'), truncated.rfind('！'))
            if break_point > 20:
                summary = truncated[:break_point] + "等"
            else:
                summary = truncated[:57] + "..."
        symptoms.append(summary)
    
    symptom_str = "".join(symptoms)
    
    if location_str:
        result = f"在{location_str}，{symptom_str}"
    else:
        result = symptom_str
    
    # 加频率标签前缀
    if freq_tag and not result.startswith("【"):
        result = freq_tag + result
    
    return result


# ============================================================
# 问题分类（用于“今日问题归纳”部分的分类展示）
# ============================================================

CATEGORY_PATTERNS = [
    ("启动/运行异常", r'闪退|崩溃|卡死|无法启动|打不开|进不去|登不上|无法.*进入|无法.*连接|服务器.*挂|宕机'),
    ("版本/更新类", r'(更新|版本|升级).*(失败|无法|出问题|异常|后.*(无法|不能|收不到))'),
    ("Loading/加载类", r'loading|加载.*(慢|长|卡|超时|失败)|进入.*慢|转星星'),
    ("数据/进度类", r'数据.*(丢失|没记录|白学|全没|消失|不对|错误)|进度.*(显示|记录|保存).*(错|不对|失败)'),
    ("声音/音频类", r'音频|声音|播放|没声音|无声|听不到|没音|没声|无音频|音量'),
    ("语音识别/判分类", r'判分|打分|识别|评测|跟读.*(异常|不对|错误)|明明.*对.*系统.*(说|判断).*不对'),
    ("关卡/内容类", r'关卡|课程|单元|知识巩固|题目|选项|内容.*(异常|有问题|错误)|层级|墙.*层级'),
    ("UI显示类", r'界面|UI|显示|排版|文案|图标|动画|动效|截断|错位|HUD|不显示'),
    ("用户反馈问题", r'反馈|建议'),
    ("其他问题", r'.*'),  # 默认分类
]


def classify_problem(cluster_msgs) -> str:
    """
    根据问题簇的消息内容判定问题分类。
    返回分类名称字符串。
    """
    all_text = " ".join(str(m[3]) or "" for m in cluster_msgs)
    all_text = re.sub(r'\[Image:[^\]]+\]', '', all_text)
    
    for category_name, pattern in CATEGORY_PATTERNS:
        if re.search(pattern, all_text, re.IGNORECASE):
            return category_name
    return "其他问题"


def summarize_cluster(cluster_msgs, idx, priority_info=None, ai_placeholder=False, placeholder_idx=0):
    """
    生成单个问题簇的结构化归纳。

    脚本负责：提取位置要素元数据 + 输出对话要点表格 + 提取结论
    当 ai_placeholder=True 时，问题描述使用 [待AI归纳:#N] 占位符，后续由 AI 填充。
    priority_info 可选，包含步骤5的优先级判定结果
    """
    loc = extract_location_elements(cluster_msgs)
    
    def clean_text(t):
        if not t:
            return ""
        t = t.replace('\n', ' ').replace('\r', ' ')  # 去换行，防止破坏表格
        t = re.sub(r'\[Image:[^\]]+\]', '', t)
        t = re.sub(r'https?://\S+', '', t)
        t = re.sub(r'\s+', ' ', t)  # 合并多余空白
        return t.strip()
    
    # 收集关键信息供 AI 归纳
    root_msg_text = ""
    for m in cluster_msgs:
        t = clean_text(str(m[3]))
        if t and len(t) > 5:
            root_msg_text = t[:100]
            break
    
    # 构建位置要素摘要
    device = loc["端"] or "未知"
    scene = loc["环节"] or "未知"
    course = loc["课程"] or "未知"
    component = loc["角色/组件"] or "未知"
    
    # 优先级标签：简洁格式，只改标题
    priority_prefix = ""
    if priority_info:
        priority_prefix = f"【{priority_info['priority']}】"
    
    lines = [f"### {priority_prefix}"]
    lines.append("")
    
    description = generate_problem_description(cluster_msgs, loc, root_msg_text, ai_placeholder=ai_placeholder, placeholder_idx=placeholder_idx)
    lines.append(f"**{idx}，问题描述：** {description}")
    lines.append("")
    conclusion = extract_conclusion(cluster_msgs)
    lines.append(conclusion)
    lines.append("")
    lines.append("| 发言人 | 对话信息 |")
    lines.append("|--------|---------|")
    
    first_speaker = cluster_msgs[0][1]
    last_speaker = cluster_msgs[-1][1]
    seen_speakers = set()
    
    # 识别多话题发起者（非首条、非简单回复、发言人切换且有实质内容）
    def is_topic_initiator(prev_name, name, text):
        if not text or len(text) < 10:
            return False
        if name == first_speaker:
            return False
        if prev_name == name:
            return False  # 同一人连续发言是续话，不是新话题
        # 过滤简单回复/确认
        simple_replies = {'好的', '收到', 'OK', 'ok', '嗯', '额', '是', '对', '行', '好', 'ok', '好嘞'}
        clean = text.strip().rstrip('。.，,！!？?')
        if clean in simple_replies or len(clean) <= 5:
            return False
        # 包含问题描述特征的词
        problem_keywords = r'问题|bug|不对|错了|错了|没有|空的|少了|多了|显示|报错|异常|失效|打不开|进不去|没有配|是不是|应该|加载|识别|跟读|spine|HUD|UI|音频|声音'
        return bool(re.search(problem_keywords, text, re.IGNORECASE))
    
    for i, m in enumerate(cluster_msgs):
        name = m[1]
        text = clean_text(str(m[3])) or ""
        media_url = str(m[4]) if m[4] else ""
        
        # 构建对话信息：优先显示文本，有媒体则附加链接
        info_parts = []
        if text:
            if len(text) > 80:
                text = text[:77] + "..."
            info_parts.append(text)
        if media_url:
            # 图片和文件统一使用可点击链接，避免 Markdown 表格 | 分隔符冲突
            label = "图片" if media_url.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp')) else "文件"
            info_parts.append(f"📎 [{label}]({media_url})")
        if not info_parts:
            info_parts.append("[图片]")
        dialogue_info = "<br>".join(info_parts) if len(info_parts) > 1 else info_parts[0]
        
        role_tag = ""
        if name == first_speaker and name not in seen_speakers:
            role_tag = "🚩 报告："
        elif name == last_speaker and i == len(cluster_msgs) - 1:
            role_tag = "✅ "
        elif i > 0 and is_topic_initiator(cluster_msgs[i-1][1], name, clean_text(str(m[3])) or ""):
            role_tag = "📌 "
        
        seen_speakers.add(name)
        lines.append(f"| {name} | {role_tag}{dialogue_info} |")
    
    return "\n".join(lines)


def extract_conclusion(cluster_msgs):
    """
    从问题簇消息中提取排查结论。
    长对话（>8条）聚焦后 30% 消息匹配，避免前面无关结论污染。
    
    返回: str 结论文本
    """
    # 长簇聚焦尾部消息（>8条时只用后 30%，至少3条）
    if len(cluster_msgs) > 8:
        focus_msgs = cluster_msgs[-max(3, len(cluster_msgs) // 3):]
    else:
        focus_msgs = cluster_msgs
    
    all_text = " ".join(str(m[3]) or "" for m in focus_msgs)
    all_text = re.sub(r'\[Image:[^\]]+\]', '', all_text)
    
    def match_any(pattern):
        return bool(re.search(pattern, all_text, re.IGNORECASE))
    
    # 收集解释性/分析性发言
    analysis_pat = r'因为|原因是|应该是|可能是|改为了|导致|造成|引起|预下载|上云|缓存|首次|正常.*情况|一般|通常|默认|预期'
    analysis_lines = []
    for m in focus_msgs:
        name = m[1]
        text = str(m[3]) or ""
        text = re.sub(r'\[Image:[^\]]+\]', '', text).strip()
        if re.search(analysis_pat, text, re.IGNORECASE):
            for sent in re.split(r'[。；\n]', text):
                if re.search(analysis_pat, sent, re.IGNORECASE) and len(sent) > 5:
                    analysis_lines.append(f"{name}判断：{sent.strip()[:80]}")
    
    # 检查是否已上传日志
    has_log = bool(re.search(r'日志.*上传|上传.*日志', all_text, re.IGNORECASE))
    has_pending = bool(re.search(r'明天.*查|排查|查一下|待查|等.*查|排查中', all_text, re.IGNORECASE))
    
    if match_any(r'已修复|已解决|修好了|已处理好'):
        return "**当前问题排查结论：** 已修复"
    if match_any(r'确认.*是.*bug|确实是.*问题|确实有.*问题'):
        return "**当前问题排查结论：** 已确认，待修复"
    # 规则4: 有解释性分析 + 日志已上传 + 排查中 → 疑似{原因}，已上传日志，排查中
    if analysis_lines and has_log and has_pending:
        first_reason = analysis_lines[0].split('：', 1)[-1][:40]
        # 精简原因描述：去掉冗余前缀，只保留核心原因
        first_reason = re.sub(r'^Loading.*是因为|^是因为|^由于', '', first_reason).strip()
        return f"**当前问题排查结论：** 疑似{first_reason}导致，已上传日志，排查中"
    # 规则5: 日志已上传 + 排查中
    if has_log and has_pending:
        return "**当前问题排查结论：** 已上传日志，排查中"
    if match_any(r'不是.*bug|就是这样的|设计如此|非问题|不是问题'):
        return "**当前问题排查结论：** 非问题，设计如此"
    if match_any(r'正常.*现象'):
        return "**当前问题排查结论：** 非问题，正常现象"
    if match_any(r'暂未|还没.*找到|查不到|没复现|未复现'):
        return "**当前问题排查结论：** 暂未排查到问题"
    # 规则7: 默认
    return "**当前问题排查结论：** 暂无结论排查中"


def generate_summary(clusters, cluster_order, skip_priority=False, ai_placeholders=False):
    """
    生成当日问题归纳 Markdown（步骤4+5）。

    步骤4：问题归纳
    步骤5：优先级判定与排序（P0 > P1 > P2 > P3）

    当 ai_placeholders=True 时，问题描述使用 [待AI归纳] 占位符，
    并将簇上下文保存到 JSON 文件供后续 AI 处理。

    返回: (markdown_str, has_content, context_json_path_or_None)
    """
    lines = ["## 今日问题归纳\n"]
    
    # 收集有效问题簇（至少2条消息）
    valid_clusters = []
    for cid in cluster_order:
        cmsgs = clusters[cid]
        if len(cmsgs) < 2:
            continue
        earliest = min(m[6] for m in cmsgs)
        valid_clusters.append({
            "cluster_id": cid,
            "msgs": cmsgs,
            "earliest_time": earliest,
        })
    
    if not valid_clusters:
        return "\n".join(lines) + "\n*（无可归纳的问题簇）*\n", False, None
    
    # 步骤5：优先级判定
    if not skip_priority:
        for vc in valid_clusters:
            vc["priority_info"] = compute_final_priority(vc["msgs"])
        
        # 按优先级排序：P0 > P1 > P2 > P3，同优先级按时间
        valid_clusters = sort_by_priority(valid_clusters)

    # 分配问题索引（用于 AI 占位符编号）
    for i, vc in enumerate(valid_clusters, 1):
        vc["_idx"] = i

    # 生成层汇总索引（按优先级 + 分类）
    grouped = defaultdict(list)
    for vc in valid_clusters:
        p = vc.get("priority_info", {}).get("priority", "P2") if not skip_priority else "P2"
        vc["category"] = classify_problem(vc["msgs"])
        grouped[p].append(vc)
    
    priority_headers = {
        "P0": "⚠️ P0级核心问题（需优先处理）",
        "P1": "⚡ P1级重要问题",
        "P2": "📌 P2级一般问题",
        "P3": "📝 P3级低优先级",
    }
    
    for p_level in ["P0", "P1", "P2", "P3"]:
        items = grouped.get(p_level, [])
        if not items:
            continue
        lines.append(f"**{priority_headers[p_level]}**")
        
        # 按分类分组
        by_category = defaultdict(list)
        for vc in items:
            by_category[vc["category"]].append(vc)
        
        cat_idx = 0
        for cat_name, cat_items in by_category.items():
            cat_idx += 1
            lines.append(f"{cat_idx}. **{cat_name}**")
            for vc in cat_items:
                desc = generate_problem_description(vc["msgs"],
                         extract_location_elements(vc["msgs"]), "", ai_placeholder=ai_placeholders, placeholder_idx=vc["_idx"])
                lines.append(f"   - {desc}")
        lines.append("")
    
    # 问题拆解
    lines.append("## 今日问题拆解\n")
    
    idx = 0
    context_data = []  # 收集簇上下文供 AI 处理
    for vc in valid_clusters:
        idx += 1
        sorted_msgs = sort_cluster_msgs(vc["msgs"])
        summary = summarize_cluster(sorted_msgs, idx, vc.get("priority_info"), ai_placeholder=ai_placeholders, placeholder_idx=vc["_idx"])
        lines.append(summary)
        lines.append("")

        # 收集 AI 上下文数据
        loc = extract_location_elements(vc["msgs"])
        context_data.append({
            "index": idx,
            "_idx": vc.get("_idx", idx),  # 占位符编号，与 generate_summary 中的 placeholder_idx 一致
            "cluster_id": vc["cluster_id"],
            "location": loc,
            "priority": vc.get("priority_info", {}).get("priority", "P2"),
            "priority_detail": vc.get("priority_info", {}).get("detail", ""),
            "category": vc.get("category", "其他问题"),
            "conclusion": extract_conclusion(sorted_msgs),
            "messages": [
                {
                    "sender": m[1],
                    "content": str(m[3]) if m[3] else "",
                    "msg_type": str(m[2]),
                    "media_url": str(m[4]) if m[4] else "",
                    "time": str(m[6]),
                }
                for m in sorted_msgs
            ],
        })

    # 保存 AI 上下文 JSON
    context_path = None
    if context_data:
        # 用数据实际日期命名（取第一个簇第一条消息的日期）
        first_msg_time = context_data[0]["messages"][0]["time"]
        data_date = first_msg_time[:10] if first_msg_time else date.today().strftime("%Y-%m-%d")
        context_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))), "output", "daily_feedback")
        os.makedirs(context_dir, exist_ok=True)
        context_path = os.path.join(context_dir, f"cluster_context_{data_date}.json")
        payload = {
            "date": data_date,
            "total_clusters": len(context_data),
            "clusters": context_data,
        }
        with open(context_path, "w", encoding="utf-8") as f:
            json.dump(payload, f, ensure_ascii=False, indent=2)
        print(f"   📝 AI 上下文已保存: {context_path}")

    return "\n".join(lines), True, context_path


# === AI 描述回写 ===
DEFAULT_AI_DESCRIPTIONS_FILE = None  # 运行时设置


def load_ai_descriptions(descriptions_path):
    """从 JSON 文件加载 AI 生成的描述。
    格式: {"date": "2026-05-11", "descriptions": [{"index": 1, "description": "..."}]}
    """
    with open(descriptions_path, "r", encoding="utf-8") as f:
        return json.load(f)


def apply_ai_descriptions_to_doc(obj_token, descriptions_data):
    """
    将 AI 生成的描述替换到知识库文档中。
    对每个问题，用实际描述替换 [待AI归纳:#N] 占位符。
    """
    descs = descriptions_data.get("descriptions", [])
    if not descs:
        print("⚠️ 无 AI 描述可应用")
        return False

    for item in descs:
        idx = item["index"]
        desc = item["description"]
        placeholder = f"[待AI归纳:#{idx}]"

        # 使用 replace_range 替换该编号的占位符（"今日问题归纳"和"今日问题拆解"各一处）
        # replace_range 每次只替换一处，所以需要多次调用
        print(f"   🔄 替换 #{idx}: {placeholder} → {desc}")
        # 先替换"今日问题归纳"中的占位符
        cmd = [
            CLI, "docs", "--api-version", "v2", "+update",
            "--doc", obj_token,
            "--command", "str_replace",
            "--pattern", re.escape(placeholder),
            "--content", desc,
            "--as", "bot",
        ]
        env = os.environ.copy()
        env["LARKSUITE_CLI_CONFIG_DIR"] = "/root/.openclaw/credentials/xiaokui"
        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=env)
            if result.returncode != 0:
                print(f"   ❌ 替换 #{idx}（归纳部分）失败: {result.stderr[:200]}")
                return False
            # 再替换"今日问题拆解"中的占位符
            result2 = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=env)
            if result2.returncode != 0:
                print(f"   ❌ 替换 #{idx}（拆解部分）失败: {result2.stderr[:200]}")
                return False
            print(f"   ✅ 替换 #{idx} 成功（归纳 + 拆解各一处）")
        except Exception as e:
            print(f"   ❌ 替换 #{idx} 异常: {e}")
            return False

    return True


def get_today_doc_obj_token():
    """获取今天日期对应的子文档 obj_token。"""
    today_str = datetime.now().strftime("%Y-%m-%d")
    doc_title = f"飞书-{today_str} 用户反馈问题归纳"
    children = list_child_nodes()
    if doc_title in children:
        return children[doc_title]["obj_token"]
    return None


# === 步骤5写入目标文档配置 ===
SUMMARY_PARENT_NODE = "RaL6whoYMijyYHkSlWrc7OLLnBy"  # 用户反馈问题汇总 wiki node_token
SUMMARY_SPACE_ID = "7612229802338045122"  # 知识空间 ID


def get_tenant_token(cred_dir=None):
    """获取 Bot 的 tenant_access_token。cred_dir 可选指定凭证目录。"""
    import urllib.request
    if cred_dir is None:
        cred_dir = os.environ.get("LARKSUITE_CLI_CONFIG_DIR", "/root/.openclaw/credentials/xiaokui")
    config = json.load(open(os.path.join(cred_dir, "config.json")))
    app_id = config["apps"][0]["appId"]
    app_secret = config["apps"][0]["appSecret"]
    
    req = urllib.request.Request(
        "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
        data=json.dumps({"app_id": app_id, "app_secret": app_secret}).encode(),
        headers={"Content-Type": "application/json"},
        method="POST"
    )
    resp = urllib.request.urlopen(req, timeout=10)
    return json.loads(resp.read())["tenant_access_token"]


def list_child_nodes():
    """列出「用户反馈问题汇总」下的所有子节点，返回 {title: {node_token, obj_token, creator}}。同名节点取 obj_edit_time 最新的。"""
    import urllib.request
    token = get_tenant_token(cred_dir="/root/.openclaw/credentials/xiaokui")
    nodes = {}
    page_token = None
    while True:
        url = f"https://open.feishu.cn/open-apis/wiki/v2/spaces/{SUMMARY_SPACE_ID}/nodes?parent_node_token={SUMMARY_PARENT_NODE}&page_size=50"
        if page_token:
            url += f"&page_token={page_token}"
        req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}"})
        resp = urllib.request.urlopen(req, timeout=10)
        data = json.loads(resp.read())
        
        for item in data.get("data", {}).get("items", []):
            title = item["title"]
            edit_time = item.get("obj_edit_time", "0")
            # 同名取更新时间最近的
            if title not in nodes or edit_time > nodes[title].get("_edit_time", "0"):
                nodes[title] = {
                    "node_token": item["node_token"],
                    "obj_token": item["obj_token"],
                    "creator": item.get("creator", ""),
                    "_edit_time": edit_time
                }
        
        if not data.get("data", {}).get("has_more"):
            break
        page_token = data["data"].get("page_token", "")
        if not page_token:
            break
    return nodes


def create_child_doc(title):
    """在「用户反馈问题汇总」下创建子文档，返回 obj_token"""
    import urllib.request, time
    token = get_tenant_token(cred_dir="/root/.openclaw/credentials/xiaokui")
    url = f"https://open.feishu.cn/open-apis/wiki/v2/spaces/{SUMMARY_SPACE_ID}/nodes"
    
    # 提取标题中的日期，生成sort_tag
    # 飞书Wiki按sort_tag升序排列子节点，因此日期越新sort_tag越小（排在前面）
    sort_tag = int(time.time())  # 默认当前时间
    try:
        # 标题格式如：飞书-2026-05-08 用户反馈问题归纳
        date_str = title.split(" ")[0]
        for prefix in ["飞书-", "微信-"]:
            if date_str.startswith(prefix):
                date_str = date_str[len(prefix):]
                break
        dt = datetime.strptime(date_str, "%Y-%m-%d")
        # 用大基数减去时间戳：日期越新时间戳越大，减后值越小 → 排在前面
        sort_tag = 9999999999 - int(dt.timestamp())
    except:
        pass
    
    body = json.dumps({
        "obj_type": "docx",
        "node_type": "origin",
        "parent_node_token": SUMMARY_PARENT_NODE,
        "title": title,
        "sort_tag": sort_tag
    }).encode()
    req = urllib.request.Request(url, data=body, headers={
        "Authorization": f"Bearer {token}",
        "Content-Type": "application/json"
    }, method="POST")
    resp = urllib.request.urlopen(req, timeout=10)
    data = json.loads(resp.read())
    if data.get("code") == 0:
        node_token = data["data"]["node"]["node_token"]
        obj_token = data["data"]["node"]["obj_token"]
        # 验证标题是否生效，若为 Untitled 则调用 update_title 修正
        actual_title = data["data"]["node"].get("title", "")
        if actual_title == "Untitled" or actual_title == "":
            print(f"   ⚠️ 标题未生效(当前:{actual_title})，修正为: {title}")
            fix_url = f"https://open.feishu.cn/open-apis/wiki/v2/spaces/{SUMMARY_SPACE_ID}/nodes/{node_token}/update_title"
            fix_body = json.dumps({"title": title}).encode()
            fix_req = urllib.request.Request(fix_url, data=fix_body, headers={
                "Authorization": f"Bearer {token}",
                "Content-Type": "application/json"
            }, method="POST")
            fix_resp = urllib.request.urlopen(fix_req, timeout=10)
            fix_data = json.loads(fix_resp.read())
            if fix_data.get("code") == 0:
                print(f"   ✅ 标题已修正: {title}")
            else:
                print(f"   ⚠️ 标题修正失败: {fix_data}")
        return obj_token
    print(f"   ⚠️ 创建子文档失败: {data}")
    return None


def _delete_child_node(obj_token):
    """删除指定子文档（使用 obj_token）。先尝试 xiaokui 凭证，失败则回退到 xiaoyan（处理旧文档归属问题）"""
    import urllib.request
    last_error = None
    for cred_dir in ["/root/.openclaw/credentials/xiaokui", "/root/.openclaw/credentials/xiaoyan"]:
        try:
            token = get_tenant_token(cred_dir=cred_dir)
            body = json.dumps({"obj_type": "docx"}).encode()
            url = f"https://open.feishu.cn/open-apis/wiki/v2/spaces/{SUMMARY_SPACE_ID}/nodes/{obj_token}"
            req = urllib.request.Request(url, data=body, headers={
                "Authorization": f"Bearer {token}",
                "Content-Type": "application/json"
            }, method="DELETE")
            resp = urllib.request.urlopen(req, timeout=10)
            data = json.loads(resp.read())
            if data.get("code") == 0:
                print(f"   🗑️  已删除旧文档")
                return True
            # 权限拒绝 / 服务暂不可用时尝试下一个凭证
            if data.get("code") in (131006, 131001):
                last_error = data
                continue
            last_error = data
            break
        except Exception as e:
            last_error = {"error": str(e)}
            continue
    print(f"   ⚠️ 删除旧文档失败: {last_error}")
    return False


def update_summary_doc_as_children(day_summaries, title_prefix="飞书-"):
    """
    将各日期的归纳结果写入「用户反馈问题汇总」的子文档中。
    day_summaries: dict, key=日期字符串(如'2026-05-06'), value=summary markdown 字符串
    title_prefix: 可选标题前缀，默认 "飞书-"，微信渠道传入 "微信-"
    
    逻辑：
    1. 列出已有子文档
    2. 对每个日期：已有则覆盖更新，无则创建新子文档
    3. 写入内容
    """
    env = get_env()
    existing_nodes = list_child_nodes()
    
    # 文件锁：防止多进程并发创建同一天的子文档
    lock_path = "/tmp/xiaokui_summary_create.lock"
    
    for day in sorted(day_summaries.keys(), reverse=True):
        title = f"{title_prefix}{day} 用户反馈问题归纳"
        content = day_summaries[day]
        
        # 加锁：整个检查+创建+写入流程串行化，彻底避免并发冲突
        with open(lock_path, 'w') as lock_f:
            fcntl.flock(lock_f, fcntl.LOCK_EX)
            existing_nodes = list_child_nodes()
            if title in existing_nodes:
                node = existing_nodes[title]
                # 检查文档是否由小葵创建，若不是则删除重建（修复 author 归属）
                if node.get("creator") != XIAOKUI_BOT_OPEN_ID:
                    print(f"   🔄 文档归属非小葵，重建: {title}")
                    _delete_child_node(node["obj_token"])
                    obj_token = create_child_doc(title)
                    if not obj_token:
                        continue
                else:
                    obj_token = node["obj_token"]
                    print(f"   📝 更新子文档: {title}")
            else:
                obj_token = create_child_doc(title)
                if obj_token:
                    print(f"   ➕ 创建子文档: {title}")
                else:
                    # 创建失败可能是并发冲突，再查一次
                    existing_nodes = list_child_nodes()
                    if title in existing_nodes:
                        obj_token = existing_nodes[title]["obj_token"]
                        print(f"   📝 已存在，使用已有子文档: {title}")
                    else:
                        continue
        
        # 写入内容（锁内已保证obj_token唯一，不会并发写入）
        # 通过临时文件传递 markdown，避免 shell 转义问题
        workspace_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
        tmp_md_path = "tmp/_summary_md.txt"
        tmp_md_abs = os.path.join(workspace_root, tmp_md_path)
        os.makedirs(os.path.dirname(tmp_md_abs), exist_ok=True)
        with open(tmp_md_abs, "w", encoding="utf-8") as f:
            f.write(content)
        result = subprocess.run(
            [CLI, "docs", "--api-version", "v2", "+update", "--doc", obj_token, "--as", "bot",
             "--command", "overwrite",
             "--content", f"@{tmp_md_path}", "--doc-format", "markdown"],
            env=env, capture_output=True, text=True, timeout=15, cwd=workspace_root
        )
        os.unlink(tmp_md_abs)
        
        try:
            d = json.loads(result.stdout)
            if d.get("ok"):
                print(f"   ✅ {title} 写入成功")
                
                # 重点校验：防止内容重复/空文档
                check_pass = False
                for retry in range(2):  # 最多重试2次
                    check_result = subprocess.run(
                        [CLI, "docs", "--api-version", "v2", "+fetch", "--doc", obj_token, "--as", "bot", "--doc-format", "markdown"],
                        env=env, capture_output=True, text=True, timeout=10
                    )
                    try:
                        check_data = json.loads(check_result.stdout)
                        saved_content = check_data.get('data', {}).get('document', {}).get('content', '')
                        dup_count = saved_content.count('## 今日问题归纳')
                        
                        if dup_count > 1:
                            print(f"   ⚠️  检测到内容重复(共{dup_count}次)，第{retry+1}次重试写入")
                            tmp_retry = "tmp/_summary_md_retry.txt"
                            with open(tmp_retry, "w", encoding="utf-8") as f:
                                f.write(content)
                            subprocess.run(
                                [CLI, "docs", "--api-version", "v2", "+update", "--doc", obj_token, "--as", "bot",
                                 "--command", "overwrite",
                                 "--content", f"@{tmp_retry}", "--doc-format", "markdown"],
                                env=env, capture_output=True, text=True, timeout=15
                            )
                            os.unlink(tmp_retry)
                        elif len(saved_content.strip()) < 100:
                            print(f"   ⚠️  检测到文档为空/内容过短，第{retry+1}次重试写入")
                            tmp_retry = "tmp/_summary_md_retry.txt"
                            with open(tmp_retry, "w", encoding="utf-8") as f:
                                f.write(content)
                            subprocess.run(
                                [CLI, "docs", "--api-version", "v2", "+update", "--doc", obj_token, "--as", "bot",
                                 "--command", "overwrite",
                                 "--content", f"@{tmp_retry}", "--doc-format", "markdown"],
                                env=env, capture_output=True, text=True, timeout=15
                            )
                            os.unlink(tmp_retry)
                        else:
                            print(f"   ✅ {title} 校验通过：长度{len(saved_content)}，无内容重复")
                            check_pass = True
                            break
                    except Exception as e:
                        print(f"   ⚠️  校验出错，重试: {str(e)[:80]}")
                if not check_pass:
                    print(f"   ❌ {title} 多次重试后仍校验失败")
            else:
                print(f"   ⚠️ {title} 写入失败: {result.stdout[:100]}")
        except Exception:
            print(f"   ⚠️ {title} 写入异常: {result.stderr[:100]}")
    
    print(f"   📂 共处理 {len(day_summaries)} 个日期的子文档")
    return True



def dispatch_summary_to_chat(day_label, summary_text, p0_only=False, doc_url=None):
    """
    将「今日问题归纳」部分发送到指定群聊。
    P0问题会 @指定人员。
    
    参数:
        day_label: 日期标签，如 '2026-04-28'
        summary_text: 今日问题归纳的 markdown 文本
        p0_only: True=仅发送 P0 部分，False=发送全部
        doc_url: 当天详细文档的飞书链接（可选，不传则回退到总文档链接）
    """
    if not DISPATCH_CHAT_ID:
        print("   ⚠️ 未配置分发群 chat_id，跳过步骤7")
        return False
    
    # 构建消息内容
    # 提取「今日问题归纳」部分（到「今日问题拆解」之前）
    # 同时过滤掉"无明确问题"条目（仅限分发消息，文档中保留）
    lines = summary_text.split("\n")
    归纳_lines = []
    in_归纳 = False
    for line in lines:
        if "今日问题归纳" in line:
            in_归纳 = True
            continue
        if "今日问题拆解" in line:
            break
        if in_归纳:
            # 过滤：跳过"无明确问题"条目
            stripped = line.strip()
            if stripped == "- 无明确问题" or stripped == "* 无明确问题":
                continue
            归纳_lines.append(line)
    
    归纳_content = "\n".join(归纳_lines).strip()
    # 检查是否有实质问题描述（非纯标题/空段落）
    has_items = any(line.strip().startswith("- ") for line in 归纳_lines)
    if not 归纳_content or not has_items:
        print("   ⚠️ 无归纳内容可分发（已过滤无明确问题条目）")
        return False
    
    # 检查是否有 P0 问题
    has_p0 = "⚠️ P0级" in 归纳_content
    
    # 如果 p0_only 模式，裁剪掉非 P0 部分
    if p0_only and has_p0:
        # 保留 P0 部分，去掉后面的 P1/P2/P3
        # 找到下一个优先级标题的位置
        for marker in ["⚡ P1级", "📌 P2级", "📝 P3级"]:
            idx = 归纳_content.find(marker)
            if idx > 0:
                归纳_content = 归纳_content[:idx].strip()
                break
    
    # 构建富文本消息（post 格式）
    # P0 @跟在标题行后面，不放单独段落
    # day_label 可能已含前缀（如 "微信-2026-05-25"），避免重复前缀
    label_prefix = "" if day_label.startswith(("微信-", "飞书-")) else "飞书-"
    title = f"📋 {label_prefix}{day_label} 用户反馈问题归纳"
    
    # 飞书 post 消息不支持 HTML 标签的 at，需要用富文本 tag
    # 把归纳内容按 P0/P1/P2/P3 段落拆分，P0 标题行后追加 at tag
    content_parts = []
    
    if has_p0 and P0_NOTIFY_USERS:
        # 找到 P0 标题行并插入 @
        p0_header = "⚠️ P0级核心问题（需优先处理）"
        if p0_header in 归纳_content:
            parts_before = 归纳_content.split(p0_header, 1)
            before_p0 = parts_before[0].strip()
            after_p0 = parts_before[1].lstrip()
            
            if before_p0:
                content_parts.append([{"tag": "text", "text": before_p0 + "\n"}])
            
            # P0 标题行 + @ + 后续内容
            p0_line = [{"tag": "text", "text": p0_header + " "}]
            for uid in P0_NOTIFY_USERS:
                p0_line.append({"tag": "at", "user_id": uid})
                p0_line.append({"tag": "text", "text": " "})
            p0_line.append({"tag": "text", "text": "\n" + after_p0})
            content_parts.append(p0_line)
        else:
            content_parts.append([{"tag": "text", "text": 归纳_content}])
    else:
        content_parts.append([{"tag": "text", "text": 归纳_content}])
    
    # 追加文档链接
    summary_doc_url = doc_url or f"https://makee-interactive.feishu.cn/wiki/{SUMMARY_PARENT_NODE}"
    content_parts.append([
        {"tag": "text", "text": "\n📄 详细文档："},
        {"tag": "a", "text": f"{label_prefix}{day_label} 用户反馈问题归纳", "href": summary_doc_url}
    ])
    
    post_content = json.dumps({
        "zh_cn": {
            "title": title,
            "content": content_parts
        }
    }, ensure_ascii=False)
    
    # 使用小葵（xiaokui）Bot 身份直接调用飞书 API 发送
    try:
        import urllib.request
        token = get_tenant_token(cred_dir=DISPATCH_CRED_DIR)
        
        body = json.dumps({
            "receive_id": DISPATCH_CHAT_ID,
            "msg_type": "post",
            "content": post_content
        }, ensure_ascii=False).encode()
        
        req = urllib.request.Request(
            "https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id",
            data=body,
            headers={
                "Authorization": f"Bearer {token}",
                "Content-Type": "application/json"
            },
            method="POST"
        )
        resp = urllib.request.urlopen(req, timeout=10)
        d = json.loads(resp.read())
        if d.get("code") == 0:
            print(f"   ✅ 问题归纳已分发到群聊" + (" (含P0 @通知)" if has_p0 else ""))
            return True
        else:
            print(f"   ⚠️ 分发失败: {d.get('msg', '')[:100]}")
    except Exception as e:
        print(f"   ⚠️ 分发异常: {e}")
    
    return False


def main():
    parser = argparse.ArgumentParser(description="飞书问题反馈同步（五步完整流程）")
    parser.add_argument("--days", type=int, default=3, help="同步最近 N 天（默认 3）")
    parser.add_argument("--date", type=str, default=None, help="只处理指定日期（格式 YYYY-MM-DD），覆盖 --days 参数")
    parser.add_argument("--dry-run", action="store_true", help="仅预览不写入")
    parser.add_argument("--skip-priority", action="store_true", help="跳过步骤5优先级判定")
    parser.add_argument("--skip-dispatch", action="store_true", help="跳过步骤7问题分发")
    parser.add_argument("--ai-placeholders", action="store_true", help="问题描述使用 [待AI归纳:#N] 占位符，并保存簇上下文 JSON 供后续 AI 处理")
    parser.add_argument("--apply-ai", type=str, default=None, metavar="DESCRIPTIONS_JSON", help="应用 AI 描述：读取指定 JSON 文件中的描述，替换知识库文档中 [待AI归纳:#N] 占位符")
    parser.add_argument("--dispatch-mode", type=str, default="all", choices=["all", "p0"], help="分发模式：all=发全部, p0=仅发P0级问题（默认 all）")
    parser.add_argument("--steps", type=str, default="1-7", help="执行步骤范围，如 '1-3'（仅写入表格）或 '4-7'（归纳+分类+分发）")
    args = parser.parse_args()

    # --apply-ai 模式：加载 AI 描述，替换占位符并覆盖写入文档
    if args.apply_ai:
        data = load_ai_descriptions(args.apply_ai)
        target_date = data.get("date", "")
        if not target_date:
            print("❌ AI 描述文件缺少 date 字段")
            sys.exit(1)
        print(f"📋 加载 {len(data.get('descriptions', []))} 条 AI 描述，日期: {target_date}")

        # 重新查询数据并生成带占位符的 markdown
        from datetime import datetime as dt_module
        td = dt_module.strptime(target_date, "%Y-%m-%d")
        next_day = (td + timedelta(days=1)).strftime("%Y-%m-%d")
        rows = query_messages(target_date, next_day)
        day_msgs = [r for r in rows if r[6][:10] == target_date]
        if not day_msgs:
            # 数据库无数据，尝试从 cluster_context JSON 重建
            workspace_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
            ctx_path = os.path.join(workspace_root, "output", "daily_feedback", f"cluster_context_{target_date}.json")
            if not os.path.exists(ctx_path):
                print(f"❌ {target_date} 无数据，且上下文文件不存在")
                sys.exit(1)
            print(f"   📝 数据库无数据，从上下文文件重建: {ctx_path}")
            with open(ctx_path, "r", encoding="utf-8") as cf:
                ctx = json.load(cf)
            clusters = {}
            cluster_order = []
            for c in ctx["clusters"]:
                cid = c["cluster_id"]
                msgs = []
                for m in c["messages"]:
                    msgs.append((
                        m.get("message_id", cid),
                        m["sender"],
                        m.get("msg_type", "text"),
                        m["content"],
                        "",
                        m.get("quote_message_id", ""),
                        m.get("time", ""),
                        0,
                    ))
                if cid not in clusters:
                    clusters[cid] = []
                    cluster_order.append(cid)
                clusters[cid].extend(msgs)
        else:
            _, clusters, cluster_order = sort_threads(day_msgs)
        summary_md, has_content, _ = generate_summary(clusters, cluster_order, ai_placeholders=True)

        # 替换占位符
        for item in data["descriptions"]:
            idx = item["index"]
            desc = item["description"]
            placeholder = f"[待AI归纳:#{idx}]"
            summary_md = summary_md.replace(placeholder, desc)
            print(f"   🔄 替换 #{idx}: {placeholder} → {desc[:50]}...")

        # 获取文档并覆盖写入（通过文件避免 shell 转义问题）
        title = f"飞书-{target_date} 用户反馈问题归纳"
        nodes = list_child_nodes()
        if title not in nodes:
            print(f"   📝 创建新文档: {title}")
            update_summary_doc_as_children({target_date: summary_md}, title_prefix="飞书-")
            nodes = list_child_nodes()
        if title not in nodes:
            print(f"❌ 未找到文档: {title}")
            sys.exit(1)
        obj_token = nodes[title]["obj_token"]
        print(f"📄 目标文档: {obj_token}")

        # 写入临时文件（lark-cli @file 要求相对路径，通过 cwd 确保在 workspace 根目录）
        workspace_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
        tmp_md = "tmp/ai_summary_md.txt"
        tmp_md_abs = os.path.join(workspace_root, tmp_md)
        os.makedirs(os.path.dirname(tmp_md_abs), exist_ok=True)
        with open(tmp_md_abs, "w", encoding="utf-8") as f:
            f.write(summary_md)

        env = os.environ.copy()
        env["LARKSUITE_CLI_CONFIG_DIR"] = "/root/.openclaw/credentials/xiaokui"
        result = subprocess.run(
            [CLI, "docs", "--api-version", "v2", "+update", "--doc", obj_token, "--as", "bot",
             "--command", "overwrite", "--content", f"@{tmp_md}", "--doc-format", "markdown"],
            env=env, capture_output=True, text=True, timeout=15, cwd=workspace_root)
        os.unlink(tmp_md_abs)  # 清理临时文件
        try:
            resp = json.loads(result.stdout)
            if resp.get("ok"):
                print(f"🎉 AI 描述已应用到文档: {title}")
                # 分发 AI 归纳到群聊
                print(f"📨 分发 AI 归纳到群聊...")
                child_node_token = nodes[title].get("node_token", SUMMARY_PARENT_NODE)
                child_url = f"https://makee-interactive.feishu.cn/wiki/{child_node_token}"
                dispatch_summary_to_chat(target_date, summary_md, p0_only=False, doc_url=child_url)
                print(f"   ✅ 已分发到群聊")
            else:
                print(f"❌ 写入失败: {result.stdout[:300]}")
                sys.exit(1)
        except json.JSONDecodeError:
            print(f"❌ 解析响应失败: {result.stdout[:300]}")
            sys.exit(1)
        return

    if args.date:
        start_date = datetime.strptime(args.date, "%Y-%m-%d").date()
        end_date = start_date
    else:
        end_date = date.today()
        start_date = end_date - timedelta(days=args.days - 1)

    print(f"📊 查询 {start_date} ~ {end_date} 数据...")
    # end_date 是当天不包含，所以用 end_date + 1 天作为上界
    next_day = (end_date + timedelta(days=1)).strftime("%Y-%m-%d")
    rows = query_messages(start_date.strftime("%Y-%m-%d"), next_day)

    # 按天分组
    groups = defaultdict(list)
    for r in rows:
        day = r[6][:10]
        groups[day].append(r)

    if not groups:
        print("✅ 无新数据，无需同步")
        return

    print(f"📋 数据分布：{dict((d, len(ms)) for d, ms in groups.items())}")

    if args.dry_run:
        print("\n🔍 Dry run，预览前 5 条：")
        for day, msgs in sorted(groups.items()):
            print(f"\n  [{day}] ({len(msgs)} 条)")
            for m in msgs[:3]:
                print(f"    {m[6]} | {m[1]} | {str(m[3])[:50]}")
        return

    existing = get_existing_sheets()
    print(f"📑 现有 sheet：{list(existing.keys())}")

    total_written = 0
    day_summaries = {}  # 收集所有天的归纳结果
    for day in sorted(groups.keys()):
        msgs = groups[day]

        # 获取或创建 sheet
        if day in existing:
            sheet_id = existing[day]
            print(f"\n🔄 [{day}] 已有 sheet {sheet_id}，覆盖写入")
        else:
            print(f"\n➕ [{day}] 创建 sheet")
            sheet_id = create_sheet(day)
            print(f"   新建 sheet_id = {sheet_id}")

        # 对话链排序（含推断引用）
        sorted_msgs, clusters, cluster_order = sort_threads(msgs)
        print(f"   排序完成：{len(sorted_msgs)} 条")

        # 根据 --steps 参数决定是否执行步骤4-6
        run_steps = args.steps
        do_summary = "4" in run_steps or "5" in run_steps or "6" in run_steps or "7" in run_steps
        
        if do_summary:
            # 步骤 4：问题归纳
            # 步骤 5：优先级判定（默认启用，--skip-priority 可跳过）
            summary, has_content, context_path = generate_summary(clusters, cluster_order, skip_priority=args.skip_priority, ai_placeholders=args.ai_placeholders)
            if has_content:
                priority_count = summary.count('优先级：') if not args.skip_priority else 0
                print(f"   归纳完成：{summary.count('### 问题')} 个问题")
                if not args.skip_priority:
                    print(f"   步骤5 优先级判定完成：{priority_count} 个问题已评定优先级并排序")
                print(f"   （归纳内容见下方）")
                print(summary)
                day_summaries[day] = summary
            else:
                print("   ⚠️ 无有效问题簇可归纳")

        # 写入表格（步骤2）
        success = write_sheet(sheet_id, sorted_msgs)
        if success:
            total_written += len(sorted_msgs)
            print(f"   ✅ 写入 {len(sorted_msgs)} 条")
        else:
            print(f"   ❌ 写入失败")

    # 统一写入「用户反馈问题汇总」文档（增量更新，保留已有日期内容）— 步骤4-6
    if day_summaries and do_summary:
        update_summary_doc_as_children(day_summaries)

    # 步骤7：问题分发（发送到群聊 + @相关人）
    if do_summary and not args.skip_dispatch:
        dispatch_mode = args.dispatch_mode
        print(f"\n📨 步骤7：问题分发（模式: {dispatch_mode}）...")
        child_nodes = list_child_nodes()
        for day, summary_text in day_summaries.items():
            # 检查当天归纳中是否有 P0 问题
            has_p0 = "⚠️ P0级" in summary_text
            if dispatch_mode == "p0" and not has_p0:
                print(f"   [{day}] 无P0问题，跳过分发")
                continue
            # 获取当天子文档链接
            child_title = f"飞书-{day} 用户反馈问题归纳"
            child_info = child_nodes.get(child_title, {})
            child_node_token = child_info.get("node_token", SUMMARY_PARENT_NODE)
            child_url = f"https://makee-interactive.feishu.cn/wiki/{child_node_token}"
            dispatch_summary_to_chat(day, summary_text, p0_only=(dispatch_mode == "p0"), doc_url=child_url)

    print(f"\n🎉 同步完成，总计写入 {total_written} 条")


if __name__ == "__main__":
    main()
