feat: 新增 studycourse-analysis 技能-角色上课情况四维分析

This commit is contained in:
xiaoban 2026-05-24 11:38:20 +08:00
parent 73cd3ea8e1
commit 8f74b8e84b
5 changed files with 1046 additions and 1 deletions

View File

@ -7,4 +7,4 @@ lark_wiki_operate_as_bot 2a37701f568849f03eb46dd938baeda171380fe252b698ac8bda69c
vala_git_workspace_backup 4cf352bec88fe84af065ba1ffcbb06647b77df0e01860faaf0bca9fd64b968ec vala_git_workspace_backup 4cf352bec88fe84af065ba1ffcbb06647b77df0e01860faaf0bca9fd64b968ec
cron-schedule b1879fa59d60e3d99cea1138674f7abac84a4aecd32743b801d41bfd6ed7181d cron-schedule b1879fa59d60e3d99cea1138674f7abac84a4aecd32743b801d41bfd6ed7181d
study-analysis 33217dc132073ecd47b921800834f6df89494da9e7708fa90f15b3de7742e37f study-analysis 33217dc132073ecd47b921800834f6df89494da9e7708fa90f15b3de7742e37f
studytime-analysis d314b8a0fcecabec1106200eae0ddb68159f033a7ef45f0a0cd590f6172430df studytime-analysis fefb11a0c2fb7085a47c626ec6b72f8fcafee797dc3340abea09139d31eb7e7b

View File

@ -58,3 +58,30 @@
- `skills/studytime-analysis/scripts/studytime_analysis.py`:新增 MySQL 连接函数 `get_mysql_connection()``fetch_role_info(role_id)`,更新 `format_report()` 输出基本角色信息 - `skills/studytime-analysis/scripts/studytime_analysis.py`:新增 MySQL 连接函数 `get_mysql_connection()``fetch_role_info(role_id)`,更新 `format_report()` 输出基本角色信息
- 已验证 2895 正常运行输出 - 已验证 2895 正常运行输出
- 已同步 SkillHub + Git - 已同步 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-2070game_chapter: ~55-399UUID 也不匹配
**映射方案**
- 每个 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-4849 个单元),与 big_map_chapter 的 unit_index 范围一致
- A2: Unit 0-4950 个单元,比 A1 多 1 个)
**已修改文件**
- `skills/studytime-analysis/scripts/studytime_analysis.py` — 重写 `fetch_chapter_info_map()`,新增全局 unit_index 计算HTML 模板更新为 Level/Unit/Lesson 三列
- 已为角色 32009zyl重新生成 HTML 并发送
- 已同步 Git + SkillHub

View File

