vargha commited on
Commit
c574b13
Β·
1 Parent(s): faa204e

Resume Feature Implementation

Browse files
Files changed (2) hide show
  1. assets/styles.css +51 -5
  2. components/review_dashboard_page.py +294 -237
assets/styles.css CHANGED
@@ -130,21 +130,55 @@
130
  }
131
 
132
  .progress-details code {
133
- background: #f8f9fa;
134
- border: 1px solid #e9ecef;
135
- border-radius: 4px;
136
  padding: 2px 6px;
137
- font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
138
  font-size: 11px;
139
- letter-spacing: 0.5px;
140
  color: #495057;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  }
142
 
143
  .remaining-items {
144
  color: #6c757d;
 
145
  font-style: italic;
146
  }
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
  @keyframes pulse {
149
  0%, 100% { transform: scale(1); }
150
  50% { transform: scale(1.1); }
@@ -179,4 +213,16 @@
179
  .remaining-items {
180
  color: #adb5bd;
181
  }
 
 
 
 
 
 
 
 
 
 
 
 
182
  }
 
130
  }
131
 
132
  .progress-details code {
133
+ background-color: #f8f9fa;
 
 
134
  padding: 2px 6px;
135
+ border-radius: 4px;
136
  font-size: 11px;
 
137
  color: #495057;
138
+ border: 1px solid #dee2e6;
139
+ }
140
+
141
+ /* Progress Position Styles */
142
+ .progress-position {
143
+ margin-top: 8px;
144
+ padding: 8px 12px;
145
+ background: linear-gradient(135deg, #f0f8ff 0%, #e6f3ff 100%);
146
+ border: 1px solid #b3d9ff;
147
+ border-radius: 6px;
148
+ font-size: 13px;
149
+ color: #0066cc;
150
+ text-align: center;
151
+ font-weight: 500;
152
+ }
153
+
154
+ .progress-position strong {
155
+ color: #004499;
156
  }
157
 
158
  .remaining-items {
159
  color: #6c757d;
160
+ font-size: 12px;
161
  font-style: italic;
162
  }
163
 
164
+ /* Position Info Styles */
165
+ .position-info {
166
+ background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
167
+ border: 1px solid #90caf9;
168
+ border-radius: 8px;
169
+ padding: 12px 16px;
170
+ margin: 8px 0;
171
+ box-shadow: 0 2px 6px rgba(33, 150, 243, 0.15);
172
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
173
+ text-align: center;
174
+ color: #1565c0;
175
+ font-weight: 500;
176
+ }
177
+
178
+ .position-info strong {
179
+ color: #0d47a1;
180
+ }
181
+
182
  @keyframes pulse {
183
  0%, 100% { transform: scale(1); }
184
  50% { transform: scale(1.1); }
 
213
  .remaining-items {
214
  color: #adb5bd;
215
  }
216
+
217
+ /* Dark mode support for position info */
218
+ .position-info {
219
+ background: linear-gradient(135deg, #1e3a5f 0%, #1e4d7b 100%);
220
+ border-color: #1976d2;
221
+ color: #90caf9;
222
+ box-shadow: 0 2px 6px rgba(33, 150, 243, 0.3);
223
+ }
224
+
225
+ .position-info strong {
226
+ color: #64b5f6;
227
+ }
228
  }
components/review_dashboard_page.py CHANGED
@@ -3,7 +3,7 @@
3
  import gradio as gr
4
  import datetime
5
  import sentry_sdk
6
- from sqlalchemy import orm
7
 
8
  from components.header import Header
9
  from utils.logger import Logger
@@ -26,6 +26,10 @@ class ReviewDashboardPage:
26
  # Review info banner
27
  with gr.Row():
28
  self.review_info = gr.Markdown("", elem_classes="review-banner")
 
 
 
 
29
 
30
  with gr.Row():
31
  # Left Column - Review Content
@@ -68,6 +72,7 @@ class ReviewDashboardPage:
68
  min_width=120
69
  )
70
  self.btn_jump = gr.Button("Go to ID", min_width=70)
 
71
 
72
  # Right Column - Audio
73
  with gr.Column(scale=2):
@@ -100,7 +105,8 @@ class ReviewDashboardPage:
100
  # List of interactive UI elements for enabling/disabling
101
  self.interactive_ui_elements = [
102
  self.btn_prev, self.btn_next, self.btn_approve, self.btn_reject,
103
- self.btn_skip, self.btn_jump, self.jump_data_id_input, self.btn_load_voice
 
104
  ]
105
 
106
  def register_callbacks(self, login_page, session_state: gr.State, root_blocks: gr.Blocks):
@@ -171,13 +177,8 @@ class ReviewDashboardPage:
171
  validation_status += f" ({validation.description})"
172
 
173
  # For deleted annotations, show special status
174
- if is_deleted:
175
- if validation_status == "Not Reviewed":
176
- validation_status = "Not Reviewed (Deleted)"
177
- elif validation_status == "Approved":
178
- validation_status = "Approved (Deleted)"
179
- elif validation_status.startswith("Rejected"):
180
- validation_status = f"Rejected (Deleted{validation_status[8:] if validation_status[8:] else ''})"
181
 
182
  return validation_status, is_deleted
183
 
@@ -223,6 +224,19 @@ class ReviewDashboardPage:
223
  Validation.validator_id == user_id
224
  ).count()
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  if total_count > 0:
227
  percentage = (reviewed_count / total_count) * 100
228
 
@@ -254,7 +268,7 @@ class ReviewDashboardPage:
254
  # Estimate remaining items
255
  remaining = total_count - reviewed_count
256
 
