From 8f74b8e84b28dd7c967906fdf65c9386e7156639 Mon Sep 17 00:00:00 2001 From: xiaoban Date: Sun, 24 May 2026 11:38:20 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20studycourse-analys?= =?UTF-8?q?is=20=E6=8A=80=E8=83=BD-=E8=A7=92=E8=89=B2=E4=B8=8A=E8=AF=BE?= =?UTF-8?q?=E6=83=85=E5=86=B5=E5=9B=9B=E7=BB=B4=E5=88=86=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vala_skill_hashes | 2 +- memory/2026-05-24.md | 27 + output/studycourse_32009.html | 155 ++++ skills/studycourse-analysis/SKILL.md | 81 ++ .../scripts/studycourse_analysis.py | 782 ++++++++++++++++++ 5 files changed, 1046 insertions(+), 1 deletion(-) create mode 100644 output/studycourse_32009.html create mode 100644 skills/studycourse-analysis/SKILL.md create mode 100644 skills/studycourse-analysis/scripts/studycourse_analysis.py diff --git a/.vala_skill_hashes b/.vala_skill_hashes index 76fa97e..3e21391 100644 --- a/.vala_skill_hashes +++ b/.vala_skill_hashes @@ -7,4 +7,4 @@ lark_wiki_operate_as_bot 2a37701f568849f03eb46dd938baeda171380fe252b698ac8bda69c vala_git_workspace_backup 4cf352bec88fe84af065ba1ffcbb06647b77df0e01860faaf0bca9fd64b968ec cron-schedule b1879fa59d60e3d99cea1138674f7abac84a4aecd32743b801d41bfd6ed7181d study-analysis 33217dc132073ecd47b921800834f6df89494da9e7708fa90f15b3de7742e37f -studytime-analysis d314b8a0fcecabec1106200eae0ddb68159f033a7ef45f0a0cd590f6172430df +studytime-analysis fefb11a0c2fb7085a47c626ec6b72f8fcafee797dc3340abea09139d31eb7e7b diff --git a/memory/2026-05-24.md b/memory/2026-05-24.md index b6b290d..fbbf943 100644 --- a/memory/2026-05-24.md +++ b/memory/2026-05-24.md @@ -58,3 +58,30 @@ - `skills/studytime-analysis/scripts/studytime_analysis.py`:新增 MySQL 连接函数 `get_mysql_connection()` 和 `fetch_role_info(role_id)`,更新 `format_report()` 输出基本角色信息 - 已验证 2895 正常运行输出 - 已同步 SkillHub + Git + +### Unit 显示修复: 季度名称 → 全局单元编号 (2026-05-24) + +[刘庆逊提出] HTML 报告中 Unit 列显示错误——显示的是季度名称(如"小镇时光""钢铁之心")而非单元数字(0-48)。 + +**根因分析**: +- `vala_game_chapter`(MySQL)无 `unit_index` 字段 +- `big_map_chapter`(PostgreSQL)有 `unit_index` 字段,但仅包含 A1 数据,且与 `vala_game_chapter` 无直接关联键 +- 两者 ID 空间不重叠(big_map: ~1720-2070,game_chapter: ~55-399),UUID 也不匹配 + +**映射方案**: +- 每个 season_package 内,`lesson_type=1` 的章节按 `id` 排序,每 5 个连续章节组成一个单元 +- Season 0(序章/L1-U0):所有章节属于 Unit 0 +- Season 1-4:每个 season 有 12 个单元(60 个 lesson 章节) +- 全局 unit_index = base_offset(season_of_quarter) + unit_within_season + - base_offset: 0→0, 1→1, 2→13, 3→25, 4→37 + +**关键 Bug**:初版按 `season_of_quarter` 分组时 A1 和 A2 混在一起,因为相同季度值合并了。修复:改为按 `(level, season_of_quarter)` 分组。 + +**验证结果**: +- A1: Unit 0-48(49 个单元),与 big_map_chapter 的 unit_index 范围一致 +- A2: Unit 0-49(50 个单元,比 A1 多 1 个) + +**已修改文件**: +- `skills/studytime-analysis/scripts/studytime_analysis.py` — 重写 `fetch_chapter_info_map()`,新增全局 unit_index 计算;HTML 模板更新为 Level/Unit/Lesson 三列 +- 已为角色 32009(zyl)重新生成 HTML 并发送 +- 已同步 Git + SkillHub diff --git a/output/studycourse_32009.html b/output/studycourse_32009.html new file mode 100644 index 0000000..fc1e43a --- /dev/null +++ b/output/studycourse_32009.html @@ -0,0 +1,155 @@ + + + + + +上课情况分析 — 角色 32009 + +
+ +

📊 上课情况分析报告 — 角色 32009(zyl)

+ +

一、基础信息

+
+
角色ID32009
+
角色姓名zyl
+
年龄12 岁
+
账号ID25120
+
手机号后4位0500
+
账号注册时间2026-04-23 13:23
+
课程激活时间-
+
课程到期时间2032-04-28 18:35
+
课程级别A2
+
购买渠道Apple App Store
+
渠道来源newmedia-daren-xhs-宣儿麻麻-0
+
设备名称小课屏E3/E3 21A900
+
操作系统Android OS 11 / API-30 (RP1A.200720.011/1699448821)
+
设备类型Handheld
+
城市金华市
+
第一次完课2026-04-24 21:38
+
最后一次完课2026-05-24 09:27
+
最后完课位置Level[A2] Unit[5] Lesson[1]
+
+ +

二、完课记录明细

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
课程开始时间完课时间耗时中互动巩固完成巩固题数巩固正确率
A2-U0-L12026-04-24 21:242026-04-24 21:3814分16秒Perfect[18,85.7%]-Good[0,0.0%]-Oops[0,0.0%]20100.0%
A2-U0-L22026-04-24 21:452026-04-24 21:5813分1秒Perfect[23,85.2%]-Good[0,0.0%]-Oops[0,0.0%]24100.0%
A2-U0-L32026-04-25 14:162026-04-25 14:3013分58秒Perfect[22,88.0%]-Good[0,0.0%]-Oops[0,0.0%]24100.0%
A2-U0-L42026-04-26 12:552026-04-26 13:1217分5秒Perfect[19,86.4%]-Good[0,0.0%]-Oops[0,0.0%]22100.0%
A2-U0-L52026-04-27 20:332026-04-27 20:4916分15秒Perfect[18,81.8%]-Good[1,4.5%]-Oops[0,0.0%]21100.0%
A2-U2-L12026-04-30 18:392026-04-30 18:5920分20秒Perfect[21,84.0%]-Good[1,4.0%]-Oops[0,0.0%]24100.0%
A2-U2-L22026-04-30 19:022026-04-30 19:2118分50秒Perfect[19,76.0%]-Good[1,4.0%]-Oops[0,0.0%]25100.0%
A2-U2-L32026-05-01 10:092026-05-01 10:3525分51秒Perfect[15,65.2%]-Good[2,8.7%]-Oops[1,4.3%]22100.0%
A2-U2-L42026-05-04 20:532026-05-04 21:1622分37秒Perfect[18,69.2%]-Good[2,7.7%]-Oops[0,0.0%]25100.0%
A2-U2-L52026-05-06 19:442026-05-06 20:0723分18秒Perfect[25,80.6%]-Good[2,6.5%]-Oops[0,0.0%]31100.0%
A2-U1-L12026-05-07 20:252026-05-07 20:4520分33秒Perfect[25,86.2%]-Good[1,3.4%]-Oops[0,0.0%]29100.0%
A2-U1-L22026-05-08 20:292026-05-08 20:5425分1秒Perfect[28,96.6%]-Good[0,0.0%]-Oops[1,3.4%]29100.0%
A2-U1-L32026-05-09 19:192026-05-09 19:4323分55秒Perfect[25,83.3%]-Good[1,3.3%]-Oops[0,0.0%]30100.0%
A2-U1-L42026-05-10 09:552026-05-10 10:1722分9秒Perfect[20,83.3%]-Good[0,0.0%]-Oops[0,0.0%]24100.0%
A2-U1-L52026-05-11 19:392026-05-11 19:5818分53秒Perfect[24,88.9%]-Good[2,7.4%]-Oops[0,0.0%]27100.0%
A2-U3-L12026-05-12 20:012026-05-12 20:2322分2秒Perfect[24,88.9%]-Good[0,0.0%]-Oops[0,0.0%]27100.0%
A2-U3-L22026-05-15 19:562026-05-15 20:1620分21秒Perfect[21,87.5%]-Good[1,4.2%]-Oops[0,0.0%]24100.0%
A2-U3-L32026-05-16 09:392026-05-16 09:5718分29秒Perfect[21,91.3%]-Good[1,4.3%]-Oops[0,0.0%]23100.0%
A2-U3-L42026-05-18 18:502026-05-18 19:1020分12秒Perfect[11,45.8%]-Good[2,8.3%]-Oops[0,0.0%]24100.0%
A2-U3-L52026-05-19 18:212026-05-19 18:4928分5秒Perfect[22,88.0%]-Good[1,4.0%]-Oops[0,0.0%]25100.0%
A2-U4-L12026-05-20 19:222026-05-20 19:4927分2秒Perfect[13,56.5%]-Good[1,4.3%]-Oops[2,8.7%]23100.0%
A2-U4-L22026-05-21 19:522026-05-21 20:0816分0秒Perfect[22,91.7%]-Good[0,0.0%]-Oops[0,0.0%]24100.0%
A2-U4-L32026-05-22 18:432026-05-22 18:5815分22秒Perfect[23,92.0%]-Good[0,0.0%]-Oops[0,0.0%]25100.0%
A2-U4-L42026-05-22 18:592026-05-22 19:2424分52秒Perfect[21,91.3%]-Good[0,0.0%]-Oops[0,0.0%]23100.0%
A2-U4-L52026-05-23 09:422026-05-23 10:0018分38秒Perfect[22,91.7%]-Good[0,0.0%]-Oops[0,0.0%]24100.0%
A2-U5-L12026-05-23 16:192026-05-24 09:271028分28秒Perfect[18,78.3%]-Good[0,0.0%]-Oops[1,4.3%]23100.0%
+ +

三、上课时间分析

+
共 26 条有效记录,平均 59.1 分钟,中位数 20.4 分钟。最快 13.0 分钟,最慢 1028.5 分钟。异常:过快 0 条(<10分钟),过慢 15 条(>20分钟)。趋势:前半段平均 19.6 分钟 → 后半段 98.5 分钟(延长 402.6%)。
+ +

四、中互动正确率分析

+
共 651 条记录。Perfect 82.6%,Good 2.9%,Oops 0.8%,优良率 85.5%。趋势:Perfect 率 82.2% → 82.7%(持平)。
+ +

五、知识巩固正确率分析

+
共 26 节课,完成巩固练习 26 节(完成率 100.0%),平均得分 100.0%。得分分布:高分(≥80)26节 / 中等 0节 / 低分(<50)0节。
+ +

总结

+
  • 🐢 完课耗时较长,建议关注学习效率
  • 📊 完课耗时中位数 20.4 分钟
  • 🎯 中互动优良率 85.6%
  • 📝 知识巩固完成率 100.0%,平均得分 100.0%
