5353 "insufficient_evidence" : "#c9ced6" ,
5454}
5555
56+ # Severity 1 (minor) → 5 (critical).
57+ SEV_COLORS = {1 : "#5b8c5a" , 2 : "#a3b18a" , 3 : "#f3a712" , 4 : "#e4572e" , 5 : "#d7263d" }
58+
59+
60+ def _clean_text (s : str | None ) -> str :
61+ """Strip HTML tags/entities from source text (e.g. Hacker News) for display."""
62+ import html as _html
63+ import re
64+
65+ if not s :
66+ return ""
67+ s = re .sub (r"<[^>]+>" , " " , s )
68+ return re .sub (r"\s+" , " " , _html .unescape (s )).strip ()
69+
70+
71+ def _pill (text : str , color : str ) -> str :
72+ return (
73+ f'<span style="background:{ color } ;color:#fff;padding:2px 10px;border-radius:12px;'
74+ f'font-size:0.82em;font-weight:600;white-space:nowrap;">{ text } </span>'
75+ )
76+
77+
78+ def _type_badge (itype : str | None ) -> str :
79+ if not itype :
80+ return _pill ("unclassified" , "#9aa5b1" )
81+ return _pill (itype , TYPE_COLORS .get (itype , "#6c757d" ))
82+
83+
84+ def _severity_chip (sev ) -> str :
85+ if sev is None or (isinstance (sev , float ) and sev != sev ): # None / NaN
86+ return _pill ("severity —" , "#9aa5b1" )
87+ sev = int (sev )
88+ return _pill (f"severity { sev } /5" , SEV_COLORS .get (sev , "#6c757d" ))
89+
5690
5791# ----------------------------------------------------------------------------- data
5892
@@ -180,8 +214,8 @@ def _render_overview(st, api):
180214 )
181215 st .plotly_chart (fig , use_container_width = True )
182216
183- tab_time , tab_sev , tab_conf = st .tabs (
184- ["📈 Over time" , "🔥 Severity" , "🎯 Confidence" ]
217+ tab_time , tab_sev , tab_conf , tab_mix , tab_scatter = st .tabs (
218+ ["📈 Over time" , "🔥 Severity" , "🎯 Confidence" , "🌳 Type mix" , "⚖️ Confidence × severity" ]
185219 )
186220 with tab_time :
187221 st .caption ("When the underlying posts were published." )
@@ -220,6 +254,32 @@ def _render_overview(st, api):
220254 fig .update_layout (template = "plotly_white" , height = 320 ,
221255 margin = dict (l = 0 , r = 0 , t = 10 , b = 0 ), yaxis_title = "incidents" )
222256 st .plotly_chart (fig , use_container_width = True )
257+ with tab_mix :
258+ st .caption ("The relative mix of incident types — area is proportional to count." )
259+ counts2 = df ["type" ].value_counts ().reset_index ()
260+ counts2 .columns = ["type" , "count" ]
261+ fig = px .treemap (counts2 , path = ["type" ], values = "count" ,
262+ color = "type" , color_discrete_map = TYPE_COLORS )
263+ fig .update_layout (template = "plotly_white" , height = 360 , margin = dict (l = 0 , r = 0 , t = 10 , b = 0 ))
264+ st .plotly_chart (fig , use_container_width = True )
265+ with tab_scatter :
266+ st .caption (
267+ "Each dot is an incident. Bottom-left (low confidence, high severity) is where "
268+ "human review matters most."
269+ )
270+ sc = df .dropna (subset = ["severity" , "confidence" ])
271+ if sc .empty :
272+ st .info ("Not enough classified data yet." )
273+ else :
274+ fig = px .scatter (
275+ sc , x = "confidence" , y = "severity" , color = "type" ,
276+ color_discrete_map = TYPE_COLORS , hover_data = ["title" ],
277+ )
278+ fig .update_traces (marker = dict (size = 12 , opacity = 0.75 ))
279+ fig .update_layout (template = "plotly_white" , height = 360 ,
280+ margin = dict (l = 0 , r = 0 , t = 10 , b = 0 ),
281+ legend = dict (orientation = "h" , y = - 0.25 ))
282+ st .plotly_chart (fig , use_container_width = True )
223283
224284 st .caption (
225285 f"Charts based on the latest { len (df )} of { total } incidents · "
@@ -228,15 +288,56 @@ def _render_overview(st, api):
228288 _glossary (st )
229289
230290
231- def _classification_caption (cls : dict ) -> str :
291+ def _styled_table (st , view ):
292+ if view .empty :
293+ st .info ("No incidents match the current filters." )
294+ return
295+ disp = view [["id" , "type" , "severity" , "confidence" , "source" , "title" , "url" ]].copy ()
296+
297+ def _sty_type (v ):
298+ c = TYPE_COLORS .get (v , "" )
299+ return f"background-color:{ c } ;color:white" if c else ""
300+
301+ def _sty_sev (v ):
302+ try :
303+ c = SEV_COLORS .get (int (v ), "" )
304+ except (TypeError , ValueError ):
305+ c = ""
306+ return f"background-color:{ c } ;color:white" if c else ""
307+
308+ styler = disp .style .map (_sty_type , subset = ["type" ]).map (_sty_sev , subset = ["severity" ])
309+ st .dataframe (
310+ styler ,
311+ use_container_width = True ,
312+ hide_index = True ,
313+ column_config = {
314+ "confidence" : st .column_config .ProgressColumn (
315+ "confidence" , min_value = 0.0 , max_value = 1.0 , format = "%.2f"
316+ ),
317+ "severity" : st .column_config .NumberColumn ("severity" , format = "%d" ),
318+ "url" : st .column_config .LinkColumn ("link" , display_text = "open ↗" ),
319+ "title" : st .column_config .TextColumn ("title" , width = "large" ),
320+ },
321+ )
322+
323+
324+ def _classification_badges (st , cls : dict ) -> None :
232325 if not cls :
233- return "Not yet classified."
326+ st .markdown (_pill ("unclassified" , "#9aa5b1" ), unsafe_allow_html = True )
327+ return
234328 conf = cls .get ("confidence" )
235329 conf_s = f"{ conf :.0%} " if isinstance (conf , (int , float )) else "—"
236- return (
237- f"**{ cls .get ('incident_type' )} ** · severity { cls .get ('severity' ) or '—' } · "
238- f"confidence { conf_s } · model `{ cls .get ('model_name' )} `"
330+ badges = " " .join (
331+ [
332+ _type_badge (cls .get ("incident_type" )),
333+ _severity_chip (cls .get ("severity" )),
334+ _pill (f"confidence { conf_s } " , "#3f88c5" ),
335+ _pill (f"model { cls .get ('model_name' , '—' )} " , "#495057" ),
336+ ]
239337 )
338+ if cls .get ("abstained" ):
339+ badges += " " + _pill ("abstained" , "#6c757d" )
340+ st .markdown (badges , unsafe_allow_html = True )
240341
241342
242343def _render_explorer (st , api ):
@@ -261,12 +362,7 @@ def _render_explorer(st, api):
261362 view = df
262363
263364 st .caption (f"Showing { len (view )} of { total } incidents." )
264- st .dataframe (
265- view ,
266- use_container_width = True ,
267- hide_index = True ,
268- column_config = None ,
269- )
365+ _styled_table (st , view )
270366
271367 st .markdown ("### 🔎 Inspect an incident" )
272368 st .caption ("See the original evidence and why it was classified." )
@@ -282,11 +378,11 @@ def _show_detail(st, api, incident_id: int):
282378 if detail .get ("url" ):
283379 st .markdown (f"[View original post ↗]({ detail ['url' ]} )" )
284380 cls = detail .get ("classification" ) or {}
285- st . markdown ( _classification_caption ( cls ) )
381+ _classification_badges ( st , cls )
286382 if cls .get ("reasoning_summary" ):
287383 st .info (f"Classifier reasoning: { cls ['reasoning_summary' ]} " )
288384 with st .expander ("Evidence (original text)" ):
289- st .write (detail .get ("body" ) or "(no body text)" )
385+ st .write (_clean_text ( detail .get ("body" ) ) or "(no body text)" )
290386
291387
292388def _render_review (st , api ):
@@ -308,11 +404,11 @@ def _render_review(st, api):
308404 st .markdown ("#### Evidence" )
309405 if detail .get ("url" ):
310406 st .markdown (f"[View original post ↗]({ detail ['url' ]} )" )
311- st .write (detail .get ("body" ) or "(no body text)" )
407+ st .write (_clean_text ( detail .get ("body" ) ) or "(no body text)" )
312408
313409 st .markdown ("#### Machine classification" )
314410 cls = detail .get ("classification" ) or {}
315- st . markdown ( _classification_caption ( cls ) )
411+ _classification_badges ( st , cls )
316412 if cls .get ("reasoning_summary" ):
317413 st .info (f"Classifier reasoning: { cls ['reasoning_summary' ]} " )
318414
@@ -337,6 +433,23 @@ def render() -> None:
337433 page = _sidebar (st )
338434 st .title ("AgentWatch — AI Incident Observatory" )
339435
436+ # Both the dashboard and API sleep on Render's free tier. On the first load after
437+ # idle, patiently wait (with a spinner) for the API to wake instead of erroring out.
438+ with st .spinner (
439+ "Connecting to the API… first load after idle can take up to ~60s while "
440+ "Render's free tier wakes the service. Thanks for your patience!"
441+ ):
442+ ready = api .wait_until_ready (timeout = 75 )
443+
444+ if not ready :
445+ st .warning (
446+ "⏳ The API is still waking up (Render free-tier cold start). It should be "
447+ "ready in a few more seconds — please retry."
448+ )
449+ if st .button ("Retry" ):
450+ st .rerun ()
451+ return
452+
340453 try :
341454 if page == "Overview" :
342455 _render_overview (st , api )
@@ -345,11 +458,7 @@ def render() -> None:
345458 elif page == "Review Queue" :
346459 _render_review (st , api )
347460 except APIUnavailable :
348- st .warning (
349- "⏳ The API isn't responding yet. This demo runs on Render's free tier, where "
350- "services sleep after ~15 minutes idle and take 30–60s to wake. Give it a "
351- "moment and retry."
352- )
461+ st .warning ("⏳ The API stopped responding mid-request. Please retry." )
353462 if st .button ("Retry" ):
354463 st .rerun ()
355464
0 commit comments