auto backup 2026-05-01 08:10:01

This commit is contained in:
--git_token 2026-05-01 08:10:01 +08:00
parent 957e53d8e3
commit ec805b0731
17 changed files with 1705 additions and 7 deletions

View File

@ -10,7 +10,8 @@ pua f6a38fdd39c22c81370abd6b979b58c767e41738d43a26fbbc23d1e933cdc701
smart-auto-model-switch bfb3547dcd6029622c7062b49ae7922614a366b6dfe88c7d0fae9dcd85fc2eb3
vala_git_workspace_backup.vala 4cf352bec88fe84af065ba1ffcbb06647b77df0e01860faaf0bca9fd64b968ec
tencent-cos-upload 172517ed41d06c48425cd961ec5972a48495cfd62ec588bc1c2912ddf31b3a06
feishu-group-msg-sync c6e6e1007f417d76582bd7f7e10da063313f2ae93f9d7ee54f629a3642c66a74
user-feedback-collector c0320451bf7ea0ce3d8ceaa603ae0a7b55c373c048363a5142258a4c23f45e81
user-feedback-data-source a95eb9142f3019fd193c46f89147dc7e0bf01dfe250202565a86f8bc52f37b13
user-feedback-processor 3bc199b29eaec4e20d7904ae13e006276532145d203df92718cbf3197825bc44
feishu-group-msg-sync 085f95a5b89fec3b6a627da25d66ffeeb0be430098387739a64f7903f0ee88d4
feishu-feedback-sync ad9934adbb72b3fb6503dc97379f0a5eb4af82e4d0bdacfbcafbd563645f68b1

View File

@ -1 +1 @@
854
860

View File

@ -0,0 +1,3 @@
{"type":"memory.recall.recorded","timestamp":"2026-04-30T03:47:21.989Z","query":"微信反馈群 数据库 表结构 MySQL","resultCount":1,"results":[{"path":"memory/2026-04-18.md","startLine":1,"endLine":5,"score":1}]}
{"type":"memory.recall.recorded","timestamp":"2026-04-30T06:52:35.560Z","query":"用户反馈数据源 飞书群 微信群 数据库表","resultCount":1,"results":[{"path":"memory/2026-04-18.md","startLine":1,"endLine":5,"score":1}]}
{"type":"memory.recall.recorded","timestamp":"2026-04-30T08:11:39.116Z","query":"飞书群反馈表格 token E8vFsCmPBhT4SCtNmnJchqeJnJe 内容测试问题反馈","resultCount":1,"results":[{"path":"memory/2026-04-18.md","startLine":1,"endLine":5,"score":1}]}

View File

@ -0,0 +1,39 @@
{
"version": 1,
"updatedAt": "2026-04-30T08:11:39.116Z",
"entries": {
"memory:memory/2026-04-18.md:1:5": {
"key": "memory:memory/2026-04-18.md:1:5",
"path": "memory/2026-04-18.md",
"startLine": 1,
"endLine": 5,
"source": "memory",
"snippet": "# 2026-04-18 工作日志 ## 术语共识 [李若松确认] 术语「飞书反馈消息数据库」默认指代用户反馈收集技能中的飞书内部测试反馈MySQL数据表 `vala_test.lark_group_message`存储「内容测试问题反馈」群oc_fabff7672e62a9ced7b326ee4a286c26的同步消息数据。",
"recallCount": 3,
"dailyCount": 0,
"groundedCount": 0,
"totalScore": 3,
"maxScore": 1,
"firstRecalledAt": "2026-04-30T03:47:21.989Z",
"lastRecalledAt": "2026-04-30T08:11:39.116Z",
"queryHashes": [
"353f9765c086",
"a6b740c99377",
"9625ed0029fd"
],
"recallDays": [
"2026-04-30"
],
"conceptTags": [
"vala-test.lark-group-message",
"工作",
"日志",
"术语",
"共识",
"若松",
"确认",
"反馈"
]
}
}
}

View File

@ -0,0 +1,10 @@
# Session: 2026-04-30 09:56:16 GMT+8
- **Session Key**: agent:xiaokui:feishu:direct:ou_088ee79216826be4a24af44f7268f880
- **Session ID**: a3cec499-614b-474a-8317-6b1413d17138
- **Source**: feishu
## Conversation Summary
user: hi
assistant: 您好若松,请问有什么需要我协助处理的?

View File

@ -0,0 +1,9 @@
# Session: 2026-04-30 10:26:17 GMT+8
- **Session Key**: agent:xiaokui:feishu:direct:ou_088ee79216826be4a24af44f7268f880
- **Session ID**: d5668b39-c920-4fc3-8184-62fad2edf228
- **Source**: feishu
## Conversation Summary
assistant: 您好若松,请问今天有什么需要我协助处理的?

View File

@ -0,0 +1,32 @@
# Session: 2026-04-30 15:08:29 GMT+8
- **Session Key**: agent:xiaokui:feishu:direct:ou_088ee79216826be4a24af44f7268f880
- **Session ID**: efb75fb6-49d4-4278-8d6c-2ef5e8fc00f6
- **Source**: feishu
## Conversation Summary
assistant: 您好若松,请问今天有什么需要我协助处理的?
user: hi
assistant: 你好若松,请问有什么需要我帮忙处理的?
user: hi
assistant: 若松,有事直接说,我在。
user: 本周记录了多少条微信群的反馈 有多少图片消息 都获取到图片链接了吗
assistant: 李若松是技术负责人,有完整权限。我需要查询微信反馈群的数据。先查一下相关的技能和数据库配置。
assistant: 数据源明确了:微信反馈存储在 `vala_test.wechat_group_message` 表。现在查询本周的数据。需要先读取 secrets 获取数据库密码。
assistant: 图片消息是 0这个有点奇怪。让我核对下 msg_type 的枚举含义,以及看看实际上有哪些消息类型。
assistant: 原来 msg_type 是字符串类型,不是数字!所以之前用 `msg_type = 3` 没查到。改用字符串 `'image'`
assistant: 数据清晰了。
本周4/23 - 4/30"用户火线救火"微信群反馈数据:
| 指标 | 数值 |
|------|------|
| **本周总消息数** | 348 条 |
| **本周图片消息数** | 68 条 |
| **图片已获链接** | 54 条 |
| **图片无链接** | 14 条(约 21% |
有 14 条图片消息没有获取到链接,主要集中在 4/23~4/28发送者包括松、王虹茗、茉茉老师、嘿哈、糖果老师、丹咪尼🌹等用户。content 里保留了图片 hash`9a1285b97cd634b2fd10fb6c3c98fb68`),但 `media_url` 为空。
这些图片可能是通过微信同步脚本进来的但当时图片下载或 COS 上传环节失败了。需要我排查一下这 14 条无链接图片的原因,或者尝试补拉吗?