257
- # Create the beautiful progress display
258
  progress_html = f"""
259
  <div class="progress-container">
260
  <div class="progress-header">
@@ -272,6 +286,9 @@ class ReviewDashboardPage:
272
  πŸ“Š <code>{progress_bar}</code>
273
  <span class="remaining-items">({remaining} remaining)</span>
274
  </div>
 
 
 
275
  </div>
276
  """
277
 
@@ -283,18 +300,64 @@ class ReviewDashboardPage:
283
  log.error(f"Error calculating review progress for user {user_id}: {e}")
284
  return f"⚠️ **Error calculating progress**"
285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
  def load_review_items_fn(session):
287
  user_id = session.get("user_id")
288
  username = session.get("username")
289
 
290
  if not user_id or not username:
291
  log.warning("load_review_items_fn: user not found in session")
292
- return [], 0, "", "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject")
293
 
294
  # Check if user is in Phase 2 (should be a reviewer)
295
  if username not in conf.REVIEW_MAPPING.values():
296
  log.warning(f"User {username} is not assigned as a reviewer")
297
- return [], 0, "", "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject")
298
 
299
  # Find which annotator this user should review
300
  target_annotator = None
@@ -305,7 +368,7 @@ class ReviewDashboardPage:
305
 
306
  if not target_annotator:
307
  log.warning(f"No target annotator found for reviewer {username}")
308
- return [], 0, "", "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject")
309
 
310
  # Load annotations from target annotator with FAST INITIAL LOADING
311
  with get_db() as db:
@@ -313,33 +376,99 @@ class ReviewDashboardPage:
313
  target_annotator_obj = db.query(Annotator).filter_by(name=target_annotator).first()
314
  if not target_annotator_obj:
315
  log.error(f"Target annotator {target_annotator} not found in database")
316
- return [], 0, f"Review Target Error: Annotator '{target_annotator}' not found.", "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject")
317
 
318
  log.info(f"Found target annotator with ID: {target_annotator_obj.id}")
319
 
320
- # FAST INITIAL QUERY: Load only essential data without complex validation processing
321
- # Reduced batch size for instant loading in HuggingFace spaces
322
- INITIAL_BATCH_SIZE = 5 # Load only 5 items initially for instant response
323
-
324
- # Simple query to get basic annotation data quickly
325
- initial_query = db.query(
326
- Annotation,
327
- TTSData.filename,
328
- TTSData.sentence
329
- ).join(
330
- TTSData, Annotation.tts_data_id == TTSData.id
331
- ).filter(
332
- Annotation.annotator_id == target_annotator_obj.id
333
- ).order_by(Annotation.id).limit(INITIAL_BATCH_SIZE)
334
-
335
- initial_results = initial_query.all()
336
-
337
  # Get total count for progress info (this is fast)
338
  total_count = db.query(Annotation).filter(
339
  Annotation.annotator_id == target_annotator_obj.id
340
  ).count()
341
 
342
- log.info(f"Fast initial load: {len(initial_results)} annotations out of {total_count} total for target annotator ID {target_annotator_obj.id}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
 
344
  # Process items with minimal data - validation status will be loaded on-demand
345
  items = []
@@ -360,100 +489,14 @@ class ReviewDashboardPage:
360
  "validation_loaded": False # Track if validation status has been loaded
361
  })
362
 
363
- # --- RESUME LOGIC: Find first unreviewed item ---
364
- initial_idx = 0
365
- if items:
366
- log.info(f"Starting resume logic search with {len(items)} items")
367
- # First, try to find the first unreviewed item that is not deleted
368
- found_unreviewed = False
369
- for i, item_data in enumerate(items):
370
- if not item_data.get("is_deleted", False):
371
- # Check if this item has been reviewed by the current validator
372
- validation = db.query(Validation).filter_by(
373
- annotation_id=item_data["annotation_id"],
374
- validator_id=user_id
375
- ).first()
376
-
377
- if not validation:
378
- # This item hasn't been reviewed yet
379
- initial_idx = i
380
- found_unreviewed = True
381
- log.info(f"βœ… Resume point found in initial batch: index {initial_idx}, TTS ID {item_data['tts_id']}, filename: {item_data['filename']}")
382
- break
383
- else:
384
- log.info(f"Item {i} (TTS ID {item_data['tts_id']}) already reviewed by validator {user_id}")
385
- else:
386
- log.info(f"Item {i} (TTS ID {item_data['tts_id']}) is deleted, skipping")
387
-
388
- # If no unreviewed non-deleted items found in initial batch,
389
- # we need to load more items to find the resume point
390
- if not found_unreviewed:
391
- log.info("No unreviewed items found in initial batch, loading more items to find resume point")
392
-
393
- # Load more items to find the resume point
394
- offset = len(items)
395
- additional_query = db.query(
396
- Annotation,
397
- TTSData.filename,
398
- TTSData.sentence
399
- ).join(
400
- TTSData, Annotation.tts_data_id == TTSData.id
401
- ).filter(
402
- Annotation.annotator_id == target_annotator_obj.id
403
- ).order_by(Annotation.id).offset(offset).limit(20) # Load more to find resume point
404
-
405
- additional_results = additional_query.all()
406
- log.info(f"Loaded {len(additional_results)} additional items to search for resume point")
407
-
408
- # Process additional items
409
- for annotation, filename, sentence in additional_results:
410
- is_deleted = not annotation.annotated_sentence or annotation.annotated_sentence.strip() == ""
411
- annotated_sentence_display = "[DELETED ANNOTATION]" if is_deleted else annotation.annotated_sentence
412
-
413
- items.append({
414
- "annotation_id": annotation.id,
415
- "tts_id": annotation.tts_data_id,
416
- "filename": filename,
417
- "sentence": sentence,
418
- "annotated_sentence": annotated_sentence_display,
419
- "is_deleted": is_deleted,
420
- "annotated_at": annotation.annotated_at.isoformat() if annotation.annotated_at else "",
421
- "validation_status": "Loading...",
422
- "validation_loaded": False
423
- })
424
-
425
- # Now search for the first unreviewed item in the expanded list
426
- log.info(f"Searching expanded list of {len(items)} items for resume point")
427
- for i, item_data in enumerate(items):
428
- if not item_data.get("is_deleted", False):
429
- validation = db.query(Validation).filter_by(
430
- annotation_id=item_data["annotation_id"],
431
- validator_id=user_id
432
- ).first()
433
-
434
- if not validation:
435
- initial_idx = i
436
- found_unreviewed = True
437
- log.info(f"βœ… Resume point found in expanded list: index {initial_idx}, TTS ID {item_data['tts_id']}, filename: {item_data['filename']}")
438
- break
439
- else:
440
- log.info(f"Item {i} (TTS ID {item_data['tts_id']}) already reviewed by validator {user_id}")
441
- else:
442
- log.info(f"Item {i} (TTS ID {item_data['tts_id']}) is deleted, skipping")
443
-
444
- # If still no unreviewed items found, start from the last item
445
- if not found_unreviewed:
446
- initial_idx = len(items) - 1 if items else 0
447
- log.info(f"⚠️ All items reviewed, starting at last item, index: {initial_idx}")
448
-
449
- log.info(f"Final resume decision: starting at index {initial_idx} with {len(items)} total items loaded")
450
-
451
  # Set initial display
452
  if items:
453
  initial_item = items[initial_idx]
454
- review_info_text = f"πŸ” **Phase 2 Review Mode** - Reviewing assigned annotations. Loaded {len(items)} of {total_count} total items."
455
- # Ensure correct order of return values for 12 outputs
456
- # items, idx, review_info, tts_id, filename, sentence, ann_sentence, annotated_at, validation_status, annotator_placeholder, audio_update, rejection_reason_update
457
  rejection_reason_val = ""
458
  rejection_visible_val = False
459
  if initial_item["validation_status"].startswith("Rejected"):
@@ -477,23 +520,17 @@ class ReviewDashboardPage:
477
  gr.update(value=None, autoplay=False), # audio_update
478
  gr.update(visible=rejection_visible_val, value=rejection_reason_val), # rejection_reason_input update
479
  False, # Reset rejection mode
480
- gr.update(value="❌ Reject") # Reset reject button
 
481
  )
482
  else:
483
- # Ensure correct order and number of return values for empty items (14 outputs)
484
- return [], 0, f"πŸ” **Phase 2 Review Mode** - No annotations found for review.", "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject")
485
-
486
- # except Exception as e:
487
- # log.error(f"Error loading review items: {e}")
488
- # sentry_sdk.capture_exception(e)
489
- # gr.Error(f"Failed to load review data: {e}")
490
- # # Ensure correct order and number of return values for error case (14 outputs)
491
- # return [], 0, "", "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject")
492
 
493
  def show_current_review_item_fn(items, idx, session):
494
  if not items or idx >= len(items) or idx < 0:
495
- # tts_id, filename, sentence, ann_sentence, annotated_at, validation_status, annotator_name_placeholder, audio_update, rejection_reason_update, rejection_mode_reset, btn_reject_update
496
- return "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject")
497
 
498
  current_item = items[idx]
499
 
@@ -526,7 +563,6 @@ class ReviewDashboardPage:
526
  # Check if this is a deleted annotation
527
  is_deleted = current_item.get("is_deleted", False)
528
 
529
- # Handle validation status display
530
  if current_item["validation_status"].startswith("Rejected"):
531
  # Extract reason from status like "Rejected (reason)" or just use empty if no parenthesis
532
  start_paren = current_item["validation_status"].find("(")
@@ -534,14 +570,9 @@ class ReviewDashboardPage:
534
  if start_paren != -1 and end_paren != -1:
535
  rejection_reason = current_item["validation_status"][start_paren+1:end_paren]
536
  rejection_visible = True
537
- elif current_item["validation_status"] == "Not Reviewed (Deleted)":
538
- # Special handling for deleted annotations that haven't been reviewed
539
- rejection_visible = False
540
- rejection_reason = ""
541
- elif current_item["validation_status"] == "Not Reviewed":
542
- # Regular unreviewed items
543
- rejection_visible = False
544
- rejection_reason = ""
545
 
546
  return (
547
  str(current_item["tts_id"]),
@@ -554,78 +585,18 @@ class ReviewDashboardPage:
554
  gr.update(value=None, autoplay=False),
555
  gr.update(visible=rejection_visible, value=rejection_reason),
556
  False, # Reset rejection mode
557
- gr.update(value="❌ Reject") # Reset reject button text
 
558
  )
559
 
560
  def update_review_info_fn(items, total_count):
561
- """Update the review info banner with current loaded items count"""
562
  if items:
563
- return f"πŸ” **Phase 2 Review Mode** - Reviewing assigned annotations. Loaded {len(items)} of {total_count} total items."
 
564
  else:
565
  return f"πŸ” **Phase 2 Review Mode** - No annotations found for review."
566
 