@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>上课情况分析 — 角色 32009</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#f5f7fa;color:#333;line-height:1.6;padding:20px}
.container{max-width:1200px;margin:0 auto}
h1{text-align:center;color:#1a1a2e;margin:20px 0 30px;font-size:24px}
h2{color:#2d3436;font-size:20px;margin:30px 0 15px;padding-bottom:8px;border-bottom:2px solid #0984e3}
.card{background:#fff;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.08);padding:24px;margin-bottom:20px}
.info-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
.info-item{display:flex;padding:8px 0;border-bottom:1px solid #f0f0f0}
.info-label{color:#636e72;min-width:140px;font-weight:500}
.info-value{color:#2d3436}
table{width:100%;border-collapse:collapse;font-size:12px}
thead{background:#0984e3;color:#fff}
th{padding:10px 6px;text-align:center;font-weight:600;white-space:nowrap}
td{padding:8px 6px;text-align:center;border-bottom:1px solid #eee;font-size:11px}
tr:hover td{background:#f0f7ff}
.summary-list{padding-left:20px}
.summary-list li{margin-bottom:8px;line-height:1.8}
.meta{text-align:center;color:#999;font-size:12px;margin-top:30px}
.text-block{background:#f8f9fd;border-left:4px solid #0984e3;padding:12px 16px;border-radius:0 8px 8px 0;margin:10px 0}
@media print{body{background:#fff}.card{box-shadow:none;border:1px solid #ddd}table{font-size:10px}td,th{padding:6px 4px}}
</style></head>
<body><div class="container">
<h1>📊 上课情况分析报告 — 角色 32009zyl</h1>
<h2>一、基础信息</h2>
<div class="card"><div class="info-grid">
<div class="info-item"><span class="info-label">角色ID</span><span class="info-value">32009</span></div>
<div class="info-item"><span class="info-label">角色姓名</span><span class="info-value">zyl</span></div>
<div class="info-item"><span class="info-label">年龄</span><span class="info-value">12 岁</span></div>
<div class="info-item"><span class="info-label">账号ID</span><span class="info-value">25120</span></div>
<div class="info-item"><span class="info-label">手机号后4位</span><span class="info-value">0500</span></div>
<div class="info-item"><span class="info-label">账号注册时间</span><span class="info-value">2026-04-23 13:23</span></div>
<div class="info-item"><span class="info-label">课程激活时间</span><span class="info-value">-</span></div>
<div class="info-item"><span class="info-label">课程到期时间</span><span class="info-value">2032-04-28 18:35</span></div>
<div class="info-item"><span class="info-label">课程级别</span><span class="info-value">A2</span></div>
<div class="info-item"><span class="info-label">购买渠道</span><span class="info-value">Apple App Store</span></div>
<div class="info-item"><span class="info-label">渠道来源</span><span class="info-value">newmedia-daren-xhs-宣儿麻麻-0</span></div>
<div class="info-item"><span class="info-label">设备名称</span><span class="info-value">小课屏E3/E3 21A900</span></div>
<div class="info-item"><span class="info-label">操作系统</span><span class="info-value">Android OS 11 / API-30 (RP1A.200720.011/1699448821)</span></div>
<div class="info-item"><span class="info-label">设备类型</span><span class="info-value">Handheld</span></div>
<div class="info-item"><span class="info-label">城市</span><span class="info-value">金华市</span></div>
<div class="info-item"><span class="info-label">第一次完课</span><span class="info-value">2026-04-24 21:38</span></div>
<div class="info-item"><span class="info-label">最后一次完课</span><span class="info-value">2026-05-24 09:27</span></div>
<div class="info-item"><span class="info-label">最后完课位置</span><span class="info-value">Level[A2] Unit[5] Lesson[1]</span></div>
</div></div>
<h2>二、完课记录明细</h2>
<div class="card" style="overflow-x:auto">
<table><thead><tr>
<th>课程</th><th>开始时间</th><th>完课时间</th><th>耗时</th><th>中互动</th><th>巩固完成</th><th>巩固题数</th><th>巩固正确率</th>
</tr></thead><tbody>
<tr>
<td>A2-U0-L1</td>
<td>2026-04-24 21:24</td><td>2026-04-24 21:38</td><td>14分16秒</td>
<td>Perfect[18,85.7%]-Good[0,0.0%]-Oops[0,0.0%]</td><td></td><td>20</td><td>100.0%</td></tr><tr>
<td>A2-U0-L2</td>
<td>2026-04-24 21:45</td><td>2026-04-24 21:58</td><td>13分1秒</td>
<td>Perfect[23,85.2%]-Good[0,0.0%]-Oops[0,0.0%]</td><td></td><td>24</td><td>100.0%</td></tr><tr>
<td>A2-U0-L3</td>
<td>2026-04-25 14:16</td><td>2026-04-25 14:30</td><td>13分58秒</td>
<td>Perfect[22,88.0%]-Good[0,0.0%]-Oops[0,0.0%]</td><td></td><td>24</td><td>100.0%</td></tr><tr>
<td>A2-U0-L4</td>
<td>2026-04-26 12:55</td><td>2026-04-26 13:12</td><td>17分5秒</td>
<td>Perfect[19,86.4%]-Good[0,0.0%]-Oops[0,0.0%]</td><td></td><td>22</td><td>100.0%</td></tr><tr>
<td>A2-U0-L5</td>
<td>2026-04-27 20:33</td><td>2026-04-27 20:49</td><td>16分15秒</td>
<td>Perfect[18,81.8%]-Good[1,4.5%]-Oops[0,0.0%]</td><td></td><td>21</td><td>100.0%</td></tr><tr>
<td>A2-U2-L1</td>
<td>2026-04-30 18:39</td><td>2026-04-30 18:59</td><td>20分20秒</td>
<td>Perfect[21,84.0%]-Good[1,4.0%]-Oops[0,0.0%]</td><td></td><td>24</td><td>100.0%</td></tr><tr>
<td>A2-U2-L2</td>
<td>2026-04-30 19:02</td><td>2026-04-30 19:21</td><td>18分50秒</td>
<td>Perfect[19,76.0%]-Good[1,4.0%]-Oops[0,0.0%]</td><td></td><td>25</td><td>100.0%</td></tr><tr>
<td>A2-U2-L3</td>
<td>2026-05-01 10:09</td><td>2026-05-01 10:35</td><td>25分51秒</td>
<td>Perfect[15,65.2%]-Good[2,8.7%]-Oops[1,4.3%]</td><td></td><td>22</td><td>100.0%</td></tr><tr>
<td>A2-U2-L4</td>
<td>2026-05-04 20:53</td><td>2026-05-04 21:16</td><td>22分37秒</td>
<td>Perfect[18,69.2%]-Good[2,7.7%]-Oops[0,0.0%]</td><td></td><td>25</td><td>100.0%</td></tr><tr>
<td>A2-U2-L5</td>
<td>2026-05-06 19:44</td><td>2026-05-06 20:07</td><td>23分18秒</td>
<td>Perfect[25,80.6%]-Good[2,6.5%]-Oops[0,0.0%]</td><td></td><td>31</td><td>100.0%</td></tr><tr>
<td>A2-U1-L1</td>
<td>2026-05-07 20:25</td><td>2026-05-07 20:45</td><td>20分33秒</td>
<td>Perfect[25,86.2%]-Good[1,3.4%]-Oops[0,0.0%]</td><td></td><td>29</td><td>100.0%</td></tr><tr>
<td>A2-U1-L2</td>
<td>2026-05-08 20:29</td><td>2026-05-08 20:54</td><td>25分1秒</td>
<td>Perfect[28,96.6%]-Good[0,0.0%]-Oops[1,3.4%]</td><td></td><td>29</td><td>100.0%</td></tr><tr>
<td>A2-U1-L3</td>
<td>2026-05-09 19:19</td><td>2026-05-09 19:43</td><td>23分55秒</td>
<td>Perfect[25,83.3%]-Good[1,3.3%]-Oops[0,0.0%]</td><td></td><td>30</td><td>100.0%</td></tr><tr>
<td>A2-U1-L4</td>
<td>2026-05-10 09:55</td><td>2026-05-10 10:17</td><td>22分9秒</td>
<td>Perfect[20,83.3%]-Good[0,0.0%]-Oops[0,0.0%]</td><td></td><td>24</td><td>100.0%</td></tr><tr>
<td>A2-U1-L5</td>
<td>2026-05-11 19:39</td><td>2026-05-11 19:58</td><td>18分53秒</td>
<td>Perfect[24,88.9%]-Good[2,7.4%]-Oops[0,0.0%]</td><td></td><td>27</td><td>100.0%</td></tr><tr>
<td>A2-U3-L1</td>
<td>2026-05-12 20:01</td><td>2026-05-12 20:23</td><td>22分2秒</td>
<td>Perfect[24,88.9%]-Good[0,0.0%]-Oops[0,0.0%]</td><td></td><td>27</td><td>100.0%</td></tr><tr>
<td>A2-U3-L2</td>
<td>2026-05-15 19:56</td><td>2026-05-15 20:16</td><td>20分21秒</td>
<td>Perfect[21,87.5%]-Good[1,4.2%]-Oops[0,0.0%]</td><td></td><td>24</td><td>100.0%</td></tr><tr>
<td>A2-U3-L3</td>
<td>2026-05-16 09:39</td><td>2026-05-16 09:57</td><td>18分29秒</td>
<td>Perfect[21,91.3%]-Good[1,4.3%]-Oops[0,0.0%]</td><td></td><td>23</td><td>100.0%</td></tr><tr>
<td>A2-U3-L4</td>
<td>2026-05-18 18:50</td><td>2026-05-18 19:10</td><td>20分12秒</td>
<td>Perfect[11,45.8%]-Good[2,8.3%]-Oops[0,0.0%]</td><td></td><td>24</td><td>100.0%</td></tr><tr>
<td>A2-U3-L5</td>
<td>2026-05-19 18:21</td><td>2026-05-19 18:49</td><td>28分5秒</td>
<td>Perfect[22,88.0%]-Good[1,4.0%]-Oops[0,0.0%]</td><td></td><td>25</td><td>100.0%</td></tr><tr>
<td>A2-U4-L1</td>
<td>2026-05-20 19:22</td><td>2026-05-20 19:49</td><td>27分2秒</td>
<td>Perfect[13,56.5%]-Good[1,4.3%]-Oops[2,8.7%]</td><td></td><td>23</td><td>100.0%</td></tr><tr>
<td>A2-U4-L2</td>
<td>2026-05-21 19:52</td><td>2026-05-21 20:08</td><td>16分0秒</td>
<td>Perfect[22,91.7%]-Good[0,0.0%]-Oops[0,0.0%]</td><td></td><td>24</td><td>100.0%</td></tr><tr>
<td>A2-U4-L3</td>
<td>2026-05-22 18:43</td><td>2026-05-22 18:58</td><td>15分22秒</td>
<td>Perfect[23,92.0%]-Good[0,0.0%]-Oops[0,0.0%]</td><td></td><td>25</td><td>100.0%</td></tr><tr>
<td>A2-U4-L4</td>
<td>2026-05-22 18:59</td><td>2026-05-22 19:24</td><td>24分52秒</td>
<td>Perfect[21,91.3%]-Good[0,0.0%]-Oops[0,0.0%]</td><td></td><td>23</td><td>100.0%</td></tr><tr>
<td>A2-U4-L5</td>
<td>2026-05-23 09:42</td><td>2026-05-23 10:00</td><td>18分38秒</td>
<td>Perfect[22,91.7%]-Good[0,0.0%]-Oops[0,0.0%]</td><td></td><td>24</td><td>100.0%</td></tr><tr>
<td>A2-U5-L1</td>
<td>2026-05-23 16:19</td><td>2026-05-24 09:27</td><td>1028分28秒</td>
<td>Perfect[18,78.3%]-Good[0,0.0%]-Oops[1,4.3%]</td><td></td><td>23</td><td>100.0%</td></tr>
</tbody></table></div>
<h2>三、上课时间分析</h2>
<div class="card"><div class="text-block">共 26 条有效记录,平均 59.1 分钟,中位数 20.4 分钟。最快 13.0 分钟,最慢 1028.5 分钟。异常:过快 0 条(<10分钟过慢 15 >20分钟。趋势前半段平均 19.6 分钟 → 后半段 98.5 分钟(延长 402.6%)。</div></div>
<h2>四、中互动正确率分析</h2>
<div class="card"><div class="text-block">共 651 条记录。Perfect 82.6%Good 2.9%Oops 0.8%,优良率 85.5%。趋势Perfect 率 82.2% → 82.7%(持平)。</div></div>
<h2>五、知识巩固正确率分析</h2>
<div class="card"><div class="text-block">共 26 节课,完成巩固练习 26 节(完成率 100.0%),平均得分 100.0%。得分分布高分≥8026节 / 中等 0节 / 低分(<500节</div></div>
<h2>总结</h2>
<div class="card"><ul class="summary-list"><li>🐢 完课耗时较长,建议关注学习效率</li><li>📊 完课耗时中位数 20.4 分钟</li><li>🎯 中互动优良率 85.6%</li><li>📝 知识巩固完成率 100.0%,平均得分 100.0%</li></ul></div>
<div class="meta">分析时间2026-05-24 11:38 | 完课26 条 | 中互动651 条 | 巩固26 条</div>
</div></body></html>

View File

@ -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 <role_id>
# 生成 HTML 报告
python3 skills/studycourse-analysis/scripts/studycourse_analysis.py <role_id> --format html -o output/studycourse_<role_id>.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 初始版本

View File

@ -0,0 +1,782 @@
#!/usr/bin/env python3
"""
studycourse-analysis 角色上课情况分析工具
用法: python3 studycourse_analysis.py <role_id> [--format html] [--output <path>]
"""
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"""<tr>
<td>{ci.get('level','')}-U{ci.get('unit_index','')}-L{ci.get('lesson_index','')}</td>
<td>{_td(sd)}</td><td>{_td(ed)}</td><td>{_fmt_duration(dur)}</td>
<td>{mid_str}</td><td>{prac_done}</td><td>{prac_cnt}</td><td>{prac_pct}</td></tr>"""
# 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"<li>{it}</li>" for it in summary_items) if summary_items else "<li>暂无分析数据</li>"
# 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"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>上课情况分析 角色 {role_id}</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:#f5f7fa;color:#333;line-height:1.6;padding:20px}}
.container{{max-width:1200px;margin:0 auto}}
h1{{text-align:center;color:#1a1a2e;margin:20px 0 30px;font-size:24px}}
h2{{color:#2d3436;font-size:20px;margin:30px 0 15px;padding-bottom:8px;border-bottom:2px solid #0984e3}}
.card{{background:#fff;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.08);padding:24px;margin-bottom:20px}}
.info-grid{{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}}
.info-item{{display:flex;padding:8px 0;border-bottom:1px solid #f0f0f0}}
.info-label{{color:#636e72;min-width:140px;font-weight:500}}
.info-value{{color:#2d3436}}
table{{width:100%;border-collapse:collapse;font-size:12px}}
thead{{background:#0984e3;color:#fff}}
th{{padding:10px 6px;text-align:center;font-weight:600;white-space:nowrap}}
td{{padding:8px 6px;text-align:center;border-bottom:1px solid #eee;font-size:11px}}
tr:hover td{{background:#f0f7ff}}
.summary-list{{padding-left:20px}}
.summary-list li{{margin-bottom:8px;line-height:1.8}}
.meta{{text-align:center;color:#999;font-size:12px;margin-top:30px}}
.text-block{{background:#f8f9fd;border-left:4px solid #0984e3;padding:12px 16px;border-radius:0 8px 8px 0;margin:10px 0}}
@media print{{body{{background:#fff}}.card{{box-shadow:none;border:1px solid #ddd}}table{{font-size:10px}}td,th{{padding:6px 4px}}}}
</style></head>
<body><div class="container">
<h1>📊 上课情况分析报告 角色 {role_id}{ri.get('nickname','')}</h1>
<h2>基础信息</h2>
<div class="card"><div class="info-grid">
<div class="info-item"><span class="info-label">角色ID</span><span class="info-value">{role_id}</span></div>
<div class="info-item"><span class="info-label">角色姓名</span><span class="info-value">{ri.get('nickname','-')}</span></div>
<div class="info-item"><span class="info-label">年龄</span><span class="info-value">{age_str}</span></div>
<div class="info-item"><span class="info-label">账号ID</span><span class="info-value">{ri.get('account_id','-')}</span></div>
<div class="info-item"><span class="info-label">手机号后4位</span><span class="info-value">{ai.get('phone_tail','-')}</span></div>
<div class="info-item"><span class="info-label">账号注册时间</span><span class="info-value">{ai.get('account_created','-')}</span></div>
<div class="info-item"><span class="info-label">课程激活时间</span><span class="info-value">{_td(act_time)}</span></div>
<div class="info-item"><span class="info-label">课程到期时间</span><span class="info-value">{_td(exp_time)}</span></div>
<div class="info-item"><span class="info-label">课程级别</span><span class="info-value">{cd.get('course_level','-')}</span></div>
<div class="info-item"><span class="info-label">购买渠道</span><span class="info-value">{ai.get('download_channel','-')}</span></div>
<div class="info-item"><span class="info-label">渠道来源</span><span class="info-value">{ai.get('key_from','-')}</span></div>
<div class="info-item"><span class="info-label">设备名称</span><span class="info-value">{dev_str}</span></div>
<div class="info-item"><span class="info-label">操作系统</span><span class="info-value">{os_str}</span></div>
<div class="info-item"><span class="info-label">设备类型</span><span class="info-value">{di.get('device_type','-')}</span></div>
<div class="info-item"><span class="info-label">城市</span><span class="info-value">{di.get('city','-')}</span></div>
<div class="info-item"><span class="info-label">第一次完课</span><span class="info-value">{first_dt}</span></div>
<div class="info-item"><span class="info-label">最后一次完课</span><span class="info-value">{last_dt}</span></div>
<div class="info-item"><span class="info-label">最后完课位置</span><span class="info-value">{last_lvl}</span></div>
</div></div>
<h2>完课记录明细</h2>
<div class="card" style="overflow-x:auto">
<table><thead><tr>
<th>课程</th><th>开始时间</th><th>完课时间</th><th>耗时</th><th>中互动</th><th>巩固完成</th><th>巩固题数</th><th>巩固正确率</th>
</tr></thead><tbody>
{rows_html}
</tbody></table></div>
<h2>上课时间分析</h2>
<div class="card"><div class="text-block">{dur_summary}</div></div>
<h2>中互动正确率分析</h2>
<div class="card"><div class="text-block">{mid_text}</div></div>
<h2>知识巩固正确率分析</h2>
<div class="card"><div class="text-block">{prac_text}</div></div>
<h2>总结</h2>
<div class="card"><ul class="summary-list">{si_html}</ul></div>
<div class="meta">分析时间{ns} | 完课{len(play_records)} | 中互动{mid_analysis.get('total',0)} | 巩固{prac_analysis.get('total_lessons',0)} </div>
</div></body></html>"""
# ── 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()