ai_member_xiaoban/skills/studycourse-analysis/scripts/studycourse_analysis.py

783 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()