226
memory/2026-04-30.md Normal file
View File

@ -0,0 +1,226 @@
# 2026-04-30 工作日志
## 今日任务
### 微信问题反馈数据同步到飞书知识库
[刘新玉] 要求将微信「用户火线救火」群近3天数据导出到飞书知识库文档"微信问题反馈"下方。
#### 执行过程
1. **数据源**MySQL `vala_test.wechat_group_message`,查询 2026-04-27 ~ 2026-04-30 数据
2. **权限问题**
- Bot 应用缺少 `wiki:node:create` 权限code:10014无法在知识库节点下直接创建文档
- `feishu_bitable_app` 仅限 App Owner李若松 ou_088ee79216826be4a24af44f7268f880使用
- 刘新玉 (ou_9d4df593d0419d705274947c5cec5ada) 无权使用 feishu 工具写入
3. **解决方案**
- 先用 `lark-cli sheets +create` 在知识空间中创建电子表格(绕过 wiki:node:create 限制,直接在空间创建)
- 成功在知识空间 `7612229802338045122` 下创建表格,自动挂载到父节点 `SB3dwaSshie7ifklKlLc2GswnqX`"微信问题反馈"文档下方)
4. **数据写入**:按天分 sheet2026-04-27 / 2026-04-28 / 2026-04-30字段与数据库一致
- 消息ID(svr_msg_id) / 发送者(sender_name) / 消息类型(msg_type) / 内容(content) / 媒体URL(media_url) / 引用消息ID(refer_msg_svrid) / 消息时间(msg_time) / 消息时间戳(msg_timestamp)
5. **最终结果**:共 99 条数据4/27: 9条, 4/28: 84条, 4/30: 6条
#### 关键标识
- 电子表格 token`RUXfsytPzhJO5kt2uwCcvdIgnLg`
- 在知识库位置https://makee-interactive.feishu.cn/wiki/R4HRwNU42iwH1Hk3OMCcB6i7n1u 下方
- 父节点 token`SB3dwaSshie7ifklKlLc2GswnqX`
- 旧临时表格(已清理):`Bh1gsZj2ehf4brt9impcNnuqnBg`
### SkillHub 同步
- 心跳触发 SkillHub 自动同步检查6 个 skill 均无变更,跳过推送
- 执行于 14:59
## 经验沉淀
### 知识库中创建电子表格的正确方法
- ❌ `lark-cli wiki node:create` → Bot 缺少 `wiki:node:create` 权限
- ❌ 在云空间创建再移动 → Bot 缺少 drive 权限
- ✅ `lark-cli sheets +create --space-id=$SPACE_ID --parent-node=$NODE_TOKEN` → 直接在知识空间创建,自动挂载到指定父节点
### lark-cli sheets +write 数据格式
- 使用 `--data-b64` 传 base64 编码的 JSON 数据,避免 shell 转义问题
- 格式:`[["cell1","cell2"],["cell3","cell4"]]`(二维数组)
- `--range='<sheet_id>!A1:Z1'` 总是指定起始单元格即可
### 飞书问题反馈数据同步到知识库(追加)
[刘新玉] 要求同步飞书「内容测试问题反馈」群近3天数据到知识库。
#### 执行过程
1. 原电子表格 `E8vFsCmPBhT4SCtNmnJchqeJnJe` 已不可用API 返回 deleted/missing
2. 在知识空间 `SB3dwaSshie7ifklKlLc2GswnqX` 下创建新电子表格「飞书问题反馈-近3天」
3. 数据源MySQL `vala_test.lark_group_message` (chatbot:xhuBx7d@uT2gUVv)
4. 按天分 sheet 写入:
- 2026-04-28: 23条HUD显示bug、组件无音频、Loading慢/数据丢失)
- 2026-04-29: 2条网络问题导致下载播放失败
- 4/30 无数据
5. 表格列:时间 | 反馈人 | 信息类型 | 信息内容(或地址)
#### 关键标识
- 电子表格 token`AHtnsehwShUVyDtjasSciIvgn7b`
- 知识库节点 token`TVivwmzqXiW3YakDUzucFMRenvf`
- 位置:知识库「飞书问题反馈」文档同级
### Bot 权限现状
- 有sheets:read/write, wiki API 读取wiki:space:get, wiki:node:read
- 无wiki:node:create, drive 操作, bitable 操作
- 结论Bot 可以通过 sheets API 在知识空间直接创建电子表格,但无法创建其他类型文档
### 飞书问题反馈表格字段统一与移动
- 将表格移动到「飞书问题反馈」文档下方wiki v2 move API
- 将表格字段从「时间|反馈人|信息类型|信息内容」改为与数据库 `lark_group_message` 一致消息ID/发送者/消息类型/内容/媒体URL/引用消息ID/消息时间/消息时间戳
- 4/29 数据因字段变更需重新写入仅2条
- 表格最终位置https://makee-interactive.feishu.cn/sheets/AHtnsehwShUVyDtjasSciIvgn7b「飞书问题反馈」文档下方
### 飞书问题反馈按引用关系重新排序
[刘新玉] 要求按问题完整解决过程排序——通过 `quote_message_id` 串联同一问题的讨论链。
#### 排序逻辑
1. 从数据库读取全部消息及引用关系
2. 构建引用图:每个消息的 `quote_message_id` 指向其父消息
3. 聚合问题链cluster同一引用链的消息归为一组连续排列
4. 同 cluster 内按时间排序,子回复紧跟父消息
5. Cluster 间按最早时间排序
6. 无引用关系的独立消息按时间线补充
#### 处理过程
- 写入前先通过 `lark-cli sheets +write` 清空 sheet`--raw-data="[]"`
- 4/28 23条 → 生成完整引用链排序,写入
- 4/29 2条 → 无引用关系,直接写入
#### 4/28 问题链总结
| 问题 | 涉及人 | 消息数 |
|------|--------|--------|
| NPC HUD显示bug仅移动端 | 徐思清→王胤鑫 | 3 |
| 关卡出现规律 | 王胤鑫→庞鸿潇→梁晨 | 4 |
| Playtesting数据记录 | 孙时敏 | 1 |
| iOS组件无音频+Loading慢/数据丢失 | 胡陈辰→安君仪/毋益飞/王胤鑫 | 11 |
| 网络问题4/29 | Ann | 2 |
### 飞书群 4/25-4/27 数据查询结果
- 查询 MySQL `lark_group_message` 2026-04-25 ~ 2026-04-27 数据
- 结果0 条,该群此时间段无消息记录
### 反馈同步 Skill 创建 [刘新玉]
将飞书问题反馈同步流程封装为 `feishu-feedback-sync` skill并计划注册定时任务。
#### Skill 文件
- `skills/feishu-feedback-sync/SKILL.md` — 完整技能文档
- `skills/feishu-feedback-sync/scripts/sync_feishu_feedback.py` — 核心同步脚本
- `scripts/sync_feishu_feedback_wrapper.sh` — 定时任务包装脚本
#### Skill 功能
1. 从 MySQL `lark_group_message` 查询近 N 天数据
2. 写入知识库电子表格(按天分 sheet
3. **反馈对话链排序**:按引用关系将同一问题讨论聚合呈现
#### 策略2推断缺失引用关系 [刘新玉]
问题:很多消息有关联但没有 `quote_message_id`(飞书 API 的 `root_id`/`parent_id` 未采集)
**推断规则(按优先级)**
1. **@提及匹配**:消息中 @了某人 → 关联到被@者最近一条消息
2. **同发送者聚类**:同一人在 2 分钟窗口内连续发多条 → 认为是对同一目标消息的回复
3. **最近不同发送者**关联到最近一条不同发送者的消息30 分钟内)
已测试效果:上午 NPC HUD 问题链成功串联,下午 iOS 问题链准确分组。部分跨话题误判仍需 AI 语义辅助策略3待后续评估
#### 触发方式
- 手动:「同步飞书反馈」「整理反馈对话链」
- 定时:每天 10:00 crontab 自动执行
## 步骤4问题归纳功能开发 [刘新玉] - 2026-04-30 18:38 完成
### 步骤4 包含两部分
1. **问题描述**:在{端}{环节}内({课程}{角色/组件}出现了{现象}
2. **当前问题排查结论**:从对话最后 1-2 条提取,匹配规则:
- "日志上传/排查/查" → "日志已上传,排查中"
- "确认/确实" → "已确认,待修复"
- "已修复/已解决" → "已修复"
- "不是 bug/设计如此" → "非问题,设计如此"
- 无明确结论 → "暂未排查到根因"
### 归纳格式
```markdown
### 问题 N
> **在{端}端{环节}内({课程}{角色/组件}出现了{现象}**
| 发言人 | 要点 |
|--------|------|
| 报告人 | 🚩 报告:... |
| ... | ... |
| 最终人 | ✅ 结论/待排查 |
```
### 维度提取规则
| 维度 | 优先级/来源 |
|------|------------|
| 端 | iOS > iPad > pad端 > Android > 移动端 > PC正则匹配忽略大小写 |
| 环节 | 关卡内/知识巩固/单元挑战/听力挑战/阅读挑战/口语挑战/写作挑战/单元强化/瓦拉学院/报告(从消息文本匹配) |
| 课程 | 匹配数字编号(如 11-2、L1 3-2 |
| 角色/组件 | NPC/HUD/音频/组件/数据/Loading/加载/日志(从消息文本匹配) |
| 现象 | 从消息中提取要害描述,截断在 35 字符以内 |
### 现象提取逻辑
1. 优先从包含 "Bug的表现是这样的"、"问题是"、"发现"、"出现" 等关键词的消息中截取描述句
2. 提取的句子去除 URL、图片标记、疑问句
3. 截断到 35 字符防止过长
### Bug 修复记录
- **idx 变量覆盖 bug**`summarize_cluster` 的 `idx` 参数被循环内 `idx = t.find(kw)` 覆盖,导致问题编号显示为字符串查找位置。修复:局部变量改名 `pos`
- **iOS 识别失败**`\biOS\b` 不匹配 "iOS线上"(无词边界)。修复:模式改为 `iOS|ios` 配合 `re.IGNORECASE`
- **编号跳跃**:单消息簇被跳过导致编号不连续。修复:`generate_summary` 中 `idx` 只对有效簇递增
### 测试验证
- 2026-04-28 数据:问题一 "暂未排查到根因",问题二 "日志已上传,排查中" — 均正确
- dry-run 模式正常运行
### 已知限制
- 「未知组件」在 NPC/HUD 边缘写法时可能漏匹配
- iOS 的两个相关话题(组件无音频 / Loading 慢因无引用关系而分成两个簇需策略3语义聚类解决
- 单消息簇被跳过(需至少 2 条消息才能形成问题)
### Skill 文件最终状态
- `skills/feishu-feedback-sync/SKILL.md`已包含完整步骤1-4的文档
- `skills/feishu-feedback-sync/scripts/sync_feishu_feedback.py`:已集成 `summarize_cluster()`、`extract_location_elements()`、`generate_summary()` 函数
- crontab 每日 10:00 执行与步骤3一起
### 步骤4 架构调整AI 归纳取代规则生成 [刘新玉] - 2026-04-30 19:07
#### 问题
脚本规则匹配生成的问题描述质量差:
- 组件匹配失败NPC/HUD → "未知组件"
- 现象摘取了完整原始消息(含 @、无关词)
- 端识别不稳定
#### 决策
**脚本输出结构化元数据 + 对话表AI 负责归纳描述。**
- 脚本 `summarize_cluster` 改为输出:
1. 位置元数据(端/环节/课程/组件)— 由 `extract_location_elements` 提取
2. 发言人-要点表格(规则生成)
3. 问题描述留 `[AI归纳]` 占位符
- 运行时 AI即助手本身根据元数据 + 对话上下文,生成精炼的问题描述
#### AI 归纳的最终输出格式(固定模板)
```markdown
### 问题 N
> **在{端}端{环节}内({课程}{角色/组件}出现了{现象}**
| 发言人 | 要点 |
|--------|------|
| ... | ... |
**当前问题排查结论:** ...
```
#### 结论提取规则增强
- 解释性关键词:上云/预下载/加载/原因是/改为了/首次 → 标记为分析性发言
- 分析性发言 + 日志上传 → 输出「疑似{原因},已上传日志,排查中」
- 分析性发言 + 无日志 → 输出「{原因},待确认」
- 无分析 + 无日志 → 改为「暂未排查到问题」(刘新玉确认,比「暂未排查到根因」更准确)
#### 4/28 最终归纳结果AI 生成)
1. **NPC HUD 显示**在移动端关卡内11-2NPC 头上的 HUD 偶尔变成一小条 → 暂未排查到问题
2. **iOS Loading 慢**:在 iOS 端关卡内L1 3-2Loading 耗时约 10 秒(正常 3 秒),导致组件数据丢失/无音频 → 疑似关卡内容上云加载导致,已上传日志,排查中
#### 结论提取的边界
- 刘新玉指出:"暂未排查到问题" vs "暂未排查到根因" → 前者更准确(问题被描述了但可能没被排查)
- 结论应基于对话中实际出现的解释性判断,而非仅关键词匹配
- 分析性发言需要语义理解("上云在加载"、"首次预下载"),规则匹配作为辅助

Binary file not shown.

View File

@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""
飞书反馈数据每日导出 Excel 并上传到知识库
- MySQL vala_test.lark_group_message 表导出前一天新增数据
- 生成 Excel 文件
- 通过 Bot 身份上传到飞书知识库指定目录下
定时任务每天 09:00 执行
"""
import os
import sys
import json
import subprocess
import logging
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pymysql
import openpyxl
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
# ============ 配置 ============
# 数据库
MYSQL_HOST = os.environ.get("MYSQL_HOST", "bj-cdb-8frbdwju.sql.tencentcdb.com")
MYSQL_PORT = int(os.environ.get("MYSQL_PORT", "25413"))
MYSQL_USER = os.environ.get("MYSQL_USER", "chatbot")
MYSQL_PASS = os.environ.get("MYSQL_PASS", "")
MYSQL_DB = os.environ.get("MYSQL_DB", "vala_test")
# 飞书知识库
WIKI_SPACE_ID = "7612229802338045122"
WIKI_PARENT_NODE = "FOxgwkVtLiTl3ZkrkIXcT8T2nJg" # "飞书问题反馈" 节点
# Bot 凭证
LARK_CLI_CONFIG = "/root/.openclaw/credentials/xiaokui"
# 输出目录
OUTPUT_DIR = "/root/.openclaw/workspace-xiaokui/output/daily_feedback"
# 时区
TZ = timezone(timedelta(hours=8))
# ============ 日志 ============
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
logger = logging.getLogger(__name__)
def load_secrets():
"""从 secrets.env 加载密钥"""
secrets_file = "/root/.openclaw/workspace-xiaokui/secrets.env"
if os.path.exists(secrets_file):
with open(secrets_file) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, val = line.split("=", 1)
os.environ.setdefault(key.strip(), val.strip())
def get_db_connection():
"""获取数据库连接"""
return pymysql.connect(
host=MYSQL_HOST,
port=MYSQL_PORT,
user=MYSQL_USER,
password=os.environ.get("MYSQL_PASS", MYSQL_PASS),
database=MYSQL_DB,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor
)
def query_yesterday_data():
"""查询前一天的飞书反馈数据"""
now = datetime.now(TZ)
yesterday_start = (now - timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0)
yesterday_end = yesterday_start + timedelta(days=1)
logger.info(f"查询时间范围: {yesterday_start.strftime('%Y-%m-%d %H:%M:%S')} ~ {yesterday_end.strftime('%Y-%m-%d %H:%M:%S')}")
conn = get_db_connection()
try:
with conn.cursor() as cursor:
sql = """
SELECT
id, chat_id, chat_name, sender_id, sender_name,
message_id, parent_id, root_id, quote_message_id,
msg_type, content, media_url, msg_time, msg_timestamp, collected_at
FROM lark_group_message
WHERE msg_time >= %s AND msg_time < %s
ORDER BY msg_timestamp ASC
"""
cursor.execute(sql, (
yesterday_start.strftime("%Y-%m-%d %H:%M:%S"),
yesterday_end.strftime("%Y-%m-%d %H:%M:%S")
))
rows = cursor.fetchall()
logger.info(f"查询到 {len(rows)} 条记录")
return rows, yesterday_start
finally:
conn.close()
def generate_excel(rows, date_obj):
"""生成 Excel 文件"""
os.makedirs(OUTPUT_DIR, exist_ok=True)
date_str = date_obj.strftime("%Y-%m-%d")
filename = f"飞书反馈_{date_str}.xlsx"
filepath = os.path.join(OUTPUT_DIR, filename)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "飞书反馈数据"
# 表头
headers = [
"ID", "群聊ID", "群聊名称", "发送者ID", "发送者姓名",
"消息ID", "父消息ID", "根消息ID", "引用消息ID",
"消息类型", "内容", "媒体URL", "消息时间", "时间戳", "入库时间"
]
# 表头样式
header_font = Font(bold=True, color="FFFFFF", size=11)
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
thin_border = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin")
)
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
cell.border = thin_border
# 数据行
for row_idx, row_data in enumerate(rows, 2):
values = [
row_data.get("id"),
row_data.get("chat_id"),
row_data.get("chat_name"),
row_data.get("sender_id"),
row_data.get("sender_name"),
row_data.get("message_id"),
row_data.get("parent_id"),
row_data.get("root_id"),
row_data.get("quote_message_id"),
row_data.get("msg_type"),
row_data.get("content"),
row_data.get("media_url"),
str(row_data.get("msg_time", "")) if row_data.get("msg_time") else "",
row_data.get("msg_timestamp"),
str(row_data.get("collected_at", "")) if row_data.get("collected_at") else "",
]
for col, val in enumerate(values, 1):
cell = ws.cell(row=row_idx, column=col, value=val)
cell.border = thin_border
cell.alignment = Alignment(vertical="center", wrap_text=True)
# 调整列宽
col_widths = [6, 30, 18, 30, 12, 30, 30, 30, 30, 10, 50, 50, 20, 15, 20]
for i, width in enumerate(col_widths, 1):
ws.column_dimensions[openpyxl.utils.get_column_letter(i)].width = width
# 冻结首行
ws.freeze_panes = "A2"
wb.save(filepath)
logger.info(f"Excel 已生成: {filepath}")
return filepath, filename
def get_bot_tenant_token():
"""获取 Bot 的 tenant_access_token"""
# 从 lark-cli 配置中读取 app_id 和 app_secret
config_path = os.path.join(LARK_CLI_CONFIG, "config.json")
if not os.path.exists(config_path):
# 尝试其他路径
config_path = os.path.join(LARK_CLI_CONFIG, "credentials.json")
# 通过 lark-cli 获取 token
result = subprocess.run(
["lark-cli", "auth", "token", "--as", "bot"],
capture_output=True, text=True,
env={**os.environ, "LARKSUITE_CLI_CONFIG_DIR": LARK_CLI_CONFIG}
)
if result.returncode == 0:
token = result.stdout.strip()
if token:
return token
raise RuntimeError(f"无法获取 Bot tenant_access_token: {result.stderr}")
def upload_to_drive(filepath, filename):
"""使用 Bot 身份上传文件到飞书云空间"""
logger.info(f"正在上传文件到飞书云空间...")
# lark-cli 要求相对路径,需要 cd 到文件所在目录
file_dir = os.path.dirname(os.path.abspath(filepath))
file_basename = os.path.basename(filepath)
result = subprocess.run(
[
"lark-cli", "drive", "+upload",
"--file", file_basename,
"--name", filename,
"--as", "bot"
],
capture_output=True, text=True,
cwd=file_dir,
env={**os.environ, "LARKSUITE_CLI_CONFIG_DIR": LARK_CLI_CONFIG}
)
if result.returncode != 0:
raise RuntimeError(f"文件上传失败: {result.stdout}\n{result.stderr}")
# 解析返回的 file_token
output = result.stdout.strip()
logger.info(f"上传响应: {output}")
try:
data = json.loads(output)
file_token = data.get("data", {}).get("file_token") or data.get("file_token")
if file_token:
logger.info(f"文件上传成功, file_token: {file_token}")
return file_token
except json.JSONDecodeError:
pass
# 尝试从文本中提取 file_token
if "file_token" in output:
import re
match = re.search(r'"file_token"\s*:\s*"([^"]+)"', output)
if match:
return match.group(1)
raise RuntimeError(f"无法从上传响应中解析 file_token: {output}")
def move_file_to_wiki(file_token, filename):
"""将云空间文件移入知识库指定节点下"""
logger.info(f"正在将文件移入知识库...")
import time
# 使用 move_docs_to_wiki API
data = {
"parent_wiki_token": WIKI_PARENT_NODE,
"obj_type": "file",
"obj_token": file_token
}
result = subprocess.run(
[
"lark-cli", "api", "POST",
f"/open-apis/wiki/v2/spaces/{WIKI_SPACE_ID}/nodes/move_docs_to_wiki",
"--data", json.dumps(data),
"--as", "bot"
],
capture_output=True, text=True,
env={**os.environ, "LARKSUITE_CLI_CONFIG_DIR": LARK_CLI_CONFIG}
)
output = result.stdout.strip()
logger.info(f"move_docs_to_wiki 响应: {output}")
try:
resp = json.loads(output)
if resp.get("code") == 0:
task_id = resp.get("data", {}).get("task_id", "")
logger.info(f"移动任务已提交, task_id: {task_id}")
# 等待异步任务完成
time.sleep(3)
# 查询任务结果
check_result = subprocess.run(
[
"lark-cli", "api", "GET",
f"/open-apis/wiki/v2/tasks/{task_id}",
"--params", '{"task_type":"move"}',
"--as", "bot"
],
capture_output=True, text=True,
env={**os.environ, "LARKSUITE_CLI_CONFIG_DIR": LARK_CLI_CONFIG}
)
check_output = check_result.stdout.strip()
check_resp = json.loads(check_output)
if check_resp.get("code") == 0:
move_results = check_resp.get("data", {}).get("task", {}).get("move_result", [])
if move_results and move_results[0].get("status") == 0:
node_token = move_results[0].get("node", {}).get("node_token", "")
wiki_url = f"https://makee-interactive.feishu.cn/wiki/{node_token}"
logger.info(f"文件已成功移入知识库: {wiki_url}")
return wiki_url
else:
raise RuntimeError(f"移动任务失败: {move_results}")
else:
raise RuntimeError(f"查询移动任务失败: {check_output}")
else:
raise RuntimeError(f"move_docs_to_wiki 失败: code={resp.get('code')}, msg={resp.get('msg')}")
except json.JSONDecodeError:
raise RuntimeError(f"move_docs_to_wiki 响应解析失败: {output}")
def send_notification(success, message):
"""给李若松发送通知"""
notify_cmd = [
"/root/.nvm/versions/node/v24.14.0/bin/openclaw",
"message", "send",
"--channel", "feishu",
"--target", "user:ou_088ee79216826be4a24af44f7268f880",
"--message", message
]
subprocess.run(notify_cmd, capture_output=True, text=True)
def main():
"""主流程"""
logger.info("=== 飞书反馈数据每日导出开始 ===")
# 加载环境变量
load_secrets()
try:
# 1. 查询前一天数据
rows, date_obj = query_yesterday_data()
# 2. 判断是否有有效数据
if not rows:
logger.info("前一天无新增数据,跳过导出")
return
# 3. 生成 Excel
filepath, filename = generate_excel(rows, date_obj)
# 4. 上传到飞书云空间
file_token = upload_to_drive(filepath, filename)
# 5. 将文件移入知识库
wiki_url = move_file_to_wiki(file_token, filename)
# 6. 发送成功通知
date_str = date_obj.strftime("%Y-%m-%d")
send_notification(True,
f"📊 飞书反馈日报导出成功\n"
f"日期:{date_str}\n"
f"记录数:{len(rows)}\n"
f"文件:{wiki_url}"
)
logger.info("=== 导出完成 ===")
except Exception as e:
logger.error(f"导出失败: {e}", exc_info=True)
send_notification(False, f"❌ 飞书反馈日报导出失败\n错误:{str(e)[:200]}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -0,0 +1,13 @@
#!/bin/bash
# 飞书反馈数据每日导出到知识库
# 定时任务:每天 09:00 执行
# 从 MySQL lark_group_message 表导出前一天新增数据为 Excel上传到飞书知识库
set -e
# 加载环境变量
source /root/.openclaw/workspace-xiaokui/secrets.env
# 执行 Python 脚本
cd /root/.openclaw/workspace-xiaokui
python3 scripts/export_lark_feedback_to_wiki.py

View File

@ -0,0 +1,21 @@
#!/bin/bash
# 任务名称:飞书问题反馈同步与对话链排序
# 执行时间:每天 10:00
# 归属 Agent小葵 xiaokui
set -e
export PATH=/root/.nvm/versions/node/v24.14.0/bin:$PATH
LOG_FILE="/var/log/xiaokui_feedback_sync.log"
log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"
}
log "=== 飞书问题反馈同步开始 ==="
cd /root/.openclaw/workspace-xiaokui
python3 skills/feishu-feedback-sync/scripts/sync_feishu_feedback.py --days 3 >> "$LOG_FILE" 2>&1
log "=== 同步完成 ==="
exit 0

View File

@ -0,0 +1,244 @@
---
name: feishu-feedback-sync
description: 同步飞书「内容测试问题反馈」群消息到知识库电子表格并执行对话链排序。用于:(1) 刘新玉说"同步飞书反馈"、"更新飞书问题反馈表格"、"整理反馈对话链"时触发;(2) 定时任务每日自动同步。数据源为 MySQL vala_test.lark_group_message目标为知识库飞书问题反馈-近3天表格。
---
# 飞书问题反馈同步与对话链排序
## 概述
从 MySQL `vala_test.lark_group_message` 读取飞书群消息,同步到知识库电子表格,并按引用关系执行「反馈对话链排序」。
### 两阶段输出(双文档分工)
| 阶段 | 目标文档 | 用途 |
|------|---------|------|
| 步骤 1-3 | 飞书问题反馈-近3天 电子表格 (`AHtnsehwShUVyDtjasSciIvgn7b`) | 原始数据初步整理,验证信息源准确性 |
| 步骤 4 | 用户反馈问题汇总 文档 (`RaL6whoYMijyYHkSlWrc7OLLnBy`) | 问题归纳输出,供二次处理 |
## 关键标识
| 项目 | 值 |
|------|-----|
| 数据库 | MySQL `bj-cdb-8frbdwju.sql.tencentcdb.com:25413`user `chatbot`,密码 `xhuBx7d@uT2gUVv`database `vala_test` |
| 源表 | `lark_group_message` |
| 目标表格 token | `AHtnsehwShUVyDtjasSciIvgn7b`步骤1-3原始数据整理 |
| 目标归纳文档 token | `RaL6whoYMijyYHkSlWrc7OLLnBy`步骤4问题归纳输出 |
| 目标知识库节点 | `TVivwmzqXiW3YakDUzucFMRenvf` |
| 知识空间 space_id | `7612229802338045122` |
| 群 chat_id | `oc_fabff7672e62a9ced7b326ee4a286c26` |
| Bot App ID | `cli_a9311791083adcc1` |
| Bot App Secret | `ThhLL1I0F2AIKnSbpozKnhm40LTPfyap` |
## 表格字段(与数据库一致)
| 列 | 数据库字段 |
|----|-----------|
| 消息ID | `message_id` |
| 发送者 | `sender_name` |
| 消息类型 | `msg_type` |
| 内容 | `content` |
| 媒体URL | `media_url` |
| 引用消息ID | `quote_message_id` |
| 消息时间 | `msg_time` |
| 消息时间戳 | `msg_timestamp` |
## 执行流程
### 步骤 1查询数据库
```sql
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 >= '{date} 00:00:00' AND msg_time < '{next_date} 00:00:00'
ORDER BY msg_time ASC
```
默认同步最近 3 天的数据。如果表格已有当天 sheet则追加新数据去重写入按 message_id 去重);如无则创建新 sheet。
### 步骤 2写入飞书表格
使用 `lark-cli sheets +write`Bot 身份)批量写入:
- 按天分 sheetsheet 名为日期,如 `2026-04-28`
- 表头固定为 8 列
- 每 sheet 最多一次写入(或分批,不超过 500 行/批)
**凭证环境:**
```bash
export PATH=/root/.nvm/versions/node/v24.14.0/bin:$PATH
export LARKSUITE_CLI_CONFIG_DIR=/root/.openclaw/credentials/xiaokui
export HOME=/root
```
**写入命令格式:**
```bash
lark-cli sheets +write \
--spreadsheet-token AHtnsehwShUVyDtjasSciIvgn7b \
--range '<sheet_id>!A1:H<n>' \
--values '<json_2d_array>' \
--as bot
```
### 步骤 3反馈对话链排序
对每个 sheet 中的数据执行引用链排序(详见下文排序逻辑),使每个问题的完整讨论过程在表格中连续呈现。
## 反馈对话链排序逻辑
### 两阶段排序
#### 阶段 A推断缺失引用策略2
由于飞书群消息同步时仅采集了 `quote_message_id`显式引用大量通过飞书「直接回复」功能产生的消息没有引用关系。策略2通过启发式规则补全
1. **@提及匹配**(最高优先级):消息中 `@某人` → 关联到被@者最近一条消息
2. **同发送者聚类**:同一发送者在 2 分钟窗口内连续发多条消息 → 认为是对同一目标的回复
3. **最近不同发送者**fallback关联到最近一条不同发送者的消息需在 30 分钟内,防止跨话题误关联)
```
示例:
12:18 徐思清: NPC HUD 报告 (无引用)
12:19 王胤鑫: o3 分支? (无引用 → 推断关联12:18因为12:18是最近不同发送者)
12:19 王胤鑫: 有固定关卡吗 (无引用 → 推断关联12:18因为同发送者2分钟窗口内)
```
#### 阶段 B引用链排序
1. **Union-Find 聚类**:将有引用关系的消息(含推断引用)合并为同一「问题簇」
2. **簇内递归展开**:从根消息开始,子回复紧跟父消息(子节点按时间排序)
3. **簇间排序**:按每个簇最早消息的时间排序
### 排序效果对比
排序前(时间平铺):
```
12:18 徐思清: NPC HUD 报告
12:19 王胤鑫: o3 分支?
12:19 王胤鑫: 有固定关卡出现吗
12:20 庞鸿潇: @王胤鑫 11-2 (引用"有固定关卡")
12:22 梁晨: @王胤鑫 没啥规律 (引用"有固定关卡")
12:28 王胤鑫: 是否只在移动端 (引用NPC HUD)
```
排序后(问题链聚合):
```
12:18 徐思清: NPC HUD 报告
12:19 王胤鑫: o3 分支? (推断引用12:18)
12:19 王胤鑫: 有固定关卡吗 (推断引用12:18)
12:20 庞鸿潇: 11-2 (引用"有固定关卡")
12:22 梁晨: 没啥规律 (引用"有固定关卡")
12:28 王胤鑫: 是否只在移动端 (引用12:18)
12:26 孙时敏: Playtesting有数据 (推断引用12:22)
```
> 缩进行为对该根消息的直接或间接回复,形成完整的「问题 → 追问 → 诊断 → 结论」链路。
### 步骤 4问题归纳
对每个问题簇生成结构化归纳,分两个部分:
1. **问题描述**:按固定格式描述问题
2. **当前问题排查结论**:从对话最后几条消息中提取排查状态
#### 4.1 问题描述格式
```
> **在{端}端{环节}内({课程}{角色/组件}出现了{现象}**
```
#### 位置要素提取维度
| 维度 | 可能的值 | 来源 |
|------|---------|------|
| 端 | 移动端、iOS、iPad、pad端、Android | 消息内容关键词 |
| 环节 | 关卡内、关卡外、知识巩固、巩固题、单元挑战、挑战、听力挑战、阅读挑战、口语挑战、写作挑战、单元强化、瓦拉学院、报告 | 消息内容关键词 |
| 课程 | 数字如 11-2、L1 3-2 | 消息内容中的数字/字母编号 |
| 角色/组件 | NPC、HUD、音频、组件等 | 消息内容关键词 |
| 现象 | 一句话概括发生了什么 | 综合理解 |
#### 4.2 归纳输出格式
每个问题簇输出一个结构化块(结论在表格上方,表格放原始对话):
```markdown
### 问题 N
**问题描述:** {AI 归纳后的精炼描述}
**当前问题排查结论:** {结论}
| 发言人 | 对话信息 |
|--------|---------|
| 报告人 | 🚩 报告:原始对话内容 |
| ... | 原始对话内容(含媒体 URL |
| 最终人 | ✅ 原始对话内容 |
```
```
#### 4.3 排查结论提取规则
脚本从**全部消息**(而非仅最后 1-2 条)中提取结论。优先匹配以下模式:
| 优先级 | 匹配模式 | 结论 |
|--------|---------|------|
| 1 | 已修复/已解决/修好了 | 已修复 |
| 2 | 确认是bug/确实是问题 | 已确认,待修复 |
| 3 | 不是bug/设计如此/非问题 | 非问题,设计如此 |
| 4 | 有解释性分析 + 日志已上传 + 排查中 | 疑似{原因},已上传日志,排查中 |
| 5 | 日志已上传 + 排查中 | 已上传日志,排查中 |
| 6 | 暂未/没复现/未复现 | 暂未排查到问题 |
| 7 | 其他(无明确结论) | 暂未排查到问题 |
**解释性分析** 的来源:消息中匹配 "因为/原因是/应该是/改为了/导致/预下载/上云/首次/正常情况" 等关键词,提取对应发言人的判断句作为疑似原因。
> 注意:脚本的规则匹配是辅助手段。运行时的 AI 可以根据完整对话上下文,修正或补全结论。
#### 4.4 归纳示例
```markdown
### 问题一
> **在移动端关卡内11-2 等NPC 头上的 HUD 偶尔变成一小条**
| 发言人 | 要点 |
|--------|------|
| 徐思清 | 🚩 报告:最近经常出现,无明显规律 |
| 王胤鑫 | 追问o3 分支只在手机包出现unity 里正常? |
| 庞鸿潇 | 确认11-2 出现了 |
| 梁晨 | 补充:只在 APP 里发现过 |
| 徐思清 | ✅ 确认:确实只在移动端出现 |
**当前问题排查结论:** 暂未排查到问题
```
```markdown
### 问题二
> **在 iOS 端关卡内L1 3-2组件数据丢失无音频Loading 耗时约 10 秒**
| 发言人 | 要点 |
|--------|------|
| 胡陈辰 | 🚩 报告iOS 线上 L1 3-2 组件无音频 |
| 安君仪 | 询问:哪个组件? |
| 胡陈辰 | 复现Loading 约 10 秒,组件数据丢失,杀 APP 重进恢复正常 |
| 毋益飞 | 解释Loading 慢因内容上云加载;要求上传日志 |
| 胡陈辰 | ✅ 日志已上传,待明天排查 |
**当前问题排查结论:** 日志已上传,排查中
```
## 定时任务
建议每日执行一次,在飞书群消息同步完毕后(`feishu-group-msg-sync` 之后)运行。
### 创建定时任务crontab
```bash
# 每天 10:00 执行(确保消息已同步入库)
0 10 * * * /bin/bash /root/.openclaw/workspace-xiaokui/scripts/sync_feishu_feedback_wrapper.sh >> /var/log/xiaokui_feedback_sync.log 2>&1
```
## 权限说明
- Bot 有 `sheets:read/write` 和 wiki API 读取权限
- Bot 缺少 `wiki:node:create`、`drive`、`bitable` 权限
- 所有操作均使用 Bot 身份 + `lark-cli` 命令
- 创建新 sheet 使用 `lark-cli api POST sheets_batch_update`

View File

@ -0,0 +1,599 @@
#!/usr/bin/env python3
"""
飞书问题反馈同步脚本
功能 MySQL 读取飞书群消息同步到知识库电子表格并执行对话链排序
用法:
python3 sync_feishu_feedback.py [--days N] [--dry-run]
--days N 同步最近 N 默认 3
--dry-run 仅打印操作不写入
"""
import sys, os, json, subprocess, argparse, re
from datetime import date, datetime, timedelta
from collections import defaultdict, deque
# === 配置 ===
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", "消息时间", "消息时间戳"]
# 推断引用策略参数
TIME_WINDOW_MIN = 2 # 同发送者聚类时间窗口(分钟)
GAP_THRESHOLD_MIN = 30 # 大时间跨度视为新话题(分钟)
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 rows[j][5]:
same_sender_parent = rows[j][5]
else:
# 同发送者无引用,关联到它的前一条不同发送者
for k in range(j - 1, -1, -1):
if rows[k][1] != sender:
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:
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)
# 簇间按最早时间排序
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
# ============================================================
# 步骤 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"瓦拉学院", "瓦拉学院"),
]
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 summarize_cluster(cluster_msgs, idx):
"""
生成单个问题簇的结构化归纳
脚本负责提取位置要素元数据 + 输出对话要点表格 + 提取结论
AI 负责基于元数据和上下文生成精炼的问题描述
输出格式
### 问题 N
> **[AI归纳: 问题描述]**
<details><summary>位置要素脚本提取</summary>{元数据}</details>
| 发言人 | 要点 |
|--------|------|
...
**当前问题排查结论** ...
"""
loc = extract_location_elements(cluster_msgs)
def clean_text(t):
if not t:
return ""
t = re.sub(r'\[Image:[^\]]+\]', '', t)
t = re.sub(r'https?://\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 "未知"
lines = [f"### 问题 {idx}"]
lines.append("")
lines.append(f"> **[AI归纳: 问题描述]**")
lines.append("")
conclusion = extract_conclusion(cluster_msgs)
lines.append(conclusion)
lines.append("")
lines.append(f"📋 脚本提取的位置要素:端=`{device}` | 环节=`{scene}` | 课程=`{course}` | 组件=`{component}`")
lines.append(f"📋 首条报告内容:{root_msg_text[:120]}")
lines.append("")
lines.append("| 发言人 | 对话信息 |")
lines.append("|--------|---------|")
first_speaker = cluster_msgs[0][1]
last_speaker = cluster_msgs[-1][1]
seen_speakers = set()
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:
info_parts.append(f"📎 [文件]({media_url})")
if not info_parts:
info_parts.append("[图片]")
dialogue_info = " | ".join(info_parts)
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 = ""
seen_speakers.add(name)
lines.append(f"| {name} | {role_tag}{dialogue_info} |")
return "\n".join(lines)
def extract_conclusion(cluster_msgs):
"""
从问题簇全部消息中提取排查结论
返回: str 结论文本
"""
all_text = " ".join(str(m[3]) or "" for m in cluster_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 cluster_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 "**当前问题排查结论:** 已确认,待修复"
# 分析性发言优先于"非问题"判断
if analysis_lines and (has_log or has_pending):
# 提取第一条分析作为疑似原因
first_reason = analysis_lines[0].split('', 1)[-1][:50]
return f"**当前问题排查结论:** 疑似{first_reason},已上传日志,排查中"
if analysis_lines:
first_reason = analysis_lines[0].split('', 1)[-1][:50]
return f"**当前问题排查结论:** 疑似{first_reason}"
if match_any(r'不是.*bug|就是这样的|设计如此|非问题|不是问题'):
return "**当前问题排查结论:** 非问题,设计如此"
if match_any(r'正常.*现象'):
return "**当前问题排查结论:** 非问题,正常现象"
if match_any(r'暂未|还没.*找到|查不到|没复现|未复现'):
return "**当前问题排查结论:** 暂未排查到问题"
# 默认
return "**当前问题排查结论:** 暂未排查到问题"
def generate_summary(clusters, cluster_order):
"""
生成当日问题归纳 Markdown
返回: str 完整的 Markdown 归纳文本
"""
lines = ["## 今日问题归纳\n"]
idx = 0
for cid in cluster_order:
cmsgs = clusters[cid]
if len(cmsgs) < 2:
continue # 跳过单条无法形成完整问题的
idx += 1
summary = summarize_cluster(cmsgs, idx)
lines.append(summary)
lines.append("")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="飞书问题反馈同步")
parser.add_argument("--days", type=int, default=3, help="同步最近 N 天(默认 3")
parser.add_argument("--dry-run", action="store_true", help="仅预览不写入")
args = parser.parse_args()
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
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)}")
# 步骤 4问题归纳
summary = generate_summary(clusters, cluster_order)
if summary:
print(f" 归纳完成:{summary.count('### 问题')} 个问题")
print(f" (归纳内容见下方)")
print(summary)
# 写入
success = write_sheet(sheet_id, sorted_msgs)
if success:
total_written += len(sorted_msgs)
print(f" ✅ 写入 {len(sorted_msgs)}")
else:
print(f" ❌ 写入失败")
print(f"\n🎉 同步完成,总计写入 {total_written}")
if __name__ == "__main__":
main()

View File

@ -214,12 +214,16 @@ def process_message(msg: dict, cos_uploader, date_str: str) -> dict:
'msg_timestamp': msg_timestamp
}
# 文本消息
if msg_type in ('text', 'post'):
# 文本消息
if msg_type == 'text':
text = re.sub(r'<[^>]*>', '', content).strip()
record['content'] = text
return record
# 富文本消息post可能同时包含文本和图片拆分为多条记录
if msg_type == 'post':
return None # 由 process_post_message 单独处理,返回多条记录
# 表情包
if msg_type == 'sticker':
record['content'] = '(表情包)'
@ -276,6 +280,124 @@ def process_message(msg: dict, cos_uploader, date_str: str) -> dict:
return record
def process_post_message(msg: dict, cos_uploader, date_str: str) -> list:
"""处理 post 类型消息(富文本),拆分文本和图片为多条记录"""
msg_id = msg.get('message_id', '')
sender_id = msg.get('sender', {}).get('id', '')
sender_name = msg.get('sender', {}).get('name', '未知')
create_time = msg.get('create_time', '')
content = msg.get('content', '')
deleted = msg.get('deleted', False)
parent_id = msg.get('parent_id')
root_id = msg.get('root_id')
if deleted:
return []
quote_message_id = extract_quote_message_id(msg)
# 解析时间戳
try:
dt = datetime.fromisoformat(create_time.replace('Z', '+00:00'))
msg_time = dt.astimezone(TZ).strftime('%Y-%m-%d %H:%M:%S')
msg_timestamp = int(dt.timestamp() * 1000)
except Exception:
msg_time = datetime.now(TZ).strftime('%Y-%m-%d %H:%M:%S')
msg_timestamp = int(datetime.now(TZ).timestamp() * 1000)
# 提取图片 keys
image_keys = re.findall(r'\[Image:\s*(img_[a-zA-Z0-9_-]+)\]', content)
# 提取纯文本去掉图片标记和HTML标签
text_content = re.sub(r'\[Image:\s*img_[a-zA-Z0-9_-]+\]', '', content)
text_content = re.sub(r'<[^>]*>', '', text_content).strip()
records = []
# 如果有文本内容,生成文本记录
if text_content:
text_record = {
'chat_id': CHAT_ID,
'chat_name': CHAT_NAME,
'sender_id': sender_id,
'sender_name': sender_name,
'message_id': msg_id,
'parent_id': parent_id,
'root_id': root_id,
'quote_message_id': quote_message_id,
'msg_type': 'post_text',
'content': text_content,
'media_url': None,
'msg_time': msg_time,
'msg_timestamp': msg_timestamp
}
records.append(text_record)
# 如果没有图片,且文本也为空(极端情况),仍保留一条原始记录
if not image_keys and not text_content:
records.append({
'chat_id': CHAT_ID,
'chat_name': CHAT_NAME,
'sender_id': sender_id,
'sender_name': sender_name,
'message_id': msg_id,
'parent_id': parent_id,
'root_id': root_id,
'quote_message_id': quote_message_id,
'msg_type': 'post',
'content': content[:500],
'media_url': None,
'msg_time': msg_time,
'msg_timestamp': msg_timestamp
})
return records
# 处理每张图片
for idx, img_key in enumerate(image_keys):
short_id = msg_id[-12:]
filename = f'{short_id}_img{idx}.png'
cos_key = f'{COS_BASE_PATH}/image/{date_str}/{filename}'
# 下载图片
local_path = download_resource(msg_id, img_key, 'image', filename)
media_url = None
if local_path:
try:
url = cos_uploader.upload(local_path, cos_key, 'image/png')
log.info(f"post图片上传COS成功: {cos_key}")
media_url = url
except Exception as e:
log.error(f"post图片上传COS失败: {cos_key} - {e}")
finally:
try:
os.remove(local_path)
except Exception:
pass
else:
log.warning(f"post图片下载失败: {msg_id} img_key={img_key}")
# 图片记录使用特殊 message_id 避免唯一键冲突(原 msg_id + _imgN 后缀)
img_record = {
'chat_id': CHAT_ID,
'chat_name': CHAT_NAME,
'sender_id': sender_id,
'sender_name': sender_name,
'message_id': f"{msg_id}_img{idx}",
'parent_id': parent_id,
'root_id': root_id,
'quote_message_id': quote_message_id,
'msg_type': 'post_image',
'content': f'图片 ({idx+1}/{len(image_keys)})',
'media_url': media_url,
'msg_time': msg_time,
'msg_timestamp': msg_timestamp
}
records.append(img_record)
return records
def insert_to_mysql(records: list):
"""批量插入记录到MySQL"""
if not records:
@ -302,6 +424,7 @@ def insert_to_mysql(records: list):
parent_id = VALUES(parent_id),
root_id = VALUES(root_id),
quote_message_id = VALUES(quote_message_id),
msg_type = VALUES(msg_type),
content = VALUES(content),
media_url = VALUES(media_url)
"""
@ -362,9 +485,15 @@ def main():
# 处理消息
records = []
for msg in messages:
record = process_message(msg, cos_uploader, date_str)
if record:
records.append(record)
msg_type = msg.get('msg_type', '')
if msg_type == 'post':
# post 富文本消息:拆分为文本+图片多条记录
post_records = process_post_message(msg, cos_uploader, date_str)
records.extend(post_records)
else:
record = process_message(msg, cos_uploader, date_str)
if record:
records.append(record)
# 写入数据库
if records: