daasime Claude Opus 4.6 commited on
Commit
5b529ce
·
1 Parent(s): 1d50892

Add Trends tab with analytics charts and Coming Soon sections

Browse files

- Risk Score Over Time: line chart with green/yellow/red zones
- Fraud Type Distribution: stacked bar chart by day
- Voice Reuse Patterns: top 10 voices bar chart with color coding
- KPI summary: total tests, avg risk, high risk %, recurring voices
- Coming Soon: Batch Analysis and Webhook/API Integration with impact

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (2) hide show
  1. app.py +196 -4
  2. src/database/models.py +101 -0
app.py CHANGED
@@ -925,6 +925,195 @@ def render_timeline(result):
925
  st.caption("Play audio while viewing the timeline above to follow speaker changes")
926
 
927
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
928
  def render_database_tab():
929
  """Render the voiceprint database tab."""
930
 
@@ -2113,7 +2302,7 @@ def main():
2113
  )
2114
 
2115
  # Tabs
2116
- tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs(["Analyzer", "Compare", "Database", "Logs", "Features", "About"])
2117
 
2118
  with tab1:
2119
  render_analyzer_tab()
@@ -2122,15 +2311,18 @@ def main():
2122
  render_compare_tab()
2123
 
2124
  with tab3:
2125
- render_database_tab()
2126
 
2127
  with tab4:
2128
- render_logs_tab()
2129
 
2130
  with tab5:
2131
- render_features_tab()
2132
 
2133
  with tab6:
 
 
 
2134
  render_about_tab()
2135
 
2136
 
 
925
  st.caption("Play audio while viewing the timeline above to follow speaker changes")
926
 
927
 
928
+ def render_trends_tab():
929
+ """Render the Trends tab with analytics charts and Coming Soon sections."""
930
+ st.markdown("### Trend Analytics")
931
+
932
+ analyzer = get_analyzer()
933
+ trend_data = analyzer.db.get_trend_data()
934
+ summary = trend_data['summary']
935
+
936
+ if summary['total'] == 0:
937
+ st.info("No tests analyzed yet. Analyze some audio to see trends here.")
938
+ # Still show Coming Soon sections
939
+ _render_coming_soon_sections()
940
+ return
941
+
942
+ # ---- KPI Summary ----
943
+ k1, k2, k3, k4 = st.columns(4)
944
+ with k1:
945
+ with st.container(border=True):
946
+ st.metric("Total Tests", summary['total'])
947
+ with k2:
948
+ with st.container(border=True):
949
+ st.metric("Avg Risk Score", summary['avg_risk'])
950
+ with k3:
951
+ with st.container(border=True):
952
+ st.metric("High Risk %", f"{summary['high_risk_pct']}%")
953
+ with k4:
954
+ with st.container(border=True):
955
+ st.metric("Recurring Voices", summary['recurring'])
956
+
957
+ # ---- Risk Score Over Time ----
958
+ st.markdown("#### Risk Score Over Time")
959
+ daily_scores = trend_data['daily_scores']
960
+ if daily_scores:
961
+ dates = [d['date'] for d in daily_scores]
962
+ scores = [d['avg_score'] for d in daily_scores]
963
+ counts = [d['count'] for d in daily_scores]
964
+
965
+ fig = go.Figure()
966
+
967
+ # Risk zones as background
968
+ fig.add_hrect(y0=0, y1=30, fillcolor="#22c55e", opacity=0.08, line_width=0)
969
+ fig.add_hrect(y0=30, y1=60, fillcolor="#eab308", opacity=0.08, line_width=0)
970
+ fig.add_hrect(y0=60, y1=100, fillcolor="#ef4444", opacity=0.08, line_width=0)
971
+
972
+ # Score line
973
+ fig.add_trace(go.Scatter(
974
+ x=dates, y=scores, mode='lines+markers',
975
+ name='Avg Risk Score',
976
+ line=dict(color='#3b82f6', width=3),
977
+ marker=dict(size=8),
978
+ text=[f"Tests: {c}" for c in counts],
979
+ hovertemplate='%{x}<br>Avg Risk: %{y}<br>%{text}<extra></extra>',
980
+ ))
981
+
982
+ # Zone labels
983
+ fig.add_annotation(x=dates[-1], y=15, text="LOW", showarrow=False,
984
+ font=dict(color='#22c55e', size=10), opacity=0.5)
985
+ fig.add_annotation(x=dates[-1], y=45, text="MEDIUM", showarrow=False,
986
+ font=dict(color='#eab308', size=10), opacity=0.5)
987
+ fig.add_annotation(x=dates[-1], y=80, text="HIGH", showarrow=False,
988
+ font=dict(color='#ef4444', size=10), opacity=0.5)
989
+
990
+ fig.update_layout(
991
+ height=300,
992
+ yaxis=dict(range=[0, 100], title='Risk Score'),
993
+ xaxis=dict(title='Date'),
994
+ margin=dict(l=40, r=20, t=10, b=40),
995
+ showlegend=False,
996
+ )
997
+ st.plotly_chart(fig, use_container_width=True)
998
+ else:
999
+ st.caption("Not enough data for risk trend chart.")
1000
+
1001
+ # ---- Fraud Type Distribution ----
1002
+ st.markdown("#### Fraud Type Distribution")
1003
+ daily_flags = trend_data['daily_flags']
1004
+ if daily_flags:
1005
+ dates = [d['date'] for d in daily_flags]
1006
+ flag_types = {
1007
+ 'Synthetic': ('#ef4444', [d['synthetic'] for d in daily_flags]),
1008
+ 'Playback': ('#f97316', [d['playback'] for d in daily_flags]),
1009
+ 'Reading': ('#eab308', [d['reading'] for d in daily_flags]),
1010
+ 'Whispers': ('#a855f7', [d['whispers'] for d in daily_flags]),
1011
+ 'Pauses': ('#6b7280', [d['pauses'] for d in daily_flags]),
1012
+ 'Wake Words': ('#dc2626', [d['wake_words'] for d in daily_flags]),
1013
+ }
1014
+
1015
+ fig = go.Figure()
1016
+ for name, (color, values) in flag_types.items():
1017
+ fig.add_trace(go.Bar(
1018
+ x=dates, y=values, name=name,
1019
+ marker_color=color, opacity=0.85,
1020
+ ))
1021
+
1022
+ fig.update_layout(
1023
+ barmode='stack',
1024
+ height=280,
1025
+ yaxis=dict(title='Tests Flagged'),
1026
+ xaxis=dict(title='Date'),
1027
+ margin=dict(l=40, r=20, t=10, b=40),
1028
+ legend=dict(orientation='h', yanchor='bottom', y=1.02, xanchor='right', x=1),
1029
+ )
1030
+ st.plotly_chart(fig, use_container_width=True)
1031
+ else:
1032
+ st.caption("Not enough data for fraud distribution chart.")
1033
+
1034
+ # ---- Voice Reuse Patterns ----
1035
+ st.markdown("#### Voice Reuse Patterns")
1036
+ top_voices = trend_data['top_voices']
1037
+ if top_voices:
1038
+ labels = [v['label'] for v in top_voices]
1039
+ counts = [v['times_seen'] for v in top_voices]
1040
+ colors = [
1041
+ '#ef4444' if v['flagged'] else '#eab308' if v['times_seen'] >= 2 else '#22c55e'
1042
+ for v in top_voices
1043
+ ]
1044
+
1045
+ fig = go.Figure()
1046
+ fig.add_trace(go.Bar(
1047
+ x=labels, y=counts,
1048
+ marker_color=colors,
1049
+ text=counts,
1050
+ textposition='auto',
1051
+ ))
1052
+ fig.update_layout(
1053
+ height=250,
1054
+ yaxis=dict(title='Tests Appeared In'),
1055
+ xaxis=dict(title='Voice ID', tickangle=-45),
1056
+ margin=dict(l=40, r=20, t=10, b=80),
1057
+ showlegend=False,
1058
+ )
1059
+ st.plotly_chart(fig, use_container_width=True)
1060
+ st.caption("Red: flagged | Yellow: recurring (2+ tests) | Green: single appearance")
1061
+ else:
1062
+ st.caption("No voiceprints recorded yet.")
1063
+
1064
+ st.divider()
1065
+
1066
+ # ---- Coming Soon Sections ----
1067
+ _render_coming_soon_sections()
1068
+
1069
+
1070
+ def _render_coming_soon_sections():
1071
+ """Render Coming Soon mockups for Batch Analysis and API Integration."""
1072
+ cs1, cs2 = st.columns(2)
1073
+
1074
+ with cs1:
1075
+ with st.container(border=True):
1076
+ st.markdown(
1077
+ '<span style="background:#3b82f6;color:white;padding:0.2rem 0.6rem;'
1078
+ 'border-radius:10px;font-size:0.7rem;font-weight:bold">COMING SOON</span>',
1079
+ unsafe_allow_html=True,
1080
+ )
1081
+ st.markdown("#### Batch Analysis")
1082
+ st.markdown("""
1083
+ - Process **20+ audio files** in one upload (ZIP or multi-select)
1084
+ - Consolidated **cross-test fraud report** with shared voice detection
1085
+ - Automatic **voice matching** across all tests in a batch
1086
+ - **Reduce evaluator review time by 80%** for high-volume testing days
1087
+ """)
1088
+ st.markdown(
1089
+ '<span style="color:#6b7280;font-size:0.8rem">'
1090
+ 'Impact: Enables evaluators processing 20+ tests/day to run all analyses '
1091
+ 'in a single operation instead of one-by-one.</span>',
1092
+ unsafe_allow_html=True,
1093
+ )
1094
+
1095
+ with cs2:
1096
+ with st.container(border=True):
1097
+ st.markdown(
1098
+ '<span style="background:#3b82f6;color:white;padding:0.2rem 0.6rem;'
1099
+ 'border-radius:10px;font-size:0.7rem;font-weight:bold">COMING SOON</span>',
1100
+ unsafe_allow_html=True,
1101
+ )
1102
+ st.markdown("#### Webhook / API Integration")
1103
+ st.markdown("""
1104
+ - **REST API** for programmatic analysis from any platform
1105
+ - Real-time **webhook alerts** on high-risk tests
1106
+ - Direct integration with **SOP and proctoring platforms**
1107
+ - **Automate fraud screening** in existing test workflows
1108
+ """)
1109
+ st.markdown(
1110
+ '<span style="color:#6b7280;font-size:0.8rem">'
1111
+ 'Impact: Connect the analyzer to your testing platform so every audio submission '
1112
+ 'is automatically screened without manual uploads.</span>',
1113
+ unsafe_allow_html=True,
1114
+ )
1115
+
1116
+
1117
  def render_database_tab():