567
- def find_next_unreviewed_item(items, current_idx, session):
568
- """Find the next unreviewed item starting from current_idx"""
569
- if not items or current_idx >= len(items):
570
- log.warning(f"find_next_unreviewed_item: Invalid items or current_idx. items: {len(items) if items else 0}, current_idx: {current_idx}")
571
- return current_idx
572
-
573
- user_id = session.get("user_id")
574
- if not user_id:
575
- log.warning("find_next_unreviewed_item: No user_id in session")
576
- return current_idx
577
-
578
- log.info(f"Searching for next unreviewed item starting from index {current_idx} (TTS ID: {items[current_idx]['tts_id'] if current_idx < len(items) else 'N/A'})")
579
-
580
- # Start searching from the next item
581
- start_idx = current_idx + 1
582
- if start_idx >= len(items):
583
- start_idx = 0 # Wrap around to beginning
584
- log.info("Wrapping around to beginning of items list")
585
-
586
- # Search forward from start_idx
587
- for i in range(len(items)):
588
- check_idx = (start_idx + i) % len(items)
589
- item_data = items[check_idx]
590
-
591
- log.info(f"Checking item {check_idx} (TTS ID: {item_data['tts_id']}, filename: {item_data['filename']})")
592
-
593
- # Skip deleted annotations
594
- if item_data.get("is_deleted", False):
595
- log.info(f"Item {check_idx} is deleted, skipping")
596
- continue
597
-
598
- # Check if this item has been reviewed
599
- if not item_data.get("validation_loaded", False):
600
- # Load validation status on-demand
601
- with get_db() as db:
602
- try:
603
- validation = db.query(Validation).filter_by(
604
- annotation_id=item_data["annotation_id"],
605
- validator_id=user_id
606
- ).first()
607
- item_data["validation_loaded"] = True
608
- if validation:
609
- item_data["validation_status"] = "Approved" if validation.validated else f"Rejected ({validation.description})" if validation.description else "Rejected"
610
- log.info(f"Item {check_idx} validation status loaded: {item_data['validation_status']}")
611
- else:
612
- item_data["validation_status"] = "Not Reviewed"
613
- log.info(f"Item {check_idx} has no validation record - unreviewed")
614
- except Exception as e:
615
- log.error(f"Error loading validation status for item {check_idx}: {e}")
616
- item_data["validation_status"] = "Error loading status"
617
-
618
- # If not reviewed, this is our target
619
- if item_data["validation_status"] == "Not Reviewed":
620
- log.info(f"βœ… Found next unreviewed item at index {check_idx} (TTS ID: {item_data['tts_id']}, filename: {item_data['filename']})")
621
- return check_idx
622
- else:
623
- log.info(f"Item {check_idx} already reviewed with status: {item_data['validation_status']}")
624
-
625
- # If no unreviewed items found, return current index
626
- log.info("⚠️ No unreviewed items found, staying at current position")
627
- return current_idx
628
-
629
  def navigate_and_load_fn(items, current_idx, direction, session):
630
  """Combined navigation and loading function"""
631
  if not items:
@@ -787,18 +758,32 @@ class ReviewDashboardPage:
787
  ).count()
788
 
789
  # Load next batch starting from where we left off
790
- offset = len(items)
791
-
792
- # FAST LOADING: Use same strategy as initial load - simple query without complex JOINs
793
- query = db.query(
794
- Annotation,
795
- TTSData.filename,
796
- TTSData.sentence
797
- ).join(
798
- TTSData, Annotation.tts_data_id == TTSData.id
799
- ).filter(
800
- Annotation.annotator_id == target_annotator_obj.id
801
- ).order_by(Annotation.id).offset(offset).limit(current_batch_size)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
802
 
803
  results = query.all()
804
 
@@ -824,17 +809,66 @@ class ReviewDashboardPage:
824
  # Combine with existing items
825
  all_items = items + new_items
826
  log.info(f"Loaded {len(new_items)} more items, total now: {len(all_items)}")
827
-
828
- # If this is the first time loading more items, we might need to find the resume point
829
- # This ensures that even if the initial batch didn't find unreviewed items,
830
- # we can still resume from the correct position
831
- if len(items) == 5 and len(all_items) > 5: # Initial batch was 5 items
832
- log.info("First load_more call, checking if we need to find resume point")
833
- # The resume logic in load_review_items_fn should have already handled this,
834
- # but we can add additional safety here if needed
835
-
836
  return all_items, total_count
837
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
838
  # Output definitions
839
  review_display_outputs = [
840
  self.tts_id, self.filename, self.sentence, self.ann_sentence,
@@ -844,7 +878,8 @@ class ReviewDashboardPage:
844
  self.audio,
845
  self.rejection_reason_input, # Added rejection reason input to display outputs
846
  self.rejection_mode_active, # Added rejection mode state
847
- self.btn_reject # Added reject button to display outputs
 
848
  ]
849
 
850
  # Trigger data loading when load_trigger changes (after successful login for a reviewer)
@@ -912,9 +947,9 @@ class ReviewDashboardPage:
912
  fn=lambda: gr.update(value="❌ Reject"), # Reset reject button
913
  outputs=[self.btn_reject]
914
  ).then(
915
- fn=lambda items, idx, session: find_next_unreviewed_item(items, idx, session),
916
  inputs=[self.items_state, self.idx_state, session_state],
917
- outputs=[self.idx_state]
918
  ).then(
919
  fn=show_current_review_item_fn,
920
  inputs=[self.items_state, self.idx_state, session_state],
@@ -935,9 +970,9 @@ class ReviewDashboardPage:
935
  inputs=[self.items_state, self.idx_state, session_state, self.rejection_mode_active],
936
  outputs=[self.header.progress_display]
937
  ).then(
938
- fn=lambda items, idx, session, rejection_mode: find_next_unreviewed_item(items, idx, session) if not rejection_mode else idx,
939
  inputs=[self.items_state, self.idx_state, session_state, self.rejection_mode_active],
940
- outputs=[self.idx_state]
941
  ).then(
942
  fn=lambda items, idx, session, rejection_mode: show_current_review_item_fn(items, idx, session) if not rejection_mode else (
943
  str(items[idx]["tts_id"]) if items and idx < len(items) else "",
@@ -963,9 +998,9 @@ class ReviewDashboardPage:
963
 
964
  # Skip button (just navigate to next)
965
  self.btn_skip.click(
966
- fn=lambda items, idx, session: find_next_unreviewed_item(items, idx, session),
967
  inputs=[self.items_state, self.idx_state, session_state],
968
- outputs=[self.idx_state]
969
  ).then(
970
  fn=show_current_review_item_fn,
971
  inputs=[self.items_state, self.idx_state, session_state],
@@ -996,6 +1031,28 @@ class ReviewDashboardPage:
996
  outputs=self.jump_data_id_input
997
  )
998
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
999
  # Load audio button
1000
  self.btn_load_voice.click(
1001
  fn=lambda: update_ui_interactive_state(False),
 
3
  import gradio as gr
4
  import datetime
5
  import sentry_sdk
6
+ from sqlalchemy import orm, func
7
 
8
  from components.header import Header
9
  from utils.logger import Logger
 
26
  # Review info banner
27
  with gr.Row():
28
  self.review_info = gr.Markdown("", elem_classes="review-banner")
29
+
30
+ # Position info display
31
+ with gr.Row():
32
+ self.position_info = gr.Markdown("", elem_classes="position-info")
33
 
34
  with gr.Row():
35
  # Left Column - Review Content
 
72
  min_width=120
73
  )
74
  self.btn_jump = gr.Button("Go to ID", min_width=70)
75
+ self.btn_jump_to_unreviewed = gr.Button("🎯 Jump to Unreviewed", min_width=150, variant="secondary")
76
 
77
  # Right Column - Audio
78
  with gr.Column(scale=2):
 
105
  # List of interactive UI elements for enabling/disabling
106
  self.interactive_ui_elements = [
107
  self.btn_prev, self.btn_next, self.btn_approve, self.btn_reject,
108
+ self.btn_skip, self.btn_jump, self.jump_data_id_input, self.btn_load_voice,
109
+ self.btn_jump_to_unreviewed
110
  ]
111
 
112
  def register_callbacks(self, login_page, session_state: gr.State, root_blocks: gr.Blocks):
 
177
  validation_status += f" ({validation.description})"
178
 
179
  # For deleted annotations, show special status
180
+ if is_deleted and validation_status == "Not Reviewed":
181
+ validation_status = "Not Reviewed (Deleted)"
 
 
 
 
 
182
 
183
  return validation_status, is_deleted
184
 
 
224
  Validation.validator_id == user_id
225
  ).count()
226
 
227
+ # Find the first unreviewed item to show current position
228
+ first_unreviewed_query = db.query(Annotation.id).outerjoin(
229
+ Validation,
230
+ (Annotation.id == Validation.annotation_id) &
231
+ (Validation.validator_id == user_id)
232
+ ).filter(
233
+ Annotation.annotator_id == target_annotator_obj.id,
234
+ Validation.id.is_(None) # No validation record exists for this reviewer
235
+ ).order_by(Annotation.id).limit(1)
236
+
237
+ first_unreviewed_result = first_unreviewed_query.first()
238
+ current_position = "Completed" if not first_unreviewed_result else f"ID: {first_unreviewed_result.id}"
239
+
240
  if total_count > 0:
241
  percentage = (reviewed_count / total_count) * 100
242
 
 
268
  # Estimate remaining items
269
  remaining = total_count - reviewed_count
270
 
271
+ # Create the beautiful progress display with current position
272
  progress_html = f"""
273
  <div class="progress-container">
274
  <div class="progress-header">
 
286
  πŸ“Š <code>{progress_bar}</code>
287
  <span class="remaining-items">({remaining} remaining)</span>
288
  </div>
289
+ <div class="progress-position">
290
+ 🎯 <strong>Current Position:</strong> {current_position}
291
+ </div>
292
  </div>
293
  """
294
 
 
300
  log.error(f"Error calculating review progress for user {user_id}: {e}")
301
  return f"⚠️ **Error calculating progress**"
302
 
303
+ def get_current_item_position_info(items, current_idx, session):
304
+ """Get information about the current item's position in the review queue"""
305
+ if not items or current_idx >= len(items):
306
+ return ""
307
+
308
+ user_id = session.get("user_id")
309
+ username = session.get("username")
310
+ if not user_id or not username:
311
+ return ""
312
+
313
+ # Find target annotator
314
+ target_annotator = None
315
+ for annotator_name, reviewer_name in conf.REVIEW_MAPPING.items():
316
+ if reviewer_name == username:
317
+ target_annotator = annotator_name
318
+ break
319
+
320
+ if not target_annotator:
321
+ return ""
322
+
323
+ with get_db() as db:
324
+ try:
325
+ # Get target annotator's ID
326
+ target_annotator_obj = db.query(Annotator).filter_by(name=target_annotator).first()
327
+ if not target_annotator_obj:
328
+ return ""
329
+
330
+ # Get total count
331
+ total_count = db.query(Annotation).filter(
332
+ Annotation.annotator_id == target_annotator_obj.id
333
+ ).count()
334
+
335
+ # Get current item's position in the overall sequence
336
+ current_item_id = items[current_idx]["annotation_id"]
337
+ position_query = db.query(func.count(Annotation.id)).filter(
338
+ Annotation.annotator_id == target_annotator_obj.id,
339
+ Annotation.id <= current_item_id
340
+ )
341
+ current_position = position_query.scalar() or 0
342
+
343
+ return f"πŸ“ **Position:** {current_position} of {total_count} items"
344
+
345
+ except Exception as e:
346
+ log.error(f"Error getting current item position: {e}")
347
+ return ""
348
+
349
  def load_review_items_fn(session):
350
  user_id = session.get("user_id")
351
  username = session.get("username")
352
 
353
  if not user_id or not username:
354
  log.warning("load_review_items_fn: user not found in session")
355
+ return [], 0, "", "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject"), ""
356
 
