Spaces:
Running
Running
Add Test History with View Report in Database tab
Browse files- Add AnalysisResult.from_dict() to reconstruct results from stored JSON
- Add Test History section in Database tab listing all past analyses
- View Report button loads full report inline with back navigation
- Results now persist across Streamlit reruns via session_state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- app.py +50 -0
- src/analyzer.py +65 -0
app.py
CHANGED
|
@@ -920,6 +920,16 @@ def render_timeline(result):
|
|
| 920 |
|
| 921 |
def render_database_tab():
|
| 922 |
"""Render the voiceprint database tab."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 923 |
st.markdown("### Voiceprint Database")
|
| 924 |
|
| 925 |
analyzer = get_analyzer()
|
|
@@ -948,6 +958,46 @@ def render_database_tab():
|
|
| 948 |
st.metric("Recurring", stats['multi_appearance'])
|
| 949 |
st.caption("Voices in 2+ tests")
|
| 950 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 951 |
# Check if clear was requested
|
| 952 |
if st.session_state.get('clear_db_search', False):
|
| 953 |
st.session_state['clear_db_search'] = False
|
|
|
|
| 920 |
|
| 921 |
def render_database_tab():
|
| 922 |
"""Render the voiceprint database tab."""
|
| 923 |
+
|
| 924 |
+
# If viewing a historical report, show it first with a back button
|
| 925 |
+
if st.session_state.get('viewing_history') and st.session_state.get('last_result'):
|
| 926 |
+
if st.button("← Back to Database"):
|
| 927 |
+
st.session_state['viewing_history'] = False
|
| 928 |
+
st.session_state['last_result'] = None
|
| 929 |
+
st.rerun()
|
| 930 |
+
render_results(st.session_state['last_result'])
|
| 931 |
+
return
|
| 932 |
+
|
| 933 |
st.markdown("### Voiceprint Database")
|
| 934 |
|
| 935 |
analyzer = get_analyzer()
|
|
|
|
| 958 |
st.metric("Recurring", stats['multi_appearance'])
|
| 959 |
st.caption("Voices in 2+ tests")
|
| 960 |
|
| 961 |
+
# ---- Test History ----
|
| 962 |
+
st.markdown("#### Test History")
|
| 963 |
+
all_tests = analyzer.db.get_all_tests()
|
| 964 |
+
if all_tests:
|
| 965 |
+
for idx, test in enumerate(all_tests):
|
| 966 |
+
date_str = test.analyzed_at.strftime('%d/%m/%Y %H:%M') if test.analyzed_at else '-'
|
| 967 |
+
risk_data = ""
|
| 968 |
+
if test.results_json:
|
| 969 |
+
try:
|
| 970 |
+
import json as _json
|
| 971 |
+
rj = _json.loads(test.results_json)
|
| 972 |
+
rs = rj.get('risk_score', '-')
|
| 973 |
+
rl = rj.get('risk_label', '')
|
| 974 |
+
risk_icon = {"LOW": "🟢", "MEDIUM": "🟡", "HIGH": "🔴"}.get(rl, "⚪")
|
| 975 |
+
risk_data = f"{risk_icon} {rs}"
|
| 976 |
+
except Exception:
|
| 977 |
+
risk_data = "-"
|
| 978 |
+
|
| 979 |
+
tc1, tc2, tc3, tc4 = st.columns([2, 3, 1, 1.5])
|
| 980 |
+
with tc1:
|
| 981 |
+
st.write(date_str)
|
| 982 |
+
with tc2:
|
| 983 |
+
st.write(test.filename[:40] if test.filename else '-')
|
| 984 |
+
with tc3:
|
| 985 |
+
st.write(risk_data)
|
| 986 |
+
with tc4:
|
| 987 |
+
if st.button("View Report", key=f"view_test_{test.test_id}_{idx}"):
|
| 988 |
+
results = analyzer.db.get_test_results(test.test_id)
|
| 989 |
+
if results:
|
| 990 |
+
from src.analyzer import AnalysisResult
|
| 991 |
+
st.session_state['last_result'] = AnalysisResult.from_dict(results)
|
| 992 |
+
st.session_state['viewing_history'] = True
|
| 993 |
+
st.rerun()
|
| 994 |
+
else:
|
| 995 |
+
st.caption("No tests analyzed yet.")
|
| 996 |
+
st.divider()
|
| 997 |
+
|
| 998 |
+
# ---- Voiceprint Database ----
|
| 999 |
+
st.markdown("#### Voiceprints")
|
| 1000 |
+
|
| 1001 |
# Check if clear was requested
|
| 1002 |
if st.session_state.get('clear_db_search', False):
|
| 1003 |
st.session_state['clear_db_search'] = False
|
src/analyzer.py
CHANGED
|
@@ -181,6 +181,71 @@ class AnalysisResult:
|
|
| 181 |
"""Convert to JSON string."""
|
| 182 |
return json.dumps(self.to_dict(), indent=2)
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
class AudioAnalyzer:
|
| 186 |
"""Main analyzer that orchestrates all phases."""
|
|
|
|
| 181 |
"""Convert to JSON string."""
|
| 182 |
return json.dumps(self.to_dict(), indent=2)
|
| 183 |
|
| 184 |
+
@classmethod
|
| 185 |
+
def from_dict(cls, d: dict) -> 'AnalysisResult':
|
| 186 |
+
"""Reconstruct an AnalysisResult from a stored dict (e.g. from DB JSON)."""
|
| 187 |
+
main = None
|
| 188 |
+
if d.get('main_speaker'):
|
| 189 |
+
ms = d['main_speaker']
|
| 190 |
+
main = SpeakerResult(
|
| 191 |
+
voiceprint_id=ms.get('voiceprint_id', ''),
|
| 192 |
+
label=ms.get('label', ''),
|
| 193 |
+
role=ms.get('role', 'main'),
|
| 194 |
+
total_seconds=ms.get('total_seconds', 0),
|
| 195 |
+
quality=ms.get('quality', 'Unknown'),
|
| 196 |
+
is_synthetic=ms.get('is_synthetic', False),
|
| 197 |
+
synthetic_score=ms.get('synthetic_score', 0),
|
| 198 |
+
is_playback=ms.get('is_playback', False),
|
| 199 |
+
playback_score=ms.get('playback_score', 0),
|
| 200 |
+
playback_indicators=ms.get('playback_indicators'),
|
| 201 |
+
times_seen=ms.get('times_seen', 1),
|
| 202 |
+
is_flagged=ms.get('is_flagged', False),
|
| 203 |
+
segments=ms.get('segments'),
|
| 204 |
+
clip_path=ms.get('clip_path'),
|
| 205 |
+
)
|
| 206 |
+
additional = []
|
| 207 |
+
for s in d.get('additional_speakers', []):
|
| 208 |
+
additional.append(SpeakerResult(
|
| 209 |
+
voiceprint_id=s.get('voiceprint_id', ''),
|
| 210 |
+
label=s.get('label', ''),
|
| 211 |
+
role=s.get('role', 'additional'),
|
| 212 |
+
total_seconds=s.get('total_seconds', 0),
|
| 213 |
+
quality=s.get('quality', 'Unknown'),
|
| 214 |
+
is_synthetic=s.get('is_synthetic', False),
|
| 215 |
+
synthetic_score=s.get('synthetic_score', 0),
|
| 216 |
+
is_playback=s.get('is_playback', False),
|
| 217 |
+
playback_score=s.get('playback_score', 0),
|
| 218 |
+
playback_indicators=s.get('playback_indicators'),
|
| 219 |
+
times_seen=s.get('times_seen', 1),
|
| 220 |
+
is_flagged=s.get('is_flagged', False),
|
| 221 |
+
segments=s.get('segments'),
|
| 222 |
+
clip_path=s.get('clip_path'),
|
| 223 |
+
))
|
| 224 |
+
return cls(
|
| 225 |
+
test_id=d.get('test_id', ''),
|
| 226 |
+
filename=d.get('filename', ''),
|
| 227 |
+
duration_seconds=d.get('duration_seconds', 0),
|
| 228 |
+
analyzed_at=d.get('analyzed_at', ''),
|
| 229 |
+
main_speaker=main,
|
| 230 |
+
additional_speakers=additional,
|
| 231 |
+
background_anomalies=d.get('background_anomalies', []),
|
| 232 |
+
wake_words=d.get('wake_words', []),
|
| 233 |
+
assistant_responses=d.get('assistant_responses', []),
|
| 234 |
+
prompt_voice_detected=d.get('prompt_voice_detected', False),
|
| 235 |
+
prompt_voice_seconds=d.get('prompt_voice_seconds', 0),
|
| 236 |
+
playback_detected=d.get('playback_detected', False),
|
| 237 |
+
playback_score=d.get('playback_score', 0),
|
| 238 |
+
playback_indicators=d.get('playback_indicators'),
|
| 239 |
+
whisper_detected=d.get('whisper_detected', False),
|
| 240 |
+
whisper_instances=d.get('whisper_instances'),
|
| 241 |
+
reading_pattern_detected=d.get('reading_pattern_detected', False),
|
| 242 |
+
reading_confidence=d.get('reading_confidence', 0),
|
| 243 |
+
reading_indicators=d.get('reading_indicators'),
|
| 244 |
+
suspicious_pauses_detected=d.get('suspicious_pauses_detected', False),
|
| 245 |
+
suspicious_pauses=d.get('suspicious_pauses'),
|
| 246 |
+
longest_pause=d.get('longest_pause', 0),
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
|
| 250 |
class AudioAnalyzer:
|
| 251 |
"""Main analyzer that orchestrates all phases."""
|