Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -112,6 +112,54 @@ def plot_radar(df: pd.DataFrame, grouped: bool, title: str, avg_label: str = Non
|
|
| 112 |
margin=dict(l=30, r=30, t=60, b=30),
|
| 113 |
)
|
| 114 |
return fig
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
# ------------------- Mongo -------------------
|
| 116 |
def _get_secret(name: str) -> str | None:
|
| 117 |
try:
|
|
@@ -343,6 +391,120 @@ if not df_final.empty and source_choice == "(All)":
|
|
| 343 |
# ------------------- Output -------------------
|
| 344 |
# fig = plot_radar(df_final, grouped, chart_title)
|
| 345 |
# st.plotly_chart(fig, use_container_width=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
df_plot = df_final.copy()
|
| 347 |
avg_label = None
|
| 348 |
|
|
@@ -662,6 +824,36 @@ with tab_analyses:
|
|
| 662 |
if idx:
|
| 663 |
st.caption("Available analyses:")
|
| 664 |
st.write(", ".join(sorted({name.title() for name in idx.keys()})))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
|
| 666 |
# # app.py — Student Skill Radar (MongoDB, secrets-based, no CSV)
|
| 667 |
# import os
|
|
|
|
| 112 |
margin=dict(l=30, r=30, t=60, b=30),
|
| 113 |
)
|
| 114 |
return fig
|
| 115 |
+
|
| 116 |
+
def _vector_from_row(row: pd.Series, cols: list[str]) -> dict:
|
| 117 |
+
return {k: (None if pd.isna(row.get(k)) else float(row.get(k))) for k in cols}
|
| 118 |
+
|
| 119 |
+
def _percent_change(new: float | None, old: float | None) -> float | None:
|
| 120 |
+
if new is None or old is None:
|
| 121 |
+
return None
|
| 122 |
+
if old == 0:
|
| 123 |
+
return None # avoid div-by-zero; you can choose to show 100% if new>0
|
| 124 |
+
return (new - old) / old * 100.0
|
| 125 |
+
|
| 126 |
+
def _merge_resp_and_likert_vector(resp_vec: dict, likert_grouped_vec: dict | None, grouped: bool, SKILL_TO_GROUPS: dict[str, list[str]], SKILL_GROUPS: dict[str, list[str]]) -> dict:
|
| 127 |
+
"""
|
| 128 |
+
Returns a merged vector:
|
| 129 |
+
- If grouped: keys are group labels
|
| 130 |
+
- If ungrouped: keys are per-skill; Likert (group) is projected to skills by averaging groups a skill belongs to
|
| 131 |
+
"""
|
| 132 |
+
if likert_grouped_vec is None:
|
| 133 |
+
return resp_vec
|
| 134 |
+
|
| 135 |
+
if grouped:
|
| 136 |
+
out = {}
|
| 137 |
+
for g in SKILL_GROUPS.keys():
|
| 138 |
+
rv = resp_vec.get(g, None)
|
| 139 |
+
lv = likert_grouped_vec.get(g, None)
|
| 140 |
+
if rv is not None and lv is not None:
|
| 141 |
+
out[g] = (rv + lv) / 2.0
|
| 142 |
+
elif rv is not None:
|
| 143 |
+
out[g] = rv
|
| 144 |
+
else:
|
| 145 |
+
out[g] = lv
|
| 146 |
+
return out
|
| 147 |
+
else:
|
| 148 |
+
# project group likert to each skill
|
| 149 |
+
out = {}
|
| 150 |
+
for s in resp_vec.keys():
|
| 151 |
+
rv = resp_vec.get(s, None)
|
| 152 |
+
groups = SKILL_TO_GROUPS.get(s, [])
|
| 153 |
+
lik_vals = [likert_grouped_vec.get(g) for g in groups if likert_grouped_vec.get(g) is not None]
|
| 154 |
+
lv = float(np.mean(lik_vals)) if lik_vals else None
|
| 155 |
+
if rv is not None and lv is not None:
|
| 156 |
+
out[s] = (rv + lv) / 2.0
|
| 157 |
+
elif rv is not None:
|
| 158 |
+
out[s] = rv
|
| 159 |
+
else:
|
| 160 |
+
out[s] = lv
|
| 161 |
+
return out
|
| 162 |
+
|
| 163 |
# ------------------- Mongo -------------------
|
| 164 |
def _get_secret(name: str) -> str | None:
|
| 165 |
try:
|
|
|
|
| 391 |
# ------------------- Output -------------------
|
| 392 |
# fig = plot_radar(df_final, grouped, chart_title)
|
| 393 |
# st.plotly_chart(fig, use_container_width=True)
|
| 394 |
+
# ============== Build per-stage vectors for comparisons ==============
|
| 395 |
+
# Columns to use based on mode
|
| 396 |
+
COLS = list(SKILL_GROUPS.keys()) if grouped else SKILLS
|
| 397 |
+
|
| 398 |
+
# Helper to extract the mean vector for (student, source) from df_resp/df_final
|
| 399 |
+
def _mean_vector_for(student: str | None, source: str | None, use_merged: bool) -> dict:
|
| 400 |
+
"""
|
| 401 |
+
use_merged=True -> read from df_final (after Likert merge)
|
| 402 |
+
use_merged=False -> read from df_resp (responses-only)
|
| 403 |
+
"""
|
| 404 |
+
df_base = df_final if use_merged else df_resp
|
| 405 |
+
if df_base.empty:
|
| 406 |
+
return {k: None for k in COLS}
|
| 407 |
+
|
| 408 |
+
if student and source:
|
| 409 |
+
label = f"{student} — {source}"
|
| 410 |
+
sub = df_base[df_base["label"] == label]
|
| 411 |
+
elif student and source is None:
|
| 412 |
+
# combined sources row (when overlay OFF)
|
| 413 |
+
sub = df_base[df_base["label"] == student]
|
| 414 |
+
else:
|
| 415 |
+
# cohort average across all rows in df_base
|
| 416 |
+
sub = df_base
|
| 417 |
+
|
| 418 |
+
if sub.empty:
|
| 419 |
+
return {k: None for k in COLS}
|
| 420 |
+
means = sub[COLS].mean(numeric_only=True)
|
| 421 |
+
return {k: (None if pd.isna(means.get(k)) else float(means.get(k))) for k in COLS}
|
| 422 |
+
|
| 423 |
+
# Build mapping skill->groups (you already used this in the Likert merge)
|
| 424 |
+
SKILL_TO_GROUPS = {s: [g for g, members in SKILL_GROUPS.items() if s in members] for s in SKILLS}
|
| 425 |
+
|
| 426 |
+
def _likert_grouped_for(student: str, stage: str) -> dict | None:
|
| 427 |
+
if stage not in ("onboarding", "closing"):
|
| 428 |
+
return None
|
| 429 |
+
lg = mongo_get_likert_grouped(mongo_uri, db_name, summaries_coll, student, stage)
|
| 430 |
+
return lg if lg else None
|
| 431 |
+
|
| 432 |
+
def _stage_vector(student: str | None, stage: str) -> dict:
|
| 433 |
+
# Which sources make up this stage?
|
| 434 |
+
if stage == "onboarding":
|
| 435 |
+
srcs = ["onboarding_responses"]
|
| 436 |
+
elif stage == "closing":
|
| 437 |
+
srcs = ["closing_responses"]
|
| 438 |
+
elif stage == "combined_weeks":
|
| 439 |
+
srcs = ["week_2_responses", "week_3_responses", "closing_responses"]
|
| 440 |
+
else:
|
| 441 |
+
srcs = []
|
| 442 |
+
|
| 443 |
+
# Response-only mean across those sources
|
| 444 |
+
if not df_resp.empty:
|
| 445 |
+
if student and source_choice == "(All)":
|
| 446 |
+
# we may have aggregated to one row per student; compute from df_raw instead
|
| 447 |
+
# build per-source labels then average
|
| 448 |
+
rows = []
|
| 449 |
+
for s in srcs:
|
| 450 |
+
lbl = f"{student} — {s}"
|
| 451 |
+
sub = df_resp[df_resp["label"] == lbl]
|
| 452 |
+
if not sub.empty:
|
| 453 |
+
rows.append(sub[COLS].mean(numeric_only=True))
|
| 454 |
+
if rows:
|
| 455 |
+
m = pd.concat(rows, axis=1).mean(axis=1)
|
| 456 |
+
resp_vec = {k: (None if pd.isna(m.get(k)) else float(m.get(k))) for k in COLS}
|
| 457 |
+
else:
|
| 458 |
+
resp_vec = {k: None for k in COLS}
|
| 459 |
+
elif student and source_choice != "(All)":
|
| 460 |
+
# if the UI is filtered to a specific source, ignore that and recompute from df_resp
|
| 461 |
+
rows = []
|
| 462 |
+
for s in srcs:
|
| 463 |
+
lbl = f"{student} — {s}"
|
| 464 |
+
sub = df_resp[df_resp["label"] == lbl]
|
| 465 |
+
if not sub.empty:
|
| 466 |
+
rows.append(sub[COLS].mean(numeric_only=True))
|
| 467 |
+
if rows:
|
| 468 |
+
m = pd.concat(rows, axis=1).mean(axis=1)
|
| 469 |
+
resp_vec = {k: (None if pd.isna(m.get(k)) else float(m.get(k))) for k in COLS}
|
| 470 |
+
else:
|
| 471 |
+
resp_vec = {k: None for k in COLS}
|
| 472 |
+
else:
|
| 473 |
+
# cohort: average across all matching sources
|
| 474 |
+
sub = df_resp[df_resp["label"].str.contains(" — ", na=False)]
|
| 475 |
+
sub = sub[sub["label"].str.split(" — ").str[1].isin(srcs)]
|
| 476 |
+
if not sub.empty:
|
| 477 |
+
m = sub[COLS].mean(numeric_only=True)
|
| 478 |
+
resp_vec = {k: (None if pd.isna(m.get(k)) else float(m.get(k))) for k in COLS}
|
| 479 |
+
else:
|
| 480 |
+
resp_vec = {k: None for k in COLS}
|
| 481 |
+
else:
|
| 482 |
+
resp_vec = {k: None for k in COLS}
|
| 483 |
+
|
| 484 |
+
# Merge in Likert for onboarding/closing (projected to skills if ungrouped)
|
| 485 |
+
if student:
|
| 486 |
+
likert_g = _likert_grouped_for(student, "onboarding" if "onboarding_responses" in srcs else ("closing" if "closing_responses" in srcs and len(srcs)==1 else None))
|
| 487 |
+
else:
|
| 488 |
+
likert_g = None # no cohort Likert
|
| 489 |
+
|
| 490 |
+
merged = _merge_resp_and_likert_vector(resp_vec, likert_g, grouped, SKILL_TO_GROUPS, SKILL_GROUPS)
|
| 491 |
+
return merged
|
| 492 |
+
|
| 493 |
+
# Build the vectors we need
|
| 494 |
+
if student_choice != "(All)":
|
| 495 |
+
vec_onb = _stage_vector(student_choice, "onboarding")
|
| 496 |
+
vec_cls = _stage_vector(student_choice, "closing")
|
| 497 |
+
vec_combo = _stage_vector(student_choice, "combined_weeks")
|
| 498 |
+
else:
|
| 499 |
+
# Cohort-wide comparison
|
| 500 |
+
vec_onb = _stage_vector(None, "onboarding")
|
| 501 |
+
vec_cls = _stage_vector(None, "closing")
|
| 502 |
+
vec_combo = _stage_vector(None, "combined_weeks")
|
| 503 |
+
|
| 504 |
+
# Compute % deltas
|
| 505 |
+
pct_onb_to_cls = {k: _percent_change(vec_cls.get(k), vec_onb.get(k)) for k in COLS}
|
| 506 |
+
pct_onb_to_combo = {k: _percent_change(vec_combo.get(k), vec_onb.get(k)) for k in COLS}
|
| 507 |
+
|
| 508 |
df_plot = df_final.copy()
|
| 509 |
avg_label = None
|
| 510 |
|
|
|
|
| 824 |
if idx:
|
| 825 |
st.caption("Available analyses:")
|
| 826 |
st.write(", ".join(sorted({name.title() for name in idx.keys()})))
|
| 827 |
+
tab_compare, = st.tabs(["📊 Comparisons"])
|
| 828 |
+
|
| 829 |
+
with tab_compare:
|
| 830 |
+
st.subheader("Onboarding vs Closing — % Change")
|
| 831 |
+
df1 = pd.DataFrame({
|
| 832 |
+
"Dimension": COLS,
|
| 833 |
+
"Onboarding": [vec_onb.get(k) for k in COLS],
|
| 834 |
+
"Closing": [vec_cls.get(k) for k in COLS],
|
| 835 |
+
"% Change": [pct_onb_to_cls.get(k) for k in COLS],
|
| 836 |
+
})
|
| 837 |
+
st.dataframe(df1.style.format({"Onboarding": "{:.2f}", "Closing": "{:.2f}", "% Change": "{:+.1f}%"}), use_container_width=True)
|
| 838 |
+
|
| 839 |
+
st.subheader("Onboarding vs (Week2+Week3+Closing) — % Change")
|
| 840 |
+
df2 = pd.DataFrame({
|
| 841 |
+
"Dimension": COLS,
|
| 842 |
+
"Onboarding": [vec_onb.get(k) for k in COLS],
|
| 843 |
+
"Weeks 2+3+Closing (combined)": [vec_combo.get(k) for k in COLS],
|
| 844 |
+
"% Change": [pct_onb_to_combo.get(k) for k in COLS],
|
| 845 |
+
})
|
| 846 |
+
st.dataframe(df2.style.format({"Onboarding": "{:.2f}", "Weeks 2+3+Closing (combined)": "{:.2f}", "% Change": "{:+.1f}%"}), use_container_width=True)
|
| 847 |
+
|
| 848 |
+
# Optional bar chart: % change Onboarding -> Closing
|
| 849 |
+
try:
|
| 850 |
+
fig_delta = go.Figure()
|
| 851 |
+
fig_delta.add_bar(x=COLS, y=[pct_onb_to_cls.get(k) if pct_onb_to_cls.get(k) is not None else 0 for k in COLS], name="%Δ Onb→Closing")
|
| 852 |
+
fig_delta.update_layout(title="% Change: Onboarding → Closing", xaxis_title="Dimension", yaxis_title="% change", margin=dict(l=20, r=20, t=50, b=20))
|
| 853 |
+
st.plotly_chart(fig_delta, use_container_width=True)
|
| 854 |
+
except Exception:
|
| 855 |
+
pass
|
| 856 |
+
|
| 857 |
|
| 858 |
# # app.py — Student Skill Radar (MongoDB, secrets-based, no CSV)
|
| 859 |
# import os
|