357
  # Check if user is in Phase 2 (should be a reviewer)
358
  if username not in conf.REVIEW_MAPPING.values():
359
  log.warning(f"User {username} is not assigned as a reviewer")
360
+ return [], 0, "", "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject"), ""
361
 
362
  # Find which annotator this user should review
363
  target_annotator = None
 
368
 
369
  if not target_annotator:
370
  log.warning(f"No target annotator found for reviewer {username}")
371
+ return [], 0, "", "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject"), ""
372
 
373
  # Load annotations from target annotator with FAST INITIAL LOADING
374
  with get_db() as db:
 
376
  target_annotator_obj = db.query(Annotator).filter_by(name=target_annotator).first()
377
  if not target_annotator_obj:
378
  log.error(f"Target annotator {target_annotator} not found in database")
379
+ return [], 0, f"Review Target Error: Annotator '{target_annotator}' not found.", "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject"), ""
380
 
381
  log.info(f"Found target annotator with ID: {target_annotator_obj.id}")
382
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
383
  # Get total count for progress info (this is fast)
384
  total_count = db.query(Annotation).filter(
385
  Annotation.annotator_id == target_annotator_obj.id
386
  ).count()
387
 
388
+ # RESUME LOGIC: Find the first unreviewed item by this reviewer
389
+ # Query to find the first unreviewed annotation for this reviewer
390
+ first_unreviewed_query = db.query(
391
+ Annotation.id,
392
+ Annotation.tts_data_id,
393
+ Annotation.annotated_sentence,
394
+ Annotation.annotated_at
395
+ ).outerjoin(
396
+ Validation,
397
+ (Annotation.id == Validation.annotation_id) &
398
+ (Validation.validator_id == user_id)
399
+ ).filter(
400
+ Annotation.annotator_id == target_annotator_obj.id,
401
+ Validation.id.is_(None) # No validation record exists for this reviewer
402
+ ).order_by(Annotation.id).limit(1)
403
+
404
+ first_unreviewed_result = first_unreviewed_query.first()
405
+
406
+ if first_unreviewed_result:
407
+ # Found unreviewed item - start from there
408
+ first_unreviewed_id = first_unreviewed_result.id
409
+ log.info(f"Found first unreviewed item with ID: {first_unreviewed_id}")
410
+
411
+ # Load items starting from the unreviewed item
412
+ # Load a batch around the unreviewed item for context
413
+ BATCH_SIZE = 10
414
+ offset = max(0, first_unreviewed_id - 5) # Start 5 items before the unreviewed item
415
+
416
+ # Query to get items around the unreviewed item
417
+ items_query = db.query(
418
+ Annotation,
419
+ TTSData.filename,
420
+ TTSData.sentence
421
+ ).join(
422
+ TTSData, Annotation.tts_data_id == TTSData.id
423
+ ).filter(
424
+ Annotation.annotator_id == target_annotator_obj.id,
425
+ Annotation.id >= offset
426
+ ).order_by(Annotation.id).limit(BATCH_SIZE)
427
+
428
+ initial_results = items_query.all()
429
+
430
+ # Find the index of the first unreviewed item in our loaded batch
431
+ initial_idx = 0
432
+ for i, (annotation, filename, sentence) in enumerate(initial_results):
433
+ if annotation.id == first_unreviewed_id:
434
+ initial_idx = i
435
+ break
436
+
437
+ log.info(f"Resuming at first unreviewed item, index: {initial_idx} (ID: {first_unreviewed_id})")
438
+ else:
439
+ # No unreviewed items found - all items have been reviewed
440
+ log.info(f"All items for {target_annotator} have been reviewed by {username}")
441
+
442
+ # Load the last batch of items to show completion
443
+ BATCH_SIZE = 10
444
+ items_query = db.query(
445
+ Annotation,
446
+ TTSData.filename,
447
+ TTSData.sentence
448
+ ).join(
449
+ TTSData, Annotation.tts_data_id == TTSData.id
450
+ ).filter(
451
+ Annotation.annotator_id == target_annotator_obj.id
452
+ ).order_by(Annotation.id.desc()).limit(BATCH_SIZE)
453
+
454
+ # Reverse the results to maintain chronological order
455
+ initial_results = list(reversed(items_query.all()))
456
+ initial_idx = len(initial_results) - 1 if initial_results else 0
457
+
458
+ log.info(f"All items reviewed, starting at last item, index: {initial_idx}")
459
+
460
+ # Set completion message
461
+ if initial_results:
462
+ review_info_text = f"πŸŽ‰ **Phase 2 Review Mode** - All annotations for {target_annotator} have been reviewed! Showing last {len(initial_results)} items for reference."
463
+ else:
464
+ review_info_text = f"πŸŽ‰ **Phase 2 Review Mode** - All annotations for {target_annotator} have been reviewed! No items to display."
465
+
466
+ return (
467
+ [],
468
+ 0,
469
+ review_info_text,
470
+ "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject"), ""
471
+ )
472
 
473
  # Process items with minimal data - validation status will be loaded on-demand
