From 7d7f2e13c9dc8618677ac202a7f811a10e211acf Mon Sep 17 00:00:00 2001 From: --git_token Date: Fri, 22 May 2026 08:10:01 +0800 Subject: [PATCH] auto backup 2026-05-22 08:10:01 --- .vala_skill_hashes | 2 +- HEARTBEAT.md | 7 - memory/.dreams/events.jsonl | 1 + memory/.dreams/short-term-recall.json | 69 ++- memory/2026-05-21.md | 124 +++++ .../ai_descriptions_2026-05-11.json | 9 - .../飞书反馈_2026-05-21.xlsx | Bin 0 -> 11154 bytes .../ai_summarize_feedback.cpython-312.pyc | Bin 0 -> 11524 bytes scripts/ai_summarize_feedback.py | 224 +++++++++ scripts/backfill_ai_descriptions.py | 449 ++++++++++++++++++ scripts/detect_p0_realtime.py | 273 +++++++++++ .../sync_feishu_feedback.cpython-312.pyc | Bin 71645 -> 74844 bytes .../scripts/sync_feishu_feedback.py | 47 +- 13 files changed, 1171 insertions(+), 34 deletions(-) create mode 100644 memory/2026-05-21.md delete mode 100644 output/daily_feedback/ai_descriptions_2026-05-11.json create mode 100644 output/daily_feedback/飞书反馈_2026-05-21.xlsx create mode 100644 scripts/__pycache__/ai_summarize_feedback.cpython-312.pyc create mode 100644 scripts/ai_summarize_feedback.py create mode 100644 scripts/backfill_ai_descriptions.py create mode 100644 scripts/detect_p0_realtime.py diff --git a/.vala_skill_hashes b/.vala_skill_hashes index a1dd954..cd25703 100644 --- a/.vala_skill_hashes +++ b/.vala_skill_hashes @@ -13,5 +13,5 @@ tencent-cos-upload 172517ed41d06c48425cd961ec5972a48495cfd62ec588bc1c2912ddf31b3 user-feedback-collector c0320451bf7ea0ce3d8ceaa603ae0a7b55c373c048363a5142258a4c23f45e81 user-feedback-data-source a95eb9142f3019fd193c46f89147dc7e0bf01dfe250202565a86f8bc52f37b13 user-feedback-processor 61783a8e9f03a973c187b359a87749ad1993dc71f8364b0a853d8b3ff64c75e8 -feishu-feedback-sync e51dd6d0c4f26898ebcc069ab19272d17e86c01a767c36c1d65ab576da38ba11 feishu-group-msg-sync 1b581de76d419e6a33db0836125efc16ef2c972013fcae6f08c03aa7e2276445 +feishu-feedback-sync 1ec556db6c8523c36efacde6fb92659a5274f65b8104f83e38eacbdc419377ba diff --git a/HEARTBEAT.md b/HEARTBEAT.md index 26b4188..f6dd388 100644 --- a/HEARTBEAT.md +++ b/HEARTBEAT.md @@ -2,10 +2,3 @@ # 自动同步Skill到公司SkillHub - 查看是否有需要推送的skill: 执行脚本 /root/.openclaw/workspace-xiaokui/scripts/sync_skill_to_skillhub.sh,如果推送成功,给李若松发送通知消息,无需推送则静默 - -# AI 问题归纳(每日) -- 检查 output/daily_feedback/ 下是否有当天日期的 cluster_context_YYYY-MM-DD.json 文件 -- 如果有,读取上下文 JSON,为每个问题簇生成精炼的问题描述 -- 将 AI 描述写入 output/daily_feedback/ai_descriptions_YYYY-MM-DD.json -- 执行 python3 skills/feishu-feedback-sync/scripts/sync_feishu_feedback.py --apply-ai output/daily_feedback/ai_descriptions_YYYY-MM-DD.json -- 完成后删除 cluster_context 文件(避免重复处理) diff --git a/memory/.dreams/events.jsonl b/memory/.dreams/events.jsonl index 5ba0ef9..f4a0f5d 100644 --- a/memory/.dreams/events.jsonl +++ b/memory/.dreams/events.jsonl @@ -13,3 +13,4 @@ {"type":"memory.recall.recorded","timestamp":"2026-05-11T11:26:09.201Z","query":"知识库 sort_tag 空 文档排序","resultCount":3,"results":[{"path":"memory/2026-05-09.md","startLine":46,"endLine":65,"score":1},{"path":"memory/2026-05-09.md","startLine":78,"endLine":95,"score":1},{"path":"memory/2026-05-06.md","startLine":20,"endLine":47,"score":1}]} {"type":"memory.recall.recorded","timestamp":"2026-05-12T06:08:28.032Z","query":"优先级规则 priority rules","resultCount":2,"results":[{"path":"memory/2026-05-07.md","startLine":23,"endLine":50,"score":1},{"path":"memory/2026-05-07.md","startLine":1,"endLine":28,"score":1}]} {"type":"memory.recall.recorded","timestamp":"2026-05-14T13:09:32.054Z","query":"微信用户反馈 数据库 表结构 wechat_group_message","resultCount":1,"results":[{"path":"memory/2026-05-07.md","startLine":86,"endLine":116,"score":1}]} +{"type":"memory.recall.recorded","timestamp":"2026-05-21T10:38:19.153Z","query":"crontab 注释 每分钟 归纳分类 P0分发 步骤","resultCount":4,"results":[{"path":"memory/2026-05-09.md","startLine":17,"endLine":37,"score":1},{"path":"memory/2026-04-17.md","startLine":1,"endLine":23,"score":1},{"path":"memory/2026-04-10.md","startLine":44,"endLine":68,"score":1},{"path":"memory/2026-04-30.md","startLine":116,"endLine":142,"score":1}]} diff --git a/memory/.dreams/short-term-recall.json b/memory/.dreams/short-term-recall.json index 35310b4..34efb26 100644 --- a/memory/.dreams/short-term-recall.json +++ b/memory/.dreams/short-term-recall.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-05-14T13:09:32.054Z", + "updatedAt": "2026-05-21T10:38:19.153Z", "entries": { "memory:memory/2026-04-18.md:1:5": { "key": "memory:memory/2026-04-18.md:1:5", @@ -81,24 +81,26 @@ "endLine": 142, "source": "memory", "snippet": "问题:很多消息有关联但没有 `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", - "recallCount": 5, + "recallCount": 6, "dailyCount": 0, "groundedCount": 0, - "totalScore": 5, + "totalScore": 6, "maxScore": 1, "firstRecalledAt": "2026-05-06T13:30:08.593Z", - "lastRecalledAt": "2026-05-11T09:48:27.002Z", + "lastRecalledAt": "2026-05-21T10:38:19.153Z", "queryHashes": [ "f865295b9ac7", "cd9c89262c30", "ac7fd0b52a4e", "49c0959dc960", - "70caeba05281" + "70caeba05281", + "2f315a9f8529" ], "recallDays": [ "2026-05-06", "2026-05-07", - "2026-05-11" + "2026-05-11", + "2026-05-21" ], "conceptTags": [ "quote-message-id", @@ -258,20 +260,22 @@ "endLine": 23, "source": "memory", "snippet": "# 2026-04-17 工作日志 ## 飞书群消息同步改造(李若松要求) ### 变更内容 - **存储从飞书表格改为 MySQL**:新建 `vala_test.lark_group_message` 表,结构参考 `wechat_group_message` - **同步频率**:从每6小时改为每4小时 - **数据范围**:2026.4.1 起的「内容测试问题反馈」群消息 - **数据库账户**:chatbot(test环境,仅对 lark_group_message 有写入权限) ### 完成事项 1. ✅ 创建 `lark_group_message` 表(唯一键 message_id 防重复) 2. ✅ 编写新同步脚本 `scripts/sync_lark_group_to_mysql.py`(基于原有 sync_group_to_sheet.py 改造) 3. ✅ 首次全量同步完成:172 条记录(2026-04-01 ~ 2026-04-17),含文本134条、图片17条、视频10条、富文本9条、表情2条 4. ✅ crontab 定时任务已替换:旧的每6小时飞书表格同步 → 新的每4小时MySQL同步 5. ✅ 更新 secrets.md 记录 chatbot 账户 6. ✅ 更新 user-feedback-collector SKILL.md 反馈数据源信息 ### 文件变更 - 新增:`scripts/sync_lark_group_to_mysql.py`(核心同步脚本) - 新增:`scripts/run_lark_group_sync.s", - "recallCount": 2, + "recallCount": 3, "dailyCount": 0, "groundedCount": 0, - "totalScore": 2, + "totalScore": 3, "maxScore": 1, "firstRecalledAt": "2026-05-08T10:25:44.365Z", - "lastRecalledAt": "2026-05-11T10:43:36.686Z", + "lastRecalledAt": "2026-05-21T10:38:19.153Z", "queryHashes": [ "cc0dd7ef50d7", - "5abc37103c15" + "5abc37103c15", + "2f315a9f8529" ], "recallDays": [ "2026-05-08", - "2026-05-11" + "2026-05-11", + "2026-05-21" ], "conceptTags": [ "vala-test.lark-group-message", @@ -454,18 +458,20 @@ "endLine": 68, "source": "memory", "snippet": "### 验证结果 - 全量同步成功:47条记录写入表格,5张图片+4个视频上传COS - crontab 每小时整点自动执行:`0 * * * *` - 群ID:oc_fabff7672e62a9ced7b326ee4a286c26 ## 封装两个通用Skill **来源:** [李若松] 要求将功能封装为可复用skill ### 1. tencent-cos-upload - 路径:`/root/.openclaw/skills/tencent-cos-upload/` - 功能:上传文件到腾讯COS并生成可访问URL - 提供命令行调用和Python模块两种方式 - 核心文件:`scripts/cos_upload.py`(CosUploader类) ### 2. feishu-group-msg-sync - 路径:`/root/.openclaw/skills/feishu-group-msg-sync/` - 功能:定期同步飞书群聊消息到电子表格,媒体上传COS - 依赖 tencent-cos-upload skill - 核心文件:`scripts/sync_group_to_sheet.py`(模板脚本,修改顶部配置即可复用) - 参考文件:`references/lark-cli-cheatsheet.md` ### 项目脚本也改为引用skill - `scripts/sync_feedback_group.py` 现在只做配置覆盖,逻辑全部引用自skill", - "recallCount": 1, + "recallCount": 2, "dailyCount": 0, "groundedCount": 0, - "totalScore": 1, + "totalScore": 2, "maxScore": 1, "firstRecalledAt": "2026-05-11T10:43:36.686Z", - "lastRecalledAt": "2026-05-11T10:43:36.686Z", + "lastRecalledAt": "2026-05-21T10:38:19.153Z", "queryHashes": [ - "5abc37103c15" + "5abc37103c15", + "2f315a9f8529" ], "recallDays": [ - "2026-05-11" + "2026-05-11", + "2026-05-21" ], "conceptTags": [ "tencent-cos-upload", @@ -664,6 +670,37 @@ "飞书问题反馈-近3天", "chat-id" ] + }, + "memory:memory/2026-05-09.md:17:37": { + "key": "memory:memory/2026-05-09.md:17:37", + "path": "memory/2026-05-09.md", + "startLine": 17, + "endLine": 37, + "source": "memory", + "snippet": "- 修复:改为 `sort_tag = 9999999999 - int(dt.timestamp())` 实现日期降序 - ⚠️ 不足:飞书 Wiki V2 API 创建节点时 `sort_tag` 参数可能被忽略(API 返回均为 null) - 兜底方案:按日期由近到远的顺序依次创建子文档,利用 `node_create_time` 自然排序 - 所有旧文档已删除并按正确顺序重建(5月8日→5月7日→4月28日),5月6日需手动创建 ### 飞书分发消息 `` 标签修复 - 根因:`dispatch_summary_to_chat` 中两步打架——第一步 `re.sub` 注入 HTML `` 文本,第二步 `content_parts` 用正确 `{\"tag\":\"at\"}` 格式插入 - 修复:删除 `re.sub` 注入原始 HTML 标签的代码,仅保留富文本 at tag ### 废弃定时任务的 crontab 清理 - 已删除 xiaokui crontab 中 `*/5 * * * *` 的「飞书问题反馈同步每分钟」任务(含 wrapper 脚本调用) - 该任务每分钟执行一次打开 MySQL 连接/查询/返回存在潜在连接泄漏风险 ### 5月9日补跑问题 - 5月9日10:00定时任务因 `IndentationError` 失败(凌晨08:10自动备份 `c3c8dbb` 损坏了脚本) - 修复:从上游版本恢复被清空的步骤4-7逻辑 + 模块常量 - 手动补跑5月8日数据(8条反馈,1个P0)成功 ## 2026-05-09 工作日志", + "recallCount": 1, + "dailyCount": 0, + "groundedCount": 0, + "totalScore": 1, + "maxScore": 1, + "firstRecalledAt": "2026-05-21T10:38:19.153Z", + "lastRecalledAt": "2026-05-21T10:38:19.153Z", + "queryHashes": [ + "2f315a9f8529" + ], + "recallDays": [ + "2026-05-21" + ], + "conceptTags": [ + "备份", + "sort-tag", + "dt.timestamp", + "node-create-time", + "dispatch-summary-to-chat", + "re.sub", + "content-parts", + "连接/查询/返回存在潜在连接泄漏风险" + ] } } } diff --git a/memory/2026-05-21.md b/memory/2026-05-21.md new file mode 100644 index 0000000..6e4a688 --- /dev/null +++ b/memory/2026-05-21.md @@ -0,0 +1,124 @@ +# 2026-05-21 工作日志 + +## P0 实时检测与推送 [刘新玉需求] + +### 背景 +- 原有两个每分钟 crontab 任务已被注释(5/11),因为每次全量重跑浪费资源且 P0 会重复推送 +- 刘新玉要求做真正的增量 P0 实时分发 + +### 完成事项 +1. ✅ 新建 `scripts/detect_p0_realtime.py`(~200行): + - 复用 `sync_feishu_feedback.py` 的聚类逻辑 + `priority_classifier.py` 的优先级判定 + - 查询最近 2 小时消息 → 聚类 → 判定 P0 → 去重(簇签名 MD5)→ 推送 + - 去重状态存在 `tmp/p0_dispatched_state.json`,24 小时自动过期 + - 每天 10:00-10:01 自动清空去重,配合全量分发 +2. ✅ crontab 每分钟执行:`* * * * * python3 detect_p0_realtime.py` +3. ✅ 步骤 4-6 不做每分钟恢复(增量改造成本高,日汇总实时性需求弱) + +### AI 归纳流程修复 [刘新玉需求] + +### 背景 +- 5/15 问题描述出现严重退化:问题一变成"可以在这里Po问题"(元指令文本),问题二变成"图片 (1/1)" +- 根因:(1) 关键词模式只能覆盖约 8 类症状,不匹配时退化到首条消息原文 (2) AI 归纳靠心跳触发,5/15 当天漏执行 + +### 完成事项 +1. ✅ 新建 `scripts/ai_summarize_feedback.py`: + - 读取 `cluster_context_{date}.json`,调用 DeepSeek API(deepseek-v4-pro)生成精炼问题描述 + - 保存 `ai_descriptions_{date}.json` → 调用 `--apply-ai` 回写到知识库 + - 回写后自动清理 context 文件 +2. ✅ crontab 每天 10:05 执行(在 10:00 全量同步之后) +3. ✅ HEARTBEAT.md 移除 AI 归纳任务(已由 crontab 接管) +4. ✅ 扩展 `generate_problem_description()` 关键词覆盖: + - 新增:语音识别/判分、内容/命名缺失、后台弹窗残留、网络/VPN、热更/打包/测试包 + - 新增:后台加载场景识别 + - 5/15 验证:问题一从"可以在这里Po问题"→"语音识别不准确(对话表达,只识别成错误内容)";问题二从"图片 (1/1)"→"后台加载提示/弹窗未自动消失,持续显示 需确认是否因 VPN/网络代理导致" +5. ✅ 未启用步骤 4-6 每分钟执行(理由同刘新玉确认) + +### 调度总览(更新后) +``` +每 5 分钟 → 群消息同步入 MySQL +每分钟 → P0 实时检测 + 增量推送 +每分钟 → 反馈数据实时导出到表格 +每天 10:00 → 全量七步处理 + 全量分发 +每天 10:05 → AI 归纳(DeepSeek 生成描述 → 回写文档) +``` +# 2026-05-21 工作日志 + +## P0 实时检测与推送 [刘新玉需求] + +### 背景 +- 原有两个每分钟 crontab 任务已被注释(5/11),因为每次全量重跑浪费资源且 P0 会重复推送 +- 刘新玉要求做真正的增量 P0 实时分发 + +### 完成事项 +1. ✅ 新建 `scripts/detect_p0_realtime.py`(~200行): + - 复用 `sync_feishu_feedback.py` 的聚类逻辑 + `priority_classifier.py` 的优先级判定 + - 查询最近 2 小时消息 → 聚类 → 判定 P0 → 去重(簇签名 MD5)→ 推送 + - 去重状态存在 `tmp/p0_dispatched_state.json`,24 小时自动过期 + - 每天 10:00-10:01 自动清空去重,配合全量分发 +2. ✅ crontab 每分钟执行:`* * * * * python3 detect_p0_realtime.py` +3. ✅ 步骤 4-6 不做每分钟恢复(增量改造成本高,日汇总实时性需求弱) + +### AI 归纳流程修复 [刘新玉需求] + +### 背景 +- 5/15 问题描述出现严重退化:问题一变成"可以在这里Po问题"(元指令文本),问题二变成"图片 (1/1)" +- 根因:(1) 关键词模式只能覆盖约 8 类症状,不匹配时退化到首条消息原文 (2) AI 归纳靠心跳触发,5/15 当天漏执行 + +### 完成事项 +1. ✅ 新建 `scripts/ai_summarize_feedback.py`: + - 读取 `cluster_context_{date}.json`,调用 DeepSeek API(deepseek-v4-pro)生成精炼问题描述 + - 保存 `ai_descriptions_{date}.json` → 调用 `--apply-ai` 回写到知识库 + - 回写后自动清理 context 文件 +2. ✅ crontab 每天 10:05 执行(在 10:00 全量同步之后) +3. ✅ HEARTBEAT.md 移除 AI 归纳任务(已由 crontab 接管) +4. ✅ 扩展 `generate_problem_description()` 关键词覆盖: + - 新增:语音识别/判分、内容/命名缺失、后台弹窗残留、网络/VPN、热更/打包/测试包 + - 新增:后台加载场景识别 + - 5/15 验证:问题一从"可以在这里Po问题"→"语音识别不准确(对话表达,只识别成错误内容)";问题二从"图片 (1/1)"→"后台加载提示/弹窗未自动消失,持续显示 需确认是否因 VPN/网络代理导致" +5. ✅ 未启用步骤 4-6 每分钟执行(理由同刘新玉确认) + +### 调度总览(更新后) +``` +每 5 分钟 → 群消息同步入 MySQL +每分钟 → P0 实时检测 + 增量推送 +每分钟 → 反馈数据实时导出到表格 +每天 10:00 → 全量七步处理 + 全量分发 +每天 10:05 → AI 归纳(DeepSeek 生成描述 → 回写文档) +``` + +--- + +## 历史数据回写(2026-05-11 ~ 2026-05-19)[刘新玉需求] + +### 需求 +- 刘新玉要求回写 5/11~5/19 共 9 天的"问题描述"为 AI 归纳版本,验证效果 + +### 工具 +- 新建 `scripts/backfill_ai_descriptions.py`:循环处理多天,读MySQL→聚类→DeepSeek生成描述→覆盖写入知识库子文档 +- 包含 AI 失败回退机制:空描述/无意义输出自动回退到关键词 `generate_problem_description` + +### 结果 +| 日期 | 问题数 | AI 归纳质量 | +|------|--------|------------| +| 5/11 | 2个(P1+P2) | ✅ Spine动画屏幕适配、U级别显示数字 | +| 5/12 | 3个(P0+P2) | ✅ Hotfix卡加载、关卡场景物丢失 | +| 5/13 | 2个(P2) | ✅ 单元名显示问号占位 | +| 5/14 | 1个(P2) | ✅ AI正确识别为无实质问题 | +| 5/15 | 2个(P2) | ✅ 语音识别"a bee"→"a b"、后台加载提示不消失 | +| 5/16-17 | 无消息 | 周末 | +| 5/18 | 1个(P0) | ✅ 热更后无法进入测试包 | +| 5/19 | 2个(P2) | ✅ 图片多选数量不匹配、配置URL报错 | + +### 关键发现 +- **5/15 对比**:关键词版吐出"可以在这里Po问题",AI版准确归纳为「"a bee"被语音识别成"a b"」和「后台加载提示持续显示无法消失」 +- **5/14**:AI正确识别当天无实质问题(闲聊/确认类消息),未强行生成描述 +- **AI 偶尔失败需 fallback**:5/12有一个簇 AI 返回空,成功回退到关键词生成「【频繁】移动端关卡4-28内应用无法打开,疑似网络/VPN导致」 + +### 文件清单(本次新建/修改) +``` +scripts/ai_summarize_feedback.py # 日常 AI 归纳脚本(crontab 用) +scripts/backfill_ai_descriptions.py # 批量回写脚本 +skills/feishu-feedback-sync/scripts/sync_feishu_feedback.py # 关键词覆盖扩展 +HEARTBEAT.md # 移除 AI 归纳心跳任务 +``` diff --git a/output/daily_feedback/ai_descriptions_2026-05-11.json b/output/daily_feedback/ai_descriptions_2026-05-11.json deleted file mode 100644 index 338df24..0000000 --- a/output/daily_feedback/ai_descriptions_2026-05-11.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "date": "2026-05-11", - "descriptions": [ - { - "index": 1, - "description": "在 132 版本中,L1 级别 U 单元名称显示为数字编号 — U 级别单元名称未命名(季度级别已有名称),新版本发布后用户将全部可见" - } - ] -} diff --git a/output/daily_feedback/飞书反馈_2026-05-21.xlsx b/output/daily_feedback/飞书反馈_2026-05-21.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..7c6bb588bfa3a0ded1e521a5672b15c8ca9a7945 GIT binary patch literal 11154 zcmZ{K1yoyIvvw%%1b4Th!HT=PQ@l9EEw~m9F2$|5dxN`MaV-=nQlJzoEe^eD-~YSc z{rcaVtaJ7{Yh~u_nLYE&v$ID-86E*2005u>%zK-SMkri=7 z(=xVX%3g5nIq`6hVoEI2-HA<36vh8s-6W*RIJl$FE7ZtDRsD$^4d=j*K`a(eSuleY@mH+&B!PDRmkE~#E5u&p z4kC^JFk!$ZFY^pGb=GG90PugBVCmv+{o97Alz#g`5Ju!H>zn*BHAQpADQnAeLL^ql zZ#It2e6bXk4)lTd45m5UEy$~L^JnVsB;v8Ou7U3?sX!^iVE&G2rpm#{JV+>l!dee)2JO3X6DTn^sr za8~=7CYsra2v_cC-5WBqNT6Is5Bh6Di@K-iDGIZUz07YZq>+W>dV>5X)Z0$2f#b)7 z%S>9PyFXYB6c0kkyz(A8{Cqx%H@567b!>ZTMt4_O2XgxU6zhCC??mm~Tn&319eLT` z^5&KIToBxUdMC>`mktLB0LWzm0PtYm@p0q?zqWR={^!p1+dn6UMjmSd_(5m&vp%+0 zPUs^q-n2mRJnX#Ip;_T4YE%(G{dh-TtK{(DUilX|lj)czDXJYgA_#0p_tUcS2ZsW_ zm!}*Shmnxf?>=T9IoFpvTRAtkUvkp}B|#+R_2+$Pr7EV)hIW8KfxvM*+`&(wu_($u z_>;@V+eW0Ebjx3%>l=FEc8-O_@>XVnrfjbzW_2_TU0zW`_o;Yp2qtynt&8FCUP>~y zL!jSp9fu#*HpQ%Gj)W+<-#CNF^r?fVUCQ^=3?ZL&28R0VZN>E*M(5@gLPEy_QxQ89 zuZkB4P`ELLh4S>Ly#lU=PTs{$tbuu*NVj({d<0r1=VM*T5wl{js2wzLd?H>E#5;X5iEKmdVUA>k+%)_UfKELx!CH+XzxglwMu74^R4SX6IMp(9+ zCrRg(bI(gc!~*aS&8cVv)JwL+Y!9T&8z(K#Ja5(@Madp}^HV;QX82S6vqnCX*#@?|96lhk& z6wx-{xS{o#Jit?>_3--PxF26F_? zl%BQ!5G|Z<<);g@?O%vB+LUuP8oICJT3g}eV77eeJ~cL~Q~&n)s^Gi6Z;!9V@-I&A zKGKKRXB)875hf|Wc`r%d_7FR9&N760Xl2BJI;pc@%^!oCFGc2jr2SJVZDqf{ow-fK zXz!@L5G=+vb1U-d06K-vBi-t*{Iw;ur3}IW56p9w_0t&wSD-*9h zx>AsKhIAXLR1dsUN8TqPf&9$mcC-bpB$vV4qhTM?9l?(X?SYvuoeoy#=I#&rH5-c0 zMA9inA>WH=P75P5X9lICeEnZ#+NOosmte}$XI}C&a%RZw^BS#`6;@;CZO-Vw+gPTenmpOkHYE?!al6i;T z{T-wIIl*+Tm|(ChTUex;={y52eoO-fA`OxI?%CN}d+)fFJGM7Y;X2$_OGFdW(fy)4 z?$WC|43~9ljOY>TJ33RHq&GbpM z$#D7O)5XvIeUIDm&4ZbGuf(hV(#6j5`+&0^u?=G}aj}nG_B zBnRmNw%5;@Ey^?J^#Q&6ja_-8vtKr6r@4||jT4iPCq(zLo!?Gm8`saguT?udJl>sc zsQ+9z4L*@JD(ilKYIyKr_t3(nglBE>B3LMjGvTLQE@_aM&r^?+{)&d|ljEE_YzrI%Y>Y3C`%*;$@anyTkKV9|zn)>x^ z&Bs-gNdjrv+DsD3J_Ms{vQJwX#{>zO-WP(=G)b+ktYLzbmM!T`0=7q5um)9XA;&(%yP`l z$KZUI-p?v_o=XpWp(e6U4^H{ z{*%h*B^G zQ>*bNz9t8#D*kE}|jsk)WTX=OE~37XogwW4z&&mBP4JSRJwPwk0t0YWR+Cr*>u9QZPK z>nkY8jk<4(tS7hXSfASor@6Z|-;GCAU*N6f`}8Iy~r9=`1(X&{~Yrq^z7iK`}a%bdF41jtt@tzqUP(cD!0skQ!aX>#3Mq z?-}=E;Pk`}Ud5nXIA!`(j$JX%Zb5Bz#fN}{dt1T6tY0}7S7ol=nB89omEh!7OS^N?plxcDF^Fe;h^ zY*{+oWxv)GO}IuaG@|e5CAQVzlxWd&Dw}>O zOHDv=uLFNuzQgfyzzzgiW0pCVnRIo}xHw|;yVB`A+1(Gdh^3Zk?NgfRd~htB``Wa!$R~MBbkWb{CW=-eTz6%}W@^dA z@P~BphbZtBA#gu%F@CQbl0=moC49k>1PQ?HI2_JuMBy}YRcB#fvBjbkYK`$=-g>o- zti5bDVY4QC_&I?Ob0zoZvB0Cm1ISzcy-Kt8;ShuIiL>*Q=)=hq*9)h6V9l7!qW%k*c0npwc?%ELu)eyiQ00sq+T1q?j{*V+0%u3MU}9jLb^ zuKc=9JihKUSyHR!nY(JDKfyCWN47@ZAXbr#YF&^;21VE`j^Z4dWe<%LVw*{JNh^8pG9F2L#KP=)x z^i4u>8)9;=4@X#29=?r`lQ)Fbe9cYuH2@X)I)hawv!4@MMrkr#{{S%69^7c54aCzR z@*v`^1ujD^xSnlL>lNCHZ;r4P2eqiSU_V$IPe;6R$>7NDB}GOkzwj4FUqRurGDb#t z@saStJ#L(?cNiaYmf^vxz<%hycEF??^yg?v?VS%#zLWL4UDSphJ=w+PXm-C z*%D94l59at%<*(6OWiN2!t5HG$x;T6&26lz#h6f6exD@{8;BIa@>0K|gMeLF8>*D2 zkWqD6DXp-thBnOyKxGiprs&H@!`$za2l#MYY_ZbDN{C$4abXf7uon$=^Cl!93WkQS z33NaNuo^p1AVkTdp9%UUbJ>zSEWH>#l4G?%b+whs%1TI|0 z;lx}Y^_s~+eTE@8(OEp)=0bRn&iIIZCy?b7Zl5 =1AdXUz^^ws&scwvc<-MF*JQ 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 literal 0 HcmV?d00001 diff --git a/scripts/ai_summarize_feedback.py b/scripts/ai_summarize_feedback.py new file mode 100644 index 0000000..dc61d74 --- /dev/null +++ b/scripts/ai_summarize_feedback.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +AI 问题归纳脚本 +读取 cluster_context_{date}.json,调用 LLM 为每个问题簇生成精炼的问题描述, +输出 ai_descriptions_{date}.json,然后回写到飞书知识库文档。 + +用法: + python3 ai_summarize_feedback.py [--date YYYY-MM-DD] [--dry-run] + +crontab: + 5 10 * * * python3 .../ai_summarize_feedback.py >> /var/log/xiaokui_ai_summarize.log 2>&1 +""" + +import sys, os, json, argparse, urllib.request +from datetime import datetime, date, timedelta + +# === 配置 === +DEEPSEEK_API_KEY = "sk-7cf94305fb12473b956fd2ed2a6db05b" +DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1" +DEEPSEEK_MODEL = "deepseek-v4-pro" + +CONTEXT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "output", "daily_feedback") +SKILL_SCRIPT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "skills", "feishu-feedback-sync", "scripts") + +SYSTEM_PROMPT = """你是一个游戏产品的问题归纳助手。你的任务是: +阅读一段来自测试群的多人对话(可能包含多个发言人、多轮讨论), +从中提炼出他们正在讨论的「具体问题是什么」,用一句中文描述清楚。 + +要求: +1. 只描述问题本身,不要评价或建议 +2. 包含关键要素:在哪个端、哪个环节、什么表现 +3. 如果对话中有多种说法,优先采用最后确认的描述 +4. 输出仅一句中文,不要加任何前缀、编号、引号或换行 +5. 如果对话全是无实质内容的闲聊(如"好的""收到"),输出"无明确问题" + +输出格式(严格):直接输出问题描述,无任何额外文字。""" + + +def load_context(date_str): + """加载指定日期的 cluster_context JSON""" + path = os.path.join(CONTEXT_DIR, f"cluster_context_{date_str}.json") + if not os.path.exists(path): + print(f" ⚠️ 无上下文文件: {path}") + return None + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + + +def build_user_prompt(cluster): + """为单个问题簇构建 LLM prompt""" + lines = [] + lines.append(f"优先级: {cluster.get('priority', '?')}") + lines.append(f"分类: {cluster.get('category', '?')}") + lines.append(f"当前排查结论: {cluster.get('conclusion', '无')}") + lines.append("") + lines.append("--- 对话记录 ---") + + for msg in cluster.get("messages", []): + sender = msg.get("sender", "?") + content = msg.get("content", "") + mtype = msg.get("msg_type", "text") + time = msg.get("time", "") + + # 跳过纯媒体消息(无有效文本) + if mtype in ("image", "post_image", "media", "file", "sticker") and not content.strip(): + continue + if not content.strip(): + continue + + # 截断过长内容 + if len(content) > 200: + content = content[:197] + "..." + + lines.append(f"[{time}] {sender}: {content}") + + return "\n".join(lines) + + +def call_deepseek(system_prompt, user_prompt, max_retries=2): + """调用 DeepSeek API 生成问题描述""" + body = json.dumps({ + "model": DEEPSEEK_MODEL, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + "temperature": 0.3, + "max_tokens": 256, + }).encode() + + for attempt in range(max_retries + 1): + try: + req = urllib.request.Request( + f"{DEEPSEEK_BASE_URL}/chat/completions", + data=body, + headers={ + "Authorization": f"Bearer {DEEPSEEK_API_KEY}", + "Content-Type": "application/json", + }, + method="POST", + ) + resp = urllib.request.urlopen(req, timeout=60) + data = json.loads(resp.read()) + content = data["choices"][0]["message"]["content"].strip() + # 清理常见的引号/前缀 + content = content.strip('"\'""'' \n') + return content + except Exception as e: + if attempt < max_retries: + print(f" ⚠️ API 调用重试 {attempt + 1}: {e}") + import time + time.sleep(2) + else: + raise + + +def generate_descriptions(context_data, dry_run=False): + """为所有问题簇生成 AI 描述""" + clusters = context_data.get("clusters", []) + if not clusters: + print(" ⚠️ 无问题簇数据") + return None + + descriptions = [] + for cluster in clusters: + idx = cluster.get("index", 0) + print(f" 🤖 处理簇 #{idx}...") + + user_prompt = build_user_prompt(cluster) + + if dry_run: + print(f" [DRY-RUN] Prompt 长度: {len(user_prompt)} chars") + # 输出前 200 字符预览 + print(f" [DRY-RUN] 对话预览: {user_prompt[:200]}...") + description = f"[DRY-RUN] 问题{idx}" + else: + try: + description = call_deepseek(SYSTEM_PROMPT, user_prompt) + except Exception as e: + print(f" ❌ 簇 #{idx} API 调用失败: {e}") + description = f"[API调用失败: {str(e)[:50]}]" + + print(f" 📝 描述: {description}") + descriptions.append({"index": idx, "description": description}) + + return descriptions + + +def apply_descriptions(date_str, descriptions): + """调用 sync_feishu_feedback.py --apply-ai 回写文档""" + sys.path.insert(0, SKILL_SCRIPT_DIR) + + # 先保存描述 JSON + desc_path = os.path.join(CONTEXT_DIR, f"ai_descriptions_{date_str}.json") + payload = {"date": date_str, "descriptions": descriptions} + with open(desc_path, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + print(f" 💾 描述已保存: {desc_path}") + + # 调用 --apply-ai + sync_script = os.path.join(SKILL_SCRIPT_DIR, "sync_feishu_feedback.py") + import subprocess + env = os.environ.copy() + env["LARKSUITE_CLI_CONFIG_DIR"] = "/root/.openclaw/credentials/xiaokui" + env["HOME"] = "/root" + env["PATH"] = "/root/.nvm/versions/node/v24.14.0/bin:" + env.get("PATH", "") + + result = subprocess.run( + ["python3", sync_script, "--apply-ai", desc_path], + capture_output=True, text=True, timeout=60, env=env + ) + + if "AI 描述已应用" in result.stdout or "✅" in result.stdout: + print(f" ✅ AI 描述已回写到知识库文档") + # 回写成功后清理上下文文件,避免心跳重复处理 + context_path = os.path.join(CONTEXT_DIR, f"cluster_context_{date_str}.json") + if os.path.exists(context_path): + os.remove(context_path) + print(f" 🗑️ 已清理上下文文件: {context_path}") + return True + else: + print(f" ❌ 回写失败: {result.stdout[:300]}") + if result.stderr: + print(f" stderr: {result.stderr[:300]}") + return False + + +def main(): + parser = argparse.ArgumentParser(description="AI 问题归纳") + parser.add_argument("--date", help="日期 YYYY-MM-DD,默认昨天") + parser.add_argument("--dry-run", action="store_true", help="仅预览不实际调用 API") + args = parser.parse_args() + + if args.date: + date_str = args.date + else: + # 默认处理昨天的数据(每天 10:05 运行,处理 10:00 生成的前一天数据) + date_str = (date.today() - timedelta(days=1)).strftime("%Y-%m-%d") + + print(f"📋 AI 问题归纳 - {date_str}") + os.makedirs(CONTEXT_DIR, exist_ok=True) + + context = load_context(date_str) + if not context: + print(" ℹ️ 无待处理数据,退出") + return + + descriptions = generate_descriptions(context, dry_run=args.dry_run) + if not descriptions: + return + + if args.dry_run: + desc_path = os.path.join(CONTEXT_DIR, f"ai_descriptions_{date_str}.json") + payload = {"date": date_str, "descriptions": descriptions} + with open(desc_path, "w", encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False, indent=2) + print(f"[DRY-RUN] 描述已保存到 {desc_path},未回写文档") + return + + apply_descriptions(date_str, descriptions) + + +if __name__ == "__main__": + main() diff --git a/scripts/backfill_ai_descriptions.py b/scripts/backfill_ai_descriptions.py new file mode 100644 index 0000000..b15e99e --- /dev/null +++ b/scripts/backfill_ai_descriptions.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +""" +批量回写 2026-05-11 ~ 2026-05-19 的问题描述(AI 归纳版) + +对每个日期: +1. 从 MySQL 读取消息 → 聚类 → 生成问题簇 +2. 调用 DeepSeek API 为每个簇生成精炼问题描述 +3. 用 AI 描述重新生成完整归纳内容(替代脚本默认的 generate_problem_description) +4. 覆盖写入飞书知识库对应日期的子文档 +""" + +import sys, os, json, urllib.request, subprocess, time, re +from datetime import datetime, date, timedelta +from collections import defaultdict + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "skills", "feishu-feedback-sync", "scripts")) + +from sync_feishu_feedback import ( + get_db_connection, sort_threads, sort_cluster_msgs, + extract_location_elements, extract_conclusion, classify_problem, + get_tenant_token, list_child_nodes, create_child_doc, + SUMMARY_PARENT_NODE, SUMMARY_SPACE_ID, DISPATCH_CRED_DIR, XIAOKUI_BOT_OPEN_ID, + CLI, get_env, +) +from priority_classifier import compute_final_priority, sort_by_priority + +# === 配置 === +DEEPSEEK_API_KEY = "sk-7cf94305fb12473b956fd2ed2a6db05b" +DEEPSEEK_BASE_URL = "https://api.deepseek.com/v1" +DEEPSEEK_MODEL = "deepseek-v4-pro" + +START_DATE = "2026-05-11" +END_DATE = "2026-05-19" + +SYSTEM_PROMPT = """你是一个游戏产品的问题归纳助手。阅读来自测试群的多人对话,用一句中文描述他们讨论的具体问题。 + +要求: +1. 只描述问题本身,不评价、不建议 +2. 包含关键要素:在哪个端/环节、什么表现 +3. 有频率信息(偶现/频繁/必现)要体现 +4. 仅输出一句中文,不加任何前缀、编号、引号或换行 +5. 如果对话全是无实质内容的闲聊,输出"无明确问题" +6. 如果是打包/热更类问题,说清楚是哪个版本/分支的包 +7. 如果是语音识别问题,说清楚识别什么内容、识别成了什么""" + + +def query_date_messages(date_str): + """读取指定日期消息""" + conn = get_db_connection() + cursor = conn.cursor() + next_date = (datetime.strptime(date_str, "%Y-%m-%d") + timedelta(days=1)).strftime("%Y-%m-%d") + cursor.execute( + """SELECT message_id, sender_name, msg_type, content, media_url, quote_message_id, + msg_time, msg_timestamp + FROM lark_group_message + WHERE msg_time >= %s AND msg_time < %s + ORDER BY msg_time ASC""", + (f"{date_str} 00:00:00", f"{next_date} 00:00:00"), + ) + rows = cursor.fetchall() + conn.close() + # 格式化时间为字符串 + formatted_rows = [] + for r in rows: + r_list = list(r) + if r_list[6] and hasattr(r_list[6], 'strftime'): + r_list[6] = r_list[6].strftime('%Y-%m-%d %H:%M:%S') + formatted_rows.append(tuple(r_list)) + return formatted_rows + + +def build_ai_prompt(cluster): + """为单个问题簇构建 LLM prompt""" + lines = [] + lines.append(f"优先级: {cluster.get('priority', '?')}") + lines.append(f"分类: {cluster.get('category', '?')}") + lines.append(f"排查结论: {cluster.get('conclusion', '无')}") + lines.append("--- 对话 ---") + + for msg in cluster.get("messages", []): + sender = msg.get("sender", "?") + content = msg.get("content", "").strip() + mtype = msg.get("msg_type", "text") + t = msg.get("time", "") + + if mtype in ("image", "post_image", "media", "file") and not content: + continue + if not content: + continue + if len(content) > 200: + content = content[:197] + "..." + + lines.append(f"[{t}] {sender}: {content}") + + return "\n".join(lines) + + +def call_deepseek(user_prompt, max_retries=2): + """调用 DeepSeek 生成问题描述""" + body = json.dumps( + { + "model": DEEPSEEK_MODEL, + "messages": [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + "temperature": 0.3, + "max_tokens": 256, + } + ).encode() + + for attempt in range(max_retries + 1): + try: + req = urllib.request.Request( + f"{DEEPSEEK_BASE_URL}/chat/completions", + data=body, + headers={"Authorization": f"Bearer {DEEPSEEK_API_KEY}", "Content-Type": "application/json"}, + method="POST", + ) + resp = urllib.request.urlopen(req, timeout=60) + data = json.loads(resp.read()) + content = data["choices"][0]["message"]["content"].strip() + content = content.strip('"\'""'' \n') + return content + except Exception as e: + if attempt < max_retries: + print(f" ⚠️ 重试 {attempt + 1}: {e}") + time.sleep(3) + else: + raise + + +def is_valid_description(desc): + """验证 AI 生成的描述是否有效""" + if not desc or not desc.strip(): + return False + # 过滤明显的垃圾输出 + garbage_patterns = [ + r'^(没有回答|没有回复|不知道|不确定|无法判断|不太清楚)[。!]?$', + r'^(没有回答。|没有回复。){3,}', # 重复"没有回答" + r'^[。!,\s]+$', # 纯标点 + r'^[??]+$', # 纯问号 + ] + for pat in garbage_patterns: + if re.search(pat, desc.strip()): + return False + # 太短(<3个中文字符) + chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', desc)) + if chinese_chars < 3: + return False + return True + + +def generate_ai_description(cluster_data, cluster_msgs): + """为单个簇调用 AI 生成描述,失败时回退到关键词生成""" + prompt = build_ai_prompt(cluster_data) + try: + desc = call_deepseek(prompt) + if is_valid_description(desc): + return desc + else: + print(f" ⚠️ AI 输出无效,使用关键词 fallback") + # 回退到关键词生成 + from sync_feishu_feedback import generate_problem_description, extract_location_elements + loc = extract_location_elements(cluster_msgs) + return generate_problem_description(cluster_msgs, loc, "") + except Exception as e: + print(f" ❌ AI 调用失败: {e},使用关键词 fallback") + from sync_feishu_feedback import generate_problem_description, extract_location_elements + loc = extract_location_elements(cluster_msgs) + return generate_problem_description(cluster_msgs, loc, "") + + +def build_summary_markdown(valid_clusters): + """ + 用 AI 描述重新生成归纳内容(替代 generate_summary) + valid_clusters: list of {cluster_id, msgs, earliest_time, priority_info, ai_description, category} + """ + lines = ["## 今日问题归纳\n"] + + # 按优先级+分类分组 + grouped = defaultdict(list) + for vc in valid_clusters: + p = vc.get("priority_info", {}).get("priority", "P2") + 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 = vc.get("ai_description", "") or vc.get("fallback_description", "未知问题") + lines.append(f" - {desc}") + lines.append("") + + # 问题拆解 + lines.append("## 今日问题拆解\n") + + idx = 0 + for vc in valid_clusters: + idx += 1 + pi = vc.get("priority_info", {}) + priority_label = pi.get("priority", "P2") + emoji = pi.get("emoji", "📌") + desc = vc.get("ai_description", "") or "未知问题" + + sorted_msgs = sort_cluster_msgs(vc["msgs"]) + + lines.append(f"### {emoji} {priority_label}") + lines.append("") + lines.append(f"**{idx},问题描述:** {desc}") + lines.append("") + conclusion = extract_conclusion(sorted_msgs) + lines.append(conclusion) + lines.append("") + lines.append("| 发言人 | 对话信息 |") + lines.append("|--------|---------|") + + first_speaker = sorted_msgs[0][1] + last_speaker = sorted_msgs[-1][1] + seen_speakers = set() + + for i, m in enumerate(sorted_msgs): + name = m[1] + text = str(m[3]).replace("\n", " ").replace("\r", " ").strip() if m[3] else "" + text = re.sub(r"\[Image:[^\]]+\]", "", text) + text = re.sub(r"https?://\S+", "", text) + text = re.sub(r"\s+", " ", text) + 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: + 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 = "
".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(sorted_msgs) - 1: + role_tag = "✅ " + + seen_speakers.add(name) + lines.append(f"| {name} | {role_tag}{dialogue_info} |") + + lines.append("") + lines.append("---") + lines.append("") + + return "\n".join(lines) + + +def write_to_kb(date_str, markdown_content): + """覆盖写入知识库子文档""" + title = f"{date_str} 问题反馈" + children = list_child_nodes() + + if title in children: + obj_token = children[title]["obj_token"] + print(f" 📝 更新: {title}") + else: + obj_token = create_child_doc(title) + if not obj_token: + children = list_child_nodes() + if title in children: + obj_token = children[title]["obj_token"] + else: + print(f" ❌ 无法创建/找到子文档: {title}") + return False + print(f" ➕ 新建: {title}") + + # 写入(lark-cli --markdown @file 要求相对路径) + os.makedirs("tmp", exist_ok=True) + tmp_md = f"tmp/_xiaokui_backfill_{date_str}.txt" + with open(tmp_md, "w", encoding="utf-8") as f: + f.write(markdown_content) + + env = get_env() + result = subprocess.run( + [CLI, "docs", "+update", "--doc", obj_token, "--as", "bot", "--mode", "overwrite", "--markdown", f"@{tmp_md}"], + env=env, + capture_output=True, + text=True, + timeout=30, + ) + os.unlink(tmp_md) + + # lark-cli 输出可能在 stdout 或 stderr + output = (result.stdout + result.stderr).strip() + try: + d = json.loads(output) + if d.get("ok"): + print(f" ✅ 写入成功") + return True + else: + print(f" ❌ 写入失败: {d.get('error', {}).get('message', output)[:200]}") + return False + except json.JSONDecodeError: + print(f" ❌ 响应解析失败: {output[:200]}") + return False + + +def process_date(date_str, dry_run=False): + """处理单个日期""" + print(f"\n{'=' * 60}") + print(f"📅 {date_str}") + print(f"{'=' * 60}") + + # 1. 读取消息 + rows = query_date_messages(date_str) + if not rows: + print(" ⚠️ 无消息") + return None + print(f" 📊 {len(rows)} 条消息") + + # 2. 聚类 + sorted_msgs, clusters, cluster_order = sort_threads(rows) + print(f" 🔗 {len(clusters)} 个簇") + + # 3. 收集有效簇(≥2条消息) + valid_clusters = [] + for cid in cluster_order: + cmsgs = clusters[cid] + if len(cmsgs) < 2: + continue + + pi = compute_final_priority(cmsgs) + cat = classify_problem(cmsgs) + + # 构建簇消息摘要(用于 AI prompt) + cluster_data = { + "cluster_id": cid, + "msgs": cmsgs, + "earliest_time": min(m[6] for m in cmsgs), + "priority_info": pi, + "category": cat, + "conclusion": extract_conclusion(sort_cluster_msgs(cmsgs)), + "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 cmsgs + ], + } + valid_clusters.append(cluster_data) + + if not valid_clusters: + print(" ⚠️ 无有效问题簇(需≥2条消息)") + return None + + # 按优先级排序 + valid_clusters = sort_by_priority(valid_clusters) + + # 4. AI 生成描述 + for vc in valid_clusters: + idx = valid_clusters.index(vc) + 1 + print(f" 🤖 簇 #{idx}/{len(valid_clusters)}...") + if not dry_run: + desc = generate_ai_description(vc, vc["msgs"]) + if desc and desc != "(待归纳)": + vc["ai_description"] = desc + print(f" ✅ {desc[:80]}...") + else: + print(f" ⚠️ AI 失败,使用空描述") + vc["ai_description"] = "(待归纳)" + # API 限速保护 + time.sleep(0.5) + else: + vc["ai_description"] = f"[DRY-RUN] 待AI归纳" + print(f" [DRY-RUN]") + + # 5. 生成完整 markdown + markdown = build_summary_markdown(valid_clusters) + + if dry_run: + print(f"\n --- 预览前 500 字符 ---") + print(markdown[:500]) + return markdown + + # 6. 写入知识库 + write_to_kb(date_str, markdown) + return markdown + + +def main(): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--date", help="仅处理指定日期") + args = parser.parse_args() + + if args.date: + dates = [args.date] + else: + start = datetime.strptime(START_DATE, "%Y-%m-%d") + end = datetime.strptime(END_DATE, "%Y-%m-%d") + dates = [(start + timedelta(days=i)).strftime("%Y-%m-%d") for i in range((end - start).days + 1)] + + print(f"🚀 批量 AI 归纳回写 {'[DRY-RUN]' if args.dry_run else ''}") + print(f" 日期范围: {dates[0]} ~ {dates[-1]} ({len(dates)} 天)") + + results = {} + for d in dates: + try: + r = process_date(d, dry_run=args.dry_run) + results[d] = "✅" if r else "⏭️" + except Exception as e: + print(f" ❌ 异常: {e}") + results[d] = f"❌ {e}" + + if not args.dry_run: + # 避免 API 频率限制 + time.sleep(1) + + print(f"\n{'=' * 60}") + print("📊 汇总:") + for d, status in results.items(): + print(f" {d}: {status}") + + +if __name__ == "__main__": + main() diff --git a/scripts/detect_p0_realtime.py b/scripts/detect_p0_realtime.py new file mode 100644 index 0000000..ec680c2 --- /dev/null +++ b/scripts/detect_p0_realtime.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +""" +P0 问题实时检测与分发 + +功能: + 1. 从 MySQL 读取最近一段时间的飞书群消息 + 2. 复用 sync_feishu_feedback.py 的聚类 + 优先级判定逻辑 + 3. 过滤已推送过的 P0 簇(去重) + 4. 仅推送新增 P0 到「小葵小葵」群 + +设计: + - 每分钟由 crontab 调用一次 + - 查询最近 2 小时的消息,确保聚类质量 + - 用「簇签名」(sorted message_ids)做去重 + - 每天 10:00 清空去重状态(与全量分发错开) + +用法: + python3 detect_p0_realtime.py [--dry-run] [--lookback-minutes 120] +""" + +import sys, os, json, urllib.request, argparse, hashlib +from datetime import datetime, timedelta +from pathlib import Path + +# 将 sync_feishu_feedback.py 所在目录加入 sys.path,以便 import +SKILL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "skills", "feishu-feedback-sync", "scripts") +sys.path.insert(0, SKILL_DIR) + +from sync_feishu_feedback import ( + get_db_connection, query_messages, sort_threads, get_tenant_token, + DISPATCH_CHAT_ID, DISPATCH_CRED_DIR, P0_NOTIFY_USERS, + MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASS, MYSQL_DB, +) +from priority_classifier import compute_final_priority + +# === 配置 === +STATE_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "tmp", "p0_dispatched_state.json") +LOOKBACK_MINUTES = 120 # 默认回顾 2 小时的消息 +CLUSTER_MIN_SIZE = 2 # 至少 2 条消息才算有效簇 + + +def load_dispatched_state(): + """加载已推送的 P0 簇签名状态。返回 {cluster_signature: dispatch_time}""" + try: + with open(STATE_FILE, "r") as f: + state = json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + state = {} + + # 清理超过 24 小时的记录 + cutoff = (datetime.now() - timedelta(hours=24)).isoformat() + state = {k: v for k, v in state.items() if v > cutoff} + return state + + +def save_dispatched_state(state): + """保存已推送状态""" + os.makedirs(os.path.dirname(STATE_FILE), exist_ok=True) + tmp = STATE_FILE + ".tmp" + with open(tmp, "w") as f: + json.dump(state, f, ensure_ascii=False, indent=2) + os.rename(tmp, STATE_FILE) + + +def cluster_signature(cluster_msgs): + """ + 生成簇的唯一签名。 + 用簇内所有 message_id 排序后拼接的 MD5,对簇成员变化敏感但适度容忍轻微变化。 + """ + ids = sorted(str(m[0]) for m in cluster_msgs) + joined = ",".join(ids) + return hashlib.md5(joined.encode()).hexdigest() + + +def is_probably_p0(cluster_msgs): + """ + 快速判断一个簇是否是 P0 级别问题。 + 返回 (is_p0: bool, priority_info: dict) + """ + if len(cluster_msgs) < CLUSTER_MIN_SIZE: + return False, None + info = compute_final_priority(cluster_msgs) + return info["priority"] == "P0", info + + +def generate_p0_alert_text(cluster_msgs, priority_info): + """ + 生成 P0 问题的简短告警文本(精简版,不含完整文档链接)。 + """ + # 收集关键信息 + root_sender = cluster_msgs[0][1] + root_time = cluster_msgs[0][6] + latest_time = cluster_msgs[-1][6] + + # 提取第一条有实质内容的消息作为摘要 + root_text = "" + for m in cluster_msgs: + t = str(m[3]) if m[3] else "" + t = t.strip() + if t and len(t) > 3: + root_text = t[:100] + break + + # 收集所有发言人 + senders = list(dict.fromkeys(m[1] for m in cluster_msgs)) # 去重保序 + + lines = [ + f"🚨 P0 实时告警", + f"", + f"**报告人:** {root_sender}", + f"**时间:** {root_time}", + f"**涉及人员:** {'、'.join(senders[:5])}" + ("等" if len(senders) > 5 else ""), + f"**消息数:** {len(cluster_msgs)} 条", + f"", + f"**摘要:** {root_text}", + f"", + f"**判定依据:** {priority_info.get('reasoning', 'P0')}", + f"**修复时限:** {priority_info.get('deadline', '2小时内')}", + ] + + return "\n".join(lines) + + +def dispatch_p0_alert(alert_text): + """ + 将 P0 告警发送到「小葵小葵」群,@ 指定人员。 + 使用飞书 post 富文本格式。 + """ + import urllib.request + token = get_tenant_token(cred_dir=DISPATCH_CRED_DIR) + + # 构建富文本内容 + content_parts = [] + for line in alert_text.split("\n"): + if line.strip(): + content_parts.append([{"tag": "text", "text": line + "\n"}]) + + # 追加 @ 通知 + if P0_NOTIFY_USERS: + at_line = [{"tag": "text", "text": "\n⚠️ 请关注: "}] + for uid in P0_NOTIFY_USERS: + at_line.append({"tag": "at", "user_id": uid}) + at_line.append({"tag": "text", "text": " "}) + content_parts.append(at_line) + + post_content = json.dumps({ + "zh_cn": { + "title": "🚨 P0 问题实时告警", + "content": content_parts + } + }, ensure_ascii=False) + + 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: + return True + else: + print(f" ⚠️ 实时分发失败: {d.get('msg', '')[:100]}") + return False + + +def should_clear_state(): + """检查是否到了每天清空状态的时间(10:00-10:01 之间清空,配合全量分发)""" + now = datetime.now() + return now.hour == 10 and now.minute <= 1 + + +def main(): + parser = argparse.ArgumentParser(description="P0 问题实时检测与分发") + parser.add_argument("--dry-run", action="store_true", help="仅打印不推送") + parser.add_argument("--lookback-minutes", type=int, default=LOOKBACK_MINUTES, + help=f"查询最近 N 分钟的消息(默认 {LOOKBACK_MINUTES})") + args = parser.parse_args() + + # 清空逻辑:每天 10:00-10:01 之间清空去重状态,让全量分发正常进行 + if should_clear_state(): + print("[P0-detect] 10:00 清空去重状态,配合全量分发") + save_dispatched_state({}) + # 10:00 的全量流程会处理,这里直接退出避免重复 + print("[P0-detect] 全量分发时段,跳过实时检测") + return + + print(f"[P0-detect] 扫描最近 {args.lookback_minutes} 分钟消息...") + lookback_start = (datetime.now() - timedelta(minutes=args.lookback_minutes)).strftime("%Y-%m-%d %H:%M:%S") + now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # 查询最近消息(直接用 SQL,避免 query_messages 的按天查询限制) + import pymysql + conn = pymysql.connect( + host=MYSQL_HOST, port=MYSQL_PORT, + user=MYSQL_USER, password=MYSQL_PASS, + database=MYSQL_DB, charset="utf8mb4" + ) + 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 + """, (lookback_start, now_str)) + rows = cursor.fetchall() + conn.close() + + print(f"[P0-detect] 查询到 {len(rows)} 条消息") + + if len(rows) < 2: + print("[P0-detect] 消息不足,退出") + return + + # 聚类 + sorted_msgs, clusters, cluster_order = sort_threads(rows) + print(f"[P0-detect] 聚类完成:{len(clusters)} 个簇") + + # 加载已推送状态 + state = load_dispatched_state() + print(f"[P0-detect] 已记录 {len(state)} 个已推送簇签名") + + # 遍历簇,找出 P0 且未推送的 + new_p0_count = 0 + for cid in cluster_order: + cmsgs = clusters[cid] + is_p0, info = is_probably_p0(cmsgs) + if not is_p0: + continue + + sig = cluster_signature(cmsgs) + if sig in state: + print(f"[P0-detect] 已推送过,跳过: sig={sig[:8]}...") + continue + + print(f"[P0-detect] 🚨 发现新 P0! sig={sig[:8]}... {len(cmsgs)}条消息") + + if args.dry_run: + alert = generate_p0_alert_text(cmsgs, info) + print(f"[DRY-RUN] 将发送:\n{alert}") + state[sig] = datetime.now().isoformat() + new_p0_count += 1 + else: + alert = generate_p0_alert_text(cmsgs, info) + success = dispatch_p0_alert(alert) + if success: + print(f"[P0-detect] ✅ P0 已实时推送") + state[sig] = datetime.now().isoformat() + new_p0_count += 1 + else: + print(f"[P0-detect] ❌ 推送失败") + + if new_p0_count > 0: + save_dispatched_state(state) + print(f"[P0-detect] 共推送 {new_p0_count} 个新 P0") + + print("[P0-detect] 完成") + return + + +if __name__ == "__main__": + main() diff --git a/skills/feishu-feedback-sync/scripts/__pycache__/sync_feishu_feedback.cpython-312.pyc b/skills/feishu-feedback-sync/scripts/__pycache__/sync_feishu_feedback.cpython-312.pyc index 25bad64c898177ae4a9a8b9b136648d7033b1720..d978e80967d1a2a994b57a979e8464a81802768c 100644 GIT binary patch delta 5513 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 diff --git a/skills/feishu-feedback-sync/scripts/sync_feishu_feedback.py b/skills/feishu-feedback-sync/scripts/sync_feishu_feedback.py index 0fa183c..53a786e 100755 --- a/skills/feishu-feedback-sync/scripts/sync_feishu_feedback.py +++ b/skills/feishu-feedback-sync/scripts/sync_feishu_feedback.py @@ -422,6 +422,7 @@ def extract_location_elements(msgs): (r"关卡内|关卡里|关卡中|进关卡|在关卡", "关卡内"), (r"关卡外", "关卡外"), (r"\bLoading\b|loading|加载|转星星", "关卡内"), + (r"后台.*加载|后台.*提示|后台.*转圈|加载.*提示.*一直|提示.*一直.*在", "关卡内"), (r"知识巩固", "知识巩固"), (r"巩固题", "巩固题"), (r"单元挑战", "单元挑战"), @@ -588,7 +589,51 @@ def generate_problem_description(cluster_msgs, location, root_text, ai_placehold 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):