fix: normalize AuDD scores + add filter to admin UI (#413)

* fix: normalize AuDD scores + add filter to admin UI

- normalize scores from integer (0-100) to float (0.0-1.0) when storing
context, fixing the 0% display issue for potential matches
- add filter controls to admin UI: pending (default), resolved, all
- preserve current filter when resolving flags or refreshing
- add unit test for score normalization

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: show match count instead of misleading 0% scores

AuDD doesn't return confidence scores when using accurate_offsets mode
(which we use for timecode data). All scores were 0, showing as "0% match".

Changed to show "{N} matches" badge instead, which is accurate and useful.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 32711970 99352dce

Changed files
+171 -38
moderation
+69 -19
moderation/src/admin.rs
··· 3 3 //! Uses htmx for interactivity with server-rendered HTML. 4 4 5 5 use axum::{ 6 - extract::State, 6 + extract::{Query, State}, 7 7 http::header::CONTENT_TYPE, 8 8 response::{IntoResponse, Response}, 9 9 Json, ··· 33 33 pub tracks: Vec<FlaggedTrack>, 34 34 } 35 35 36 + /// Query parameters for listing flags. 37 + #[derive(Debug, Deserialize, Default)] 38 + pub struct ListFlagsQuery { 39 + /// Filter: "pending" (default), "resolved", or "all" 40 + #[serde(default = "default_filter")] 41 + pub filter: String, 42 + } 43 + 44 + fn default_filter() -> String { 45 + "pending".to_string() 46 + } 47 + 36 48 /// Request to resolve (negate) a flag. 37 49 #[derive(Debug, Deserialize)] 38 50 pub struct ResolveRequest { ··· 82 94 /// List all flagged tracks - returns JSON for API, HTML for htmx. 83 95 pub async fn list_flagged( 84 96 State(state): State<AppState>, 97 + Query(query): Query<ListFlagsQuery>, 85 98 ) -> Result<Json<ListFlaggedResponse>, AppError> { 86 99 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 87 - let tracks = db.get_pending_flags().await?; 100 + let all_tracks = db.get_pending_flags().await?; 101 + let tracks = filter_tracks(all_tracks, &query.filter); 88 102 Ok(Json(ListFlaggedResponse { tracks })) 89 103 } 90 104 91 105 /// Render flags as HTML partial for htmx. 92 - pub async fn list_flagged_html(State(state): State<AppState>) -> Result<Response, AppError> { 106 + pub async fn list_flagged_html( 107 + State(state): State<AppState>, 108 + Query(query): Query<ListFlagsQuery>, 109 + ) -> Result<Response, AppError> { 93 110 let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 94 - let tracks = db.get_pending_flags().await?; 111 + let all_tracks = db.get_pending_flags().await?; 112 + let tracks = filter_tracks(all_tracks, &query.filter); 95 113 96 - let html = render_flags_list(&tracks); 114 + let html = render_flags_list(&tracks, &query.filter); 97 115 98 116 Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()) 117 + } 118 + 119 + /// Filter tracks based on filter parameter. 120 + fn filter_tracks(tracks: Vec<FlaggedTrack>, filter: &str) -> Vec<FlaggedTrack> { 121 + match filter { 122 + "resolved" => tracks.into_iter().filter(|t| t.resolved).collect(), 123 + "all" => tracks, 124 + _ => tracks.into_iter().filter(|t| !t.resolved).collect(), // "pending" is default 125 + } 99 126 } 100 127 101 128 /// Resolve (negate) a copyright flag, marking it as a false positive. ··· 220 247 Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()) 221 248 } 222 249 223 - /// Render the flags list as HTML. 224 - fn render_flags_list(tracks: &[FlaggedTrack]) -> String { 250 + /// Render the flags list as HTML with filter controls. 251 + fn render_flags_list(tracks: &[FlaggedTrack], current_filter: &str) -> String { 252 + let pending_active = if current_filter == "pending" { " active" } else { "" }; 253 + let resolved_active = if current_filter == "resolved" { " active" } else { "" }; 254 + let all_active = if current_filter == "all" { " active" } else { "" }; 255 + 256 + let filter_buttons = format!( 257 + "<div class=\"filter-row\">\ 258 + <span class=\"filter-label\">show:</span>\ 259 + <button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=pending\" hx-target=\"#flags-list\">pending</button>\ 260 + <button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=resolved\" hx-target=\"#flags-list\">resolved</button>\ 261 + <button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=all\" hx-target=\"#flags-list\">all</button>\ 262 + </div>", 263 + pending_active, 264 + resolved_active, 265 + all_active, 266 + ); 267 + 225 268 if tracks.is_empty() { 226 - return r#"<div class="empty">no flagged tracks</div>"#.to_string(); 269 + let empty_msg = match current_filter { 270 + "pending" => "no pending flags", 271 + "resolved" => "no resolved flags", 272 + _ => "no flagged tracks", 273 + }; 274 + return format!( 275 + "{}<div class=\"empty\">{}</div>", 276 + filter_buttons, empty_msg 277 + ); 227 278 } 228 279 229 280 let cards: Vec<String> = tracks.iter().map(render_flag_card).collect(); 230 - cards.join("\n") 281 + format!("{}\n{}", filter_buttons, cards.join("\n")) 231 282 } 232 283 233 284 /// Extract namespace from AT URI (e.g., "fm.plyr.dev" from "at://did:plc:xxx/fm.plyr.dev.track/yyy") ··· 276 327 }) 277 328 .unwrap_or_default(); 278 329 279 - let score_badge = ctx 280 - .and_then(|c| c.highest_score) 281 - .filter(|&s| s > 0.0) 282 - .map(|s| { 330 + // Show match count instead of score (AuDD doesn't provide scores in accurate_offsets mode) 331 + let match_count_badge = ctx 332 + .and_then(|c| c.matches.as_ref()) 333 + .filter(|m| !m.is_empty()) 334 + .map(|matches| { 283 335 format!( 284 - r#"<span class="badge score">{}% match</span>"#, 285 - (s * 100.0) as i32 336 + r#"<span class="badge matches">{} matches</span>"#, 337 + matches.len() 286 338 ) 287 339 }) 288 340 .unwrap_or_default(); ··· 303 355 .map(|m| { 304 356 format!( 305 357 r#"<div class="match-item"> 306 - <span><span class="title">{}</span> <span class="artist">by {}</span></span> 307 - <span class="score">{}%</span> 358 + <span class="title">{}</span> <span class="artist">by {}</span> 308 359 </div>"#, 309 360 html_escape(&m.title), 310 361 html_escape(&m.artist), 311 - (m.score * 100.0) as i32 312 362 ) 313 363 }) 314 364 .collect(); ··· 373 423 track_info, 374 424 html_escape(&track.uri), 375 425 env_badge, 376 - score_badge, 426 + match_count_badge, 377 427 status_badge, 378 428 matches_html, 379 429 action_button
+39 -2
moderation/src/handlers.rs
··· 46 46 "copyright-violation".to_string() 47 47 } 48 48 49 + /// Normalize a score from integer (0-100) to float (0.0-1.0) range. 50 + /// AuDD returns scores as integers like 85 meaning 85%. 51 + fn normalize_score(score: f64) -> f64 { 52 + if score > 1.0 { 53 + score / 100.0 54 + } else { 55 + score 56 + } 57 + } 58 + 49 59 #[derive(Debug, Serialize)] 50 60 pub struct EmitLabelResponse { 51 61 pub seq: i64, ··· 169 179 track_title: ctx.track_title, 170 180 artist_handle: ctx.artist_handle, 171 181 artist_did: ctx.artist_did, 172 - highest_score: ctx.highest_score, 173 - matches: ctx.matches, 182 + highest_score: ctx.highest_score.map(normalize_score), 183 + matches: ctx.matches.map(|matches| { 184 + matches 185 + .into_iter() 186 + .map(|mut m| { 187 + m.score = normalize_score(m.score); 188 + m 189 + }) 190 + .collect() 191 + }), 174 192 resolution_reason: None, 175 193 resolution_notes: None, 176 194 }; ··· 187 205 188 206 Ok(Json(EmitLabelResponse { seq, label })) 189 207 } 208 + 209 + #[cfg(test)] 210 + mod tests { 211 + use super::*; 212 + 213 + #[test] 214 + fn test_normalize_score() { 215 + // Integer scores (0-100) should be converted to 0.0-1.0 216 + assert!((normalize_score(85.0) - 0.85).abs() < 0.001); 217 + assert!((normalize_score(100.0) - 1.0).abs() < 0.001); 218 + assert!((normalize_score(50.0) - 0.5).abs() < 0.001); 219 + 220 + // Scores already in 0.0-1.0 range should stay unchanged 221 + assert!((normalize_score(0.85) - 0.85).abs() < 0.001); 222 + assert!((normalize_score(1.0) - 1.0).abs() < 0.001); 223 + assert!((normalize_score(0.5) - 0.5).abs() < 0.001); 224 + assert!((normalize_score(0.0) - 0.0).abs() < 0.001); 225 + } 226 + }
+43 -8
moderation/static/admin.css
··· 125 125 margin-bottom: 20px; 126 126 } 127 127 128 + /* filter controls */ 129 + .filter-row { 130 + display: flex; 131 + align-items: center; 132 + gap: 8px; 133 + margin-bottom: 16px; 134 + padding-bottom: 16px; 135 + border-bottom: 1px solid var(--border-subtle); 136 + } 137 + 138 + .filter-label { 139 + color: var(--text-tertiary); 140 + font-size: 0.85rem; 141 + margin-right: 4px; 142 + } 143 + 144 + .filter-btn { 145 + font-family: inherit; 146 + font-size: 0.8rem; 147 + padding: 6px 12px; 148 + border-radius: 4px; 149 + border: 1px solid var(--border-default); 150 + background: var(--bg-tertiary); 151 + color: var(--text-secondary); 152 + cursor: pointer; 153 + transition: all 0.15s ease; 154 + } 155 + 156 + .filter-btn:hover { 157 + background: var(--bg-hover); 158 + border-color: var(--border-emphasis); 159 + color: var(--text-primary); 160 + } 161 + 162 + .filter-btn.active { 163 + background: var(--accent); 164 + color: var(--bg-primary); 165 + border-color: var(--accent); 166 + } 167 + 128 168 .header-row h2 { 129 169 margin: 0; 130 170 font-size: 1.1rem; ··· 205 245 color: var(--success); 206 246 } 207 247 208 - .badge.score { 209 - background: rgba(239, 68, 68, 0.15); 210 - color: var(--error); 248 + .badge.matches { 249 + background: rgba(251, 191, 36, 0.15); 250 + color: #fbbf24; 211 251 } 212 252 213 253 .badge.env { ··· 250 290 251 291 .match-item .artist { 252 292 color: var(--text-tertiary); 253 - } 254 - 255 - .match-item .score { 256 - color: var(--error); 257 - font-weight: 500; 258 293 } 259 294 260 295 /* actions */
+3 -7
moderation/static/admin.html
··· 25 25 <div id="main-content" style="display: none;"> 26 26 <div class="header-row"> 27 27 <h2>flagged tracks</h2> 28 - <button class="btn btn-secondary" 29 - hx-get="/admin/flags-html" 30 - hx-target="#flags-list" 31 - hx-indicator="#refresh-indicator"> 32 - <span id="refresh-indicator" class="htmx-indicator">...</span> 28 + <button class="btn btn-secondary" onclick="refreshFlagsList()"> 33 29 refresh 34 30 </button> 35 31 </div> 36 32 37 33 <div id="flags-list" class="flags-list" 38 - hx-get="/admin/flags-html" 39 - hx-trigger="load, flagsUpdated from:body" 34 + hx-get="/admin/flags-html?filter=pending" 35 + hx-trigger="load" 40 36 hx-indicator="#loading"> 41 37 <div id="loading" class="loading htmx-indicator">loading...</div> 42 38 </div>
+17 -2
moderation/static/admin.js
··· 1 1 // Set up auth header listener first (before any htmx requests) 2 2 let currentToken = null; 3 + let currentFilter = 'pending'; // track current filter state 3 4 4 5 document.body.addEventListener('htmx:configRequest', function(evt) { 5 6 if (currentToken) { 6 7 evt.detail.headers['X-Moderation-Key'] = currentToken; 8 + } 9 + }); 10 + 11 + // Track filter changes via htmx 12 + document.body.addEventListener('htmx:afterRequest', function(evt) { 13 + const url = evt.detail.pathInfo?.requestPath || ''; 14 + const match = url.match(/filter=(\w+)/); 15 + if (match) { 16 + currentFilter = match[1]; 7 17 } 8 18 }); 9 19 ··· 115 125 }) 116 126 .then(response => { 117 127 if (response.ok) { 118 - // Trigger refresh of flags list 119 - htmx.trigger('#flags-list', 'flagsUpdated'); 120 128 return response.text(); 121 129 } 122 130 throw new Error('Failed to resolve'); ··· 127 135 if (match) { 128 136 showToast(match[0], 'success'); 129 137 } 138 + // Refresh flags list with current filter 139 + refreshFlagsList(); 130 140 }) 131 141 .catch(err => { 132 142 showToast('failed to resolve: ' + err.message, 'error'); 133 143 cancelResolve(btn); 134 144 }); 145 + } 146 + 147 + // Refresh flags list preserving current filter 148 + function refreshFlagsList() { 149 + htmx.ajax('GET', `/admin/flags-html?filter=${currentFilter}`, '#flags-list'); 135 150 } 136 151 137 152 // Cancel: restore original button