docs: add clear end-to-end README, update app.py and requirements, remove temp lock file
This commit is contained in:
1 parent
9710cf9948
commit
ef0c0ca258
4 files changed
+347
-22
No files matched your search
@@ -4,6 +4,7 @@ HIFFI Artist Intelligence - Executive Dashboard
|
||||
Clean dark-themed design with aligned dataframes, responsive cards, and scouting dossier.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import pandas as pd
|
||||
import plotly.express as px
|
||||
@@ -107,13 +108,37 @@ def load_data():
|
||||
"daily_view_velocity",
|
||||
"momentum_30d",
|
||||
"confidence_score",
|
||||
"hiffi_rank",
|
||||
"breakout_rank",
|
||||
]
|
||||
for col in numeric_cols:
|
||||
if col in df.columns:
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce")
|
||||
|
||||
# Fallback assignment for subgenre if not yet computed in pipeline
|
||||
if "subgenre" not in df.columns:
|
||||
def infer_subgenre(r):
|
||||
subs = r.get("subscriber_count", 0) or 0
|
||||
bvi = r.get("breakout_velocity_index", 0) or 0
|
||||
if bvi >= 70:
|
||||
return "Breakout / Emerging"
|
||||
elif subs >= 15000000:
|
||||
return "Legends"
|
||||
else:
|
||||
return "Trap & Street"
|
||||
df["subgenre"] = df.apply(infer_subgenre, axis=1)
|
||||
|
||||
return df
|
||||
|
||||
|
||||
def to_excel_bytes(dataframe: pd.DataFrame) -> bytes:
|
||||
"""Converts dataframe to downloadable in-memory Excel spreadsheet."""
|
||||
output = io.BytesIO()
|
||||
with pd.ExcelWriter(output, engine="openpyxl") as writer:
|
||||
dataframe.to_excel(writer, index=False, sheet_name="HIFFI_Cohort")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
df = load_data()
|
||||
|
||||
# ----------------- Sidebar Controls -----------------
|
||||
@@ -135,6 +160,17 @@ with st.sidebar:
|
||||
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
||||
st.markdown("##### 🛡️ Filters")
|
||||
|
||||
# Subgenre / Vertical Filter
|
||||
all_verticals = sorted(df["subgenre"].dropna().unique().tolist())
|
||||
target_presets = ["Legends", "Trap & Street", "UK Drill", "Lyricists", "Breakout / Emerging"]
|
||||
vertical_choices = [v for v in target_presets if v in all_verticals] + [v for v in all_verticals if v not in target_presets]
|
||||
|
||||
selected_verticals = st.multiselect(
|
||||
"Hip-Hop Vertical",
|
||||
options=vertical_choices,
|
||||
default=vertical_choices,
|
||||
)
|
||||
|
||||
available_statuses = df["health_status"].dropna().unique().tolist()
|
||||
selected_statuses = st.multiselect(
|
||||
"Channel Status",
|
||||
@@ -150,15 +186,41 @@ with st.sidebar:
|
||||
step=0.05,
|
||||
)
|
||||
|
||||
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
||||
st.caption("Momentum (35%) • Velocity (30%) • Reach (20%) • Cadence (15%)")
|
||||
|
||||
# Filter logic
|
||||
filtered_df = df[
|
||||
(df["health_status"].isin(selected_statuses))
|
||||
& (df["subgenre"].isin(selected_verticals))
|
||||
& (df["confidence_score"] >= min_conf)
|
||||
].copy()
|
||||
|
||||
# Sidebar Data Export Buttons (Filters applied)
|
||||
with st.sidebar:
|
||||
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
||||
st.markdown("##### 💾 Export Filtered Cohort")
|
||||
|
||||
col_csv, col_xlsx = st.columns(2)
|
||||
|
||||
csv_payload = filtered_df.to_csv(index=False).encode("utf-8")
|
||||
col_csv.download_button(
|
||||
label="📥 CSV",
|
||||
data=csv_payload,
|
||||
file_name="hiffi_filtered_cohort.csv",
|
||||
mime="text/csv",
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
excel_payload = to_excel_bytes(filtered_df)
|
||||
col_xlsx.download_button(
|
||||
label="📊 Excel",
|
||||
data=excel_payload,
|
||||
file_name="hiffi_filtered_cohort.xlsx",
|
||||
mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
st.markdown("<div class='custom-divider'></div>", unsafe_allow_html=True)
|
||||
st.caption("Momentum (35%) • Velocity (30%) • Reach (20%) • Cadence (15%)")
|
||||
|
||||
# ----------------- Main Header -----------------
|
||||
head_col1, head_col2 = st.columns([3, 1])
|
||||
with head_col1:
|
||||
@@ -218,7 +280,7 @@ with k4:
|
||||
<div class="kpi-box">
|
||||
<div class="kpi-title" style="color: #ef4444;">Dormant / Excluded</div>
|
||||
<div class="kpi-value" style="color: #ef4444;">{zombies}</div>
|
||||
<div class="kpi-subtitle">Disqualified (<NA> Rank)</div>
|
||||
<div class="kpi-subtitle">Disqualified (<NA> Rank)</div>
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
@@ -243,14 +305,14 @@ if len(top3) >= 3:
|
||||
(p3, top3.iloc[2], "🥉 RANK #3", "podium-bronze", "#f97316"),
|
||||
]
|
||||
|
||||
for col, row, rank_label, css_class, accent_color in podium_data:
|
||||
for col, row_data, rank_label, css_class, accent_color in podium_data:
|
||||
badge_cls = (
|
||||
"badge-active"
|
||||
if row["health_status"] == "ACTIVE_HEALTHY"
|
||||
if row_data["health_status"] == "ACTIVE_HEALTHY"
|
||||
else "badge-evergreen"
|
||||
)
|
||||
bvi_str = f"{row['breakout_velocity_index']:.1f}" if pd.notna(row.get("breakout_velocity_index")) else "N/A"
|
||||
vel_str = f"{int(row['daily_view_velocity']):,}" if pd.notna(row.get("daily_view_velocity")) else "N/A"
|
||||
bvi_str = f"{row_data['breakout_velocity_index']:.1f}" if pd.notna(row_data.get("breakout_velocity_index")) else "N/A"
|
||||
vel_str = f"{int(row_data['daily_view_velocity']):,}" if pd.notna(row_data.get("daily_view_velocity")) else "N/A"
|
||||
|
||||
with col:
|
||||
st.markdown(
|
||||
@@ -258,14 +320,14 @@ if len(top3) >= 3:
|
||||
<div class="podium-tile {css_class}">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||
<span style="font-weight: 800; color: {accent_color}; font-size: 0.85rem;">{rank_label}</span>
|
||||
<span class="badge {badge_cls}">{row['health_status'].replace('_', ' ')}</span>
|
||||
<span class="badge {badge_cls}">{row_data['health_status'].replace('_', ' ')}</span>
|
||||
</div>
|
||||
<div style="font-size: 1.35rem; font-weight: 800; color: #ffffff; margin-bottom: 2px;">{row['name']}</div>
|
||||
<div style="font-size: 0.8rem; color: #6b7280; margin-bottom: 12px;">{row.get('channel_title', '')}</div>
|
||||
<div style="font-size: 1.35rem; font-weight: 800; color: #ffffff; margin-bottom: 2px;">{row_data['name']}</div>
|
||||
<div style="font-size: 0.8rem; color: #6b7280; margin-bottom: 12px;">{row_data.get('channel_title', '')}</div>
|
||||
<div style="display: flex; justify-content: space-between; background: #0b0f17; padding: 10px 14px; border-radius: 8px;">
|
||||
<div>
|
||||
<div style="font-size: 0.68rem; color: #9ca3af; text-transform: uppercase;">HIFFI Score</div>
|
||||
<div style="font-size: 1.25rem; font-weight: 800; color: {accent_color};">{row['hiffi_score']:.1f}</div>
|
||||
<div style="font-size: 1.25rem; font-weight: 800; color: {accent_color};">{row_data['hiffi_score']:.1f}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.68rem; color: #9ca3af; text-transform: uppercase;">Breakout Index</div>
|
||||
@@ -295,6 +357,7 @@ if view_mode == "🏆 Overall HIFFI Rank":
|
||||
cols = [
|
||||
"hiffi_rank",
|
||||
"name",
|
||||
"subgenre",
|
||||
"hiffi_score",
|
||||
"breakout_rank",
|
||||
"breakout_velocity_index",
|
||||
@@ -314,6 +377,7 @@ elif view_mode == "🚀 Breakout Velocity (BVI)":
|
||||
cols = [
|
||||
"breakout_rank",
|
||||
"name",
|
||||
"subgenre",
|
||||
"breakout_velocity_index",
|
||||
"hiffi_rank",
|
||||
"hiffi_score",
|
||||
@@ -325,6 +389,7 @@ else:
|
||||
table_df = filtered_df.sort_values(by=["is_rank_eligible", "hiffi_score"], ascending=[False, False]).copy()
|
||||
cols = [
|
||||
"name",
|
||||
"subgenre",
|
||||
"hiffi_score",
|
||||
"breakout_velocity_index",
|
||||
"daily_view_velocity",
|
||||
@@ -334,14 +399,18 @@ else:
|
||||
"is_rank_eligible",
|
||||
]
|
||||
|
||||
# Keep only columns that exist in DataFrame to prevent KeyError
|
||||
render_cols = [c for c in cols if c in table_df.columns]
|
||||
|
||||
st.dataframe(
|
||||
table_df[cols].reset_index(drop=True),
|
||||
table_df[render_cols].reset_index(drop=True),
|
||||
use_container_width=True,
|
||||
height=290,
|
||||
column_config={
|
||||
"hiffi_rank": st.column_config.NumberColumn("Rank", format="%d", width="small"),
|
||||
"breakout_rank": st.column_config.NumberColumn("BVI Rank", format="%d", width="small"),
|
||||
"name": st.column_config.TextColumn("Artist", width="medium"),
|
||||
"subgenre": st.column_config.TextColumn("Vertical", width="medium"),
|
||||
"hiffi_score": st.column_config.ProgressColumn(
|
||||
"HIFFI Score",
|
||||
format="%.1f",
|
||||
@@ -451,6 +520,18 @@ st.markdown("#### 🔎 Artist Dossier")
|
||||
selected_name = st.selectbox("Inspect Artist Details:", df["name"].unique(), label_visibility="collapsed")
|
||||
row = df[df["name"] == selected_name].iloc[0]
|
||||
|
||||
# Resolve YouTube channel direct link
|
||||
yt_url = str(row.get("youtube_url", "")).strip()
|
||||
yt_id = str(row.get("channel_id", row.get("youtube_channel_id", ""))).strip()
|
||||
|
||||
if (not yt_url or yt_url == "nan") and yt_id and yt_id != "nan":
|
||||
if yt_id.startswith("UC"):
|
||||
yt_url = f"https://www.youtube.com/channel/{yt_id}"
|
||||
elif yt_id.startswith("@"):
|
||||
yt_url = f"https://www.youtube.com/{yt_id}"
|
||||
else:
|
||||
yt_url = f"https://www.youtube.com/@{yt_id}"
|
||||
|
||||
d1, d2, d3, d4 = st.columns(4)
|
||||
|
||||
with d1:
|
||||
@@ -475,15 +556,25 @@ badge_cls = (
|
||||
else "badge-dormant"
|
||||
)
|
||||
|
||||
# Dossier info box with integrated Official YouTube Button
|
||||
st.markdown(
|
||||
f"""
|
||||
<div style="background: #111827; padding: 12px 16px; border-radius: 8px; border: 1px solid #1f2937; margin-top: 10px; display: flex; gap: 24px; align-items: center; font-size: 0.85rem;">
|
||||
<div><b>Artist:</b> {row['name']}</div>
|
||||
<div><b>Channel:</b> {row.get('channel_title', 'Unknown')}</div>
|
||||
<div><b>Status:</b> <span class="badge {badge_cls}">{row.get('health_status', 'UNKNOWN')}</span></div>
|
||||
<div><b>Zombie Flag:</b> {'⚠️ Yes' if row.get('is_zombie') else '✅ Clean'}</div>
|
||||
<div><b>Eligible for Rank:</b> {'✅ Yes' if row.get('is_rank_eligible') else '❌ Disqualified'}</div>
|
||||
<div style="background: #111827; padding: 14px 18px; border-radius: 8px; border: 1px solid #1f2937; margin-top: 10px; display: flex; justify-content: space-between; align-items: center; font-size: 0.85rem;">
|
||||
<div style="display: flex; gap: 24px; align-items: center;">
|
||||
<div><b>Artist:</b> {row['name']}</div>
|
||||
<div><b>Vertical:</b> <span style="color: #a855f7;">{row.get('subgenre', 'N/A')}</span></div>
|
||||
<div><b>Channel:</b> {row.get('channel_title', 'Unknown')}</div>
|
||||
<div><b>Status:</b> <span class="badge {badge_cls}">{row.get('health_status', 'UNKNOWN')}</span></div>
|
||||
<div><b>Zombie Flag:</b> {'⚠️ Yes' if row.get('is_zombie') else '✅ Clean'}</div>
|
||||
<div><b>Eligible for Rank:</b> {'✅ Yes' if row.get('is_rank_eligible') else '❌ Disqualified'}</div>
|
||||
</div>
|
||||
</div>
|
||||
""",
|
||||
unsafe_allow_html=True,
|
||||
)
|
||||
)
|
||||
|
||||
st.write("")
|
||||
if yt_url and yt_url != "nan":
|
||||
st.link_button("▶ Open Official YouTube Channel", yt_url, use_container_width=False)
|
||||
else:
|
||||
st.caption("⚠️ No verified YouTube channel link available for this artist.")
|
||||
Reference in new issue
Block a user