streamlit_growth_dashboard.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845
  1. import configparser
  2. import os
  3. from urllib.parse import quote_plus
  4. from datetime import date, timedelta
  5. import altair as alt
  6. import pandas as pd
  7. import streamlit as st
  8. try:
  9. from sqlalchemy import create_engine, text
  10. from sqlalchemy.exc import OperationalError
  11. except ImportError:
  12. st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
  13. st.error("请安装: pip install sqlalchemy pymysql")
  14. st.stop()
  15. st.set_page_config(page_title="Growth Segmentation Dashboard", layout="wide")
  16. # ---- UI i18n ----
  17. I18N = {
  18. "zh": {
  19. "dashboard_title": "Lyrria 增长看板",
  20. "sidebar_lang": "Language / 语言",
  21. "sidebar_filters": "筛选器",
  22. "sidebar_segment": "用户分层(users.user_segment)",
  23. "sidebar_school": "学校(org_name)",
  24. "sidebar_exclude_test": "排除 users.is_test_user = 1",
  25. "sidebar_target_date": "目标日期",
  26. "sidebar_view_members": "查看共创社区成员",
  27. "community_dialog_title": "共创社区成员",
  28. "community_subheader": "共创社区成员",
  29. "community_rule": "规则:未退出社区,且邮箱来自 users.email 或手机号注册后在 early_community 留存邮箱。",
  30. "community_count": "参与成员数",
  31. "community_empty": "当前没有符合条件的共创社区成员。",
  32. "dau_trend": "DAU 趋势",
  33. "curr_trend": "CURR 趋势",
  34. "axis_date": "日期",
  35. "axis_dau": "DAU",
  36. "axis_curr": "CURR(%)",
  37. "tooltip_curr": "CURR(%)",
  38. "tooltip_prev_day_active": "昨日活跃",
  39. "tooltip_retained_today": "今日留存",
  40. "tooltip_dau_today": "今日 DAU",
  41. "trend3_caption_1": "30 天走势:左轴=每日用户对话总时长(秒),右轴=页面浏览量(次)。",
  42. "trend3_caption_2": "图例说明:蓝线=对话时长(秒);橙线=Lumi 商店 PV;绿线=Lumi 装扮 PV。",
  43. "line_duration": "对话时长(秒)",
  44. "line_shop_pv": "Lumi 商店 PV",
  45. "line_outfits_pv": "Lumi 装扮 PV",
  46. "legend_lines": "曲线",
  47. "axis_duration": "对话时长(秒)",
  48. "axis_pv_count": "页面浏览量(次)",
  49. "tooltip_pv_type": "PV 类型",
  50. "tooltip_pv": "PV",
  51. "segments_on": "分群(日期:{date})",
  52. "segment_new": "新用户",
  53. "segment_current": "持续活跃",
  54. "segment_reactivated": "重新活跃",
  55. "segment_at_risk_7d": "风险流失(7 天)",
  56. "segment_at_risk_30d": "风险流失(30 天)",
  57. "segment_dormant_30d": "沉睡(>=30 天)",
  58. "active_today_composition": "今日活跃构成",
  59. "not_active_today": "今日未活跃",
  60. "metric_inactive_total": "未活跃 / 总用户",
  61. "caption_active_comp": "今日 DAU 的构成。",
  62. "caption_inactive_comp": "当前筛选人群中,今日未活跃用户的分布。",
  63. "hint_share_dau": "占 DAU 的比例。",
  64. "hint_share_inactive": "占今日未活跃用户的比例。",
  65. "caption_of": "占 {denom} 的 {pct}。{hint}",
  66. "drilldown_title": "明细下钻(前 50 位用户)",
  67. "drilldown_caption": "邮箱、手机号为 `users` 表字段;未录入则为空。",
  68. "tab_active_today": "今日活跃",
  69. "tab_at_risk": "风险流失",
  70. "tab_dormant": "沉睡",
  71. },
  72. "en": {
  73. "dashboard_title": "Lyrria Growth Dashboard",
  74. "sidebar_lang": "Language / 语言",
  75. "sidebar_filters": "Filters",
  76. "sidebar_segment": "users.user_segment",
  77. "sidebar_school": "School (org_name)",
  78. "sidebar_exclude_test": "Exclude users.is_test_user = 1",
  79. "sidebar_target_date": "Target date",
  80. "sidebar_view_members": "View Community Members",
  81. "community_dialog_title": "Community Members",
  82. "community_subheader": "Community Members",
  83. "community_rule": "Rule: not opted out, and email comes from users.email or from early_community after phone sign-up.",
  84. "community_count": "Members Count",
  85. "community_empty": "No community members match the current rule.",
  86. "dau_trend": "DAU trend",
  87. "curr_trend": "CURR trend",
  88. "axis_date": "Date",
  89. "axis_dau": "DAU",
  90. "axis_curr": "CURR (%)",
  91. "tooltip_curr": "CURR (%)",
  92. "tooltip_prev_day_active": "Prev-day active",
  93. "tooltip_retained_today": "Retained today",
  94. "tooltip_dau_today": "DAU today",
  95. "trend3_caption_1": "30-day trend: left axis = total daily conversation duration (s), right axis = page views.",
  96. "trend3_caption_2": "Legend: blue = Conversation duration (s); orange = Lumi shop PV; green = Lumi outfits PV.",
  97. "line_duration": "Conversation duration (s)",
  98. "line_shop_pv": "Lumi shop PV",
  99. "line_outfits_pv": "Lumi outfits PV",
  100. "legend_lines": "Lines",
  101. "axis_duration": "Conversation duration (s)",
  102. "axis_pv_count": "Page views (count)",
  103. "tooltip_pv_type": "PV type",
  104. "tooltip_pv": "PV",
  105. "segments_on": "Segments on {date}",
  106. "segment_new": "New",
  107. "segment_current": "Current",
  108. "segment_reactivated": "Reactivated",
  109. "segment_at_risk_7d": "At-risk (7d)",
  110. "segment_at_risk_30d": "At-risk (30d)",
  111. "segment_dormant_30d": "Dormant (>=30d)",
  112. "active_today_composition": "Active today composition",
  113. "not_active_today": "Not active today",
  114. "metric_inactive_total": "Inactive / Total",
  115. "caption_active_comp": "How today's DAU is composed.",
  116. "caption_inactive_comp": "Breakdown of users who are not active today (within the current filtered cohort).",
  117. "hint_share_dau": "Share of DAU.",
  118. "hint_share_inactive": "Share of users not active today.",
  119. "caption_of": "{pct} of {denom}. {hint}",
  120. "drilldown_title": "Drill-down (first 50 users)",
  121. "drilldown_caption": "Email and phone come from the `users` table; blank means missing.",
  122. "tab_active_today": "Active today",
  123. "tab_at_risk": "At-risk",
  124. "tab_dormant": "Dormant",
  125. },
  126. }
  127. def get_lang() -> str:
  128. selected = st.sidebar.selectbox(I18N["en"]["sidebar_lang"], options=["中文", "English"], index=1)
  129. return "zh" if selected == "中文" else "en"
  130. lang = get_lang()
  131. def t(key: str, **kwargs) -> str:
  132. text = I18N.get(lang, I18N["en"]).get(key, key)
  133. return text.format(**kwargs) if kwargs else text
  134. # ---- MySQL config from setting.conf ----
  135. SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
  136. DEFAULT_CONF = os.path.join(SCRIPT_DIR, "setting.conf")
  137. def load_config(conf_path: str) -> configparser.ConfigParser | None:
  138. if not os.path.isfile(conf_path):
  139. return None
  140. cfg = configparser.ConfigParser(interpolation=None)
  141. cfg.read(conf_path, encoding="utf-8")
  142. return cfg
  143. def get_mysql_config(cfg: configparser.ConfigParser) -> dict:
  144. return {
  145. "host": cfg.get("mysql", "host", fallback="127.0.0.1"),
  146. "port": cfg.getint("mysql", "port", fallback=3306),
  147. "user": cfg.get("mysql", "user", fallback="root"),
  148. "password": cfg.get("mysql", "password", fallback=""),
  149. "database": cfg.get("mysql", "database", fallback="Lyrria"),
  150. "charset": cfg.get("mysql", "charset", fallback="utf8mb4"),
  151. }
  152. cfg = load_config(os.getenv("DASHBOARD_CONF", DEFAULT_CONF))
  153. if not cfg or not cfg.has_section("mysql"):
  154. st.error(f"配置文件不存在或缺少 [mysql] 节: {DEFAULT_CONF}")
  155. st.stop()
  156. mysql_kw = get_mysql_config(cfg)
  157. def _make_engine_url() -> str:
  158. user = quote_plus(mysql_kw["user"])
  159. password = quote_plus(mysql_kw["password"])
  160. host = mysql_kw["host"]
  161. port = mysql_kw["port"]
  162. database = mysql_kw["database"]
  163. charset = mysql_kw["charset"]
  164. return f"mysql+pymysql://{user}:{password}@{host}:{port}/{database}?charset={quote_plus(charset)}"
  165. @st.cache_resource
  166. def _get_engine():
  167. # 使用连接保活与周期回收,减少 MySQL 空闲断连(2013/2006)问题
  168. engine = create_engine(
  169. _make_engine_url(),
  170. pool_pre_ping=True,
  171. pool_recycle=1800,
  172. pool_size=5,
  173. max_overflow=10,
  174. pool_timeout=30,
  175. connect_args={
  176. "connect_timeout": 10,
  177. "read_timeout": 120,
  178. "write_timeout": 120,
  179. },
  180. )
  181. with engine.connect() as c:
  182. c.execute(text("SELECT 1"))
  183. return engine
  184. try:
  185. engine = _get_engine()
  186. except Exception as e:
  187. st.error(f"MySQL 连接失败: {e}")
  188. st.stop()
  189. def read_df(sql: str, params: tuple = ()) -> pd.DataFrame:
  190. try:
  191. return pd.read_sql_query(sql, engine, params=params)
  192. except OperationalError:
  193. # 连接中断时主动释放连接池并重试一次
  194. try:
  195. engine.dispose()
  196. except Exception:
  197. pass
  198. return pd.read_sql_query(sql, engine, params=params)
  199. @st.cache_data(ttl=300)
  200. def read_community_members_df() -> pd.DataFrame:
  201. """读取参与共创社区的成员列表(包含邮箱来源)"""
  202. return read_df(
  203. """
  204. SELECT
  205. u.user_id,
  206. COALESCE(NULLIF(TRIM(u.email), ''), NULLIF(TRIM(uec.email), '')) AS community_email,
  207. CASE
  208. WHEN u.email IS NOT NULL AND TRIM(u.email) <> '' THEN 'users.email'
  209. ELSE 'user_early_community.email'
  210. END AS email_source,
  211. u.phone,
  212. COALESCE(ucc.opted_out, 0) AS opted_out,
  213. uec.joined_at
  214. FROM users u
  215. LEFT JOIN user_community_choice ucc
  216. ON ucc.user_id = u.user_id
  217. LEFT JOIN user_early_community uec
  218. ON uec.user_id = u.user_id
  219. WHERE
  220. COALESCE(ucc.opted_out, 0) = 0
  221. AND (
  222. (u.email IS NOT NULL AND TRIM(u.email) <> '')
  223. OR (
  224. (u.email IS NULL OR TRIM(u.email) = '')
  225. AND u.phone IS NOT NULL AND TRIM(u.phone) <> ''
  226. AND uec.email IS NOT NULL AND TRIM(uec.email) <> ''
  227. )
  228. )
  229. ORDER BY community_email, u.user_id
  230. """
  231. )
  232. def _render_community_members_content():
  233. members_df = read_community_members_df()
  234. st.caption(t("community_rule"))
  235. st.metric(t("community_count"), f"{len(members_df):,}")
  236. if members_df.empty:
  237. st.info(t("community_empty"))
  238. return
  239. st.dataframe(
  240. members_df,
  241. use_container_width=True,
  242. hide_index=True,
  243. )
  244. def _apply_dialog_extra_wide_style():
  245. st.markdown(
  246. """
  247. <style>
  248. div[data-testid="stDialog"] div[role="dialog"] {
  249. width: min(96vw, 1600px) !important;
  250. max-width: min(96vw, 1600px) !important;
  251. }
  252. </style>
  253. """,
  254. unsafe_allow_html=True,
  255. )
  256. if hasattr(st, "dialog"):
  257. @st.dialog(t("community_dialog_title"), width="large")
  258. def show_community_members_dialog():
  259. _apply_dialog_extra_wide_style()
  260. _render_community_members_content()
  261. else:
  262. def show_community_members_dialog():
  263. st.subheader(t("community_subheader"))
  264. _render_community_members_content()
  265. st.title(t("dashboard_title"))
  266. # ---- Sidebar ----
  267. st.sidebar.header(t("sidebar_filters"))
  268. segments = read_df("SELECT DISTINCT user_segment AS seg FROM users WHERE user_segment IS NOT NULL ORDER BY seg")["seg"].dropna().tolist()
  269. org_df = read_df(
  270. """
  271. SELECT DISTINCT uda.org_id_snapshot AS org_id, o.org_name
  272. FROM user_daily_activity uda
  273. LEFT JOIN organizations o
  274. ON o.org_id COLLATE utf8mb4_unicode_ci = uda.org_id_snapshot COLLATE utf8mb4_unicode_ci
  275. WHERE uda.org_id_snapshot IS NOT NULL
  276. ORDER BY
  277. COALESCE(
  278. o.org_name COLLATE utf8mb4_unicode_ci,
  279. uda.org_id_snapshot COLLATE utf8mb4_unicode_ci
  280. ),
  281. uda.org_id_snapshot COLLATE utf8mb4_unicode_ci
  282. """
  283. )
  284. org_df["org_id"] = org_df["org_id"].astype(str)
  285. org_map = {
  286. row["org_id"]: (str(row["org_name"]).strip() if pd.notna(row["org_name"]) and str(row["org_name"]).strip() else row["org_id"])
  287. for _, row in org_df.iterrows()
  288. }
  289. orgs = org_df["org_id"].tolist()
  290. segment_default = [s for s in segments if s != "internal"] if segments else []
  291. segment_sel = st.sidebar.multiselect(t("sidebar_segment"), options=segments, default=segment_default)
  292. org_sel = st.sidebar.multiselect(
  293. t("sidebar_school"),
  294. options=orgs,
  295. default=[],
  296. format_func=lambda org_id: org_map.get(org_id, org_id),
  297. )
  298. exclude_test = st.sidebar.checkbox(t("sidebar_exclude_test"), value=True)
  299. # Date bounds
  300. max_day = pd.to_datetime(read_df("SELECT MAX(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
  301. min_day = pd.to_datetime(read_df("SELECT MIN(activity_date) AS d FROM user_daily_activity")["d"].iloc[0]).date()
  302. today = date.today()
  303. # UX: 默认选择今天,允许选择今天之前所有日期(若库里最小日期晚于今天,则回退为该最小日期)
  304. target_day = st.sidebar.date_input(
  305. t("sidebar_target_date"),
  306. value=today if today >= min_day else min_day,
  307. min_value=min_day,
  308. max_value=today if today >= min_day else min_day,
  309. )
  310. st.sidebar.markdown("---")
  311. if st.sidebar.button(t("sidebar_view_members"), use_container_width=True):
  312. show_community_members_dialog()
  313. # ---- Load users (for universe + test filter) ----
  314. users = read_df("SELECT user_id, is_test_user, phone, email, user_segment FROM users")
  315. if exclude_test:
  316. users = users[users["is_test_user"].fillna(0).astype(int) == 0].copy()
  317. # Base eligibility: phone/email at least one non-empty.
  318. phone_non_empty = users["phone"].fillna("").astype(str).str.strip() != ""
  319. email_non_empty = users["email"].fillna("").astype(str).str.strip() != ""
  320. users = users[phone_non_empty | email_non_empty].copy()
  321. if segment_sel:
  322. users = users[users["user_segment"].isin(segment_sel)].copy()
  323. universe_users = set(users["user_id"].astype(str).tolist())
  324. # ---- Pull daily activity for trend window + recent history (for 7/30d sets) ----
  325. # We only need recent history for rolling-window sets (7/30d) and trend chart(最近30天).
  326. # Keep a small buffer beyond 30 days to avoid off-by-one and sparse-data gaps.
  327. history_days = 35
  328. start_recent = target_day - timedelta(days=history_days)
  329. daily = read_df(
  330. """
  331. SELECT activity_date, user_id, active_flag, session_count, message_count,
  332. first_seen_date, user_segment_snapshot, org_id_snapshot
  333. FROM user_daily_activity
  334. WHERE activity_date >= %s AND activity_date <= %s
  335. """,
  336. (start_recent.isoformat(), target_day.isoformat()),
  337. )
  338. daily["activity_date"] = pd.to_datetime(daily["activity_date"]).dt.date
  339. daily["first_seen_date"] = pd.to_datetime(daily["first_seen_date"]).dt.date
  340. # Apply snapshot filters
  341. if org_sel:
  342. daily = daily[daily["org_id_snapshot"].isin(org_sel)]
  343. # Apply user universe filter (test users removed)
  344. daily = daily[daily["user_id"].isin(universe_users)]
  345. # Cohort base comes from users table (test/contact/segment filters).
  346. # If org filter is selected, intersect with users ever seen in that org (full history).
  347. if org_sel:
  348. org_placeholders = ",".join(["%s"] * len(org_sel))
  349. org_df = read_df(
  350. f"""
  351. SELECT DISTINCT user_id
  352. FROM user_daily_activity
  353. WHERE org_id_snapshot IN ({org_placeholders})
  354. """,
  355. tuple(org_sel),
  356. )
  357. org_users = set(org_df["user_id"].astype(str).tolist())
  358. cohort_universe = universe_users & org_users
  359. else:
  360. cohort_universe = universe_users
  361. # ---- Trend (DAU, by day) ----
  362. st.subheader(t("dau_trend"))
  363. # 最近 30 天(含 target_day);若早于库内最小日期则截断
  364. trend_start = max(min_day, target_day - timedelta(days=29))
  365. dau_raw = (
  366. daily[
  367. (daily["activity_date"] >= trend_start)
  368. & (daily["activity_date"] <= target_day)
  369. & (daily["active_flag"] == 1)
  370. ]
  371. .groupby("activity_date")["user_id"]
  372. .nunique()
  373. .reset_index(name="dau")
  374. )
  375. # 补齐日期轴到 target_day:无数据的日期显示 0
  376. trend_days = pd.DataFrame(
  377. {"activity_date": pd.date_range(start=trend_start, end=target_day, freq="D").date}
  378. )
  379. trend = trend_days.merge(dau_raw, on="activity_date", how="left")
  380. trend["dau"] = trend["dau"].fillna(0).astype(int)
  381. trend["day"] = trend["activity_date"].astype(str)
  382. chart = (
  383. alt.Chart(trend)
  384. .mark_line(point=True)
  385. .encode(
  386. x=alt.X("day:N", axis=alt.Axis(title=t("axis_date"), labelAngle=0)),
  387. y=alt.Y("dau:Q", axis=alt.Axis(title=t("axis_dau"))),
  388. tooltip=["day", "dau"],
  389. )
  390. )
  391. st.altair_chart(chart, use_container_width=True)
  392. # ---- Trend (CURR, by day from curr_daily) ----
  393. st.subheader(t("curr_trend"))
  394. curr_start = max(min_day, target_day - timedelta(days=29))
  395. def _compute_curr_from_daily(daily_df: pd.DataFrame, start_day: date, end_day: date) -> pd.DataFrame:
  396. """基于已筛选口径的 user_daily_activity 现算 CURR(避免使用全量汇总表导致筛选不联动)。"""
  397. df = daily_df.copy()
  398. if df.empty:
  399. out = pd.DataFrame({"activity_date": pd.date_range(start=start_day, end=end_day, freq="D").date})
  400. out["prev_day_active_count"] = 0
  401. out["retained_count"] = 0
  402. out["total_active_today"] = 0
  403. out["curr_rate_pct"] = 0.0
  404. return out
  405. df = df[df["active_flag"] == 1][["activity_date", "user_id"]].copy()
  406. df["user_id"] = df["user_id"].astype(str)
  407. # 构造 day -> active_users(set) 的映射,后续按天 O(1) 取集合
  408. active_by_day = df.groupby("activity_date")["user_id"].apply(lambda s: set(s.tolist())).to_dict()
  409. days = pd.date_range(start=start_day, end=end_day, freq="D").date
  410. rows: list[dict] = []
  411. for d in days:
  412. prev_d = d - timedelta(days=1)
  413. prev_active = active_by_day.get(prev_d, set())
  414. today_active = active_by_day.get(d, set())
  415. prev_cnt = len(prev_active)
  416. retained_cnt = len(prev_active & today_active) if prev_cnt else 0
  417. total_today = len(today_active)
  418. curr_rate = (retained_cnt / prev_cnt * 100.0) if prev_cnt else 0.0
  419. rows.append(
  420. {
  421. "activity_date": d,
  422. "prev_day_active_count": prev_cnt,
  423. "retained_count": retained_cnt,
  424. "curr_rate_pct": curr_rate,
  425. "total_active_today": total_today,
  426. }
  427. )
  428. return pd.DataFrame(rows)
  429. curr_plot = _compute_curr_from_daily(daily, curr_start, target_day)
  430. curr_plot["day"] = curr_plot["activity_date"].astype(str)
  431. curr_chart = (
  432. alt.Chart(curr_plot)
  433. .mark_line(point=True, color="#f59e0b")
  434. .encode(
  435. x=alt.X("day:N", axis=alt.Axis(title=t("axis_date"), labelAngle=0)),
  436. y=alt.Y("curr_rate_pct:Q", axis=alt.Axis(title=t("axis_curr"), format=".2f")),
  437. tooltip=[
  438. "day",
  439. alt.Tooltip("curr_rate_pct:Q", title=t("tooltip_curr"), format=".2f"),
  440. alt.Tooltip("prev_day_active_count:Q", title=t("tooltip_prev_day_active")),
  441. alt.Tooltip("retained_count:Q", title=t("tooltip_retained_today")),
  442. alt.Tooltip("total_active_today:Q", title=t("tooltip_dau_today")),
  443. ],
  444. )
  445. )
  446. st.altair_chart(curr_chart, use_container_width=True)
  447. # ---- Trend (Shop/Outfits PV + Conversation duration, 30d; dual-axis, 3 lines) ----
  448. # 数据源均来自 user_events(已登录用户):
  449. # - page.lumi_shop__enter: 家具商店页面浏览(次)
  450. # - page.lumi_outfits__enter: Lumi 装扮页面浏览(次)
  451. # - conversation.chatbot__end + properties.duration_seconds: 对话时长(秒)
  452. trend3_start = curr_start
  453. trend3_params: list = [trend3_start.isoformat(), target_day.isoformat()]
  454. trend3_user_filters: list[str] = []
  455. # users.is_test_user
  456. if exclude_test:
  457. trend3_user_filters.append("COALESCE(u.is_test_user, 0) = 0")
  458. # phone/email 至少一个非空(与上方 users 口径一致)
  459. trend3_user_filters.append("(TRIM(COALESCE(u.phone, '')) <> '' OR TRIM(COALESCE(u.email, '')) <> '')")
  460. # users.user_segment(来自 users 表)
  461. if segment_sel:
  462. seg_placeholders = ",".join(["%s"] * len(segment_sel))
  463. trend3_user_filters.append(f"u.user_segment IN ({seg_placeholders})")
  464. trend3_params += list(segment_sel)
  465. # org filter:与 cohort_universe 的“用户曾出现在该 org”口径一致
  466. if org_sel:
  467. org_placeholders = ",".join(["%s"] * len(org_sel))
  468. trend3_user_filters.append(
  469. f"""
  470. EXISTS (
  471. SELECT 1
  472. FROM user_daily_activity uda2
  473. WHERE uda2.user_id = e.user_id
  474. AND uda2.org_id_snapshot IN ({org_placeholders})
  475. )
  476. """
  477. )
  478. trend3_params += list(org_sel)
  479. trend3_user_where = " AND " + " AND ".join(f"({c})" for c in trend3_user_filters) if trend3_user_filters else ""
  480. trend3_df = read_df(
  481. f"""
  482. SELECT
  483. DATE(e.event_time) AS activity_date,
  484. SUM(CASE WHEN e.event_name = 'page.lumi_shop__enter' THEN 1 ELSE 0 END) AS lumi_shop_pv,
  485. SUM(CASE WHEN e.event_name = 'page.lumi_outfits__enter' THEN 1 ELSE 0 END) AS lumi_outfits_pv,
  486. SUM(
  487. CASE
  488. WHEN e.event_name <> 'conversation.chatbot__end' THEN 0
  489. WHEN JSON_EXTRACT(e.properties, '$.duration_seconds') IS NULL THEN 0
  490. ELSE CAST(JSON_EXTRACT(e.properties, '$.duration_seconds') AS DECIMAL(18, 3))
  491. END
  492. ) AS total_conversation_duration_seconds
  493. FROM user_events e
  494. INNER JOIN users u
  495. ON u.user_id = e.user_id
  496. WHERE e.event_time >= %s
  497. AND e.event_time < DATE_ADD(%s, INTERVAL 1 DAY)
  498. AND e.event_name IN (
  499. 'page.lumi_shop__enter',
  500. 'page.lumi_outfits__enter',
  501. 'conversation.chatbot__end'
  502. )
  503. {trend3_user_where}
  504. GROUP BY DATE(e.event_time)
  505. ORDER BY activity_date
  506. """,
  507. tuple(trend3_params),
  508. )
  509. if not trend3_df.empty:
  510. trend3_df["activity_date"] = pd.to_datetime(trend3_df["activity_date"]).dt.date
  511. # 补齐日期轴到 target_day:无数据的日期显示 0
  512. trend3_days = pd.DataFrame(
  513. {"activity_date": pd.date_range(start=trend3_start, end=target_day, freq="D").date}
  514. )
  515. trend3_plot = trend3_days.merge(trend3_df, on="activity_date", how="left")
  516. for col in ["lumi_shop_pv", "lumi_outfits_pv", "total_conversation_duration_seconds"]:
  517. if col in trend3_plot.columns:
  518. trend3_plot[col] = trend3_plot[col].fillna(0)
  519. trend3_plot["day"] = trend3_plot["activity_date"].astype(str)
  520. st.caption(t("trend3_caption_1"))
  521. st.caption(t("trend3_caption_2"))
  522. duration_label = t("line_duration")
  523. shop_label = t("line_shop_pv")
  524. outfits_label = t("line_outfits_pv")
  525. duration_line = (
  526. alt.Chart(trend3_plot)
  527. .transform_calculate(line_type=f"'{duration_label}'")
  528. .mark_line(point=True, color="#3b82f6")
  529. .encode(
  530. x=alt.X("day:N", axis=alt.Axis(title=t("axis_date"), labelAngle=0)),
  531. y=alt.Y(
  532. "total_conversation_duration_seconds:Q",
  533. axis=alt.Axis(title=t("axis_duration")),
  534. scale=alt.Scale(domainMin=0),
  535. ),
  536. color=alt.Color(
  537. "line_type:N",
  538. scale=alt.Scale(
  539. domain=[duration_label, shop_label, outfits_label],
  540. range=["#3b82f6", "#f59e0b", "#10b981"],
  541. ),
  542. legend=alt.Legend(title=t("legend_lines")),
  543. ),
  544. tooltip=[
  545. "day",
  546. alt.Tooltip("total_conversation_duration_seconds:Q", title=duration_label, format=",.0f"),
  547. alt.Tooltip("lumi_shop_pv:Q", title=shop_label, format=",d"),
  548. alt.Tooltip("lumi_outfits_pv:Q", title=outfits_label, format=",d"),
  549. ],
  550. )
  551. )
  552. pv_lines = (
  553. alt.Chart(trend3_plot)
  554. .transform_fold(
  555. ["lumi_shop_pv", "lumi_outfits_pv"],
  556. as_=["pv_type", "pv_count"],
  557. )
  558. .transform_calculate(
  559. line_type=f"""
  560. datum.pv_type === 'lumi_shop_pv' ? '{shop_label}' : '{outfits_label}'
  561. """
  562. )
  563. .mark_line(point=True)
  564. .encode(
  565. x=alt.X("day:N"),
  566. y=alt.Y(
  567. "pv_count:Q",
  568. axis=alt.Axis(title=t("axis_pv_count"), orient="right"),
  569. scale=alt.Scale(domainMin=0),
  570. ),
  571. color=alt.Color(
  572. "line_type:N",
  573. scale=alt.Scale(
  574. domain=[duration_label, shop_label, outfits_label],
  575. range=["#3b82f6", "#f59e0b", "#10b981"],
  576. ),
  577. legend=alt.Legend(title=t("legend_lines")),
  578. ),
  579. tooltip=[
  580. "day",
  581. alt.Tooltip("pv_type:N", title=t("tooltip_pv_type")),
  582. alt.Tooltip("pv_count:Q", title=t("tooltip_pv"), format=",d"),
  583. alt.Tooltip("total_conversation_duration_seconds:Q", title=duration_label, format=",.0f"),
  584. ],
  585. )
  586. )
  587. trend3_chart = alt.layer(duration_line, pv_lines).resolve_scale(y="independent")
  588. st.altair_chart(trend3_chart, use_container_width=True)
  589. # ---- Segment computation on target_day ----
  590. d0 = target_day
  591. d7 = d0 - timedelta(days=7)
  592. d30 = d0 - timedelta(days=30)
  593. # Active sets (from recent daily window)
  594. active_today = set(daily.loc[(daily["activity_date"] == d0) & (daily["active_flag"] == 1), "user_id"])
  595. active_past_7 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d7) & (daily["active_flag"] == 1), "user_id"])
  596. active_past_30 = set(daily.loc[(daily["activity_date"] < d0) & (daily["activity_date"] >= d30) & (daily["active_flag"] == 1), "user_id"])
  597. # --- IMPORTANT: Dormant requires "last active before today" using FULL history ---
  598. # We compute:
  599. # prev_last_active_date(user) = max(activity_date) where active_flag=1 and activity_date < target_day
  600. #
  601. # This query uses the daily table (fast) and avoids limiting to a 35-day window.
  602. # Note: For segment/org filters, we still compute last_active across all time, but only for cohort_universe users.
  603. params = [d0.isoformat()]
  604. user_filter_sql = ""
  605. if cohort_universe:
  606. # MySQL 单次 IN 变量过多时可改为临时表
  607. placeholders = ",".join(["%s"] * len(cohort_universe))
  608. user_filter_sql = f" AND user_id IN ({placeholders}) "
  609. params += list(cohort_universe)
  610. last_active_df = read_df(
  611. f"""
  612. SELECT user_id, MAX(activity_date) AS last_active_date
  613. FROM user_daily_activity
  614. WHERE active_flag = 1
  615. AND activity_date < %s
  616. {user_filter_sql}
  617. GROUP BY user_id
  618. """,
  619. tuple(params),
  620. )
  621. if not last_active_df.empty:
  622. last_active_df["last_active_date"] = pd.to_datetime(last_active_df["last_active_date"]).dt.date
  623. prev_last_active = dict(zip(last_active_df["user_id"], last_active_df["last_active_date"])) if not last_active_df.empty else {}
  624. def prev_last_active_date(uid: str):
  625. return prev_last_active.get(uid, None)
  626. # New users: use daily.first_seen_date (which you specified is users.created_at, written once and never overwritten)
  627. # Definition: first_seen_date == target_day AND active today
  628. # (If you want "registered today regardless of active", remove the active_today constraint.)
  629. first_seen_map = daily.groupby("user_id")["first_seen_date"].min().to_dict()
  630. new_users = {u for u in active_today if first_seen_map.get(u) == d0}
  631. # Current / Reactivated
  632. current_users = {u for u in active_today if u in active_past_7}
  633. reactivated_users = {u for u in active_today if (u in active_past_30) and (u not in active_past_7)}
  634. # Inactive segments
  635. at_risk_wau = {u for u in active_past_7 if u not in active_today}
  636. at_risk_mau = {u for u in active_past_30 if (u not in active_past_7) and (u not in active_today)}
  637. # Dormant: users not active today AND (never active before today OR previous last active < d0-30)
  638. dormant_users = {
  639. u for u in cohort_universe
  640. if (u not in active_today) and ((prev_last_active_date(u) is None) or (prev_last_active_date(u) < d30))
  641. }
  642. # ---- Segments visualization (Duolingo-like, no tables) ----
  643. st.subheader(t("segments_on", date=d0.isoformat()))
  644. active_buckets = [
  645. ("segment_new", len(new_users)),
  646. ("segment_current", len(current_users)),
  647. ("segment_reactivated", len(reactivated_users)),
  648. ]
  649. active_total = len(active_today)
  650. inactive_total = max(0, len(cohort_universe) - len(active_today))
  651. inactive_buckets = [
  652. ("segment_at_risk_7d", len(at_risk_wau)),
  653. ("segment_at_risk_30d", len(at_risk_mau)),
  654. ("segment_dormant_30d", len(dormant_users)),
  655. ]
  656. def segment_card(label: str, value: int, denom: int, hint: str):
  657. pct = (value / denom) if denom > 0 else 0.0
  658. st.markdown(
  659. (
  660. "<div style='display:flex; align-items:baseline; gap:8px;'>"
  661. f"<span style='font-weight:700;'>{label}</span>"
  662. f"<span style='font-size:30px; font-weight:800;'>{value:,}</span>"
  663. "</div>"
  664. ),
  665. unsafe_allow_html=True,
  666. )
  667. st.progress(min(max(pct, 0.0), 1.0))
  668. st.caption(t("caption_of", pct=f"{pct:.1%}", denom=f"{denom:,}", hint=hint))
  669. def drilldown_table(user_ids: set, users_df: pd.DataFrame, limit: int = 50) -> pd.DataFrame:
  670. """Sorted user_ids (first `limit`), joined to users.email / users.phone."""
  671. ids = sorted(str(u) for u in user_ids)[:limit]
  672. if not ids:
  673. return pd.DataFrame(columns=["user_id", "email", "phone"])
  674. id_df = pd.DataFrame({"user_id": ids})
  675. u = users_df.copy()
  676. u["user_id"] = u["user_id"].astype(str)
  677. return id_df.merge(u[["user_id", "email", "phone"]], on="user_id", how="left")
  678. # KPI tiles (top row)
  679. #k1, k2, k3, k4, k5 = st.columns(5)
  680. #k1.metric("DAU", len(active_today))
  681. #k2.metric("New", len(new_users))
  682. #k3.metric("Current", len(current_users))
  683. #k4.metric("Reactivated", len(reactivated_users))
  684. #k5.metric("Resurrected", len(resurrected_users))
  685. #
  686. #k6, k7, k8 = st.columns(3)
  687. #k6.metric("At-risk WAU", len(at_risk_wau))
  688. #k7.metric("At-risk MAU", len(at_risk_mau))
  689. #k8.metric("Dormant", len(dormant_users))
  690. st.markdown("")
  691. left, right = st.columns(2, gap="large")
  692. with left:
  693. t1, t2 = st.columns([3, 1], vertical_alignment="center")
  694. with t1:
  695. st.markdown(f"### {t('active_today_composition')}")
  696. with t2:
  697. st.metric("DAU", len(active_today))
  698. st.caption(t("caption_active_comp"))
  699. for label_key, val in active_buckets:
  700. segment_card(t(label_key), val, active_total, t("hint_share_dau"))
  701. with right:
  702. t1, t2 = st.columns([2, 2], vertical_alignment="center")
  703. with t1:
  704. st.markdown(f"### {t('not_active_today')}")
  705. with t2:
  706. st.metric(t("metric_inactive_total"), f"{inactive_total:,} / {len(cohort_universe):,}")
  707. st.caption(t("caption_inactive_comp"))
  708. for label_key, val in inactive_buckets:
  709. segment_card(t(label_key), val, inactive_total, t("hint_share_inactive"))
  710. st.markdown("---")
  711. # ---- Drill down ----
  712. st.subheader(t("drilldown_title"))
  713. st.caption(t("drilldown_caption"))
  714. tab1, tab2, tab3 = st.tabs([t("tab_active_today"), t("tab_at_risk"), t("tab_dormant")])
  715. with tab1:
  716. st.dataframe(
  717. drilldown_table(active_today, users),
  718. use_container_width=True,
  719. hide_index=True,
  720. )
  721. with tab2:
  722. c_wau, c_mau = st.columns(2)
  723. with c_wau:
  724. st.markdown("**at_risk_wau**")
  725. st.dataframe(
  726. drilldown_table(at_risk_wau, users),
  727. use_container_width=True,
  728. hide_index=True,
  729. )
  730. with c_mau:
  731. st.markdown("**at_risk_mau**")
  732. st.dataframe(
  733. drilldown_table(at_risk_mau, users),
  734. use_container_width=True,
  735. hide_index=True,
  736. )
  737. with tab3:
  738. st.dataframe(
  739. drilldown_table(dormant_users, users),
  740. use_container_width=True,
  741. hide_index=True,
  742. )
  743. #st.caption(
  744. # "Notes: Dormant/Resurrected use prev_last_active_date computed from full user_daily_activity history "
  745. # "(MAX(activity_date) where active_flag=1 and activity_date < target_day). "
  746. # "If your cohort is very large, replace the IN (...) filter with a temp table join."
  747. #)