|
2 | 2 |
|
3 | 3 | import logging |
4 | 4 |
|
| 5 | +from core.feature_flags import flag_set |
5 | 6 | from core.mixins import GetParentObjectMixin |
6 | 7 | from core.permissions import ViewClassPermission, all_permissions |
7 | 8 | from core.utils.common import is_community |
@@ -405,6 +406,162 @@ def put(self, request, *args, **kwargs): |
405 | 406 | return super(TaskAPI, self).put(request, *args, **kwargs) |
406 | 407 |
|
407 | 408 |
|
| 409 | +@method_decorator( |
| 410 | + name='get', |
| 411 | + decorator=extend_schema( |
| 412 | + tags=['Tasks'], |
| 413 | + summary='Get task label distribution', |
| 414 | + description='Get aggregated label distribution across all annotations for a task. ' |
| 415 | + 'Returns counts of each label value grouped by control tag. ' |
| 416 | + 'This is an efficient endpoint that avoids N+1 queries.', |
| 417 | + responses={ |
| 418 | + '200': OpenApiResponse( |
| 419 | + description='Label distribution data', |
| 420 | + examples=[ |
| 421 | + OpenApiExample( |
| 422 | + name='response', |
| 423 | + value={ |
| 424 | + 'total_annotations': 100, |
| 425 | + 'distributions': { |
| 426 | + 'label': { |
| 427 | + 'type': 'rectanglelabels', |
| 428 | + 'labels': {'Car': 45, 'Person': 30, 'Dog': 25}, |
| 429 | + }, |
| 430 | + }, |
| 431 | + }, |
| 432 | + media_type='application/json', |
| 433 | + ) |
| 434 | + ], |
| 435 | + ) |
| 436 | + }, |
| 437 | + extensions={ |
| 438 | + 'x-fern-audiences': ['internal'], |
| 439 | + }, |
| 440 | + ), |
| 441 | +) |
| 442 | +class TaskAgreementAPI(generics.RetrieveAPIView): |
| 443 | + """ |
| 444 | + Efficient endpoint for getting label distribution without fetching all annotations. |
| 445 | +
|
| 446 | + This endpoint aggregates annotation results at the database level to avoid N+1 queries. |
| 447 | + It returns pre-computed label counts for the Distribution row in the Summary view. |
| 448 | + """ |
| 449 | + |
| 450 | + permission_required = ViewClassPermission(GET=all_permissions.tasks_view) |
| 451 | + queryset = Task.objects.all() |
| 452 | + |
| 453 | + def get(self, request, pk): |
| 454 | + # This endpoint is gated by feature flag |
| 455 | + if not flag_set('fflag_fix_all_fit_720_lazy_load_annotations', user=request.user): |
| 456 | + raise PermissionDenied('Feature not enabled') |
| 457 | + |
| 458 | + try: |
| 459 | + task = Task.objects.get(pk=pk) |
| 460 | + except Task.DoesNotExist: |
| 461 | + return Response({'error': 'Task not found'}, status=404) |
| 462 | + |
| 463 | + # Check project access using LSO's native permission check |
| 464 | + if not task.project.has_permission(request.user): |
| 465 | + raise PermissionDenied('You do not have permission to view this task') |
| 466 | + |
| 467 | + # Get all annotations for this task with their results in a single query |
| 468 | + annotations = Annotation.objects.filter( |
| 469 | + task=task, |
| 470 | + was_cancelled=False, |
| 471 | + ).values_list('result', flat=True) |
| 472 | + |
| 473 | + total_annotations = len(annotations) |
| 474 | + distributions = {} |
| 475 | + |
| 476 | + def merge_result_into_distributions(result): |
| 477 | + """Merge a single result (list of labeling items) into distributions in place.""" |
| 478 | + if not result or not isinstance(result, list): |
| 479 | + return |
| 480 | + for item in result: |
| 481 | + if not isinstance(item, dict): |
| 482 | + continue |
| 483 | + from_name = item.get('from_name', '') |
| 484 | + result_type = item.get('type', '') |
| 485 | + value = item.get('value', {}) |
| 486 | + |
| 487 | + if from_name not in distributions: |
| 488 | + distributions[from_name] = { |
| 489 | + 'type': result_type, |
| 490 | + 'labels': {}, |
| 491 | + 'values': [], |
| 492 | + } |
| 493 | + |
| 494 | + if result_type.endswith('labels'): |
| 495 | + labels = value.get(result_type, []) |
| 496 | + if isinstance(labels, list): |
| 497 | + for label in labels: |
| 498 | + if label not in distributions[from_name]['labels']: |
| 499 | + distributions[from_name]['labels'][label] = 0 |
| 500 | + distributions[from_name]['labels'][label] += 1 |
| 501 | + |
| 502 | + elif result_type == 'choices': |
| 503 | + choices = value.get('choices', []) |
| 504 | + if isinstance(choices, list): |
| 505 | + for choice in choices: |
| 506 | + if choice not in distributions[from_name]['labels']: |
| 507 | + distributions[from_name]['labels'][choice] = 0 |
| 508 | + distributions[from_name]['labels'][choice] += 1 |
| 509 | + |
| 510 | + elif result_type == 'rating': |
| 511 | + rating = value.get('rating') |
| 512 | + if rating is not None: |
| 513 | + distributions[from_name]['values'].append(rating) |
| 514 | + |
| 515 | + elif result_type == 'number': |
| 516 | + number = value.get('number') |
| 517 | + if number is not None: |
| 518 | + distributions[from_name]['values'].append(number) |
| 519 | + |
| 520 | + elif result_type == 'taxonomy': |
| 521 | + taxonomy = value.get('taxonomy', []) |
| 522 | + if isinstance(taxonomy, list): |
| 523 | + for path in taxonomy: |
| 524 | + if isinstance(path, list) and path: |
| 525 | + leaf = path[-1] |
| 526 | + if leaf not in distributions[from_name]['labels']: |
| 527 | + distributions[from_name]['labels'][leaf] = 0 |
| 528 | + distributions[from_name]['labels'][leaf] += 1 |
| 529 | + |
| 530 | + elif result_type == 'pairwise': |
| 531 | + selected = value.get('selected') |
| 532 | + if selected: |
| 533 | + if selected not in distributions[from_name]['labels']: |
| 534 | + distributions[from_name]['labels'][selected] = 0 |
| 535 | + distributions[from_name]['labels'][selected] += 1 |
| 536 | + |
| 537 | + # Process annotation results |
| 538 | + for result in annotations: |
| 539 | + merge_result_into_distributions(result) |
| 540 | + |
| 541 | + # Include prediction results in distribution counts so aggregate matches |
| 542 | + # client-side (develop / FF off). total_annotations stays annotation count only. |
| 543 | + predictions = Prediction.objects.filter(task=task).values_list('result', flat=True) |
| 544 | + for result in predictions: |
| 545 | + # Prediction.result can be list (same as annotation) or dict |
| 546 | + if isinstance(result, list): |
| 547 | + merge_result_into_distributions(result) |
| 548 | + |
| 549 | + # Post-process: calculate averages for numeric types |
| 550 | + for from_name, dist in distributions.items(): |
| 551 | + if dist['values']: |
| 552 | + dist['average'] = sum(dist['values']) / len(dist['values']) |
| 553 | + dist['count'] = len(dist['values']) |
| 554 | + # Remove raw values from response to keep it lightweight |
| 555 | + del dist['values'] |
| 556 | + |
| 557 | + return Response( |
| 558 | + { |
| 559 | + 'total_annotations': total_annotations, |
| 560 | + 'distributions': distributions, |
| 561 | + } |
| 562 | + ) |
| 563 | + |
| 564 | + |
408 | 565 | @method_decorator( |
409 | 566 | name='get', |
410 | 567 | decorator=extend_schema( |
|
0 commit comments