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