+ +
分析时间:2026-05-24 11:38 | 完课:26 条 | 中互动:651 条 | 巩固:26 条
+ +
\ No newline at end of file diff --git a/skills/studycourse-analysis/SKILL.md b/skills/studycourse-analysis/SKILL.md new file mode 100644 index 0000000..593afaf --- /dev/null +++ b/skills/studycourse-analysis/SKILL.md @@ -0,0 +1,81 @@ +# studycourse-analysis — 角色上课情况分析 + +## 功能 + +分析角色上课情况,从四个维度输出详细报告: + +1. **基础信息**:角色姓名、年龄、账号、手机号、注册时间、购买渠道、设备信息、首/末次完课 +2. **完课耗时分析**:平均/中位数完成时间、异常耗时(<10 分钟或>20 分钟)、耗时趋势 +3. **中互动正确率分析**:Perfect/Good/Oops 占比、优良率、趋势变化 +4. **知识巩固分析**:巩固练习完成率、得分分布 + +Markdown 报告末尾提示用户可选择生成 HTML 文件,确认后生成。 + +## 触发词 + +`分析角色[ID]的上课情况` + +如:`分析角色32009的上课情况` + +## 数据源 + +| 类型 | 库 | 表 | +|------|-----|-----| +| MySQL vala_user | `vala_app_character` | 角色基本信息、purchase_season_package | +| MySQL vala_user | `vala_app_account` | 账号信息、download_channel、key_from | +| MySQL vala | `vala_game_chapter` + `vala_game_season_package` | 章节 Level/Unit/Lesson 映射 | +| PostgreSQL vala | `user_course_detail` | 课程激活时间、到期时间、course_level | +| PostgreSQL vala | `user_login_app_info` | 设备信息(device_name/model/type/os) | +| PostgreSQL vala | `user_chapter_play_record_0~7` | 完课记录(play_status=1) | +| PostgreSQL vala | `user_component_play_record_0~7` | 中互动记录(play_result: Perfect/Good/Oops) | +| PostgreSQL vala | `user_chapter_settlement_data_0~7` | 结算数据(含 practiceSkillPoint/practiceCount) | + +## 使用方式 + +```bash +cd /root/.openclaw/workspace-xiaoban + +# 设置环境变量(secrets.md) +source secrets.md + +# 生成 Markdown 报告 +python3 skills/studycourse-analysis/scripts/studycourse_analysis.py + +# 生成 HTML 报告 +python3 skills/studycourse-analysis/scripts/studycourse_analysis.py --format html -o output/studycourse_.html +``` + +## 交互流程 + +1. 用户发送 `分析角色[ID]的上课情况` +2. 运行 Markdown 分析报告,显示四步分析结果 +3. 报告末尾自动提示 `💡 是否需要将以上详细分析报告生成一份 HTML 文件?回复「是」或「需要」即可。` +4. 用户回复「是」或「需要」后,生成 HTML 文件并发送 + +## HTML 结构 + +5 个 card 布局 section: + +1. **基础信息** — 角色ID、姓名、年龄、账号ID、手机号后4位、注册时间、课程激活/到期、购买渠道、设备、首/末次完课 +2. **完课记录明细** — 表格,每行含 Level/Unit/Lesson、开始/完课时间、耗时、中互动汇总、巩固完成/题数/正确率 +3. **上课时间分析** — 耗时统计分析 +4. **中互动正确率分析** — 正确率趋势 +5. **知识巩固正确率分析** — 得分分布 + +## 关键逻辑 + +- **章节映射**:复用 studytime-analysis 的 fetch_chapter_info_map(),按 (level, season_of_quarter) 分组计算全局 unit_index +- **巩固判断**:`settlement_data.practiceSkillPoint > 0` 视为已完成巩固(`isPractice` 字段不可靠) +- **中互动**:`user_component_play_record.play_result` 统计 Perfect/Good/Oops/Pass/Failed +- **异常耗时**:<10 分钟为过快,>20 分钟为过慢 +- **趋势分析**:将数据按时间分前后两半,对比均值变化 + +## 凭证 + +数据库凭证从环境变量注入,不硬编码。配置项与 studytime-analysis 共用: +- `PG_DB_HOST/PORT/USER/PASSWORD/DATABASE` +- `MYSQL_HOST_online/PORT_online/USERNAME_online/PASSWORD_online` + +## 版本 + +v1.0.0 — 2026-05-24 初始版本 diff --git a/skills/studycourse-analysis/scripts/studycourse_analysis.py b/skills/studycourse-analysis/scripts/studycourse_analysis.py new file mode 100644 index 0000000..e869b5f --- /dev/null +++ b/skills/studycourse-analysis/scripts/studycourse_analysis.py @@ -0,0 +1,782 @@ +#!/usr/bin/env python3 +""" +studycourse-analysis — 角色上课情况分析工具 +用法: python3 studycourse_analysis.py [--format html] [--output ] +""" + +import os, sys, argparse, json +import psycopg2, psycopg2.extras, pymysql +from datetime import datetime, timedelta +from collections import defaultdict, OrderedDict + +# ── 配置 ── +PG_CONFIG = { + "host": os.environ.get("PG_DB_HOST", "bj-postgres-16pob4sg.sql.tencentcdb.com"), + "port": int(os.environ.get("PG_DB_PORT", "28591")), + "user": os.environ.get("PG_DB_USER", "ai_member"), + "password": os.environ.get("PG_DB_PASSWORD", ""), + "dbname": os.environ.get("PG_DB_DATABASE", "vala"), +} +MYSQL_HOST = os.environ.get("MYSQL_HOST_online", "bj-cdb-dh2fkqa0.sql.tencentcdb.com") +MYSQL_PORT = int(os.environ.get("MYSQL_PORT_online", "27751")) +MYSQL_USER = os.environ.get("MYSQL_USERNAME_online", "read_only") +MYSQL_PASS = os.environ.get("MYSQL_PASSWORD_online", "") + +WEEKDAY_NAMES = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + + +# ── 数据库连接 ── +def pg_conn(): + return psycopg2.connect(host=PG_CONFIG["host"], port=PG_CONFIG["port"], + user=PG_CONFIG["user"], password=PG_CONFIG["password"], dbname=PG_CONFIG["dbname"]) + +def my_conn(db="vala_user"): + return pymysql.connect(host=MYSQL_HOST, port=MYSQL_PORT, user=MYSQL_USER, + password=MYSQL_PASS, db=db, charset="utf8mb4") + + +# ── 章节全局单元映射(复用 studytime-analysis 逻辑) ── +_chapter_map = None + +def fetch_chapter_info_map(): + global _chapter_map + if _chapter_map is not None: + return _chapter_map + conn = my_conn("vala") + try: + with conn.cursor() as cur: + cur.execute("""SELECT gc.id, IFNULL(sp.level,''), IFNULL(sp.season_of_quarter,-1), + gc.`index`, gc.lesson_type + FROM vala_game_chapter gc + LEFT JOIN vala_game_season_package sp ON gc.season_package_id=sp.id + ORDER BY sp.level, sp.season_of_quarter, gc.id""") + rows = cur.fetchall() + finally: + conn.close() + from collections import OrderedDict as OD + seasons = OD() + for ch_id, lv, sq, li, lt in rows: + sq = int(sq) if sq is not None else -1 + lt = int(lt) if lt is not None else 1 + key = (lv or "", sq) + seasons.setdefault(key, []).append((int(ch_id), lv or "", int(li or 0), lt)) + def base(s): + return 0 if s <= 0 else 1 + 12 * (s - 1) + _chapter_map = {} + for (lv_key, sq), ch_list in seasons.items(): + regular = [(cid, lv, li) for cid, lv, li, lt in ch_list if lt == 1] + uid_map = {} + if sq <= 0: + for cid, lv, li in regular: + uid_map[cid] = 0 + else: + for pos, (cid, lv, li) in enumerate(regular): + uid_map[cid] = pos // 5 + for cid, lv, li, lt in ch_list: + u = uid_map.get(cid, -1) + _chapter_map[cid] = dict(level=lv, + unit_index=base(sq) + u if u >= 0 else -1, + lesson_index=li, lesson_type=lt) + return _chapter_map + + +# ── 数据查询 ── + +def fetch_role_info(role_id): + conn = my_conn("vala_user") + try: + with conn.cursor() as cur: + cur.execute("""SELECT c.id, c.account_id, c.nickname, c.gender, c.birthday, + c.created_at, c.purchase_season_package + FROM vala_app_character c WHERE c.id=%s""", (role_id,)) + row = cur.fetchone() + finally: + conn.close() + if not row: + return None + rid, aid, nn, gd, bd, crt, psp = row + gs = {0: "女", 1: "男"}.get(gd, str(gd) if gd is not None else "") + age = "" + if bd: + try: age = datetime.now().year - int(str(bd).split("-")[0]) + except: pass + return dict(role_id=rid, account_id=aid, nickname=nn or "", gender=gs, age=age, + reg_time=crt.strftime("%Y-%m-%d %H:%M") if crt else "-", + purchase_packages=str(psp) if psp else "") + + +def fetch_account_info(account_id): + conn = my_conn("vala_user") + try: + with conn.cursor() as cur: + cur.execute("""SELECT id, tel, download_channel, key_from, created_at, pay_status + FROM vala_app_account WHERE id=%s""", (account_id,)) + row = cur.fetchone() + finally: + conn.close() + if not row: + return {} + aid, tel, ch, kf, crt, ps = row + pt = "" + if tel: + d = ''.join(c for c in str(tel) if c.isdigit()) + pt = d[-4:] if len(d) >= 4 else d + return dict(account_id=aid, phone_tail=pt, download_channel=ch or "-", + key_from=kf or "-", account_created=crt.strftime("%Y-%m-%d %H:%M") if crt else "-", + pay_status=ps) + + +def fetch_course_detail(role_id): + conn = pg_conn() + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute("SELECT * FROM user_course_detail WHERE user_id=%s ORDER BY id LIMIT 1", (role_id,)) + return cur.fetchone() + finally: + conn.close() + + +def fetch_device_info(account_id): + conn = pg_conn() + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute("SELECT * FROM user_login_app_info WHERE account_id=%s ORDER BY created_at DESC LIMIT 1", + (account_id,)) + return cur.fetchone() + finally: + conn.close() + + +def fetch_play_records(role_id): + parts, params = [], {} + for i in range(8): + pn = f"r{i}" + params[pn] = role_id + parts.append(f"""SELECT user_id, chapter_id, chapter_unique_id, level, created_at, updated_at + FROM user_chapter_play_record_{i} WHERE user_id=%({pn})s AND play_status=1""") + sql = f"SELECT * FROM ({' UNION ALL '.join(parts)}) t ORDER BY updated_at ASC" + conn = pg_conn() + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(sql, params) + return cur.fetchall() + finally: + conn.close() + + +def fetch_component_records(role_id): + """获取中互动记录""" + parts, params = [], {} + for i in range(8): + pn = f"r{i}" + params[pn] = role_id + parts.append(f"""SELECT user_id, chapter_id, section_id, component_unique_code, + play_result, skill_points, c_type, c_id, created_at, updated_at, pass_time, + read_word_count, speak_count, listen_sentence_count, write_word_count + FROM user_component_play_record_{i} WHERE user_id=%({pn})s""") + sql = f"SELECT * FROM ({' UNION ALL '.join(parts)}) t ORDER BY updated_at ASC" + conn = pg_conn() + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(sql, params) + return cur.fetchall() + finally: + conn.close() + + +def fetch_settlement_data(role_id): + """获取结算数据(含巩固题信息)""" + parts, params = [], {} + for i in range(8): + pn = f"r{i}" + params[pn] = role_id + parts.append(f"""SELECT user_id, chapter_id, unique_id, settlement_data, created_at, updated_at, level + FROM user_chapter_settlement_data_{i} WHERE user_id=%({pn})s""") + sql = f"SELECT * FROM ({' UNION ALL '.join(parts)}) t ORDER BY updated_at ASC" + conn = pg_conn() + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(sql, params) + rows = cur.fetchall() + for r in rows: + if r.get("settlement_data"): + try: + r["sd_parsed"] = json.loads(r["settlement_data"]) + except: + r["sd_parsed"] = {} + return rows + finally: + conn.close() + + +# ── 分析函数 ── + +def analyze_duration(records): + """分析完课耗时""" + durations = [] + anomalies_short = [] + anomalies_long = [] + for r in records: + sd = r.get("created_at") + ed = r.get("updated_at") + if not sd or not ed: + continue + d = (ed - sd).total_seconds() / 60.0 # 分钟 + durations.append(d) + if d < 10: + anomalies_short.append((r, d)) + elif d > 20: + anomalies_long.append((r, d)) + if not durations: + return dict(count=0, mean=0, median=0, min=0, max=0, + anomalies_short=[], anomalies_long=[]) + srt = sorted(durations) + n = len(srt) + med = srt[n//2] if n%2==1 else (srt[n//2-1]+srt[n//2])/2 + return dict(count=n, mean=round(sum(srt)/n, 1), median=round(med, 1), + min=round(srt[0], 1), max=round(srt[-1], 1), + anomalies_short=anomalies_short, anomalies_long=anomalies_long) + + +def analyze_mid_interaction(comp_records, chapter_map): + """分析中互动情况""" + if not comp_records: + return dict(total=0, perfect=0, good=0, oops=0, failed=0, pass_cnt=0, none_cnt=0, + by_chapter={}, trend_chapters=[]) + counts = defaultdict(int) + ch_data = defaultdict(lambda: {"perfect": 0, "good": 0, "oops": 0, "total": 0}) + chapter_order = [] + seen = set() + for r in comp_records: + pr = r.get("play_result", "None") + counts[pr] = counts.get(pr, 0) + 1 + cid = r.get("chapter_id") + if cid: + if cid not in seen: + seen.add(cid) + chapter_order.append(cid) + ch_data[cid]["total"] += 1 + if pr == "Perfect": + ch_data[cid]["perfect"] += 1 + elif pr == "Good": + ch_data[cid]["good"] += 1 + elif pr == "Oops": + ch_data[cid]["oops"] += 1 + total = sum(counts.values()) + # By chapter for trend + trend = [] + for cid in chapter_order: + d = ch_data[cid] + if d["total"] == 0: continue + ci = chapter_map.get(cid, {}) + trend.append(dict( + chapter_id=cid, level=ci.get("level",""), unit=ci.get("unit_index",-1), + lesson=ci.get("lesson_index", 0), + total=d["total"], perfect=d["perfect"], good=d["good"], oops=d["oops"], + perfect_pct=round(d["perfect"]/d["total"]*100,1), + good_pct=round(d["good"]/d["total"]*100,1), + oops_pct=round(d["oops"]/d["total"]*100,1), + )) + return dict(total=total, perfect=counts.get("Perfect",0), good=counts.get("Good",0), + oops=counts.get("Oops",0), failed=counts.get("Failed",0), + pass_cnt=counts.get("Pass",0), none_cnt=counts.get("None",0), + trend_chapters=trend) + + +def analyze_practice(settlements): + """分析知识巩固情况""" + if not settlements: + return dict(total_lessons=0, completed=0, rate=0, + avg_score=0, records=[]) + records = [] + completed = 0 + scores = [] + for s in settlements: + sd = s.get("sd_parsed", {}) + if not sd: + continue + prac_count = sd.get("practiceCount", 0) or 0 + prac_score = sd.get("practiceSkillPoint", 0) or 0 + kp_count = sd.get("knowledgePointCount", 0) or 0 + # isPractice 字段不可靠(始终为 false),改用 practiceScore > 0 判断 + has_prac = prac_score > 0 + if has_prac: + completed += 1 + pct = round(prac_score / 100 * 100, 1) if prac_score > 0 else 0 + scores.append(pct) + records.append(dict( + chapter_id=s.get("chapter_id"), + updated_at=s.get("updated_at"), + is_practice=has_prac, + practice_count=prac_count, + practice_score=prac_score, + practice_pct=round(prac_score/100*100,1) if prac_score > 0 else 0, + kp_count=kp_count, + )) + total = len(records) + return dict(total_lessons=total, completed=completed, + completion_rate=round(completed/total*100,1) if total>0 else 0, + avg_score=round(sum(scores)/len(scores),1) if scores else 0, + records=records) + + +def _fmt_duration(mins): + if mins is None or mins < 0: return "-" + m = int(mins) + s = int((mins - m) * 60) + return f"{m}分{s}秒" if m > 0 else f"{s}秒" + + +def _td(dt): + return dt.strftime('%Y-%m-%d %H:%M') if dt else "-" + + +# ── Markdown ── + +def format_markdown(role_id, role_info, account_info, course_detail, device_info, + play_records, dur_analysis, mid_analysis, prac_analysis): + cm = fetch_chapter_info_map() + ns = datetime.now().strftime('%Y-%m-%d %H:%M') + L = [] + + ri = role_info or {} + ai = account_info or {} + cd = course_detail or {} + di = device_info or {} + + L.append(f"# 📊 上课情况分析报告 — 角色 {role_id}({ri.get('nickname','')})\n") + + # ── 第一步:基础信息 ── + L.append("## 一、基础信息\n") + L.append("| 项目 | 详情 |") + L.append("|------|------|") + L.append(f"| 角色ID | {role_id} |") + L.append(f"| 角色姓名 | {ri.get('nickname','-')} |") + if ri.get('age'): L.append(f"| 年龄 | {ri['age']} 岁 |") + L.append(f"| 账号ID | {ri.get('account_id','-')} |") + L.append(f"| 手机号后4位 | {ai.get('phone_tail','-')} |") + L.append(f"| 账号注册时间 | {ai.get('account_created','-')} |") + + # 课程购买时间 + act_time = cd.get("active_time") + exp_time = cd.get("expire_time") + L.append(f"| 课程激活时间 | {_td(act_time)}" if act_time else "| 课程激活时间 | - |") + L.append(f"| 课程到期时间 | {_td(exp_time)}" if exp_time else "| 课程到期时间 | - |") + L.append(f"| 课程级别 | {cd.get('course_level','-')} |") + L.append(f"| 购买渠道 | {ai.get('download_channel','-')} |") + L.append(f"| 渠道来源 | {ai.get('key_from','-')} |") + + # 设备信息 + dev_parts = [] + if di.get("device_name"): dev_parts.append(f"{di['device_name']}") + if di.get("device_model"): dev_parts.append(f"{di['device_model']}") + if not dev_parts: dev_parts.append("-") + L.append(f"| 设备名称 | {' / '.join(dev_parts)} |") + L.append(f"| 操作系统 | {di.get('os_info','-')[:80]} |") + L.append(f"| 设备类型 | {di.get('device_type','-')} |") + L.append(f"| 城市 | {di.get('city','-')} |") + + # 完课信息 + if play_records: + first = play_records[0]["updated_at"] + last = play_records[-1]["updated_at"] + L.append(f"| 第一次完课 | {_td(first)} |") + L.append(f"| 最后一次完课 | {_td(last)} |") + lcid = play_records[-1].get("chapter_id") + lci = cm.get(lcid, {}) + L.append(f"| 最后一次完课位置 | Level[{lci.get('level','-')}] Unit[{lci.get('unit_index','-')}] Lesson[{lci.get('lesson_index','-')}] |") + L.append("") + + L.append(f"**分析时间**: {ns}") + L.append(f"**完课记录总数**: {len(play_records)} 条") + L.append(f"**中互动记录总数**: {mid_analysis.get('total',0)} 条") + L.append(f"**知识巩固记录总数**: {prac_analysis.get('total_lessons',0)} 条\n") + + # ── 第二步:完课耗时分析 ── + L.append("---\n## 二、完课耗时分析\n") + da = dur_analysis + if da["count"] == 0: + L.append("> ⚠️ 无有效完课记录。\n") + else: + L.append(f"- 有效完课记录: **{da['count']}** 条") + L.append(f"- 平均完成时间: **{da['mean']}** 分钟") + L.append(f"- 中位数完成时间: **{da['median']}** 分钟") + L.append(f"- 最快: **{da['min']}** 分钟 | 最慢: **{da['max']}** 分钟\n") + + if da["anomalies_short"]: + L.append(f"### ⚡ 过快完成(<10分钟,共 {len(da['anomalies_short'])} 条)\n") + L.append("| 日期 | 课程Level/Unit/Lesson | 耗时 |") + L.append("|------|-----------------------|------|") + for r, d in da["anomalies_short"][:20]: + cid = r.get("chapter_id") + ci = cm.get(cid, {}) + L.append(f"| {_td(r['updated_at'])} | {ci.get('level','')}-U{ci.get('unit_index','')}-L{ci.get('lesson_index','')} | {_fmt_duration(d)} |") + if len(da["anomalies_short"]) > 20: + L.append(f"| ... | 等共 {len(da['anomalies_short'])} 条 | |") + L.append("") + + if da["anomalies_long"]: + L.append(f"### 🐢 过慢完成(>20分钟,共 {len(da['anomalies_long'])} 条)\n") + L.append("| 日期 | 课程Level/Unit/Lesson | 耗时 |") + L.append("|------|-----------------------|------|") + for r, d in da["anomalies_long"][:20]: + cid = r.get("chapter_id") + ci = cm.get(cid, {}) + L.append(f"| {_td(r['updated_at'])} | {ci.get('level','')}-U{ci.get('unit_index','')}-L{ci.get('lesson_index','')} | {_fmt_duration(d)} |") + if len(da["anomalies_long"]) > 20: + L.append(f"| ... | 等共 {len(da['anomalies_long'])} 条 | |") + L.append("") + + # 趋势 + if da["count"] >= 5: + L.append("### 耗时趋势分析") + # 简单前半/后半对比 + mid = da["count"] // 2 + half1 = sorted([(r["updated_at"] - r["created_at"]).total_seconds()/60 + for r in play_records[:mid] if r.get("created_at") and r.get("updated_at")]) + half2 = sorted([(r["updated_at"] - r["created_at"]).total_seconds()/60 + for r in play_records[mid:] if r.get("created_at") and r.get("updated_at")]) + m1 = round(sum(half1)/len(half1),1) if half1 else 0 + m2 = round(sum(half2)/len(half2),1) if half2 else 0 + if m1 > 0: + change = round((m2-m1)/m1*100,1) + trend_desc = "缩短" if change < -5 else ("延长" if change > 5 else "基本持平") + L.append(f"- 前半段平均耗时: **{m1}** 分钟 → 后半段: **{m2}** 分钟({trend_desc} {abs(change)}%)") + L.append("") + + # ── 第三步:中互动分析 ── + L.append("---\n## 三、中互动正确率分析\n") + ma = mid_analysis + if ma.get("total", 0) == 0: + L.append("> ⚠️ 无中互动记录。\n") + else: + t = ma["total"] + perfect_pct = round(ma["perfect"]/t*100,1) + good_pct = round(ma["good"]/t*100,1) + oops_pct = round(ma["oops"]/t*100,1) + failed_pct = round(ma.get("failed",0)/t*100,1) + pass_pct = round(ma.get("pass_cnt",0)/t*100,1) + + L.append(f"| 结果 | 数量 | 占比 |") + L.append(f"|------|------|------|") + L.append(f"| Perfect ✨ | {ma['perfect']} | {perfect_pct}% |") + L.append(f"| Good 👍 | {ma['good']} | {good_pct}% |") + L.append(f"| Oops 😅 | {ma['oops']} | {oops_pct}% |") + if ma.get("pass_cnt", 0) > 0: + L.append(f"| Pass | {ma['pass_cnt']} | {pass_pct}% |") + if ma.get("failed", 0) > 0: + L.append(f"| Failed | {ma['failed']} | {failed_pct}% |") + L.append("") + + # 优良率 + excellent = perfect_pct + good_pct + L.append(f"**优良率(Perfect+Good)**: {excellent}%\n") + + # 趋势 + if ma.get("trend_chapters"): + mtr = ma["trend_chapters"] + # Split in half + mh = len(mtr)//2 + h1_p = [c["perfect_pct"] for c in mtr[:mh]] + h2_p = [c["perfect_pct"] for c in mtr[mh:]] + h1_avg = round(sum(h1_p)/len(h1_p),1) if h1_p else 0 + h2_avg = round(sum(h2_p)/len(h2_p),1) if h2_p else 0 + if h1_avg > 0: + trend = "上升" if h2_avg > h1_avg + 5 else ("下降" if h2_avg < h1_avg - 5 else "持平") + L.append(f"**趋势**: 前半段平均 Perfect 率 {h1_avg}% → 后半段 {h2_avg}%({trend})") + L.append("") + + # ── 第四步:巩固题分析 ── + L.append("---\n## 四、知识巩固分析\n") + pa = prac_analysis + if pa.get("total_lessons", 0) == 0: + L.append("> ⚠️ 无知识巩固记录。\n") + else: + L.append(f"- 总课程数: **{pa['total_lessons']}** 节") + L.append(f"- 完成巩固练习: **{pa['completed']}** 节(完成率 **{pa['completion_rate']}%**)") + L.append(f"- 巩固题平均得分: **{pa['avg_score']}**%(满分100)\n") + + # 未完成的课程 + skipped = [r for r in pa.get("records", []) if not r["is_practice"]] + if skipped: + L.append(f"### 未完成巩固练习的课程(共 {len(skipped)} 节)\n") + L.append("| 课程 | 日期 |") + L.append("|------|------|") + for r in skipped[:15]: + cid = r["chapter_id"] + ci = cm.get(cid, {}) + L.append(f"| {ci.get('level','')}-U{ci.get('unit_index','')}-L{ci.get('lesson_index','')} | {_td(r['updated_at'])} |") + if len(skipped) > 15: + L.append(f"| ... | 等共 {len(skipped)} 节 |") + L.append("") + + # 得分分布 + scores = [r["practice_score"] for r in pa.get("records", []) if r["is_practice"]] + if scores: + high = sum(1 for s in scores if s >= 80) + mid_s = sum(1 for s in scores if 50 <= s < 80) + low = sum(1 for s in scores if s < 50) + L.append(f"### 巩固题得分分布") + L.append(f"- 高分(≥80分): **{high}** 节({round(high/len(scores)*100,1)}%)") + L.append(f"- 中等(50-79分): **{mid_s}** 节({round(mid_s/len(scores)*100,1)}%)") + L.append(f"- 低分(<50分): **{low}** 节({round(low/len(scores)*100,1)}%)") + L.append("") + + L.append("---") + L.append("> 💡 是否需要将以上详细分析报告生成一份 HTML 文件?回复「是」或「需要」即可。") + return "\n".join(L) + + +# ── HTML ── + +def format_html(role_id, role_info, account_info, course_detail, device_info, + play_records, dur_analysis, mid_analysis, prac_analysis): + cm = fetch_chapter_info_map() + ns = datetime.now().strftime('%Y-%m-%d %H:%M') + + ri = role_info or {} + ai = account_info or {} + cd = course_detail or {} + di = device_info or {} + + # Part 1: Basic info + act_time = cd.get("active_time") + exp_time = cd.get("expire_time") + dev_parts = [] + if di.get("device_name"): dev_parts.append(di['device_name']) + if di.get("device_model"): dev_parts.append(di['device_model']) + if not dev_parts: dev_parts.append("-") + + first_dt = last_dt = last_lvl = "-" + if play_records: + first_dt = _td(play_records[0]["updated_at"]) + last_dt = _td(play_records[-1]["updated_at"]) + lcid = play_records[-1].get("chapter_id") + lci = cm.get(lcid, {}) + last_lvl = f"Level[{lci.get('level','-')}] Unit[{lci.get('unit_index','-')}] Lesson[{lci.get('lesson_index','-')}]" + + # Part 2: Detail table + rows_html = "" + prac_dict = {r["chapter_id"]: r for r in prac_analysis.get("records", [])} + ma_trend = {t["chapter_id"]: t for t in mid_analysis.get("trend_chapters", [])} + for i, pr in enumerate(play_records, 1): + cid = pr.get("chapter_id") + ci = cm.get(cid, {}) + sd = pr.get("created_at") + ed = pr["updated_at"] + dur = (ed - sd).total_seconds() / 60 if sd and ed else None + + # Mid-interaction info + mt = ma_trend.get(cid, {}) + mid_str = f"Perfect[{mt.get('perfect',0)},{mt.get('perfect_pct',0)}%]-Good[{mt.get('good',0)},{mt.get('good_pct',0)}%]-Oops[{mt.get('oops',0)},{mt.get('oops_pct',0)}%]" if mt else "-" + + # Practice info + pd = prac_dict.get(cid) + prac_done = "是" if pd and pd.get("is_practice") else "否" + prac_cnt = pd.get("practice_count", "-") if pd else "-" + prac_pct = f"{pd.get('practice_pct','-')}%" if pd and pd.get("is_practice") else "-" + + rows_html += f""" + {ci.get('level','')}-U{ci.get('unit_index','')}-L{ci.get('lesson_index','')} + {_td(sd)}{_td(ed)}{_fmt_duration(dur)} + {mid_str}{prac_done}{prac_cnt}{prac_pct}""" + + # Part 3: Duration analysis + da = dur_analysis + dur_summary = "无有效完课记录。" + if da["count"] > 0: + trend_text = "" + if da["count"] >= 5: + mid = da["count"] // 2 + half1 = sorted([(r["updated_at"] - r["created_at"]).total_seconds()/60 + for r in play_records[:mid] if r.get("created_at") and r.get("updated_at")]) + half2 = sorted([(r["updated_at"] - r["created_at"]).total_seconds()/60 + for r in play_records[mid:] if r.get("created_at") and r.get("updated_at")]) + m1 = round(sum(half1)/len(half1),1) if half1 else 0 + m2 = round(sum(half2)/len(half2),1) if half2 else 0 + if m1 > 0: + change = round((m2-m1)/m1*100,1) + trend = "缩短" if change < -5 else ("延长" if change > 5 else "持平") + trend_text = f"趋势:前半段平均 {m1} 分钟 → 后半段 {m2} 分钟({trend} {abs(change)}%)。" + dur_summary = (f"共 {da['count']} 条有效记录,平均 {da['mean']} 分钟," + f"中位数 {da['median']} 分钟。最快 {da['min']} 分钟,最慢 {da['max']} 分钟。" + f"异常:过快 {len(da['anomalies_short'])} 条(<10分钟)," + f"过慢 {len(da['anomalies_long'])} 条(>20分钟)。{trend_text}") + + # Part 4: Mid-interaction analysis + ma = mid_analysis + mid_text = "无中互动记录。" + if ma.get("total", 0) > 0: + t = ma["total"] + p_pct = round(ma["perfect"]/t*100,1) + g_pct = round(ma["good"]/t*100,1) + o_pct = round(ma["oops"]/t*100,1) + exc = p_pct + g_pct + trend_text = "" + if ma.get("trend_chapters"): + mtr = ma["trend_chapters"] + mh = len(mtr)//2 + h1_p = [c["perfect_pct"] for c in mtr[:mh]] if mtr[:mh] else [0] + h2_p = [c["perfect_pct"] for c in mtr[mh:]] if mtr[mh:] else [0] + h1_avg = round(sum(h1_p)/len(h1_p),1) + h2_avg = round(sum(h2_p)/len(h2_p),1) + if h1_avg > 0: + trend = "上升" if h2_avg > h1_avg + 5 else ("下降" if h2_avg < h1_avg - 5 else "持平") + trend_text = f"趋势:Perfect 率 {h1_avg}% → {h2_avg}%({trend})。" + mid_text = (f"共 {t} 条记录。Perfect {p_pct}%,Good {g_pct}%,Oops {o_pct}%," + f"优良率 {exc}%。{trend_text}") + + # Part 5: Practice analysis + pa = prac_analysis + prac_text = "无知识巩固记录。" + if pa.get("total_lessons", 0) > 0: + scores = [r["practice_score"] for r in pa.get("records", []) if r["is_practice"]] + score_dist = "" + if scores: + high = sum(1 for s in scores if s >= 80) + mid_s = sum(1 for s in scores if 50 <= s < 80) + low = sum(1 for s in scores if s < 50) + score_dist = f"得分分布:高分(≥80){high}节 / 中等 {mid_s}节 / 低分(<50){low}节。" + prac_text = (f"共 {pa['total_lessons']} 节课,完成巩固练习 {pa['completed']} 节" + f"(完成率 {pa['completion_rate']}%),平均得分 {pa['avg_score']}%。{score_dist}") + + # Summary + summary_items = [] + if da["count"] > 0: + if da["mean"] < 15: + summary_items.append("⚡ 完课速度较快,平均不足15分钟,建议关注是否有跳课现象") + elif da["mean"] > 20: + summary_items.append("🐢 完课耗时较长,建议关注学习效率") + summary_items.append(f"📊 完课耗时中位数 {da['median']} 分钟") + if ma.get("total", 0) > 0: + exc = round(ma["perfect"]/ma["total"]*100 + ma["good"]/ma["total"]*100,1) + summary_items.append(f"🎯 中互动优良率 {exc}%") + if pa.get("total_lessons", 0) > 0: + summary_items.append(f"📝 知识巩固完成率 {pa['completion_rate']}%,平均得分 {pa['avg_score']}%") + si_html = "".join(f"
  • {it}
  • " for it in summary_items) if summary_items else "
  • 暂无分析数据
  • " + + # Precompute values for HTML template (avoid f-string concatenation) + age_str = f"{ri['age']} 岁" if ri.get('age') else '-' + dev_str = '/'.join(dev_parts) + os_str = (di.get('os_info','-') or '-')[:80] + + return f""" + + + + +上课情况分析 — 角色 {role_id} + +
    + +

    📊 上课情况分析报告 — 角色 {role_id}({ri.get('nickname','')})

    + +

    一、基础信息

    +
    +
    角色ID{role_id}
    +
    角色姓名{ri.get('nickname','-')}
    +
    年龄{age_str}
    +
    账号ID{ri.get('account_id','-')}
    +
    手机号后4位{ai.get('phone_tail','-')}
    +
    账号注册时间{ai.get('account_created','-')}
    +
    课程激活时间{_td(act_time)}
    +
    课程到期时间{_td(exp_time)}
    +
    课程级别{cd.get('course_level','-')}
    +
    购买渠道{ai.get('download_channel','-')}
    +
    渠道来源{ai.get('key_from','-')}
    +
    设备名称{dev_str}
    +
    操作系统{os_str}
    +
    设备类型{di.get('device_type','-')}
    +
    城市{di.get('city','-')}
    +
    第一次完课{first_dt}
    +
    最后一次完课{last_dt}
    +
    最后完课位置{last_lvl}
    +
    + +

    二、完课记录明细

    +
    + + + +{rows_html} +
    课程开始时间完课时间耗时中互动巩固完成巩固题数巩固正确率
    + +

    三、上课时间分析

    +
    {dur_summary}
    + +

    四、中互动正确率分析

    +
    {mid_text}
    + +

    五、知识巩固正确率分析

    +
    {prac_text}
    + +

    总结

    +
      {si_html}
    + +
    分析时间:{ns} | 完课:{len(play_records)} 条 | 中互动:{mid_analysis.get('total',0)} 条 | 巩固:{prac_analysis.get('total_lessons',0)} 条
    + +
    """ + + +# ── main ── +def main(): + ap = argparse.ArgumentParser(description="角色上课情况分析工具") + ap.add_argument("role_id", type=int) + ap.add_argument("--format", choices=["md","html"], default="md") + ap.add_argument("--output","-o", default=None) + args = ap.parse_args() + + role_id = args.role_id + + ri = fetch_role_info(role_id) + if not ri: + print(f"角色 {role_id} 不存在") + sys.exit(1) + + ai = fetch_account_info(ri["account_id"]) + cd = fetch_course_detail(role_id) + di = fetch_device_info(ri["account_id"]) + pr = fetch_play_records(role_id) + cr = fetch_component_records(role_id) + sd = fetch_settlement_data(role_id) + + da = analyze_duration(pr) + ma = analyze_mid_interaction(cr, fetch_chapter_info_map()) + pa = analyze_practice(sd) + + if args.format == "html": + out = format_html(role_id, ri, ai, cd, di, pr, da, ma, pa) + if args.output: + os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True) + with open(args.output, "w", encoding="utf-8") as f: + f.write(out) + print(f"HTML 报告已保存到: {args.output}") + else: + print(out) + else: + print(format_markdown(role_id, ri, ai, cd, di, pr, da, ma, pa)) + + +if __name__ == "__main__": + main()