Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
cc8ef38
feat(FIT-720): Add global image cache for annotation switching
yyassi-heartex Jan 30, 2026
7e707ee
feat(FIT-720): Add global image cache for annotation switching
yyassi-heartex Jan 30, 2026
6980d38
feat(FIT-720): Add UI virtualization for large annotation counts
yyassi-heartex Jan 30, 2026
b604ba6
feat(FIT-720): Add TaskDistributionAPI for efficient label aggregation
yyassi-heartex Jan 30, 2026
e445e54
Merge remote-tracking branch 'origin/develop' into fb-fit-720/distrib…
yyassi-heartex Feb 5, 2026
4da79b7
increasing e2e ci timeout
yyassi-heartex Feb 5, 2026
7a690e2
Merge remote-tracking branch 'origin/develop' into fb-fit-720/distrib…
yyassi-heartex Feb 6, 2026
38fc8cd
cleaning up commit
yyassi-heartex Feb 6, 2026
d53c34f
pulling from the correct FF
yyassi-heartex Feb 6, 2026
121e638
clean up function that's not being used
yyassi-heartex Feb 6, 2026
3d79f2f
clean up comments
yyassi-heartex Feb 6, 2026
032350d
clean up comments
yyassi-heartex Feb 6, 2026
e57b101
Merge remote-tracking branch 'origin/develop' into fb-fit-720/distrib…
yyassi-heartex Feb 6, 2026
1e4df0d
making sure everything lines up with develop
yyassi-heartex Feb 6, 2026
a86dd72
fixing values when FF is on
yyassi-heartex Feb 6, 2026
867de94
making sure FF off works properly
yyassi-heartex Feb 6, 2026
261ebae
fixing unit test
yyassi-heartex Feb 6, 2026
12cdeb4
Sync Follow Merge dependencies
robot-ci-heartex Feb 6, 2026
52302c4
fixing pytest
yyassi-heartex Feb 6, 2026
cf93dc0
Merge branch 'fb-fit-720/distribution' of github.com:heartexlabs/labe…
yyassi-heartex Feb 6, 2026
0589d17
lint cleanup
yyassi-heartex Feb 6, 2026
ec29524
Merge branch 'develop' into fb-fit-720/distribution
yyassi-heartex Feb 9, 2026
54bcb54
Sync Follow Merge dependencies
matt-bernstein Feb 9, 2026
22c397f
Merge branch 'develop' into 'fb-fit-720/distribution'
matt-bernstein Feb 9, 2026
b488b7b
Sync Follow Merge dependencies
robot-ci-heartex Feb 9, 2026
1fb8cf7
registring new endpoint
yyassi-heartex Feb 9, 2026
2ad53ed
Merge branch 'fb-fit-720/distribution' of github.com:heartexlabs/labe…
yyassi-heartex Feb 9, 2026
e33b265
Sync Follow Merge dependencies
robot-ci-heartex Feb 9, 2026
863fd92
Merge remote-tracking branch 'origin/develop' into fb-fit-720/distrib…
yyassi-heartex Feb 9, 2026
15e609d
Merge branch 'fb-fit-720/distribution' of github.com:heartexlabs/labe…
yyassi-heartex Feb 9, 2026
51ab8cc
Merge remote-tracking branch 'origin/develop' into fb-fit-720/distrib…
yyassi-heartex Feb 9, 2026
a74ba2c
updating to /agreement instead of /distribution
yyassi-heartex Feb 9, 2026
c9a2837
Sync Follow Merge dependencies
robot-ci-heartex Feb 9, 2026
1d221fe
Merge branch 'develop' into 'fb-fit-720/distribution'
robot-ci-heartex Feb 9, 2026
1722e88
Sync Follow Merge dependencies
robot-ci-heartex Feb 9, 2026
c2b36df
Sync Follow Merge dependencies
yyassi-heartex Feb 9, 2026
6bb4a94
updating className to TaskAgreementAPI instead of TaskDistributionAPI
yyassi-heartex Feb 9, 2026
3617534
Sync Follow Merge dependencies
robot-ci-heartex Feb 9, 2026
afe2978
Sync Follow Merge dependencies
robot-ci-heartex Feb 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions label_studio/core/all_urls.json
Original file line number Diff line number Diff line change
Expand Up @@ -575,6 +575,12 @@
"name": "tasks:api:task-annotations-drafts",
"decorators": ""
},
{
"url": "/api/tasks/<int:pk>/agreement/",
"module": "tasks.api.TaskAgreementAPI",
"name": "tasks:api:task-agreement",
"decorators": ""
},
{
"url": "/api/annotations/<int:pk>/",
"module": "tasks.api.AnnotationAPI",
Expand Down
157 changes: 157 additions & 0 deletions label_studio/tasks/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import logging

from core.feature_flags import flag_set
from core.mixins import GetParentObjectMixin
from core.permissions import ViewClassPermission, all_permissions
from core.utils.common import is_community
Expand Down Expand Up @@ -405,6 +406,162 @@ def put(self, request, *args, **kwargs):
return super(TaskAPI, self).put(request, *args, **kwargs)


@method_decorator(
name='get',
decorator=extend_schema(
tags=['Tasks'],
summary='Get task label distribution',
description='Get aggregated label distribution across all annotations for a task. '
'Returns counts of each label value grouped by control tag. '
'This is an efficient endpoint that avoids N+1 queries.',
responses={
'200': OpenApiResponse(
description='Label distribution data',
examples=[
OpenApiExample(
name='response',
value={
'total_annotations': 100,
'distributions': {
'label': {
'type': 'rectanglelabels',
'labels': {'Car': 45, 'Person': 30, 'Dog': 25},
},
},
},
media_type='application/json',
)
],
)
},
extensions={
'x-fern-audiences': ['internal'],
},
),
)
class TaskAgreementAPI(generics.RetrieveAPIView):
"""
Efficient endpoint for getting label distribution without fetching all annotations.

This endpoint aggregates annotation results at the database level to avoid N+1 queries.
It returns pre-computed label counts for the Distribution row in the Summary view.
"""

permission_required = ViewClassPermission(GET=all_permissions.tasks_view)
queryset = Task.objects.all()

def get(self, request, pk):
# This endpoint is gated by feature flag
if not flag_set('fflag_fix_all_fit_720_lazy_load_annotations', user=request.user):
raise PermissionDenied('Feature not enabled')

try:
task = Task.objects.get(pk=pk)
except Task.DoesNotExist:
return Response({'error': 'Task not found'}, status=404)

# Check project access using LSO's native permission check
if not task.project.has_permission(request.user):
raise PermissionDenied('You do not have permission to view this task')

# Get all annotations for this task with their results in a single query
annotations = Annotation.objects.filter(
task=task,
was_cancelled=False,
).values_list('result', flat=True)

total_annotations = len(annotations)
distributions = {}

def merge_result_into_distributions(result):
"""Merge a single result (list of labeling items) into distributions in place."""
if not result or not isinstance(result, list):
return
for item in result:
if not isinstance(item, dict):
continue
from_name = item.get('from_name', '')
result_type = item.get('type', '')
value = item.get('value', {})

if from_name not in distributions:
distributions[from_name] = {
'type': result_type,
'labels': {},
'values': [],
}

if result_type.endswith('labels'):
labels = value.get(result_type, [])
if isinstance(labels, list):
for label in labels:
if label not in distributions[from_name]['labels']:
distributions[from_name]['labels'][label] = 0
distributions[from_name]['labels'][label] += 1

elif result_type == 'choices':
choices = value.get('choices', [])
if isinstance(choices, list):
for choice in choices:
if choice not in distributions[from_name]['labels']:
distributions[from_name]['labels'][choice] = 0
distributions[from_name]['labels'][choice] += 1

elif result_type == 'rating':
rating = value.get('rating')
if rating is not None:
distributions[from_name]['values'].append(rating)

elif result_type == 'number':
number = value.get('number')
if number is not None:
distributions[from_name]['values'].append(number)

elif result_type == 'taxonomy':
taxonomy = value.get('taxonomy', [])
if isinstance(taxonomy, list):
for path in taxonomy:
if isinstance(path, list) and path:
leaf = path[-1]
if leaf not in distributions[from_name]['labels']:
distributions[from_name]['labels'][leaf] = 0
distributions[from_name]['labels'][leaf] += 1

elif result_type == 'pairwise':
selected = value.get('selected')
if selected:
if selected not in distributions[from_name]['labels']:
distributions[from_name]['labels'][selected] = 0
distributions[from_name]['labels'][selected] += 1

# Process annotation results
for result in annotations:
merge_result_into_distributions(result)

# Include prediction results in distribution counts so aggregate matches
# client-side (develop / FF off). total_annotations stays annotation count only.
predictions = Prediction.objects.filter(task=task).values_list('result', flat=True)
for result in predictions:
# Prediction.result can be list (same as annotation) or dict
if isinstance(result, list):
merge_result_into_distributions(result)

# Post-process: calculate averages for numeric types
for from_name, dist in distributions.items():
if dist['values']:
dist['average'] = sum(dist['values']) / len(dist['values'])
dist['count'] = len(dist['values'])
# Remove raw values from response to keep it lightweight
del dist['values']

return Response(
{
'total_annotations': total_annotations,
'distributions': distributions,
}
)


@method_decorator(
name='get',
decorator=extend_schema(
Expand Down
Loading
Loading