474
  items = []
 
489
  "validation_loaded": False # Track if validation status has been loaded
490
  })
491
 
492
+ log.info(f"Fast initial load: {len(items)} annotations out of {total_count} total for target annotator ID {target_annotator_obj.id}")
493
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  # Set initial display
495
  if items:
496
  initial_item = items[initial_idx]
497
+ review_info_text = f"πŸ” **Phase 2 Review Mode** - Reviewing assigned annotations. Loaded {len(items)} of {total_count} total items. Resuming from ID: {initial_item['annotation_id']}"
498
+
499
+ # Ensure correct order of return values for 14 outputs
500
  rejection_reason_val = ""
501
  rejection_visible_val = False
502
  if initial_item["validation_status"].startswith("Rejected"):
 
520
  gr.update(value=None, autoplay=False), # audio_update
521
  gr.update(visible=rejection_visible_val, value=rejection_reason_val), # rejection_reason_input update
522
  False, # Reset rejection mode
523
+ gr.update(value="❌ Reject"), # Reset reject button
524
+ get_current_item_position_info(items, initial_idx, session) # Position info
525
  )
526
  else:
527
+ # Ensure correct order and number of return values for empty items (15 outputs)
528
+ return [], 0, f"πŸ” **Phase 2 Review Mode** - No annotations found for review.", "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject"), ""
 
 
 
 
 
 
 
529
 
530
  def show_current_review_item_fn(items, idx, session):
531
  if not items or idx >= len(items) or idx < 0:
532
+ # tts_id, filename, sentence, ann_sentence, annotated_at, validation_status, annotator_name_placeholder, audio_update, rejection_reason_update, rejection_mode_reset, btn_reject_update, position_info
533
+ return "", "", "", "", "", "", "", gr.update(value=None, autoplay=False), gr.update(visible=False, value=""), False, gr.update(value="❌ Reject"), ""
534
 
535
  current_item = items[idx]
536
 
 
563
  # Check if this is a deleted annotation
564
  is_deleted = current_item.get("is_deleted", False)
565
 
 
566
  if current_item["validation_status"].startswith("Rejected"):
567
  # Extract reason from status like "Rejected (reason)" or just use empty if no parenthesis
568
  start_paren = current_item["validation_status"].find("(")
 
570
  if start_paren != -1 and end_paren != -1:
571
  rejection_reason = current_item["validation_status"][start_paren+1:end_paren]
572
  rejection_visible = True
573
+
574
+ # Get position info
575
+ position_info = get_current_item_position_info(items, idx, session)
 
 
 
 
 
576
 
577
  return (
578
  str(current_item["tts_id"]),
 
585
  gr.update(value=None, autoplay=False),
586
  gr.update(visible=rejection_visible, value=rejection_reason),
587
  False, # Reset rejection mode
588
+ gr.update(value="❌ Reject"), # Reset reject button text
589
+ position_info # Position info
590
  )
591
 
592
  def update_review_info_fn(items, total_count):
593
+ """Update the review info banner with current loaded items count and position info"""
594
  if items:
595
+ current_item_id = items[0]["annotation_id"] if items else "N/A"
596
+ return f"πŸ” **Phase 2 Review Mode** - Reviewing assigned annotations. Loaded {len(items)} of {total_count} total items. Starting from ID: {current_item_id}"
597
  else:
598
  return f"πŸ” **Phase 2 Review Mode** - No annotations found for review."
599
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
  def navigate_and_load_fn(items, current_idx, direction, session):
601
  """Combined navigation and loading function"""
602
  if not items:
 
758
  ).count()
759
 
760
  # Load next batch starting from where we left off
761
+ if items:
762
+ # Get the last loaded annotation ID to continue from there
763
+ last_loaded_id = items[-1]["annotation_id"]
764
+
765
+ # Load next batch starting after the last loaded item
766
+ query = db.query(
767
+ Annotation,
768
+ TTSData.filename,
769
+ TTSData.sentence
770
+ ).join(
771
+ TTSData, Annotation.tts_data_id == TTSData.id
772
+ ).filter(
773
+ Annotation.annotator_id == target_annotator_obj.id,
774
+ Annotation.id > last_loaded_id # Continue from where we left off
775
+ ).order_by(Annotation.id).limit(current_batch_size)
776
+ else:
777
+ # No items loaded yet, start from the beginning
778
+ query = db.query(
779
+ Annotation,
780
+ TTSData.filename,
781
+ TTSData.sentence
782
+ ).join(
783
+ TTSData, Annotation.tts_data_id == TTSData.id
784
+ ).filter(
785
+ Annotation.annotator_id == target_annotator_obj.id
786
+ ).order_by(Annotation.id).limit(current_batch_size)
787
 
788
  results = query.all()
789
 
 
809
  # Combine with existing items
810
  all_items = items + new_items
811
  log.info(f"Loaded {len(new_items)} more items, total now: {len(all_items)}")
 
 
 
 
 
 
 
 
 
812
  return all_items, total_count
813
 
