Skip to content

Commit c02a00a

Browse files
yyassi-heartexrobot-ci-heartexmatt-bernstein
authored
fix: FIT-720: Avoid loading all annotations in LabelStream when the user is admin and goes to previous tasks (#9350)
Co-authored-by: robot-ci-heartex <robot-ci-heartex@users.noreply.github.com> Co-authored-by: matt-bernstein <matt-bernstein@users.noreply.github.com> Co-authored-by: yyassi-heartex <yyassi-heartex@users.noreply.github.com>
1 parent 4751c58 commit c02a00a

File tree

12 files changed

+805
-96
lines changed

12 files changed

+805
-96
lines changed

label_studio/core/all_urls.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,12 @@
575575
"name": "tasks:api:task-annotations-drafts",
576576
"decorators": ""
577577
},
578+
{
579+
"url": "/api/tasks/<int:pk>/agreement/",
580+
"module": "tasks.api.TaskAgreementAPI",
581+
"name": "tasks:api:task-agreement",
582+
"decorators": ""
583+
},
578584
{
579585
"url": "/api/annotations/<int:pk>/",
580586
"module": "tasks.api.AnnotationAPI",

label_studio/tasks/api.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44

5+
from core.feature_flags import flag_set
56
from core.mixins import GetParentObjectMixin
67
from core.permissions import ViewClassPermission, all_permissions
78
from core.utils.common import is_community
@@ -405,6 +406,162 @@ def put(self, request, *args, **kwargs):
405406
return super(TaskAPI, self).put(request, *args, **kwargs)
406407

407408

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+
408565
@method_decorator(
409566
name='get',
410567
decorator=extend_schema(

0 commit comments

Comments
 (0)