danielhjerresen commited on
Commit
a6d0935
·
verified ·
1 Parent(s): 56db5c7

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile.txt +14 -0
  2. docker-compose.yml +8 -0
  3. dockerignore.txt +6 -0
  4. streamlit_app.py +412 -0
Dockerfile.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ ENV PYTHONUNBUFFERED=1
6
+
7
+ COPY requirements.txt /app/requirements.txt
8
+ RUN pip install --no-cache-dir -r /app/requirements.txt
9
+
10
+ COPY . /app
11
+
12
+ EXPOSE 8501
13
+
14
+ CMD ["streamlit", "run", "streamlit_app.py", "--server.address=0.0.0.0", "--server.port=8501"]
docker-compose.yml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ app:
3
+ build: .
4
+ container_name: news-event-app
5
+ ports:
6
+ - "8501:8501"
7
+ volumes:
8
+ - ../data:/app/data
dockerignore.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .git
4
+ .env
5
+ .venv
6
+ venv
streamlit_app.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # frontend/streamlit_app.py
2
+ import json
3
+ import os
4
+
5
+ import pandas as pd
6
+ import requests
7
+ import streamlit as st
8
+
9
+
10
+ st.set_page_config(
11
+ page_title="Green Energy News Event Dashboard",
12
+ page_icon="📰",
13
+ layout="wide",
14
+ )
15
+
16
+ API_BASE_URL = st.secrets.get(
17
+ "API_BASE_URL",
18
+ os.getenv("API_BASE_URL", "https://Signe22-Article-Data-API.hf.space"),
19
+ )
20
+
21
+
22
+ @st.cache_data(ttl=300)
23
+ def load_classified_articles() -> pd.DataFrame:
24
+ try:
25
+ response = requests.get(
26
+ f"{API_BASE_URL}/articles",
27
+ params={"limit": 500},
28
+ timeout=30,
29
+ )
30
+ response.raise_for_status()
31
+ data = response.json()
32
+
33
+ df = pd.DataFrame(data)
34
+
35
+ if df.empty:
36
+ return df
37
+
38
+ df["published_at"] = pd.to_datetime(df.get("published_at"), errors="coerce", utc=True)
39
+ df["classified_at"] = pd.to_datetime(df.get("classified_at"), errors="coerce", utc=True)
40
+
41
+ df["published_date"] = df["published_at"].dt.date
42
+ df["published_day"] = df["published_at"].dt.strftime("%Y-%m-%d")
43
+
44
+ return df
45
+
46
+ except Exception as error:
47
+ st.error(f"Failed to load articles from API: {error}")
48
+ return pd.DataFrame()
49
+
50
+
51
+ @st.cache_data(ttl=300)
52
+ def load_daily_summary() -> dict:
53
+ try:
54
+ response = requests.get(f"{API_BASE_URL}/summary/daily", timeout=30)
55
+ response.raise_for_status()
56
+ summary = response.json()
57
+
58
+ if not isinstance(summary, dict):
59
+ return {}
60
+
61
+ return normalize_summary_payload(summary)
62
+
63
+ except Exception as error:
64
+ st.error(f"Failed to load daily summary: {error}")
65
+ return {}
66
+
67
+
68
+ def normalize_summary_payload(summary: dict) -> dict:
69
+ """
70
+ Supports both the improved API shape and the previous legacy shape.
71
+
72
+ Preferred shape:
73
+ {
74
+ "summary_date": "...",
75
+ "generated_at": "...",
76
+ "executive_summary": "...",
77
+ "key_signal": "...",
78
+ "recommended_focus": "...",
79
+ "decision_implications": [...],
80
+ "watchlist": [...],
81
+ "top_stories": [...]
82
+ }
83
+
84
+ Legacy shape:
85
+ {
86
+ "summary_date": "...",
87
+ "short_summary": "...",
88
+ "key_focus": "...",
89
+ "top_stories": "{\"executive_summary\": ..., \"top_stories\": [...]}"
90
+ }
91
+ """
92
+ normalized = dict(summary)
93
+
94
+ nested_summary = summary.get("summary_json") or summary.get("top_stories")
95
+
96
+ if isinstance(nested_summary, str):
97
+ try:
98
+ parsed = json.loads(nested_summary)
99
+ if isinstance(parsed, dict):
100
+ normalized.update(parsed)
101
+ except Exception:
102
+ pass
103
+
104
+ elif isinstance(nested_summary, dict) and "top_stories" in nested_summary:
105
+ normalized.update(nested_summary)
106
+
107
+ normalized["executive_summary"] = (
108
+ normalized.get("executive_summary")
109
+ or normalized.get("short_summary")
110
+ or ""
111
+ )
112
+
113
+ normalized["recommended_focus"] = (
114
+ normalized.get("recommended_focus")
115
+ or normalized.get("key_focus")
116
+ or ""
117
+ )
118
+
119
+ if not isinstance(normalized.get("decision_implications"), list):
120
+ normalized["decision_implications"] = []
121
+
122
+ if not isinstance(normalized.get("watchlist"), list):
123
+ normalized["watchlist"] = []
124
+
125
+ if not isinstance(normalized.get("top_stories"), list):
126
+ normalized["top_stories"] = []
127
+
128
+ return normalized
129
+
130
+
131
+ def apply_filters(df: pd.DataFrame) -> pd.DataFrame:
132
+ st.sidebar.header("Filters")
133
+
134
+ label_options = sorted(df["label"].dropna().unique().tolist()) if not df.empty else []
135
+ source_options = sorted(df["source"].dropna().unique().tolist()) if not df.empty else []
136
+
137
+ default_labels = [
138
+ label
139
+ for label in label_options
140
+ if label != "not relevant to field"
141
+ ]
142
+
143
+ selected_labels = st.sidebar.multiselect(
144
+ "Action categories",
145
+ options=label_options,
146
+ default=default_labels,
147
+ )
148
+
149
+ selected_sources = st.sidebar.multiselect(
150
+ "Sources",
151
+ options=source_options,
152
+ default=[],
153
+ )
154
+
155
+ min_date = df["published_date"].min() if not df.empty else None
156
+ max_date = df["published_date"].max() if not df.empty else None
157
+
158
+ date_range = None
159
+ if min_date and max_date:
160
+ date_range = st.sidebar.date_input(
161
+ "Date range",
162
+ value=(min_date, max_date),
163
+ min_value=min_date,
164
+ max_value=max_date,
165
+ )
166
+
167
+ search_term = st.sidebar.text_input("Search title or description")
168
+
169
+ filtered = df.copy()
170
+
171
+ if selected_labels:
172
+ filtered = filtered[filtered["label"].isin(selected_labels)]
173
+
174
+ if selected_sources:
175
+ filtered = filtered[filtered["source"].isin(selected_sources)]
176
+
177
+ if date_range and len(date_range) == 2:
178
+ start_date, end_date = date_range
179
+ filtered = filtered[
180
+ (filtered["published_date"] >= start_date)
181
+ & (filtered["published_date"] <= end_date)
182
+ ]
183
+
184
+ if search_term:
185
+ search_term = search_term.strip()
186
+
187
+ title_matches = filtered["title"].fillna("").str.contains(
188
+ search_term,
189
+ case=False,
190
+ na=False,
191
+ regex=False,
192
+ )
193
+
194
+ description_matches = filtered["description"].fillna("").str.contains(
195
+ search_term,
196
+ case=False,
197
+ na=False,
198
+ regex=False,
199
+ )
200
+
201
+ filtered = filtered[title_matches | description_matches]
202
+
203
+ return filtered
204
+
205
+
206
+ def render_metrics(df: pd.DataFrame, filtered_df: pd.DataFrame) -> None:
207
+ c1, c2, c3, c4 = st.columns(4)
208
+
209
+ c1.metric("Articles", len(df))
210
+ c2.metric("Filtered", len(filtered_df))
211
+ c3.metric("Sources", df["source"].nunique() if "source" in df else 0)
212
+ c4.metric("Categories", df["label"].nunique() if "label" in df else 0)
213
+
214
+
215
+ def render_bullet_list(items: list[str], empty_message: str) -> None:
216
+ if not items:
217
+ st.info(empty_message)
218
+ return
219
+
220
+ for item in items:
221
+ st.markdown(f"- {item}")
222
+
223
+
224
+ def render_daily_summary(summary: dict) -> None:
225
+ st.subheader("Daily AI Summary")
226
+
227
+ if not summary:
228
+ st.info("No daily summary available yet.")
229
+ return
230
+
231
+ summary_date = summary.get("summary_date", "Unknown")
232
+ generated_at = summary.get("generated_at")
233
+
234
+ if generated_at:
235
+ st.caption(f"Summary date: {summary_date} · Generated at: {generated_at}")
236
+ else:
237
+ st.caption(f"Summary date: {summary_date}")
238
+
239
+ st.markdown("### Executive Summary")
240
+ st.write(summary.get("executive_summary") or "No summary available.")
241
+
242
+ st.markdown("### Key Signal")
243
+ st.write(summary.get("key_signal") or "No key signal available.")
244
+
245
+ st.markdown("### Recommended Focus")
246
+ st.write(summary.get("recommended_focus") or "No focus available.")
247
+
248
+ st.markdown("### Decision Implications")
249
+ render_bullet_list(
250
+ summary.get("decision_implications", []),
251
+ "No decision implications available.",
252
+ )
253
+
254
+ st.markdown("### Watchlist")
255
+ render_bullet_list(
256
+ summary.get("watchlist", []),
257
+ "No watchlist available.",
258
+ )
259
+
260
+ stories = summary.get("top_stories", [])
261
+
262
+ if not stories:
263
+ st.info("No top stories available.")
264
+ return
265
+
266
+ st.markdown("### Top Stories")
267
+
268
+ for story in stories:
269
+ if not isinstance(story, dict):
270
+ continue
271
+
272
+ title = story.get("title", "Untitled story")
273
+ label = story.get("label", "Unknown")
274
+ source = story.get("source", "Unknown source")
275
+ published_at = story.get("published_at")
276
+ description = story.get("description", "")
277
+ why_it_matters = story.get("why_it_matters", "")
278
+ decision_relevance = story.get("decision_relevance", "")
279
+ url = story.get("url")
280
+ article_id = story.get("article_id")
281
+
282
+ if pd.notnull(published_at):
283
+ published_at = pd.to_datetime(published_at, errors="coerce", utc=True)
284
+
285
+ if pd.notnull(published_at):
286
+ published_at = published_at.strftime("%Y-%m-%d %H:%M UTC")
287
+ else:
288
+ published_at = "Unknown date"
289
+ else:
290
+ published_at = "Unknown date"
291
+
292
+ with st.expander(title):
293
+ c1, c2, c3 = st.columns(3)
294
+ c1.markdown(f"**Category:** {label}")
295
+ c2.markdown(f"**Source:** {source}")
296
+ c3.markdown(f"**Published:** {published_at}")
297
+
298
+ if description:
299
+ st.markdown("**Description**")
300
+ st.write(description)
301
+
302
+ if why_it_matters:
303
+ st.markdown("**Why this matters**")
304
+ st.write(why_it_matters)
305
+
306
+ if decision_relevance:
307
+ st.markdown("**Decision relevance**")
308
+ st.write(decision_relevance)
309
+
310
+ if url:
311
+ st.markdown(f"[Open article]({url})")
312
+
313
+ if article_id:
314
+ st.caption(f"Article ID: {article_id}")
315
+
316
+
317
+ def render_article_browser(df: pd.DataFrame) -> None:
318
+ st.subheader("Article Browser")
319
+
320
+ if df.empty:
321
+ st.info("No articles available for browsing.")
322
+ return
323
+
324
+ sort_option = st.selectbox(
325
+ "Sort articles by",
326
+ options=[
327
+ "Newest first",
328
+ "Oldest first",
329
+ "Action category",
330
+ "Source",
331
+ ],
332
+ index=0,
333
+ )
334
+
335
+ display_df = df.copy()
336
+
337
+ if sort_option == "Newest first":
338
+ display_df = display_df.sort_values("published_at", ascending=False)
339
+ elif sort_option == "Oldest first":
340
+ display_df = display_df.sort_values("published_at", ascending=True)
341
+ elif sort_option == "Action category":
342
+ display_df = display_df.sort_values(["label", "published_at"], ascending=[True, False])
343
+ elif sort_option == "Source":
344
+ display_df = display_df.sort_values(["source", "published_at"], ascending=[True, False])
345
+
346
+ max_rows = st.slider("Number of articles to display", 5, 100, 20)
347
+ display_df = display_df.head(max_rows)
348
+
349
+ for _, row in display_df.iterrows():
350
+ title = row.get("title", "Untitled article")
351
+
352
+ published_str = (
353
+ row["published_at"].strftime("%Y-%m-%d %H:%M UTC")
354
+ if pd.notnull(row.get("published_at"))
355
+ else "Unknown"
356
+ )
357
+
358
+ with st.expander(title):
359
+ meta1, meta2, meta3 = st.columns(3)
360
+ meta1.markdown(f"**Action:** {row.get('label', 'Unknown')}")
361
+ meta2.markdown(f"**Source:** {row.get('source', 'Unknown source')}")
362
+ meta3.markdown(f"**Published:** {published_str}")
363
+
364
+ description = row.get("description")
365
+ if pd.notnull(description) and str(description).strip():
366
+ st.markdown("**Description**")
367
+ st.write(description)
368
+
369
+ url = row.get("url")
370
+ if pd.notnull(url) and str(url).strip():
371
+ st.markdown(f"[Open article]({url})")
372
+
373
+ st.markdown("**More details**")
374
+
375
+ article_id = row.get("article_id")
376
+ if pd.notnull(article_id):
377
+ st.caption(f"Article ID: {article_id}")
378
+
379
+ raw_label = row.get("raw_label")
380
+ if pd.notnull(raw_label) and str(raw_label).strip():
381
+ st.caption(f"Model output: {raw_label}")
382
+
383
+
384
+ def main() -> None:
385
+ st.title("📰 Green Energy News Event Dashboard")
386
+ st.write(
387
+ "This dashboard gives an overview of classified green energy and climate-tech news, "
388
+ "with filters for action categories, dates, sources, and search terms."
389
+ )
390
+
391
+ df = load_classified_articles()
392
+ summary = load_daily_summary()
393
+
394
+ if df.empty:
395
+ st.warning("No classified articles found yet. Check whether the API is live and returning data.")
396
+ return
397
+
398
+ filtered_df = apply_filters(df)
399
+
400
+ render_metrics(df, filtered_df)
401
+
402
+ tab1, tab2 = st.tabs(["Daily Summary", "Articles"])
403
+
404
+ with tab1:
405
+ render_daily_summary(summary)
406
+
407
+ with tab2:
408
+ render_article_browser(filtered_df)
409
+
410
+
411
+ if __name__ == "__main__":
412
+ main()