1118
  """Render the voiceprint database tab."""
1119
 
 
2302
  )
2303
 
2304
  # Tabs
2305
+ tab1, tab2, tab3, tab4, tab5, tab6, tab7 = st.tabs(["Analyzer", "Compare", "Trends", "Database", "Logs", "Features", "About"])
2306
 
2307
  with tab1:
2308
  render_analyzer_tab()
 
2311
  render_compare_tab()
2312
 
2313
  with tab3:
2314
+ render_trends_tab()
2315
 
2316
  with tab4:
2317
+ render_database_tab()
2318
 
2319
  with tab5:
2320
+ render_logs_tab()
2321
 
2322
  with tab6:
2323
+ render_features_tab()
2324
+
2325
+ with tab7:
2326
  render_about_tab()
2327
 
2328
 
src/database/models.py CHANGED
@@ -387,3 +387,104 @@ class Database:
387
  ]
388
  finally:
389
  session.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  ]
388
  finally:
389
  session.close()
390
+
391
+ def get_trend_data(self):
392
+ """Get aggregated trend data for charts."""
393
+ import json as _json
394
+ from collections import defaultdict
395
+
396
+ session = self.get_session()
397
+ try:
398
+ all_tests = session.query(TestAnalysis).order_by(
399
+ TestAnalysis.analyzed_at
400
+ ).all()
401
+
402
+ daily_scores = defaultdict(lambda: {'scores': [], 'count': 0, 'high_risk': 0})
403
+ daily_flags = defaultdict(lambda: {
404
+ 'synthetic': 0, 'playback': 0, 'reading': 0,
405
+ 'whispers': 0, 'pauses': 0, 'wake_words': 0
406
+ })
407
+
408
+ total_risk = 0.0
409
+ high_risk_count = 0
410
+
411
+ for t in all_tests:
412
+ day = t.analyzed_at.strftime('%Y-%m-%d') if t.analyzed_at else 'unknown'
413
+ risk = 0
414
+ if t.results_json:
415
+ try:
416
+ r = _json.loads(t.results_json)
417
+ risk = r.get('risk_score', 0)
418
+ daily_scores[day]['scores'].append(risk)
419
+ daily_scores[day]['count'] += 1
420
+ if risk > 60:
421
+ daily_scores[day]['high_risk'] += 1
422
+ high_risk_count += 1
423
+ total_risk += risk
424
+
425
+ if r.get('main_speaker', {}).get('is_synthetic', False):
426
+ daily_flags[day]['synthetic'] += 1
427
+ if r.get('playback_detected', False):
428
+ daily_flags[day]['playback'] += 1
429
+ if r.get('reading_pattern_detected', False):
430
+ daily_flags[day]['reading'] += 1
431
+ if r.get('whisper_detected', False):
432
+ daily_flags[day]['whispers'] += 1
433
+ if r.get('suspicious_pauses_detected', False):
434
+ daily_flags[day]['pauses'] += 1
435
+ if len(r.get('wake_words', [])) > 0:
436
+ daily_flags[day]['wake_words'] += 1
437
+ except Exception:
438
+ pass
439
+
440
+ total = len(all_tests)
441
+ avg_risk = (total_risk / total) if total > 0 else 0
442
+ high_risk_pct = (high_risk_count / total * 100) if total > 0 else 0
443
+
444
+ scores_list = []
445
+ for day in sorted(daily_scores.keys()):
446
+ d = daily_scores[day]
447
+ scores_list.append({
448
+ 'date': day,
449
+ 'avg_score': round(sum(d['scores']) / len(d['scores']), 1),
450
+ 'count': d['count'],
451
+ 'high_risk': d['high_risk'],
452
+ })
453
+
454
+ flags_list = []
455
+ for day in sorted(daily_flags.keys()):
456
+ entry = {'date': day}
457
+ entry.update(daily_flags[day])
458
+ flags_list.append(entry)
459
+
460
+ # Top voices
461
+ all_vps = session.query(Voiceprint).order_by(
462
+ Voiceprint.times_seen.desc()
463
+ ).limit(10).all()
464
+ top_voices = [
465
+ {
466
+ 'id': vp.id,
467
+ 'label': vp.label or vp.id,
468
+ 'times_seen': vp.times_seen,
469
+ 'flagged': vp.is_flagged,
470
+ }
471
+ for vp in all_vps
472
+ ]
473
+
474
+ recurring = session.query(Voiceprint).filter(
475
+ Voiceprint.times_seen >= 2
476
+ ).count()
477
+
478
+ return {
479
+ 'daily_scores': scores_list,
480
+ 'daily_flags': flags_list,
481
+ 'top_voices': top_voices,
482
+ 'summary': {
483
+ 'total': total,
484
+ 'avg_risk': round(avg_risk, 1),
485
+ 'high_risk_pct': round(high_risk_pct, 1),
486
+ 'recurring': recurring,
487
+ },
488
+ }
489
+ finally:
490
+ session.close()