isabelle0629 commited on
Commit
5e05b80
·
verified ·
1 Parent(s): 0efee55

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +50 -12
  2. app.py +380 -0
  3. requirements.txt +6 -0
README.md CHANGED
@@ -1,12 +1,50 @@
1
- ---
2
- title: StreamSmartRecommender
3
- emoji: 🌖
4
- colorFrom: red
5
- colorTo: purple
6
- sdk: gradio
7
- sdk_version: 6.13.0
8
- app_file: app.py
9
- pinned: false
10
- ---
11
-
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # StreamSmart Recommender - Hugging Face Space
2
+
3
+ Files included:
4
+ - `app.py` - main Gradio app for Hugging Face Space
5
+ - `requirements.txt` - Python dependencies
6
+ - `n8n_streamsmart_workflow.json` - importable n8n workflow
7
+
8
+ ## 1) Hugging Face setup
9
+ Create a new **Gradio Space** and replace the default files with:
10
+ - `app.py`
11
+ - `requirements.txt`
12
+
13
+ Optional secret:
14
+ - `N8N_WEBHOOK_URL` = your production n8n webhook URL
15
+
16
+ ## 2) CSV formats
17
+ ### Reviews CSV required columns
18
+ - `title`
19
+ - `review_text`
20
+
21
+ Optional:
22
+ - `genre`
23
+ - `rating`
24
+ - `user_segment`
25
+
26
+ ### Watch-time CSV required columns
27
+ - `title`
28
+ - `genre`
29
+ - `avg_watch_time`
30
+ - `completion_rate`
31
+ - `drop_off_rate`
32
+ - `rewatch_rate`
33
+ - `click_through_rate`
34
+
35
+ Rates should be decimals such as `0.82`, not percentages like `82%`.
36
+
37
+ ## 3) n8n setup
38
+ In n8n:
39
+ 1. Import `n8n_streamsmart_workflow.json`
40
+ 2. Open the Webhook node and copy the **Production URL**
41
+ 3. In Hugging Face Space settings, add a secret named `N8N_WEBHOOK_URL`
42
+ 4. Rebuild the Space
43
+ 5. Run the analysis in the app, then click `Send Top Recommendations to n8n`
44
+
45
+ ## 4) What the app does
46
+ - scores review sentiment with VADER
47
+ - merges title-level sentiment with watch metrics
48
+ - computes a weighted recommendation score
49
+ - labels each title with a business action
50
+ - sends the scored results to one n8n workflow
app.py ADDED
@@ -0,0 +1,380 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import json
4
+ from typing import Optional, Tuple
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ import gradio as gr
9
+ import plotly.express as px
10
+ import requests
11
+ from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
12
+
13
+ APP_TITLE = "StreamSmart Recommender"
14
+ APP_SUBTITLE = (
15
+ "Improve streaming recommendations by combining viewer review sentiment "
16
+ "with watch-time and engagement metrics."
17
+ )
18
+
19
+ analyzer = SentimentIntensityAnalyzer()
20
+
21
+ REQUIRED_REVIEW_COLS = ["title", "review_text"]
22
+ REQUIRED_WATCH_COLS = [
23
+ "title",
24
+ "genre",
25
+ "avg_watch_time",
26
+ "completion_rate",
27
+ "drop_off_rate",
28
+ "rewatch_rate",
29
+ "click_through_rate",
30
+ ]
31
+
32
+
33
+ def clean_text(text: str) -> str:
34
+ if pd.isna(text):
35
+ return ""
36
+ text = str(text).strip().replace("\n", " ")
37
+ return " ".join(text.split())
38
+
39
+
40
+ def compute_sentiment(text: str) -> float:
41
+ return analyzer.polarity_scores(clean_text(text))["compound"]
42
+
43
+
44
+ def minmax(series: pd.Series) -> pd.Series:
45
+ series = pd.to_numeric(series, errors="coerce").fillna(0)
46
+ min_v = series.min()
47
+ max_v = series.max()
48
+ if max_v == min_v:
49
+ return pd.Series(np.full(len(series), 0.5), index=series.index)
50
+ return (series - min_v) / (max_v - min_v)
51
+
52
+
53
+ def sentiment_label(score: float) -> str:
54
+ if score >= 0.2:
55
+ return "Positive"
56
+ if score <= -0.2:
57
+ return "Negative"
58
+ return "Neutral"
59
+
60
+
61
+ def action_label(score: float) -> str:
62
+ if score >= 80:
63
+ return "Promote strongly"
64
+ if score >= 65:
65
+ return "Promote selectively"
66
+ if score >= 45:
67
+ return "Investigate mismatch"
68
+ return "Reduce priority"
69
+
70
+
71
+ def business_explanation(row: pd.Series) -> str:
72
+ s = row["avg_sentiment"]
73
+ c = row["completion_rate"]
74
+ d = row["drop_off_rate"]
75
+ score = row["recommendation_score"]
76
+
77
+ if s >= 0.2 and c >= 0.7 and d <= 0.3:
78
+ return (
79
+ f"{row['title']} has strong viewer satisfaction and high completion, so it is a good candidate "
80
+ "for broader recommendation placement."
81
+ )
82
+ if s >= 0.2 and c < 0.7:
83
+ return (
84
+ f"{row['title']} gets positive reactions from viewers who engage with it, but completion is weaker. "
85
+ "This suggests the title may perform better with more targeted audience matching."
86
+ )
87
+ if s < 0.2 and c >= 0.7:
88
+ return (
89
+ f"{row['title']} keeps viewers watching, but sentiment is not especially strong. This may indicate "
90
+ "good initial appeal with weaker perceived quality or expectation mismatch."
91
+ )
92
+ if score < 45:
93
+ return (
94
+ f"{row['title']} shows weak satisfaction and engagement signals overall, so it should not be prioritized "
95
+ "in recommendation slots until content positioning improves."
96
+ )
97
+ return (
98
+ f"{row['title']} is a mixed case: some engagement indicators are promising, but the platform should review "
99
+ "audience fit, metadata, or recommendation placement before scaling promotion."
100
+ )
101
+
102
+
103
+ def validate_columns(df: pd.DataFrame, required_cols: list, name: str) -> None:
104
+ missing = [c for c in required_cols if c not in df.columns]
105
+ if missing:
106
+ raise gr.Error(f"{name} is missing required columns: {missing}")
107
+
108
+
109
+ def make_demo_data() -> Tuple[pd.DataFrame, pd.DataFrame]:
110
+ reviews = pd.DataFrame(
111
+ {
112
+ "title": [
113
+ "Midnight City", "Midnight City", "Ocean Echoes", "Ocean Echoes",
114
+ "Crimson Truth", "Crimson Truth", "Quiet Orbit", "Quiet Orbit",
115
+ "Laugh Track", "Laugh Track", "Golden Hour", "Golden Hour",
116
+ ],
117
+ "review_text": [
118
+ "Amazing pacing and really addictive storyline.",
119
+ "Loved the characters and watched it in one sitting.",
120
+ "Beautiful idea but too slow in the middle.",
121
+ "Strong visuals, but I almost stopped halfway.",
122
+ "Suspenseful and smart, one of the best thrillers.",
123
+ "Great acting and excellent ending.",
124
+ "Interesting concept but not very engaging.",
125
+ "Felt too long and the story did not pull me in.",
126
+ "Funny and light, easy to keep watching.",
127
+ "Very entertaining and rewatchable.",
128
+ "Good cast but the episodes drag a bit.",
129
+ "Not bad, but I expected more excitement.",
130
+ ],
131
+ "genre": [
132
+ "Sci-Fi", "Sci-Fi", "Drama", "Drama", "Thriller", "Thriller",
133
+ "Sci-Fi", "Sci-Fi", "Comedy", "Comedy", "Drama", "Drama",
134
+ ],
135
+ }
136
+ )
137
+
138
+ watch = pd.DataFrame(
139
+ {
140
+ "title": ["Midnight City", "Ocean Echoes", "Crimson Truth", "Quiet Orbit", "Laugh Track", "Golden Hour"],
141
+ "genre": ["Sci-Fi", "Drama", "Thriller", "Sci-Fi", "Comedy", "Drama"],
142
+ "avg_watch_time": [83, 58, 79, 41, 72, 54],
143
+ "completion_rate": [0.86, 0.61, 0.81, 0.39, 0.76, 0.57],
144
+ "drop_off_rate": [0.18, 0.33, 0.21, 0.48, 0.24, 0.37],
145
+ "rewatch_rate": [0.31, 0.15, 0.27, 0.08, 0.25, 0.11],
146
+ "click_through_rate": [0.42, 0.36, 0.39, 0.29, 0.41, 0.34],
147
+ }
148
+ )
149
+ return reviews, watch
150
+
151
+
152
+ def run_analysis(reviews_file, watch_file, use_demo: bool):
153
+ if use_demo:
154
+ reviews_df, watch_df = make_demo_data()
155
+ else:
156
+ if reviews_file is None or watch_file is None:
157
+ raise gr.Error("Upload both CSV files or use the demo dataset.")
158
+ reviews_df = pd.read_csv(reviews_file.name)
159
+ watch_df = pd.read_csv(watch_file.name)
160
+
161
+ validate_columns(reviews_df, REQUIRED_REVIEW_COLS, "Reviews CSV")
162
+ validate_columns(watch_df, REQUIRED_WATCH_COLS, "Watch-time CSV")
163
+
164
+ reviews = reviews_df.copy()
165
+ watch = watch_df.copy()
166
+
167
+ reviews["review_text"] = reviews["review_text"].apply(clean_text)
168
+ reviews["sentiment_score"] = reviews["review_text"].apply(compute_sentiment)
169
+ reviews["sentiment_label"] = reviews["sentiment_score"].apply(sentiment_label)
170
+
171
+ agg_dict = {
172
+ "sentiment_score": ["mean", "count"],
173
+ }
174
+ if "genre" in reviews.columns:
175
+ review_agg = reviews.groupby("title", as_index=False).agg(
176
+ avg_sentiment=("sentiment_score", "mean"),
177
+ review_count=("sentiment_score", "count"),
178
+ dominant_genre=("genre", lambda s: s.mode().iat[0] if not s.mode().empty else s.iloc[0]),
179
+ )
180
+ else:
181
+ review_agg = reviews.groupby("title", as_index=False).agg(
182
+ avg_sentiment=("sentiment_score", "mean"),
183
+ review_count=("sentiment_score", "count"),
184
+ )
185
+ review_agg["dominant_genre"] = "Unknown"
186
+
187
+ merged = pd.merge(watch, review_agg, on="title", how="left")
188
+ merged["avg_sentiment"] = merged["avg_sentiment"].fillna(0)
189
+ merged["review_count"] = merged["review_count"].fillna(0).astype(int)
190
+ merged["genre"] = merged["genre"].fillna(merged["dominant_genre"]).fillna("Unknown")
191
+
192
+ merged["sentiment_norm"] = minmax(merged["avg_sentiment"])
193
+ merged["completion_norm"] = minmax(merged["completion_rate"])
194
+ merged["watch_norm"] = minmax(merged["avg_watch_time"])
195
+ merged["rewatch_norm"] = minmax(merged["rewatch_rate"])
196
+ merged["ctr_norm"] = minmax(merged["click_through_rate"])
197
+ merged["dropoff_norm"] = minmax(merged["drop_off_rate"])
198
+
199
+ raw_score = (
200
+ 0.35 * merged["sentiment_norm"]
201
+ + 0.30 * merged["completion_norm"]
202
+ + 0.20 * merged["watch_norm"]
203
+ + 0.10 * merged["rewatch_norm"]
204
+ + 0.05 * merged["ctr_norm"]
205
+ - 0.15 * merged["dropoff_norm"]
206
+ )
207
+
208
+ merged["recommendation_score"] = (raw_score.clip(lower=0) * 100).round(2)
209
+ merged["action"] = merged["recommendation_score"].apply(action_label)
210
+ merged["explanation"] = merged.apply(business_explanation, axis=1)
211
+ merged = merged.sort_values("recommendation_score", ascending=False).reset_index(drop=True)
212
+
213
+ summary = (
214
+ f"Reviews analyzed: {len(reviews)} | Titles scored: {merged['title'].nunique()} | "
215
+ f"Average sentiment: {merged['avg_sentiment'].mean():.2f} | "
216
+ f"Average completion rate: {merged['completion_rate'].mean():.2f}"
217
+ )
218
+
219
+ top_table = merged[
220
+ [
221
+ "title", "genre", "avg_sentiment", "avg_watch_time", "completion_rate",
222
+ "drop_off_rate", "rewatch_rate", "click_through_rate", "review_count",
223
+ "recommendation_score", "action"
224
+ ]
225
+ ]
226
+
227
+ top_plot = px.bar(
228
+ merged.head(10),
229
+ x="title",
230
+ y="recommendation_score",
231
+ title="Top Titles by Recommendation Score",
232
+ )
233
+ scatter_plot = px.scatter(
234
+ merged,
235
+ x="avg_sentiment",
236
+ y="completion_rate",
237
+ size="avg_watch_time",
238
+ hover_name="title",
239
+ color="genre",
240
+ title="Sentiment vs Completion Rate",
241
+ )
242
+ genre_plot = px.bar(
243
+ merged.groupby("genre", as_index=False)["recommendation_score"].mean().sort_values("recommendation_score", ascending=False),
244
+ x="genre",
245
+ y="recommendation_score",
246
+ title="Average Recommendation Score by Genre",
247
+ )
248
+
249
+ processed_csv = io.StringIO()
250
+ top_table.to_csv(processed_csv, index=False)
251
+
252
+ payload = merged.to_json(orient="records")
253
+ return summary, top_table, top_plot, scatter_plot, genre_plot, payload, processed_csv.getvalue()
254
+
255
+
256
+ def inspect_title(payload: str, selected_title: str):
257
+ if not payload:
258
+ raise gr.Error("Run the analysis first.")
259
+ records = json.loads(payload)
260
+ df = pd.DataFrame(records)
261
+ if selected_title not in df["title"].values:
262
+ raise gr.Error("Title not found.")
263
+ row = df[df["title"] == selected_title].iloc[0]
264
+ return (
265
+ f"Title: {row['title']}\n"
266
+ f"Genre: {row['genre']}\n"
267
+ f"Average sentiment: {row['avg_sentiment']:.2f}\n"
268
+ f"Average watch time: {row['avg_watch_time']:.2f}\n"
269
+ f"Completion rate: {row['completion_rate']:.2f}\n"
270
+ f"Drop-off rate: {row['drop_off_rate']:.2f}\n"
271
+ f"Recommendation score: {row['recommendation_score']:.2f}\n"
272
+ f"Suggested action: {row['action']}\n\n"
273
+ f"Explanation: {row['explanation']}"
274
+ )
275
+
276
+
277
+ def update_title_choices(payload: str):
278
+ if not payload:
279
+ return gr.Dropdown(choices=[], value=None)
280
+ df = pd.DataFrame(json.loads(payload))
281
+ choices = sorted(df["title"].dropna().unique().tolist())
282
+ value = choices[0] if choices else None
283
+ return gr.Dropdown(choices=choices, value=value)
284
+
285
+
286
+ def send_to_n8n(payload: str):
287
+ if not payload:
288
+ raise gr.Error("Run the analysis first.")
289
+
290
+ webhook_url = os.getenv("N8N_WEBHOOK_URL", "").strip()
291
+ if not webhook_url:
292
+ return (
293
+ "N8N_WEBHOOK_URL is not set yet. Add it as a Hugging Face Space secret, then try again."
294
+ )
295
+
296
+ data = json.loads(payload)
297
+ top5 = data[:5]
298
+ response = requests.post(webhook_url, json={"app": APP_TITLE, "top_recommendations": top5, "all_results": data}, timeout=60)
299
+ response.raise_for_status()
300
+
301
+ try:
302
+ result = response.json()
303
+ return f"n8n workflow ran successfully. Response: {json.dumps(result, indent=2)}"
304
+ except Exception:
305
+ return f"n8n workflow ran successfully. Raw response: {response.text}"
306
+
307
+
308
+ with gr.Blocks(title=APP_TITLE) as demo:
309
+ gr.Markdown(f"# {APP_TITLE}\n\n{APP_SUBTITLE}")
310
+ gr.Markdown(
311
+ "This app combines qualitative viewer review sentiment with quantitative watch-time metrics "
312
+ "to score how strongly each title should be recommended on a streaming platform."
313
+ )
314
+
315
+ payload_state = gr.State("")
316
+ csv_state = gr.State("")
317
+
318
+ with gr.Tab("1. Upload & Run"):
319
+ use_demo = gr.Checkbox(label="Use built-in demo dataset", value=True)
320
+ reviews_file = gr.File(label="Upload reviews CSV", file_types=[".csv"])
321
+ watch_file = gr.File(label="Upload watch-time CSV", file_types=[".csv"])
322
+ run_btn = gr.Button("Run Analysis", variant="primary")
323
+ summary_box = gr.Textbox(label="Processing Summary", lines=2)
324
+
325
+ with gr.Tab("2. Dashboard"):
326
+ results_table = gr.Dataframe(label="Scored Titles")
327
+ chart_1 = gr.Plot(label="Top Recommendation Scores")
328
+ chart_2 = gr.Plot(label="Sentiment vs Completion")
329
+ chart_3 = gr.Plot(label="Genre Performance")
330
+
331
+ with gr.Tab("3. Title Drilldown"):
332
+ title_dropdown = gr.Dropdown(label="Select a title", choices=[])
333
+ detail_box = gr.Textbox(label="Title Recommendation Detail", lines=10)
334
+ inspect_btn = gr.Button("Explain Selected Title")
335
+
336
+ with gr.Tab("4. n8n Automation"):
337
+ gr.Markdown(
338
+ "This button sends your scored results to one n8n workflow through a webhook. "
339
+ "Set the `N8N_WEBHOOK_URL` secret in your Hugging Face Space first."
340
+ )
341
+ n8n_btn = gr.Button("Send Top Recommendations to n8n")
342
+ n8n_status = gr.Textbox(label="n8n Status", lines=5)
343
+
344
+ with gr.Tab("5. Download"):
345
+ download_file = gr.File(label="Download processed CSV")
346
+
347
+ def save_csv_text(csv_text: str):
348
+ path = "/tmp/processed_streamsmart_results.csv"
349
+ with open(path, "w", encoding="utf-8") as f:
350
+ f.write(csv_text)
351
+ return path
352
+
353
+ run_btn.click(
354
+ fn=run_analysis,
355
+ inputs=[reviews_file, watch_file, use_demo],
356
+ outputs=[summary_box, results_table, chart_1, chart_2, chart_3, payload_state, csv_state],
357
+ ).then(
358
+ fn=update_title_choices,
359
+ inputs=[payload_state],
360
+ outputs=[title_dropdown],
361
+ ).then(
362
+ fn=save_csv_text,
363
+ inputs=[csv_state],
364
+ outputs=[download_file],
365
+ )
366
+
367
+ inspect_btn.click(
368
+ fn=inspect_title,
369
+ inputs=[payload_state, title_dropdown],
370
+ outputs=[detail_box],
371
+ )
372
+
373
+ n8n_btn.click(
374
+ fn=send_to_n8n,
375
+ inputs=[payload_state],
376
+ outputs=[n8n_status],
377
+ )
378
+
379
+ if __name__ == "__main__":
380
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=4.44.0
2
+ pandas>=2.2.2
3
+ numpy>=1.26.4
4
+ plotly>=5.24.1
5
+ requests>=2.32.3
6
+ vaderSentiment>=3.3.2