814
+ def _jump_to_next_unreviewed_fn(items, session):
815
+ """Helper function to jump to the next unreviewed item in the review queue."""
816
+ user_id = session.get("user_id")
817
+ username = session.get("username")
818
+ if not user_id or not username:
819
+ gr.Error("User not logged in")
820
+ return items, 0, ""
821
+
822
+ # Find target annotator
823
+ target_annotator = None
824
+ for annotator_name, reviewer_name in conf.REVIEW_MAPPING.items():
825
+ if reviewer_name == username:
826
+ target_annotator = annotator_name
827
+ break
828
+
829
+ if not target_annotator:
830
+ gr.Error("User is not assigned as a reviewer")
831
+ return items, 0, ""
832
+
833
+ with get_db() as db:
834
+ try:
835
+ # Get target annotator's ID
836
+ target_annotator_obj = db.query(Annotator).filter_by(name=target_annotator).first()
837
+ if not target_annotator_obj:
838
+ return items, 0, f"⚠️ **Error:** Annotator '{target_annotator}' not found"
839
+
840
+ # Find the first unreviewed annotation for this reviewer
841
+ first_unreviewed_query = db.query(Annotation.id).outerjoin(
842
+ Validation,
843
+ (Annotation.id == Validation.annotation_id) &
844
+ (Validation.validator_id == user_id)
845
+ ).filter(
846
+ Annotation.annotator_id == target_annotator_obj.id,
847
+ Validation.id.is_(None) # No validation record exists for this reviewer
848
+ ).order_by(Annotation.id).limit(1)
849
+
850
+ first_unreviewed_result = first_unreviewed_query.first()
851
+
852
+ if first_unreviewed_result:
853
+ target_id = first_unreviewed_result.id
854
+ log.info(f"Jumping to next unreviewed item with ID: {target_id}")
855
+
856
+ # Find this item in the current loaded items
857
+ for i, item in enumerate(items):
858
+ if item["annotation_id"] == target_id:
859
+ return items, i, f"🎯 **Jumped to unreviewed item ID: {target_id}**"
860
+
861
+ # If not found in current items, we need to load more items
862
+ # For now, just show a message
863
+ return items, 0, f"🎯 **Next unreviewed item ID: {target_id}** - Use navigation to reach it"
864
+ else:
865
+ log.info(f"No unreviewed items found for user {user_id}. Review queue is empty.")
866
+ return items, 0, "πŸŽ‰ **Phase 2 Review Mode** - All annotations for your assigned annotators have been reviewed! Your review queue is empty."
867
+ except Exception as e:
868
+ log.error(f"Error jumping to next unreviewed item: {e}")
869
+ sentry_sdk.capture_exception(e)
870
+ return items, 0, f"⚠️ **Error:** Could not find next unreviewed item. Error: {e}"
871
+
872
  # Output definitions
873
  review_display_outputs = [
874
  self.tts_id, self.filename, self.sentence, self.ann_sentence,
 
878
  self.audio,
879
  self.rejection_reason_input, # Added rejection reason input to display outputs
880
  self.rejection_mode_active, # Added rejection mode state
881
+ self.btn_reject, # Added reject button to display outputs
882
+ self.position_info # Added position info to display outputs
883
  ]
884
 
885
  # Trigger data loading when load_trigger changes (after successful login for a reviewer)
 
947
  fn=lambda: gr.update(value="❌ Reject"), # Reset reject button
948
  outputs=[self.btn_reject]
949
  ).then(
950
+ fn=lambda items, idx, session: navigate_and_load_fn(items, idx, "next", session),
951
  inputs=[self.items_state, self.idx_state, session_state],
952
+ outputs=[self.items_state, self.idx_state, self.review_info]
953
  ).then(
954
  fn=show_current_review_item_fn,
955
  inputs=[self.items_state, self.idx_state, session_state],
 
970
  inputs=[self.items_state, self.idx_state, session_state, self.rejection_mode_active],
971
  outputs=[self.header.progress_display]
972
  ).then(
973
+ fn=lambda items, idx, session, rejection_mode: navigate_and_load_fn(items, idx, "next", session) if not rejection_mode else (items, idx, ""),
974
  inputs=[self.items_state, self.idx_state, session_state, self.rejection_mode_active],
975
+ outputs=[self.items_state, self.idx_state, self.review_info]
976
  ).then(
977
  fn=lambda items, idx, session, rejection_mode: show_current_review_item_fn(items, idx, session) if not rejection_mode else (
978
  str(items[idx]["tts_id"]) if items and idx < len(items) else "",
 
998
 
999
  # Skip button (just navigate to next)
1000
  self.btn_skip.click(
1001
+ fn=lambda items, idx, session: navigate_and_load_fn(items, idx, "next", session),
1002
  inputs=[self.items_state, self.idx_state, session_state],
1003
+ outputs=[self.items_state, self.idx_state, self.review_info]
1004
  ).then(
1005
  fn=show_current_review_item_fn,
1006
  inputs=[self.items_state, self.idx_state, session_state],
 
1031
  outputs=self.jump_data_id_input
1032
  )
1033
 
1034
+ # Jump to Unreviewed button
1035
+ self.btn_jump_to_unreviewed.click(
1036
+ fn=lambda: update_ui_interactive_state(False),
1037
+ outputs=self.interactive_ui_elements
1038
+ ).then(
1039
+ fn=lambda items, session: _jump_to_next_unreviewed_fn(items, session),
1040
+ inputs=[self.items_state, session_state],
1041
+ outputs=[self.items_state, self.idx_state, self.review_info]
1042
+ ).then(
1043
+ fn=show_current_review_item_fn,
1044
+ inputs=[self.items_state, self.idx_state, session_state],
1045
+ outputs=review_display_outputs
1046
+ ).then(
1047
+ # Auto-load audio with autoplay after jumping
1048
+ fn=download_voice_fn,
1049
+ inputs=[self.filename],
1050
+ outputs=[self.audio, self.original_audio_state, self.audio]
1051
+ ).then(
1052
+ fn=lambda: update_ui_interactive_state(True),
1053
+ outputs=self.interactive_ui_elements
1054
+ )
1055
+
1056
  # Load audio button
1057
  self.btn_load_voice.click(
1058
  fn=lambda: update_ui_interactive_state(False),