From cc8ef38fe4da54953b57dabc6c33f870770b43f2 Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 30 Jan 2026 15:09:34 -0500 Subject: [PATCH 01/28] feat(FIT-720): Add global image cache for annotation switching Add ImageCache.ts utility to prevent re-downloading images when switching between annotations on the same task. This improves performance by: - Caching images as blob URLs with reference counting - Deduplicating concurrent requests for the same image - Automatic cache eviction with LRU strategy - Error recovery with retry logic for failed loads Changes: - Add ImageCache.ts with ImageCacheManager class - Update ImageEntity.js to use global cache when FF enabled - Add onError handler to Image.jsx for blob URL failures - Add FF_FIT_720_LAZY_LOAD_ANNOTATIONS feature flag All changes are gated behind fflag_fix_all_fit_720_lazy_load_annotations --- web/biome.json | 3 +- .../editor/src/components/ImageView/Image.jsx | 15 +- .../src/tags/object/Image/ImageEntity.js | 163 +++++++-- web/libs/editor/src/utils/ImageCache.ts | 344 ++++++++++++++++++ web/libs/editor/src/utils/feature-flags.ts | 7 + 5 files changed, 504 insertions(+), 28 deletions(-) create mode 100644 web/libs/editor/src/utils/ImageCache.ts diff --git a/web/biome.json b/web/biome.json index a107e1f9aacc..227f6540177e 100644 --- a/web/biome.json +++ b/web/biome.json @@ -40,7 +40,8 @@ "a11y": { "noAutofocus": "off", "useKeyWithClickEvents": "off", - "noLabelWithoutControl": "off" + "noLabelWithoutControl": "off", + "noRedundantAlt": "off" }, "suspicious": { "noExplicitAny": "off", diff --git a/web/libs/editor/src/components/ImageView/Image.jsx b/web/libs/editor/src/components/ImageView/Image.jsx index 9f4e5a81dcc9..dd2dd7428b4d 100644 --- a/web/libs/editor/src/components/ImageView/Image.jsx +++ b/web/libs/editor/src/components/ImageView/Image.jsx @@ -40,6 +40,13 @@ export const Image = observer( [updateImageSize, imageEntity], ); + const onError = useCallback(() => { + // Handle image load failure (e.g., revoked blob URL, empty blob, network error) + // This sets the error state so the UI can show an error message + imageEntity.setError(true); + imageEntity.setImageLoaded(false); + }, [imageEntity]); + return (
{overlay} @@ -56,6 +63,7 @@ export const Image = observer( ref={ref} src={imageEntity.currentSrc} onLoad={onLoad} + onError={onError} isLoaded={imageEntity.imageLoaded} imageTransform={imageTransform} /> @@ -85,7 +93,7 @@ const ImageProgress = observer(({ downloading, progress, error, src, usedValue } const imgDefaultProps = { crossOrigin: "anonymous" }; const ImageRenderer = observer( - forwardRef(({ src, onLoad, imageTransform, isLoaded }, ref) => { + forwardRef(({ src, onLoad, onError, imageTransform, isLoaded }, ref) => { const imageStyles = useMemo(() => { // We can't just skip rendering the image because we need its dimensions to be set // so we just hide it with 0x0 dimensions. @@ -105,8 +113,9 @@ const ImageRenderer = observer( }; }, [imageTransform, isLoaded]); - // biome-ignore lint/a11y/noRedundantAlt: The use of this component justifies this alt text - return image; + return ( + image + ); }), ); diff --git a/web/libs/editor/src/tags/object/Image/ImageEntity.js b/web/libs/editor/src/tags/object/Image/ImageEntity.js index 4a2ac7c74629..b2c928559ffc 100644 --- a/web/libs/editor/src/tags/object/Image/ImageEntity.js +++ b/web/libs/editor/src/tags/object/Image/ImageEntity.js @@ -1,5 +1,6 @@ import { types, getParent } from "mobx-state-tree"; import { FileLoader } from "../../../utils/FileLoader"; +import { imageCache } from "../../../utils/ImageCache"; import { clamp } from "../../../utils/utilities"; import { FF_IMAGE_MEMORY_USAGE, isFF } from "../../../utils/feature-flags"; @@ -65,6 +66,10 @@ export const ImageEntity = types currentSrc: undefined, /** Is image loaded using `` tag and cached by the browser */ imageLoaded: false, + /** Track if we've attempted a retry after error */ + _retryAttempted: false, + /** Track the original URL for error recovery */ + _originalUrl: undefined, })) .views((self) => ({ get parent() { @@ -79,48 +84,110 @@ export const ImageEntity = types preload() { if (self.ensurePreloaded() || !self.src) return; - if (isFF(FF_IMAGE_MEMORY_USAGE)) { + // Store original URL for error recovery + self._originalUrl = self.src; + + // FIT-720: Use global image cache to prevent re-downloading on annotation switch + const crossOrigin = self.imageCrossOrigin; + + // Check if already cached in global cache + const cached = imageCache.get(self.src); + if (cached) { + // Add reference to prevent cache eviction while we're using this image + imageCache.addRef(self.src); + self.setCurrentSrc(cached.blobUrl); + self.setDownloaded(true); + self.setProgress(1); + self.setDownloading(false); + // DO NOT set imageLoaded here - wait for the actual onLoad event + // This prevents false positives when blob URLs are invalid/revoked + return; + } + + // Check if currently loading (deduplication) + if (imageCache.isLoading(self.src)) { self.setDownloading(true); - new Promise((resolve) => { - const img = new Image(); - // Get from the image tag - const crossOrigin = self.imageCrossOrigin; - if (crossOrigin) img.crossOrigin = crossOrigin; - img.onload = () => { - self.setCurrentSrc(self.src); + imageCache + .getPendingLoad(self.src) + ?.then((result) => { + imageCache.addRef(self.src); + self.setCurrentSrc(result.blobUrl); self.setDownloaded(true); self.setProgress(1); self.setDownloading(false); - self.setImageLoaded(true); - resolve(); - }; - img.onerror = () => { + // DO NOT set imageLoaded here - wait for onLoad + }) + .catch(() => { self.setError(true); self.setDownloading(false); - resolve(); - }; - img.src = self.src; - }); + }); return; } self.setDownloading(true); - fileLoader - .download(self.src, (_t, _l, progress) => { + + // Use the global cache for loading + imageCache + .load(self.src, crossOrigin, (progress) => { self.setProgress(progress); }) - .then((url) => { + .then((result) => { + imageCache.addRef(self.src); + self.setCurrentSrc(result.blobUrl); self.setDownloaded(true); + self.setProgress(1); self.setDownloading(false); - self.setCurrentSrc(url); + // DO NOT set imageLoaded here - wait for onLoad }) .catch(() => { - self.setDownloading(false); - self.setError(true); + // Fallback to old behavior if global cache fails + if (isFF(FF_IMAGE_MEMORY_USAGE)) { + const img = new Image(); + if (crossOrigin) img.crossOrigin = crossOrigin; + img.onload = () => { + self.setCurrentSrc(self.src); + self.setDownloaded(true); + self.setProgress(1); + self.setDownloading(false); + // imageLoaded will be set by onLoad in the component + }; + img.onerror = () => { + self.setError(true); + self.setDownloading(false); + }; + img.src = self.src; + } else { + fileLoader + .download(self.src, (_t, _l, progress) => { + self.setProgress(progress); + }) + .then((url) => { + self.setDownloaded(true); + self.setDownloading(false); + self.setCurrentSrc(url); + // imageLoaded will be set by onLoad in the component + }) + .catch(() => { + self.setDownloading(false); + self.setError(true); + }); + } }); }, ensurePreloaded() { + // FIT-720: First check global image cache + const cached = imageCache.get(self.src); + if (cached) { + imageCache.addRef(self.src); + self.setDownloading(false); + self.setDownloaded(true); + self.setProgress(1); + self.setCurrentSrc(cached.blobUrl); + // DO NOT set imageLoaded here - wait for onLoad + return true; + } + if (isFF(FF_IMAGE_MEMORY_USAGE)) return self.currentSrc !== undefined; if (fileLoader.isError(self.src)) { @@ -138,6 +205,16 @@ export const ImageEntity = types return false; }, + /** + * Release the reference to the cached image + * Should be called when the component unmounts or switches images + */ + releaseImage() { + if (self.src) { + imageCache.releaseRef(self.src); + } + }, + setImageLoaded(value) { self.imageLoaded = value; }, @@ -158,8 +235,46 @@ export const ImageEntity = types self.currentSrc = src; }, - setError() { - self.error = true; + /** + * Set error state and attempt recovery if not already retried + * @param {boolean} value - Whether to set error state + */ + setError(value = true) { + if (value && !self._retryAttempted && self._originalUrl) { + // Attempt recovery: force re-fetch from original URL + self._retryAttempted = true; + self.error = false; + + // Remove potentially corrupt cache entry + imageCache.forceRemove(self.src); + + // Re-attempt load + self.setDownloading(true); + self.setDownloaded(false); + self.setImageLoaded(false); + self.setCurrentSrc(undefined); + + const crossOrigin = self.imageCrossOrigin; + + imageCache + .load(self.src, crossOrigin, (progress) => { + self.setProgress(progress); + }) + .then((result) => { + imageCache.addRef(self.src); + self.setCurrentSrc(result.blobUrl); + self.setDownloaded(true); + self.setProgress(1); + self.setDownloading(false); + }) + .catch(() => { + // Final failure - set error state + self.error = true; + self.setDownloading(false); + }); + } else { + self.error = value; + } }, })) .actions((self) => ({ diff --git a/web/libs/editor/src/utils/ImageCache.ts b/web/libs/editor/src/utils/ImageCache.ts new file mode 100644 index 000000000000..9c4c72e48769 --- /dev/null +++ b/web/libs/editor/src/utils/ImageCache.ts @@ -0,0 +1,344 @@ +/** + * Global image cache that persists across annotation switches + * This prevents re-downloading the same images when switching between annotations on the same task + */ + +type CachedImage = { + blobUrl: string; + naturalWidth: number; + naturalHeight: number; + timestamp: number; + /** Reference count - number of active users of this blob URL */ + refCount: number; + /** Original URL for re-fetching if blob URL becomes invalid */ + originalUrl: string; +}; + +class ImageCacheManager { + private cache: Map = new Map(); + private pendingLoads: Map> = new Map(); + /** Track revoked blob URLs to detect invalid references */ + private revokedUrls: Set = new Set(); + + // Cache for 30 minutes by default + private readonly maxAge = 30 * 60 * 1000; + // Maximum cache size (100 images) + private readonly maxSize = 100; + // Minimum blob size in bytes (reject empty blobs) + private readonly minBlobSize = 100; + + /** + * Check if an image is already cached and valid + */ + has(url: string): boolean { + const cached = this.cache.get(url); + if (!cached) return false; + + // Check if cache entry is still valid (age check) + if (Date.now() - cached.timestamp > this.maxAge) { + this.safeRevokeBlobUrl(cached); + this.cache.delete(url); + return false; + } + + // Check if blob URL has been revoked + if (this.revokedUrls.has(cached.blobUrl)) { + this.cache.delete(url); + return false; + } + + return true; + } + + /** + * Get a cached image's blob URL + * Returns undefined if the cache entry is invalid or blob URL was revoked + */ + get(url: string): CachedImage | undefined { + const cached = this.cache.get(url); + if (!cached) return undefined; + + // Check if cache entry is still valid (age check) + if (Date.now() - cached.timestamp > this.maxAge) { + this.safeRevokeBlobUrl(cached); + this.cache.delete(url); + return undefined; + } + + // Check if blob URL has been revoked (validity check) + if (this.revokedUrls.has(cached.blobUrl)) { + this.cache.delete(url); + return undefined; + } + + return cached; + } + + /** + * Increment reference count for a cached image + * Call this when starting to use a cached blob URL + */ + addRef(url: string): void { + const cached = this.cache.get(url); + if (cached) { + cached.refCount++; + } + } + + /** + * Decrement reference count for a cached image + * Call this when done using a cached blob URL + */ + releaseRef(url: string): void { + const cached = this.cache.get(url); + if (cached && cached.refCount > 0) { + cached.refCount--; + } + } + + /** + * Safely revoke a blob URL and track it as revoked + */ + private safeRevokeBlobUrl(cached: CachedImage): void { + if (cached.blobUrl && !this.revokedUrls.has(cached.blobUrl)) { + URL.revokeObjectURL(cached.blobUrl); + this.revokedUrls.add(cached.blobUrl); + // Limit the size of revoked URLs set to prevent memory leaks + if (this.revokedUrls.size > 1000) { + const iterator = this.revokedUrls.values(); + for (let i = 0; i < 500; i++) { + const next = iterator.next(); + if (next.done) break; + this.revokedUrls.delete(next.value); + } + } + } + } + + /** + * Check if a blob URL is known to be revoked/invalid + */ + isBlobUrlRevoked(blobUrl: string): boolean { + return this.revokedUrls.has(blobUrl); + } + + /** + * Check if an image is currently being loaded + */ + isLoading(url: string): boolean { + return this.pendingLoads.has(url); + } + + /** + * Get the pending load promise for an image + */ + getPendingLoad(url: string): Promise | undefined { + return this.pendingLoads.get(url); + } + + /** + * Load an image and cache it + * If the same image is already being loaded, return the existing promise (deduplication) + */ + async load(url: string, crossOrigin?: string, onProgress?: (progress: number) => void): Promise { + // Check cache first + const cached = this.get(url); + if (cached) { + onProgress?.(1); + return cached; + } + + // Check if already loading (deduplication) + const pending = this.pendingLoads.get(url); + if (pending) { + return pending; + } + + // Start new load + const loadPromise = this.loadImage(url, crossOrigin, onProgress); + this.pendingLoads.set(url, loadPromise); + + try { + const result = await loadPromise; + return result; + } finally { + this.pendingLoads.delete(url); + } + } + + private async loadImage( + url: string, + crossOrigin?: string, + onProgress?: (progress: number) => void, + ): Promise { + return new Promise((resolve, reject) => { + // Use fetch with XHR for progress tracking + const xhr = new XMLHttpRequest(); + xhr.responseType = "blob"; + + xhr.addEventListener("load", async () => { + if (xhr.readyState === 4 && xhr.status === 200) { + const blob = xhr.response as Blob; + + // Validate blob size - reject empty or too small blobs + if (!blob || blob.size < this.minBlobSize) { + reject(new Error(`Empty or invalid image data received: ${url} (size: ${blob?.size ?? 0} bytes)`)); + return; + } + + // Validate content type is an image + if (blob.type && !blob.type.startsWith("image/")) { + reject(new Error(`Invalid content type for image: ${blob.type} (url: ${url})`)); + return; + } + + const blobUrl = URL.createObjectURL(blob); + + // Get natural dimensions by loading into an Image + const img = new Image(); + if (crossOrigin) img.crossOrigin = crossOrigin; + + img.onload = () => { + // Validate dimensions - reject if image has no dimensions + if (img.naturalWidth === 0 || img.naturalHeight === 0) { + URL.revokeObjectURL(blobUrl); + this.revokedUrls.add(blobUrl); + reject(new Error(`Image has invalid dimensions (0x0): ${url}`)); + return; + } + + const cachedImage: CachedImage = { + blobUrl, + naturalWidth: img.naturalWidth, + naturalHeight: img.naturalHeight, + timestamp: Date.now(), + refCount: 0, + originalUrl: url, + }; + + // Ensure cache doesn't grow too large + this.ensureCacheSize(); + this.cache.set(url, cachedImage); + + resolve(cachedImage); + }; + + img.onerror = () => { + URL.revokeObjectURL(blobUrl); + this.revokedUrls.add(blobUrl); + reject(new Error(`Failed to load image dimensions: ${url}`)); + }; + + img.src = blobUrl; + } else { + reject(new Error(`Failed to download image: ${xhr.status}`)); + } + }); + + xhr.addEventListener("progress", (e) => { + if (e.lengthComputable) { + onProgress?.(e.loaded / e.total); + } + }); + + xhr.addEventListener("error", () => { + reject(new Error(`Network error loading image: ${url}`)); + }); + + xhr.open("GET", url); + xhr.send(); + }); + } + + private ensureCacheSize(): void { + if (this.cache.size >= this.maxSize) { + // Remove oldest entries that are not in use (refCount === 0) + const entriesToRemove = this.cache.size - this.maxSize + 1; + let removed = 0; + + // First pass: remove entries with refCount === 0 + for (const [key, value] of this.cache) { + if (removed >= entriesToRemove) break; + // Only remove entries that are not actively in use + if (value.refCount === 0) { + this.safeRevokeBlobUrl(value); + this.cache.delete(key); + removed++; + } + } + + // If we couldn't remove enough entries (all in use), log a warning + // but don't force-remove in-use entries to prevent rendering failures + if (removed < entriesToRemove) { + console.warn( + `ImageCache: Unable to evict ${entriesToRemove - removed} entries (all in use). ` + + `Cache size: ${this.cache.size}, active refs: ${this.getActiveRefCount()}`, + ); + } + } + } + + /** + * Get count of images with active references + */ + private getActiveRefCount(): number { + let count = 0; + for (const value of this.cache.values()) { + if (value.refCount > 0) count++; + } + return count; + } + + /** + * Clear the entire cache (useful for cleanup) + * Only clears entries with no active references + */ + clear(): void { + for (const [key, value] of this.cache) { + if (value.refCount === 0) { + this.safeRevokeBlobUrl(value); + this.cache.delete(key); + } + } + } + + /** + * Force clear all cache entries (use with caution - may break active images) + */ + forceClear(): void { + for (const value of this.cache.values()) { + this.safeRevokeBlobUrl(value); + } + this.cache.clear(); + } + + /** + * Remove a specific image from cache + * Only removes if not in active use (refCount === 0) + */ + remove(url: string): void { + const cached = this.cache.get(url); + if (cached) { + if (cached.refCount === 0) { + this.safeRevokeBlobUrl(cached); + this.cache.delete(url); + } else { + console.warn(`ImageCache: Cannot remove ${url} - still in use (refCount: ${cached.refCount})`); + } + } + } + + /** + * Force remove a specific image from cache (use with caution) + */ + forceRemove(url: string): void { + const cached = this.cache.get(url); + if (cached) { + this.safeRevokeBlobUrl(cached); + this.cache.delete(url); + } + } +} + +// Singleton instance - persists across annotation switches +export const imageCache = new ImageCacheManager(); diff --git a/web/libs/editor/src/utils/feature-flags.ts b/web/libs/editor/src/utils/feature-flags.ts index 72fa9d7cf660..b9cadb13a2e1 100644 --- a/web/libs/editor/src/utils/feature-flags.ts +++ b/web/libs/editor/src/utils/feature-flags.ts @@ -163,6 +163,13 @@ export const FF_IMAGE_MEMORY_USAGE = "fflag_feat_front_optic_1479_improve_image_ export const FF_VIDEO_FRAME_SEEK_PRECISION = "fflag_fix_front_optic_1608_improve_video_frame_seek_precision_short"; +/** + * Lazy load annotations in LabelStream to improve performance for tasks with many annotations + * Also enables virtualization of annotation tabs carousel + * @link https://app.launchdarkly.com/default/production/features/fflag_fix_all_fit_720_lazy_load_annotations + */ +export const FF_FIT_720_LAZY_LOAD_ANNOTATIONS = "fflag_fix_all_fit_720_lazy_load_annotations"; + /** * Strict task overlap enforcement - prevents annotators from submitting * annotations when task overlap limit has been reached From 7e707ee14e9a1d5670c5259070db76ae28db6704 Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 30 Jan 2026 15:09:34 -0500 Subject: [PATCH 02/28] feat(FIT-720): Add global image cache for annotation switching Add ImageCache.ts utility to prevent re-downloading images when switching between annotations on the same task. This improves performance by: - Caching images as blob URLs with reference counting - Deduplicating concurrent requests for the same image - Automatic cache eviction with LRU strategy - Error recovery with retry logic for failed loads Changes: - Add ImageCache.ts with ImageCacheManager class - Update ImageEntity.js to use global cache when FF enabled - Add onError handler to Image.jsx for blob URL failures - Add FF_FIT_720_LAZY_LOAD_ANNOTATIONS feature flag All changes are gated behind fflag_fix_all_fit_720_lazy_load_annotations --- .../src/tags/object/Image/ImageEntity.js | 201 +++++++++++------- 1 file changed, 127 insertions(+), 74 deletions(-) diff --git a/web/libs/editor/src/tags/object/Image/ImageEntity.js b/web/libs/editor/src/tags/object/Image/ImageEntity.js index b2c928559ffc..c331ce8c8c61 100644 --- a/web/libs/editor/src/tags/object/Image/ImageEntity.js +++ b/web/libs/editor/src/tags/object/Image/ImageEntity.js @@ -2,7 +2,7 @@ import { types, getParent } from "mobx-state-tree"; import { FileLoader } from "../../../utils/FileLoader"; import { imageCache } from "../../../utils/ImageCache"; import { clamp } from "../../../utils/utilities"; -import { FF_IMAGE_MEMORY_USAGE, isFF } from "../../../utils/feature-flags"; +import { FF_FIT_720_LAZY_LOAD_ANNOTATIONS, FF_IMAGE_MEMORY_USAGE, isFF } from "../../../utils/feature-flags"; const fileLoader = new FileLoader(); @@ -88,28 +88,51 @@ export const ImageEntity = types self._originalUrl = self.src; // FIT-720: Use global image cache to prevent re-downloading on annotation switch - const crossOrigin = self.imageCrossOrigin; + if (isFF(FF_FIT_720_LAZY_LOAD_ANNOTATIONS)) { + const crossOrigin = self.imageCrossOrigin; - // Check if already cached in global cache - const cached = imageCache.get(self.src); - if (cached) { - // Add reference to prevent cache eviction while we're using this image - imageCache.addRef(self.src); - self.setCurrentSrc(cached.blobUrl); - self.setDownloaded(true); - self.setProgress(1); - self.setDownloading(false); - // DO NOT set imageLoaded here - wait for the actual onLoad event - // This prevents false positives when blob URLs are invalid/revoked - return; - } + // Check if already cached in global cache + const cached = imageCache.get(self.src); + if (cached) { + // Add reference to prevent cache eviction while we're using this image + imageCache.addRef(self.src); + self.setCurrentSrc(cached.blobUrl); + self.setDownloaded(true); + self.setProgress(1); + self.setDownloading(false); + // DO NOT set imageLoaded here - wait for the actual onLoad event + // This prevents false positives when blob URLs are invalid/revoked + return; + } + + // Check if currently loading (deduplication) + if (imageCache.isLoading(self.src)) { + self.setDownloading(true); + imageCache + .getPendingLoad(self.src) + ?.then((result) => { + imageCache.addRef(self.src); + self.setCurrentSrc(result.blobUrl); + self.setDownloaded(true); + self.setProgress(1); + self.setDownloading(false); + // DO NOT set imageLoaded here - wait for onLoad + }) + .catch(() => { + self.setError(true); + self.setDownloading(false); + }); + return; + } - // Check if currently loading (deduplication) - if (imageCache.isLoading(self.src)) { self.setDownloading(true); + + // Use the global cache for loading imageCache - .getPendingLoad(self.src) - ?.then((result) => { + .load(self.src, crossOrigin, (progress) => { + self.setProgress(progress); + }) + .then((result) => { imageCache.addRef(self.src); self.setCurrentSrc(result.blobUrl); self.setDownloaded(true); @@ -118,74 +141,104 @@ export const ImageEntity = types // DO NOT set imageLoaded here - wait for onLoad }) .catch(() => { + // Fallback to old behavior if global cache fails + self.fallbackPreload(); + }); + return; + } + + if (isFF(FF_IMAGE_MEMORY_USAGE)) { + self.setDownloading(true); + new Promise((resolve) => { + const img = new Image(); + // Get from the image tag + const crossOrigin = self.imageCrossOrigin; + if (crossOrigin) img.crossOrigin = crossOrigin; + img.onload = () => { + self.setCurrentSrc(self.src); + self.setDownloaded(true); + self.setProgress(1); + self.setDownloading(false); + self.setImageLoaded(true); + resolve(); + }; + img.onerror = () => { self.setError(true); self.setDownloading(false); - }); + resolve(); + }; + img.src = self.src; + }); return; } self.setDownloading(true); - - // Use the global cache for loading - imageCache - .load(self.src, crossOrigin, (progress) => { + fileLoader + .download(self.src, (_t, _l, progress) => { self.setProgress(progress); }) - .then((result) => { - imageCache.addRef(self.src); - self.setCurrentSrc(result.blobUrl); + .then((url) => { self.setDownloaded(true); - self.setProgress(1); self.setDownloading(false); - // DO NOT set imageLoaded here - wait for onLoad + self.setCurrentSrc(url); }) .catch(() => { - // Fallback to old behavior if global cache fails - if (isFF(FF_IMAGE_MEMORY_USAGE)) { - const img = new Image(); - if (crossOrigin) img.crossOrigin = crossOrigin; - img.onload = () => { - self.setCurrentSrc(self.src); - self.setDownloaded(true); - self.setProgress(1); - self.setDownloading(false); - // imageLoaded will be set by onLoad in the component - }; - img.onerror = () => { - self.setError(true); - self.setDownloading(false); - }; - img.src = self.src; - } else { - fileLoader - .download(self.src, (_t, _l, progress) => { - self.setProgress(progress); - }) - .then((url) => { - self.setDownloaded(true); - self.setDownloading(false); - self.setCurrentSrc(url); - // imageLoaded will be set by onLoad in the component - }) - .catch(() => { - self.setDownloading(false); - self.setError(true); - }); - } + self.setDownloading(false); + self.setError(true); }); }, + /** + * Fallback preload method for when global cache fails + */ + fallbackPreload() { + const crossOrigin = self.imageCrossOrigin; + if (isFF(FF_IMAGE_MEMORY_USAGE)) { + const img = new Image(); + if (crossOrigin) img.crossOrigin = crossOrigin; + img.onload = () => { + self.setCurrentSrc(self.src); + self.setDownloaded(true); + self.setProgress(1); + self.setDownloading(false); + // imageLoaded will be set by onLoad in the component + }; + img.onerror = () => { + self.setError(true); + self.setDownloading(false); + }; + img.src = self.src; + } else { + fileLoader + .download(self.src, (_t, _l, progress) => { + self.setProgress(progress); + }) + .then((url) => { + self.setDownloaded(true); + self.setDownloading(false); + self.setCurrentSrc(url); + // imageLoaded will be set by onLoad in the component + }) + .catch(() => { + self.setDownloading(false); + self.setError(true); + }); + } + }, + ensurePreloaded() { // FIT-720: First check global image cache - const cached = imageCache.get(self.src); - if (cached) { - imageCache.addRef(self.src); - self.setDownloading(false); - self.setDownloaded(true); - self.setProgress(1); - self.setCurrentSrc(cached.blobUrl); - // DO NOT set imageLoaded here - wait for onLoad - return true; + if (isFF(FF_FIT_720_LAZY_LOAD_ANNOTATIONS)) { + const cached = imageCache.get(self.src); + if (cached) { + imageCache.addRef(self.src); + self.setDownloading(false); + self.setDownloaded(true); + self.setProgress(1); + self.setCurrentSrc(cached.blobUrl); + // DO NOT set imageLoaded here - wait for onLoad + return true; + } } if (isFF(FF_IMAGE_MEMORY_USAGE)) return self.currentSrc !== undefined; @@ -210,7 +263,7 @@ export const ImageEntity = types * Should be called when the component unmounts or switches images */ releaseImage() { - if (self.src) { + if (self.src && isFF(FF_FIT_720_LAZY_LOAD_ANNOTATIONS)) { imageCache.releaseRef(self.src); } }, @@ -240,7 +293,7 @@ export const ImageEntity = types * @param {boolean} value - Whether to set error state */ setError(value = true) { - if (value && !self._retryAttempted && self._originalUrl) { + if (value && !self._retryAttempted && self._originalUrl && isFF(FF_FIT_720_LAZY_LOAD_ANNOTATIONS)) { // Attempt recovery: force re-fetch from original URL self._retryAttempted = true; self.error = false; @@ -272,9 +325,9 @@ export const ImageEntity = types self.error = true; self.setDownloading(false); }); - } else { - self.error = value; + return; } + self.error = value; }, })) .actions((self) => ({ From 6980d38f3077a8e1fab64aa7192affbaa8c5883b Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 30 Jan 2026 15:13:53 -0500 Subject: [PATCH 03/28] feat(FIT-720): Add UI virtualization for large annotation counts Add react-window virtualization for AnnotationsCarousel and Grid components to improve performance when viewing tasks with many annotations. Changes: - AnnotationsCarousel: Virtualize annotation tabs when >50 items - Grid: Add VirtualizedGrid component for Compare view when >10 items - Add styles for virtualized containers UI virtualization only - hydration triggers will be added in stub-hydration branch. All changes gated behind fflag_fix_all_fit_720_lazy_load_annotations. --- .../AnnotationsCarousel.scss | 9 +- .../AnnotationsCarousel.tsx | 220 +++++++++++++++--- web/libs/editor/src/components/App/Grid.jsx | 182 ++++++++++++++- .../src/components/App/Grid.module.scss | 37 ++- 4 files changed, 404 insertions(+), 44 deletions(-) diff --git a/web/libs/editor/src/components/AnnotationsCarousel/AnnotationsCarousel.scss b/web/libs/editor/src/components/AnnotationsCarousel/AnnotationsCarousel.scss index 147dc6f1afdc..274b2ce287fa 100644 --- a/web/libs/editor/src/components/AnnotationsCarousel/AnnotationsCarousel.scss +++ b/web/libs/editor/src/components/AnnotationsCarousel/AnnotationsCarousel.scss @@ -23,11 +23,18 @@ } &_scrolled { - &::before { + &::before { opacity: 1; } } + // FIT-720: Virtualized carousel styles + &_virtualized { + .annotations-carousel__container { + overflow: visible; + } + } + &__container { display: flex; width: 100%; diff --git a/web/libs/editor/src/components/AnnotationsCarousel/AnnotationsCarousel.tsx b/web/libs/editor/src/components/AnnotationsCarousel/AnnotationsCarousel.tsx index aec5edc02a17..34ed78d1cfa9 100644 --- a/web/libs/editor/src/components/AnnotationsCarousel/AnnotationsCarousel.tsx +++ b/web/libs/editor/src/components/AnnotationsCarousel/AnnotationsCarousel.tsx @@ -1,17 +1,48 @@ import { Button, IconChevronLeft, IconChevronRight } from "@humansignal/ui"; import { observer } from "mobx-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { FixedSizeList as List, ListChildComponentProps } from "react-window"; +import AutoSizer from "react-virtualized-auto-sizer"; import { cn } from "../../utils/bem"; import { clamp, sortAnnotations } from "../../utils/utilities"; +import { FF_FIT_720_LAZY_LOAD_ANNOTATIONS, isFF } from "../../utils/feature-flags"; import { AnnotationButton } from "./AnnotationButton"; import "./AnnotationsCarousel.scss"; +// FIT-720: Virtualization constants +const ITEM_WIDTH = 200; // Approximate width of each annotation button (min-width: 186px + gap) +const ITEM_GAP = 4; // Gap between items (--spacing-tighter) +const VIRTUALIZATION_THRESHOLD = 50; // Only virtualize if more than this many items + interface AnnotationsCarouselInterface { store: any; annotationStore: any; commentStore?: any; } +// FIT-720: Item data type for virtualized list +interface ItemData { + entities: any[]; + capabilities: any; + annotationStore: any; + store: any; +} + +const VirtualizedAnnotationButton = ({ index, style, data }: ListChildComponentProps) => { + const entity = data.entities[index]; + return ( +
+ +
+ ); +}; + export const AnnotationsCarousel = observer(({ store, annotationStore }: AnnotationsCarouselInterface) => { const [entities, setEntities] = useState([]); const enableAnnotations = store.hasInterface("annotations:tabs"); @@ -19,12 +50,63 @@ export const AnnotationsCarousel = observer(({ store, annotationStore }: Annotat const enableCreateAnnotation = store.hasInterface("annotations:add-new"); const groundTruthEnabled = store.hasInterface("ground-truth"); const enableAnnotationDelete = store.hasInterface("annotations:delete"); + const listRef = useRef(null); const carouselRef = useRef(); const containerRef = useRef(); + + // FIT-720: Track scroll position for virtualized navigation buttons + const [scrollOffset, setScrollOffset] = useState(0); + const [containerWidth, setContainerWidth] = useState(0); + + // Original: Track position for non-virtualized CSS transform scrolling const [currentPosition, setCurrentPosition] = useState(0); - const [isLeftDisabled, setIsLeftDisabled] = useState(false); - const [isRightDisabled, setIsRightDisabled] = useState(false); + const [isLeftDisabledOriginal, setIsLeftDisabledOriginal] = useState(false); + const [isRightDisabledOriginal, setIsRightDisabledOriginal] = useState(false); + + const capabilities = useMemo( + () => ({ + enablePredictions, + enableCreateAnnotation, + groundTruthEnabled, + enableAnnotations, + enableAnnotationDelete, + }), + [enablePredictions, enableCreateAnnotation, groundTruthEnabled, enableAnnotations, enableAnnotationDelete], + ); + + // FIT-720: Sort entities once + const sortedEntities = useMemo(() => sortAnnotations(entities), [entities]); + + // FIT-720: Calculate total width and determine if virtualization is needed + const totalWidth = sortedEntities.length * (ITEM_WIDTH + ITEM_GAP); + // FIT-720: Only virtualize when FF is enabled AND there are many items + const shouldVirtualize = isFF(FF_FIT_720_LAZY_LOAD_ANNOTATIONS) && sortedEntities.length > VIRTUALIZATION_THRESHOLD; + + // FIT-720: Navigation button states for virtualized list + const isLeftDisabled = scrollOffset <= 0; + const isRightDisabled = scrollOffset >= totalWidth - containerWidth; + const showControls = totalWidth > containerWidth; + const handleScroll = useCallback(({ scrollOffset: newOffset }: { scrollOffset: number }) => { + setScrollOffset(newOffset); + }, []); + + const scrollLeft = useCallback(() => { + if (listRef.current) { + const newOffset = Math.max(0, scrollOffset - containerWidth); + listRef.current.scrollTo(newOffset); + } + }, [scrollOffset, containerWidth]); + + const scrollRight = useCallback(() => { + if (listRef.current) { + const maxOffset = totalWidth - containerWidth; + const newOffset = Math.min(maxOffset, scrollOffset + containerWidth); + listRef.current.scrollTo(newOffset); + } + }, [scrollOffset, containerWidth, totalWidth]); + + // Original: Update position for non-virtualized CSS transform scrolling const updatePosition = useCallback( (_e: React.MouseEvent, goLeft = true) => { if (containerRef.current && carouselRef.current) { @@ -38,19 +120,25 @@ export const AnnotationsCarousel = observer(({ store, annotationStore }: Annotat [containerRef, carouselRef, currentPosition], ); + // Original: Update button disabled states for non-virtualized scrolling useEffect(() => { - setIsLeftDisabled(currentPosition <= 0); - setIsRightDisabled( - currentPosition >= (carouselRef.current?.clientWidth ?? 0) - (containerRef.current?.clientWidth ?? 0), - ); - }, [ - entities.length, - containerRef.current, - carouselRef.current, - currentPosition, - window.innerWidth, - window.innerHeight, - ]); + if (!shouldVirtualize) { + setIsLeftDisabledOriginal(currentPosition <= 0); + setIsRightDisabledOriginal( + currentPosition >= (carouselRef.current?.clientWidth ?? 0) - (containerRef.current?.clientWidth ?? 0), + ); + } + }, [sortedEntities.length, containerRef.current, carouselRef.current, currentPosition, shouldVirtualize]); + + // FIT-720: Scroll to selected annotation when it changes (virtualized only) + useEffect(() => { + if (shouldVirtualize && listRef.current && annotationStore.selected) { + const selectedIndex = sortedEntities.findIndex((e: any) => e?.id === annotationStore.selected?.id); + if (selectedIndex >= 0) { + listRef.current.scrollToItem(selectedIndex, "center"); + } + } + }, [annotationStore.selected?.id, sortedEntities, shouldVirtualize]); useEffect(() => { const newEntities = []; @@ -61,7 +149,84 @@ export const AnnotationsCarousel = observer(({ store, annotationStore }: Annotat setEntities(newEntities); }, [annotationStore, JSON.stringify(annotationStore.predictions), JSON.stringify(annotationStore.annotations)]); - return enableAnnotations || enablePredictions || enableCreateAnnotation ? ( + // FIT-720: Data passed to virtualized items + const itemData = useMemo( + () => ({ + entities: sortedEntities, + capabilities, + annotationStore, + store, + }), + [sortedEntities, capabilities, annotationStore, store], + ); + + if (!(enableAnnotations || enablePredictions || enableCreateAnnotation)) { + return null; + } + + // FIT-720: Use virtualization for large lists when FF is enabled + if (shouldVirtualize) { + return ( +
0, virtualized: true }) + .toClassName()} + > +
+ + {({ width, height }) => { + // Update container width for navigation calculations + if (width !== containerWidth) { + setContainerWidth(width - 77); // Account for controls width + } + return ( + // @ts-expect-error - react-window types incompatible with React 18 + + {VirtualizedAnnotationButton} + + ); + }} + +
+ {showControls && ( +
+ + +
+ )} +
+ ); + } + + // Original: Non-virtualized rendering (FF off or small lists) + return (
0 }) @@ -70,44 +235,39 @@ export const AnnotationsCarousel = observer(({ store, annotationStore }: Annotat >
- {sortAnnotations(entities).map((entity) => ( + {sortedEntities.map((entity) => ( ))}
- {(!isLeftDisabled || !isRightDisabled) && ( + {(!isLeftDisabledOriginal || !isRightDisabledOriginal) && (
)}
- ) : null; + ); }); diff --git a/web/libs/editor/src/components/App/Grid.jsx b/web/libs/editor/src/components/App/Grid.jsx index ec36aa1a63d2..3279a39a690b 100644 --- a/web/libs/editor/src/components/App/Grid.jsx +++ b/web/libs/editor/src/components/App/Grid.jsx @@ -1,20 +1,29 @@ /** - * @deprecated this file is not used; App/ViewAll is used instead + * Grid component for Compare view - renders annotation panels side-by-side + * FIT-720: Added virtualization support for large annotation counts */ -import React, { Component } from "react"; +import React, { Component, useCallback, useMemo, useRef, useState } from "react"; import { Spin } from "antd"; import { Button, Tooltip } from "@humansignal/ui"; import { LeftCircleOutlined, RightCircleOutlined } from "@ant-design/icons"; +import { FixedSizeList as List } from "react-window"; +import AutoSizer from "react-virtualized-auto-sizer"; +import { observer } from "mobx-react"; import styles from "./Grid.module.scss"; import { EntityTab } from "../AnnotationTabs/AnnotationTabs"; import { observe } from "mobx"; import Konva from "konva"; import { Annotation } from "./Annotation"; import { isDefined } from "../../utils/utilities"; -import { FF_DEV_3391, isFF } from "../../utils/feature-flags"; +import { FF_DEV_3391, FF_FIT_720_LAZY_LOAD_ANNOTATIONS, isFF } from "../../utils/feature-flags"; import { moveStylesBetweenHeadTags } from "../../utils/html"; +// FIT-720: Virtualization constants for Compare view +const PANEL_WIDTH = 500; // Width of each annotation panel (approximately 50% of typical viewport) +const PANEL_GAP = 30; // Gap between panels (matches $gap in Grid.module.scss) +const VIRTUALIZATION_THRESHOLD = 10; // Only virtualize if more than this many annotations + /***** DON'T TRY THIS AT HOME *****/ /* Grid renders a container which remains untouched all the process. @@ -52,7 +61,157 @@ class Item extends Component { } } -export default class Grid extends Component { +// FIT-720: Virtualized annotation panel (UI only - hydration will be added in stub-hydration branch) +const VirtualizedAnnotationPanel = observer(({ annotation, root, style, onSelect }) => { + return ( +
+
+ onSelect(annotation)} + prediction={annotation.type === "prediction"} + bordered={false} + style={{ height: 44 }} + /> + +
+
+ ); +}); + +// FIT-720: Virtualized Grid component (UI only - hydration triggers will be added in stub-hydration branch) +const VirtualizedGrid = observer(({ store, annotations, root }) => { + const listRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + + // Filter visible annotations + const visibleAnnotations = useMemo(() => annotations.filter((c) => !c.hidden), [annotations]); + + // Calculate panel width based on container (aim for ~50% width, min PANEL_WIDTH) + const panelWidth = useMemo(() => { + if (containerWidth > 0) { + const halfWidth = Math.floor((containerWidth - PANEL_GAP) / 2); + return Math.max(halfWidth, PANEL_WIDTH); + } + return PANEL_WIDTH; + }, [containerWidth]); + + const totalWidth = visibleAnnotations.length * (panelWidth + PANEL_GAP); + const [scrollOffset, setScrollOffset] = useState(0); + + // Navigation states + const isLeftDisabled = scrollOffset <= 0; + const isRightDisabled = scrollOffset >= totalWidth - containerWidth; + const showControls = totalWidth > containerWidth; + + const handleScroll = useCallback(({ scrollOffset: newOffset }) => { + setScrollOffset(newOffset); + }, []); + + const scrollLeft = useCallback(() => { + if (listRef.current) { + const newOffset = Math.max(0, scrollOffset - panelWidth - PANEL_GAP); + listRef.current.scrollTo(newOffset); + } + }, [scrollOffset, panelWidth]); + + const scrollRight = useCallback(() => { + if (listRef.current) { + const maxOffset = totalWidth - containerWidth; + const newOffset = Math.min(maxOffset, scrollOffset + panelWidth + PANEL_GAP); + listRef.current.scrollTo(newOffset); + } + }, [scrollOffset, containerWidth, totalWidth, panelWidth]); + + const select = useCallback( + (c) => { + c.type === "annotation" + ? store.selectAnnotation(c.id, { exitViewAll: true }) + : store.selectPrediction(c.id, { exitViewAll: true }); + }, + [store], + ); + + // Item data for virtualized list + const itemData = useMemo( + () => ({ + annotations: visibleAnnotations, + root, + onSelect: select, + }), + [visibleAnnotations, root, select], + ); + + // Row renderer + const renderPanel = useCallback(({ index, style, data }) => { + const annotation = data.annotations[index]; + return ( + + ); + }, []); + + return ( +
+
+ + {({ width, height }) => { + if (width !== containerWidth) { + setContainerWidth(width); + } + return ( + + {renderPanel} + + ); + }} + +
+ {showControls && ( + <> + + + + )} +
+ ); +}); + +// Original Grid class component (used when FF is off or few annotations) +class GridClassComponent extends Component { state = { item: 0, loaded: new Set(), @@ -233,3 +392,18 @@ export default class Grid extends Component { ); } } + +// FIT-720: Grid wrapper that chooses virtualized or original based on FF and annotation count +export default function Grid(props) { + const { annotations } = props; + const visibleCount = annotations.filter((c) => !c.hidden).length; + + // FIT-720: Use virtualization when FF is enabled AND there are many annotations + const shouldVirtualize = isFF(FF_FIT_720_LAZY_LOAD_ANNOTATIONS) && visibleCount > VIRTUALIZATION_THRESHOLD; + + if (shouldVirtualize) { + return ; + } + + return ; +} diff --git a/web/libs/editor/src/components/App/Grid.module.scss b/web/libs/editor/src/components/App/Grid.module.scss index e51b65c5c4d6..d2fe78f35387 100644 --- a/web/libs/editor/src/components/App/Grid.module.scss +++ b/web/libs/editor/src/components/App/Grid.module.scss @@ -18,9 +18,14 @@ $gap: 30px; padding: 0 var(--spacing-tight) var(--spacing-base) var(--spacing-tight); overflow: auto; position: relative; + + // FIT-720: Virtualized grid uses react-window, not CSS grid + &:has([data-virtualized="true"]) { + display: block; + } } -.grid > div > h4 { +.grid>div>h4 { cursor: pointer; } @@ -30,7 +35,8 @@ $gap: 30px; min-width: 0; // famous flex hack to prevent block from growing enormously margin-top: var(--spacing-tight); - &::before, &::after { + &::before, + &::after { content: ''; position: absolute; top: 0; @@ -51,13 +57,24 @@ $gap: 30px; } } -.container > button { +// FIT-720: Virtualized container needs explicit height for AutoSizer +.containerVirtualized { + position: relative; + flex-grow: 1; + min-width: 0; + height: calc(100vh - 200px); // Account for header/tabs + min-height: 400px; +} + +.container>button, +.containerVirtualized>button { position: absolute; top: 0; width: $gap; height: 100%; + z-index: 10; - & span > span { + & span>span { width: 24px; height: 24px; border-radius: 50%; @@ -76,15 +93,17 @@ $gap: 30px; } } -.container > button.left { +.container>button.left, +.containerVirtualized>button.left { left: 0; } -.container > button.right { +.container>button.right, +.containerVirtualized>button.right { right: 0; } -.grid > div:global(.hover) { +.grid>div:global(.hover) { background: var(--color-primary-emphasis-subtle); h4 { @@ -93,10 +112,10 @@ $gap: 30px; } /* don't let the empty blocks to break the nice grid */ -.grid > div:empty { +.grid>div:empty { display: none; } :global(.ant-btn.ant-btn-text.ant-btn-icon-only:hover) { background-color: var(--color-primary-emphasis-subtle); -} \ No newline at end of file +} From b604ba6a46794be432ca9a71d5ec5bf48334d61a Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 30 Jan 2026 15:19:00 -0500 Subject: [PATCH 04/28] feat(FIT-720): Add TaskDistributionAPI for efficient label aggregation Add new backend endpoint /api/tasks//distribution/ that returns pre-aggregated label distribution data for the Summary view's Distribution row, avoiding N+1 queries. Backend changes: - TaskDistributionAPI class in tasks/api.py - URL route in tasks/urls.py Frontend changes: - Aggregation.tsx uses TanStack Query to fetch distribution data - ApiAggregationCell component renders from API data - Falls back to client-side computation when FF is off All changes gated behind fflag_fix_all_fit_720_lazy_load_annotations. --- label_studio/tasks/api.py | 157 ++++++++++++++++ label_studio/tasks/urls.py | 2 + .../components/TaskSummary/Aggregation.tsx | 171 ++++++++++++++++-- 3 files changed, 316 insertions(+), 14 deletions(-) diff --git a/label_studio/tasks/api.py b/label_studio/tasks/api.py index b6adbeddf88a..0fb22dce525e 100644 --- a/label_studio/tasks/api.py +++ b/label_studio/tasks/api.py @@ -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 @@ -376,6 +377,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 TaskDistributionAPI(generics.RetrieveAPIView): + """ + FIT-720: 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): + # FIT-720: This endpoint is gated by feature flag + if not flag_set('fflag_fix_all_fit_720_lazy_load_annotations', user=request.user): + return Response({'error': 'Feature not enabled'}, status=404) + + 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 + # We only need the 'result' field, not the full annotation + annotations = Annotation.objects.filter( + task=task, + was_cancelled=False, + ).values_list('result', flat=True) + + total_annotations = len(annotations) + distributions = {} + + # Process results to extract label distributions + for result in annotations: + if not result: + continue + + # result is a list of labeling results + 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', {}) + + # Initialize distribution for this control if not exists + if from_name not in distributions: + distributions[from_name] = { + 'type': result_type, + 'labels': {}, + 'values': [], + } + + # Extract values based on type + if result_type.endswith('labels'): + # Labels type: rectanglelabels, polygonlabels, etc. + 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 type + 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 type - collect values for averaging + rating = value.get('rating') + if rating is not None: + distributions[from_name]['values'].append(rating) + + elif result_type == 'number': + # Number type - collect values for averaging + number = value.get('number') + if number is not None: + distributions[from_name]['values'].append(number) + + elif result_type == 'taxonomy': + # Taxonomy type - extract leaf nodes + taxonomy = value.get('taxonomy', []) + if isinstance(taxonomy, list): + for path in taxonomy: + if isinstance(path, list) and path: + leaf = path[-1] # Get leaf node + if leaf not in distributions[from_name]['labels']: + distributions[from_name]['labels'][leaf] = 0 + distributions[from_name]['labels'][leaf] += 1 + + elif result_type == 'pairwise': + # Pairwise type + 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 + + # 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( diff --git a/label_studio/tasks/urls.py b/label_studio/tasks/urls.py index 251ec76ec4b3..f56794be6269 100644 --- a/label_studio/tasks/urls.py +++ b/label_studio/tasks/urls.py @@ -21,6 +21,8 @@ api.AnnotationDraftListAPI.as_view(), name='task-annotations-drafts', ), + # FIT-720: Distribution endpoint for Summary view + path('/distribution/', api.TaskDistributionAPI.as_view(), name='task-distribution'), ] _api_annotations_urlpatterns = [ diff --git a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx index 3da2aeb886e2..ddc913d627e2 100644 --- a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx +++ b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx @@ -1,13 +1,38 @@ import { useLayoutEffect, useRef, useState } from "react"; import { cnm, IconChevronDown } from "@humansignal/ui"; import type { Header } from "@tanstack/react-table"; +import { useQuery } from "@tanstack/react-query"; import type { RawResult } from "../../stores/types"; import { Chip } from "./Chip"; import type { AnnotationSummary, ControlTag } from "./types"; import { getLabelCounts } from "./utils"; +import { FF_FIT_720_LAZY_LOAD_ANNOTATIONS, isFF } from "../../utils/feature-flags"; import styles from "./TaskSummary.module.scss"; +// FIT-720: Type for distribution API response +type DistributionData = { + total_annotations: number; + distributions: Record< + string, + { + type: string; + labels: Record; + average?: number; + count?: number; + } + >; +}; + +// FIT-720: Fetch function for distribution data +const fetchDistribution = async (taskId: number | string): Promise => { + const response = await fetch(`/api/tasks/${taskId}/distribution/`); + if (!response.ok) { + throw new Error("Failed to load distribution"); + } + return response.json(); +}; + const resultValue = (result: RawResult) => { if (result.type === "textarea") { return result.value.text; @@ -168,24 +193,121 @@ export const AggregationCell = ({ return N/A; }; +// FIT-720: Skeleton loader for distribution cells +const DistributionSkeleton = () => ( +
+
+
+
+); + +// FIT-720: Cell component that renders from API distribution data +const ApiAggregationCell = ({ + control, + distribution, + totalAnnotations, + isExpanded, +}: { + control: ControlTag; + distribution?: { type: string; labels: Record; average?: number; count?: number }; + totalAnnotations: number; + isExpanded: boolean; +}) => { + if (!distribution || Object.keys(distribution.labels).length === 0) { + // Check if it's a numeric type with average + if (distribution?.average !== undefined) { + return ( + + Avg: {distribution.average.toFixed(1)} + {distribution.type === "rating" && } + + ); + } + return No data; + } + + // Sort labels by count descending + const sortedLabels = Object.entries(distribution.labels).sort(([, a], [, b]) => b - a); + + // Handle choices/taxonomy with percentages + if (distribution.type === "choices" || distribution.type === "taxonomy") { + return ( +
+ {sortedLabels.map(([label, count]) => ( + + {label} + + ))} +
+ ); + } + + // Handle labels and other types with counts + return ( +
+ {sortedLabels.map(([label, count]) => ( + + {label} + + ))} +
+ ); +}; + /** * Renders the complete aggregation/distribution row across all columns. * Includes a toggle button in the first cell that only appears when content overflows. * The toggle expands/collapses the cells to show full content. + * + * FIT-720: With lazy loading, fetches distribution from dedicated API endpoint + * for efficient aggregation without N+1 queries. */ export const AggregationTableRow = ({ headers, controls, annotations, + taskId, }: { headers: Header[]; controls: ControlTag[]; annotations: AnnotationSummary[]; + taskId?: number | string; }) => { const [isExpanded, setIsExpanded] = useState(false); const [hasOverflow, setHasOverflow] = useState(false); const rowRef = useRef(null); + // For non-lazy loading mode, compute from annotations as before + const useApiData = isFF(FF_FIT_720_LAZY_LOAD_ANNOTATIONS) && taskId; + + // FIT-720: Use TanStack Query for distribution data - handles deduplication and caching + const { + data: distributionData, + isLoading, + error, + } = useQuery({ + queryKey: ["task-distribution", taskId], + queryFn: () => fetchDistribution(taskId!), + enabled: useApiData && !!taskId, + staleTime: 30000, // Consider data fresh for 30 seconds + gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes (formerly cacheTime) + }); + useLayoutEffect(() => { if (!rowRef.current) return; @@ -196,7 +318,7 @@ export const AggregationTableRow = ({ }); setHasOverflow(hasOverflowingCells); - }, [annotations, controls]); + }, [annotations, controls, distributionData]); return ( @@ -210,18 +332,26 @@ export const AggregationTableRow = ({ )} style={{ width: header.getSize() }} > - {hasOverflow ? ( - - ) : ( - Distribution - )} +
+ {hasOverflow ? ( + + ) : ( + Distribution + )} + {/* FIT-720: Show total count from API */} + {useApiData && distributionData && ( + + {distributionData.total_annotations} annotations + + )} +
) : ( - + {isLoading ? ( + + ) : error ? ( + Failed to load + ) : useApiData && distributionData ? ( + + ) : ( + + )} ), )} From 4da79b79ab1281b473acedf9d224b8dd4013485e Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Thu, 5 Feb 2026 13:52:43 -0500 Subject: [PATCH 05/28] increasing e2e ci timeout --- .github/workflows/tests-yarn-e2e.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests-yarn-e2e.yml b/.github/workflows/tests-yarn-e2e.yml index f0a762744cca..1f5bb89c3485 100644 --- a/.github/workflows/tests-yarn-e2e.yml +++ b/.github/workflows/tests-yarn-e2e.yml @@ -15,7 +15,7 @@ jobs: main: name: "yarn e2e" runs-on: ubuntu-latest - timeout-minutes: 60 + timeout-minutes: 90 steps: - uses: hmarr/debug-action@v3.0.0 From 38fc8cd8f90c1a9cd92bac203b55f17e03878697 Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 6 Feb 2026 14:54:51 -0500 Subject: [PATCH 06/28] cleaning up commit --- web/biome.json | 3 +- .../components/TaskSummary/Aggregation.tsx | 5 - .../src/tags/object/Image/ImageEntity.js | 2 +- web/libs/editor/src/utils/ImageCache.ts | 344 ------------------ 4 files changed, 2 insertions(+), 352 deletions(-) delete mode 100644 web/libs/editor/src/utils/ImageCache.ts diff --git a/web/biome.json b/web/biome.json index 227f6540177e..a107e1f9aacc 100644 --- a/web/biome.json +++ b/web/biome.json @@ -40,8 +40,7 @@ "a11y": { "noAutofocus": "off", "useKeyWithClickEvents": "off", - "noLabelWithoutControl": "off", - "noRedundantAlt": "off" + "noLabelWithoutControl": "off" }, "suspicious": { "noExplicitAny": "off", diff --git a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx index ddc913d627e2..6c08632d2cb4 100644 --- a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx +++ b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx @@ -10,7 +10,6 @@ import { FF_FIT_720_LAZY_LOAD_ANNOTATIONS, isFF } from "../../utils/feature-flag import styles from "./TaskSummary.module.scss"; -// FIT-720: Type for distribution API response type DistributionData = { total_annotations: number; distributions: Record< @@ -24,7 +23,6 @@ type DistributionData = { >; }; -// FIT-720: Fetch function for distribution data const fetchDistribution = async (taskId: number | string): Promise => { const response = await fetch(`/api/tasks/${taskId}/distribution/`); if (!response.ok) { @@ -193,7 +191,6 @@ export const AggregationCell = ({ return N/A; }; -// FIT-720: Skeleton loader for distribution cells const DistributionSkeleton = () => (
@@ -201,7 +198,6 @@ const DistributionSkeleton = () => (
); -// FIT-720: Cell component that renders from API distribution data const ApiAggregationCell = ({ control, distribution, @@ -295,7 +291,6 @@ export const AggregationTableRow = ({ // For non-lazy loading mode, compute from annotations as before const useApiData = isFF(FF_FIT_720_LAZY_LOAD_ANNOTATIONS) && taskId; - // FIT-720: Use TanStack Query for distribution data - handles deduplication and caching const { data: distributionData, isLoading, diff --git a/web/libs/editor/src/tags/object/Image/ImageEntity.js b/web/libs/editor/src/tags/object/Image/ImageEntity.js index 98f6d944756e..307607b2c838 100644 --- a/web/libs/editor/src/tags/object/Image/ImageEntity.js +++ b/web/libs/editor/src/tags/object/Image/ImageEntity.js @@ -2,7 +2,7 @@ import { types, getParent, addDisposer } from "mobx-state-tree"; import { FileLoader } from "../../../utils/FileLoader"; import { imageCache } from "@humansignal/core"; import { clamp } from "../../../utils/utilities"; -import { FF_FIT_720_LAZY_LOAD_ANNOTATIONS, FF_IMAGE_MEMORY_USAGE, isFF } from "../../../utils/feature-flags"; +import { FF_IMAGE_MEMORY_USAGE, isFF } from "../../../utils/feature-flags"; const fileLoader = new FileLoader(); diff --git a/web/libs/editor/src/utils/ImageCache.ts b/web/libs/editor/src/utils/ImageCache.ts deleted file mode 100644 index 9c4c72e48769..000000000000 --- a/web/libs/editor/src/utils/ImageCache.ts +++ /dev/null @@ -1,344 +0,0 @@ -/** - * Global image cache that persists across annotation switches - * This prevents re-downloading the same images when switching between annotations on the same task - */ - -type CachedImage = { - blobUrl: string; - naturalWidth: number; - naturalHeight: number; - timestamp: number; - /** Reference count - number of active users of this blob URL */ - refCount: number; - /** Original URL for re-fetching if blob URL becomes invalid */ - originalUrl: string; -}; - -class ImageCacheManager { - private cache: Map = new Map(); - private pendingLoads: Map> = new Map(); - /** Track revoked blob URLs to detect invalid references */ - private revokedUrls: Set = new Set(); - - // Cache for 30 minutes by default - private readonly maxAge = 30 * 60 * 1000; - // Maximum cache size (100 images) - private readonly maxSize = 100; - // Minimum blob size in bytes (reject empty blobs) - private readonly minBlobSize = 100; - - /** - * Check if an image is already cached and valid - */ - has(url: string): boolean { - const cached = this.cache.get(url); - if (!cached) return false; - - // Check if cache entry is still valid (age check) - if (Date.now() - cached.timestamp > this.maxAge) { - this.safeRevokeBlobUrl(cached); - this.cache.delete(url); - return false; - } - - // Check if blob URL has been revoked - if (this.revokedUrls.has(cached.blobUrl)) { - this.cache.delete(url); - return false; - } - - return true; - } - - /** - * Get a cached image's blob URL - * Returns undefined if the cache entry is invalid or blob URL was revoked - */ - get(url: string): CachedImage | undefined { - const cached = this.cache.get(url); - if (!cached) return undefined; - - // Check if cache entry is still valid (age check) - if (Date.now() - cached.timestamp > this.maxAge) { - this.safeRevokeBlobUrl(cached); - this.cache.delete(url); - return undefined; - } - - // Check if blob URL has been revoked (validity check) - if (this.revokedUrls.has(cached.blobUrl)) { - this.cache.delete(url); - return undefined; - } - - return cached; - } - - /** - * Increment reference count for a cached image - * Call this when starting to use a cached blob URL - */ - addRef(url: string): void { - const cached = this.cache.get(url); - if (cached) { - cached.refCount++; - } - } - - /** - * Decrement reference count for a cached image - * Call this when done using a cached blob URL - */ - releaseRef(url: string): void { - const cached = this.cache.get(url); - if (cached && cached.refCount > 0) { - cached.refCount--; - } - } - - /** - * Safely revoke a blob URL and track it as revoked - */ - private safeRevokeBlobUrl(cached: CachedImage): void { - if (cached.blobUrl && !this.revokedUrls.has(cached.blobUrl)) { - URL.revokeObjectURL(cached.blobUrl); - this.revokedUrls.add(cached.blobUrl); - // Limit the size of revoked URLs set to prevent memory leaks - if (this.revokedUrls.size > 1000) { - const iterator = this.revokedUrls.values(); - for (let i = 0; i < 500; i++) { - const next = iterator.next(); - if (next.done) break; - this.revokedUrls.delete(next.value); - } - } - } - } - - /** - * Check if a blob URL is known to be revoked/invalid - */ - isBlobUrlRevoked(blobUrl: string): boolean { - return this.revokedUrls.has(blobUrl); - } - - /** - * Check if an image is currently being loaded - */ - isLoading(url: string): boolean { - return this.pendingLoads.has(url); - } - - /** - * Get the pending load promise for an image - */ - getPendingLoad(url: string): Promise | undefined { - return this.pendingLoads.get(url); - } - - /** - * Load an image and cache it - * If the same image is already being loaded, return the existing promise (deduplication) - */ - async load(url: string, crossOrigin?: string, onProgress?: (progress: number) => void): Promise { - // Check cache first - const cached = this.get(url); - if (cached) { - onProgress?.(1); - return cached; - } - - // Check if already loading (deduplication) - const pending = this.pendingLoads.get(url); - if (pending) { - return pending; - } - - // Start new load - const loadPromise = this.loadImage(url, crossOrigin, onProgress); - this.pendingLoads.set(url, loadPromise); - - try { - const result = await loadPromise; - return result; - } finally { - this.pendingLoads.delete(url); - } - } - - private async loadImage( - url: string, - crossOrigin?: string, - onProgress?: (progress: number) => void, - ): Promise { - return new Promise((resolve, reject) => { - // Use fetch with XHR for progress tracking - const xhr = new XMLHttpRequest(); - xhr.responseType = "blob"; - - xhr.addEventListener("load", async () => { - if (xhr.readyState === 4 && xhr.status === 200) { - const blob = xhr.response as Blob; - - // Validate blob size - reject empty or too small blobs - if (!blob || blob.size < this.minBlobSize) { - reject(new Error(`Empty or invalid image data received: ${url} (size: ${blob?.size ?? 0} bytes)`)); - return; - } - - // Validate content type is an image - if (blob.type && !blob.type.startsWith("image/")) { - reject(new Error(`Invalid content type for image: ${blob.type} (url: ${url})`)); - return; - } - - const blobUrl = URL.createObjectURL(blob); - - // Get natural dimensions by loading into an Image - const img = new Image(); - if (crossOrigin) img.crossOrigin = crossOrigin; - - img.onload = () => { - // Validate dimensions - reject if image has no dimensions - if (img.naturalWidth === 0 || img.naturalHeight === 0) { - URL.revokeObjectURL(blobUrl); - this.revokedUrls.add(blobUrl); - reject(new Error(`Image has invalid dimensions (0x0): ${url}`)); - return; - } - - const cachedImage: CachedImage = { - blobUrl, - naturalWidth: img.naturalWidth, - naturalHeight: img.naturalHeight, - timestamp: Date.now(), - refCount: 0, - originalUrl: url, - }; - - // Ensure cache doesn't grow too large - this.ensureCacheSize(); - this.cache.set(url, cachedImage); - - resolve(cachedImage); - }; - - img.onerror = () => { - URL.revokeObjectURL(blobUrl); - this.revokedUrls.add(blobUrl); - reject(new Error(`Failed to load image dimensions: ${url}`)); - }; - - img.src = blobUrl; - } else { - reject(new Error(`Failed to download image: ${xhr.status}`)); - } - }); - - xhr.addEventListener("progress", (e) => { - if (e.lengthComputable) { - onProgress?.(e.loaded / e.total); - } - }); - - xhr.addEventListener("error", () => { - reject(new Error(`Network error loading image: ${url}`)); - }); - - xhr.open("GET", url); - xhr.send(); - }); - } - - private ensureCacheSize(): void { - if (this.cache.size >= this.maxSize) { - // Remove oldest entries that are not in use (refCount === 0) - const entriesToRemove = this.cache.size - this.maxSize + 1; - let removed = 0; - - // First pass: remove entries with refCount === 0 - for (const [key, value] of this.cache) { - if (removed >= entriesToRemove) break; - // Only remove entries that are not actively in use - if (value.refCount === 0) { - this.safeRevokeBlobUrl(value); - this.cache.delete(key); - removed++; - } - } - - // If we couldn't remove enough entries (all in use), log a warning - // but don't force-remove in-use entries to prevent rendering failures - if (removed < entriesToRemove) { - console.warn( - `ImageCache: Unable to evict ${entriesToRemove - removed} entries (all in use). ` + - `Cache size: ${this.cache.size}, active refs: ${this.getActiveRefCount()}`, - ); - } - } - } - - /** - * Get count of images with active references - */ - private getActiveRefCount(): number { - let count = 0; - for (const value of this.cache.values()) { - if (value.refCount > 0) count++; - } - return count; - } - - /** - * Clear the entire cache (useful for cleanup) - * Only clears entries with no active references - */ - clear(): void { - for (const [key, value] of this.cache) { - if (value.refCount === 0) { - this.safeRevokeBlobUrl(value); - this.cache.delete(key); - } - } - } - - /** - * Force clear all cache entries (use with caution - may break active images) - */ - forceClear(): void { - for (const value of this.cache.values()) { - this.safeRevokeBlobUrl(value); - } - this.cache.clear(); - } - - /** - * Remove a specific image from cache - * Only removes if not in active use (refCount === 0) - */ - remove(url: string): void { - const cached = this.cache.get(url); - if (cached) { - if (cached.refCount === 0) { - this.safeRevokeBlobUrl(cached); - this.cache.delete(url); - } else { - console.warn(`ImageCache: Cannot remove ${url} - still in use (refCount: ${cached.refCount})`); - } - } - } - - /** - * Force remove a specific image from cache (use with caution) - */ - forceRemove(url: string): void { - const cached = this.cache.get(url); - if (cached) { - this.safeRevokeBlobUrl(cached); - this.cache.delete(url); - } - } -} - -// Singleton instance - persists across annotation switches -export const imageCache = new ImageCacheManager(); From d53c34fe7ac9adc48d2ced084600f4a266e5c1d9 Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 6 Feb 2026 14:57:35 -0500 Subject: [PATCH 07/28] pulling from the correct FF --- web/libs/editor/src/components/TaskSummary/Aggregation.tsx | 4 ++-- web/libs/editor/src/utils/feature-flags.ts | 7 ------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx index 6c08632d2cb4..ce7df2da538e 100644 --- a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx +++ b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx @@ -6,7 +6,7 @@ import type { RawResult } from "../../stores/types"; import { Chip } from "./Chip"; import type { AnnotationSummary, ControlTag } from "./types"; import { getLabelCounts } from "./utils"; -import { FF_FIT_720_LAZY_LOAD_ANNOTATIONS, isFF } from "../../utils/feature-flags"; +import { isActive, FF_FIT_720_LAZY_LOAD_ANNOTATIONS } from "@humansignal/core/lib/utils/feature-flags"; import styles from "./TaskSummary.module.scss"; @@ -289,7 +289,7 @@ export const AggregationTableRow = ({ const rowRef = useRef(null); // For non-lazy loading mode, compute from annotations as before - const useApiData = isFF(FF_FIT_720_LAZY_LOAD_ANNOTATIONS) && taskId; + const useApiData = isActive(FF_FIT_720_LAZY_LOAD_ANNOTATIONS) && taskId; const { data: distributionData, diff --git a/web/libs/editor/src/utils/feature-flags.ts b/web/libs/editor/src/utils/feature-flags.ts index b9cadb13a2e1..72fa9d7cf660 100644 --- a/web/libs/editor/src/utils/feature-flags.ts +++ b/web/libs/editor/src/utils/feature-flags.ts @@ -163,13 +163,6 @@ export const FF_IMAGE_MEMORY_USAGE = "fflag_feat_front_optic_1479_improve_image_ export const FF_VIDEO_FRAME_SEEK_PRECISION = "fflag_fix_front_optic_1608_improve_video_frame_seek_precision_short"; -/** - * Lazy load annotations in LabelStream to improve performance for tasks with many annotations - * Also enables virtualization of annotation tabs carousel - * @link https://app.launchdarkly.com/default/production/features/fflag_fix_all_fit_720_lazy_load_annotations - */ -export const FF_FIT_720_LAZY_LOAD_ANNOTATIONS = "fflag_fix_all_fit_720_lazy_load_annotations"; - /** * Strict task overlap enforcement - prevents annotators from submitting * annotations when task overlap limit has been reached From 121e638a095a046fcc93e440b11cb044e8b28b04 Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 6 Feb 2026 15:04:49 -0500 Subject: [PATCH 08/28] clean up function that's not being used --- .../src/tags/object/Image/ImageEntity.js | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/web/libs/editor/src/tags/object/Image/ImageEntity.js b/web/libs/editor/src/tags/object/Image/ImageEntity.js index 307607b2c838..7f48b7d45267 100644 --- a/web/libs/editor/src/tags/object/Image/ImageEntity.js +++ b/web/libs/editor/src/tags/object/Image/ImageEntity.js @@ -149,44 +149,6 @@ export const ImageEntity = types }); }, - /** - * Fallback preload method for when global cache fails - */ - fallbackPreload() { - const crossOrigin = self.imageCrossOrigin; - if (isFF(FF_IMAGE_MEMORY_USAGE)) { - const img = new Image(); - if (crossOrigin) img.crossOrigin = crossOrigin; - img.onload = () => { - self.setCurrentSrc(self.src); - self.setDownloaded(true); - self.setProgress(1); - self.setDownloading(false); - // imageLoaded will be set by onLoad in the component - }; - img.onerror = () => { - self.setError(true); - self.setDownloading(false); - }; - img.src = self.src; - } else { - fileLoader - .download(self.src, (_t, _l, progress) => { - self.setProgress(progress); - }) - .then((url) => { - self.setDownloaded(true); - self.setDownloading(false); - self.setCurrentSrc(url); - // imageLoaded will be set by onLoad in the component - }) - .catch(() => { - self.setDownloading(false); - self.setError(true); - }); - } - }, - ensurePreloaded() { const cached = imageCache.get(self.src); if (cached) { From 3d79f2fa0f65276912657772331f32aec26e5612 Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 6 Feb 2026 15:06:04 -0500 Subject: [PATCH 09/28] clean up comments --- web/libs/editor/src/components/App/Grid.jsx | 2 +- web/libs/editor/src/components/TaskSummary/Aggregation.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/libs/editor/src/components/App/Grid.jsx b/web/libs/editor/src/components/App/Grid.jsx index 6fe9440c6506..3e092984e87b 100644 --- a/web/libs/editor/src/components/App/Grid.jsx +++ b/web/libs/editor/src/components/App/Grid.jsx @@ -1,6 +1,6 @@ /** * Grid component for Compare view - renders annotation panels side-by-side - * FIT-720: Added virtualization support for large annotation counts + * Added virtualization support for large annotation counts */ import React, { Component, useCallback, useMemo, useRef, useState } from "react"; diff --git a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx index ce7df2da538e..7c7a5fa17caa 100644 --- a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx +++ b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx @@ -270,7 +270,7 @@ const ApiAggregationCell = ({ * Includes a toggle button in the first cell that only appears when content overflows. * The toggle expands/collapses the cells to show full content. * - * FIT-720: With lazy loading, fetches distribution from dedicated API endpoint + * With lazy loading, fetches distribution from dedicated API endpoint * for efficient aggregation without N+1 queries. */ export const AggregationTableRow = ({ @@ -340,7 +340,7 @@ export const AggregationTableRow = ({ ) : ( Distribution )} - {/* FIT-720: Show total count from API */} + {/* Show total count from API */} {useApiData && distributionData && ( {distributionData.total_annotations} annotations From 032350d8ba659a6ad81a1a007c833ff63722dde2 Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 6 Feb 2026 15:06:48 -0500 Subject: [PATCH 10/28] clean up comments --- label_studio/tasks/api.py | 4 ++-- label_studio/tasks/urls.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/label_studio/tasks/api.py b/label_studio/tasks/api.py index 3588c42198bf..0751a1dcfc60 100644 --- a/label_studio/tasks/api.py +++ b/label_studio/tasks/api.py @@ -441,7 +441,7 @@ def put(self, request, *args, **kwargs): ) class TaskDistributionAPI(generics.RetrieveAPIView): """ - FIT-720: Efficient endpoint for getting label distribution without fetching all annotations. + 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. @@ -451,7 +451,7 @@ class TaskDistributionAPI(generics.RetrieveAPIView): queryset = Task.objects.all() def get(self, request, pk): - # FIT-720: This endpoint is gated by feature flag + # This endpoint is gated by feature flag if not flag_set('fflag_fix_all_fit_720_lazy_load_annotations', user=request.user): return Response({'error': 'Feature not enabled'}, status=404) diff --git a/label_studio/tasks/urls.py b/label_studio/tasks/urls.py index f56794be6269..7628456cbd34 100644 --- a/label_studio/tasks/urls.py +++ b/label_studio/tasks/urls.py @@ -21,7 +21,7 @@ api.AnnotationDraftListAPI.as_view(), name='task-annotations-drafts', ), - # FIT-720: Distribution endpoint for Summary view + # Distribution endpoint for Summary view path('/distribution/', api.TaskDistributionAPI.as_view(), name='task-distribution'), ] From 1e4df0dbc4d5b64c535df3fc112a109b2aec1b17 Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 6 Feb 2026 15:44:47 -0500 Subject: [PATCH 11/28] making sure everything lines up with develop --- label_studio/tasks/tests/test_api.py | 325 +++++++++++++++++- web/libs/editor/src/components/App/App.jsx | 122 +++---- .../components/TaskSummary/Aggregation.tsx | 15 +- .../TaskSummary/LabelingSummary.tsx | 4 +- .../components/TaskSummary/TaskSummary.tsx | 1 + 5 files changed, 399 insertions(+), 68 deletions(-) diff --git a/label_studio/tasks/tests/test_api.py b/label_studio/tasks/tests/test_api.py index 36401646dc3c..289e2da97297 100644 --- a/label_studio/tasks/tests/test_api.py +++ b/label_studio/tasks/tests/test_api.py @@ -3,7 +3,7 @@ from organizations.tests.factories import OrganizationFactory from projects.tests.factories import ProjectFactory from rest_framework.test import APITestCase -from tasks.tests.factories import TaskFactory +from tasks.tests.factories import AnnotationFactory, TaskFactory class TestTaskAPI(APITestCase): @@ -236,3 +236,326 @@ def test_get_task_resolve_uri_false_with_multiple_url_fields(self): assert response_data['image_2'] == 'gs://bucket-2/image2.png' assert response_data['audio'] == 'azure-blob://container/audio.mp3' assert response_data['text'] == 'Plain text field' + + +class TestTaskDistributionAPI(APITestCase): + """Tests for TaskDistributionAPI (GET /api/tasks//distribution/).""" + + @classmethod + def setUpTestData(cls): + cls.organization = OrganizationFactory() + cls.project = ProjectFactory(organization=cls.organization) + cls.user = cls.organization.created_by + + @patch('tasks.api.flag_set') + def test_distribution_returns_404_when_feature_flag_disabled(self, mock_flag_set): + mock_flag_set.return_value = False + task = TaskFactory(project=self.project) + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/tasks/{task.id}/distribution/') + assert response.status_code == 404 + assert response.json() == {'error': 'Feature not enabled'} + + @patch('tasks.api.flag_set') + def test_distribution_returns_404_for_nonexistent_task(self, mock_flag_set): + mock_flag_set.return_value = True + self.client.force_authenticate(user=self.user) + response = self.client.get('/api/tasks/99999/distribution/') + assert response.status_code == 404 + assert response.json() == {'error': 'Task not found'} + + @patch('tasks.api.flag_set') + def test_distribution_permission_denied_for_other_project(self, mock_flag_set): + mock_flag_set.return_value = True + other_org = OrganizationFactory() + other_project = ProjectFactory(organization=other_org) + task = TaskFactory(project=other_project) + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/tasks/{task.id}/distribution/') + assert response.status_code == 403 + + @patch('tasks.api.flag_set') + def test_distribution_empty_task_returns_zero_annotations(self, mock_flag_set): + mock_flag_set.return_value = True + task = TaskFactory(project=self.project) + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/tasks/{task.id}/distribution/') + assert response.status_code == 200 + data = response.json() + assert data['total_annotations'] == 0 + assert data['distributions'] == {} + + @patch('tasks.api.flag_set') + def test_distribution_with_rectanglelabels(self, mock_flag_set): + mock_flag_set.return_value = True + task = TaskFactory(project=self.project) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'label', + 'to_name': 'image', + 'type': 'rectanglelabels', + 'value': {'rectanglelabels': ['Car', 'Car']}, + } + ], + ) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'label', + 'to_name': 'image', + 'type': 'rectanglelabels', + 'value': {'rectanglelabels': ['Person']}, + } + ], + ) + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/tasks/{task.id}/distribution/') + assert response.status_code == 200 + data = response.json() + assert data['total_annotations'] == 2 + assert data['distributions']['label'] == { + 'type': 'rectanglelabels', + 'labels': {'Car': 2, 'Person': 1}, + } + + @patch('tasks.api.flag_set') + def test_distribution_with_choices(self, mock_flag_set): + mock_flag_set.return_value = True + task = TaskFactory(project=self.project) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'sentiment', + 'to_name': 'text', + 'type': 'choices', + 'value': {'choices': ['Positive']}, + } + ], + ) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'sentiment', + 'to_name': 'text', + 'type': 'choices', + 'value': {'choices': ['Negative']}, + } + ], + ) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'sentiment', + 'to_name': 'text', + 'type': 'choices', + 'value': {'choices': ['Positive']}, + } + ], + ) + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/tasks/{task.id}/distribution/') + assert response.status_code == 200 + data = response.json() + assert data['total_annotations'] == 3 + assert data['distributions']['sentiment'] == { + 'type': 'choices', + 'labels': {'Positive': 2, 'Negative': 1}, + } + + @patch('tasks.api.flag_set') + def test_distribution_with_rating(self, mock_flag_set): + mock_flag_set.return_value = True + task = TaskFactory(project=self.project) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'rating', + 'to_name': 'text', + 'type': 'rating', + 'value': {'rating': 4}, + } + ], + ) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'rating', + 'to_name': 'text', + 'type': 'rating', + 'value': {'rating': 5}, + } + ], + ) + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/tasks/{task.id}/distribution/') + assert response.status_code == 200 + data = response.json() + assert data['total_annotations'] == 2 + assert data['distributions']['rating']['type'] == 'rating' + assert data['distributions']['rating']['average'] == 4.5 + assert data['distributions']['rating']['count'] == 2 + assert 'values' not in data['distributions']['rating'] + + @patch('tasks.api.flag_set') + def test_distribution_with_number(self, mock_flag_set): + mock_flag_set.return_value = True + task = TaskFactory(project=self.project) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'count', + 'to_name': 'text', + 'type': 'number', + 'value': {'number': 10}, + } + ], + ) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'count', + 'to_name': 'text', + 'type': 'number', + 'value': {'number': 20}, + } + ], + ) + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/tasks/{task.id}/distribution/') + assert response.status_code == 200 + data = response.json() + assert data['total_annotations'] == 2 + assert data['distributions']['count']['type'] == 'number' + assert data['distributions']['count']['average'] == 15.0 + assert data['distributions']['count']['count'] == 2 + + @patch('tasks.api.flag_set') + def test_distribution_with_taxonomy(self, mock_flag_set): + mock_flag_set.return_value = True + task = TaskFactory(project=self.project) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'tax', + 'to_name': 'text', + 'type': 'taxonomy', + 'value': {'taxonomy': [['Animals', 'Dog']]}, + } + ], + ) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'tax', + 'to_name': 'text', + 'type': 'taxonomy', + 'value': {'taxonomy': [['Animals', 'Cat']]}, + } + ], + ) + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/tasks/{task.id}/distribution/') + assert response.status_code == 200 + data = response.json() + assert data['total_annotations'] == 2 + assert data['distributions']['tax'] == { + 'type': 'taxonomy', + 'labels': {'Dog': 1, 'Cat': 1}, + } + + @patch('tasks.api.flag_set') + def test_distribution_with_pairwise(self, mock_flag_set): + mock_flag_set.return_value = True + task = TaskFactory(project=self.project) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'pair', + 'to_name': 'text', + 'type': 'pairwise', + 'value': {'selected': 'left'}, + } + ], + ) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'pair', + 'to_name': 'text', + 'type': 'pairwise', + 'value': {'selected': 'right'}, + } + ], + ) + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/tasks/{task.id}/distribution/') + assert response.status_code == 200 + data = response.json() + assert data['total_annotations'] == 2 + assert data['distributions']['pair'] == { + 'type': 'pairwise', + 'labels': {'left': 1, 'right': 1}, + } + + @patch('tasks.api.flag_set') + def test_distribution_excludes_cancelled_annotations(self, mock_flag_set): + mock_flag_set.return_value = True + task = TaskFactory(project=self.project) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'label', + 'to_name': 'image', + 'type': 'rectanglelabels', + 'value': {'rectanglelabels': ['Car']}, + } + ], + ) + AnnotationFactory( + task=task, + project=self.project, + was_cancelled=True, + result=[ + { + 'from_name': 'label', + 'to_name': 'image', + 'type': 'rectanglelabels', + 'value': {'rectanglelabels': ['Person']}, + } + ], + ) + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/tasks/{task.id}/distribution/') + assert response.status_code == 200 + data = response.json() + assert data['total_annotations'] == 1 + assert data['distributions']['label']['labels'] == {'Car': 1} diff --git a/web/libs/editor/src/components/App/App.jsx b/web/libs/editor/src/components/App/App.jsx index d5c4302639ec..d99106010acd 100644 --- a/web/libs/editor/src/components/App/App.jsx +++ b/web/libs/editor/src/components/App/App.jsx @@ -32,6 +32,8 @@ import { sanitizeHtml } from "../../utils/html"; import { reactCleaner } from "../../utils/reactCleaner"; import { guidGenerator } from "../../utils/unique"; import { isDefined, sortAnnotations } from "../../utils/utilities"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { queryClient } from "@humansignal/core/lib/utils/query-client"; import { ToastProvider, ToastViewport } from "@humansignal/ui/lib/toast/toast"; /** @@ -241,72 +243,74 @@ class App extends Component { className={cn("editor").mod({ fullscreen: settings.fullscreen }).toClassName()} ref={isFF(FF_LSDV_4620_3_ML) ? reactCleaner(this) : null} > - - - - {newUIEnabled ? ( - store.toggleDescription()} - title={store.hasInterface("review") ? "Review Instructions" : "Labeling Instructions"} - > - {store.description} - - ) : ( - <> - {store.showingDescription && ( -
- {/* biome-ignore lint/security/noDangerouslySetInnerHtml: we need html here and it's sanitized */} -
-
- )} - - )} - - {isDefined(store) && store.hasInterface("topbar") && } -
+ + + + {newUIEnabled ? ( - isBulkMode || !store.hasInterface("side-column") ? ( - <> - {mainContent} - {store.hasInterface("topbar") && } - + store.toggleDescription()} + title={store.hasInterface("review") ? "Review Instructions" : "Labeling Instructions"} + > + {store.description} + + ) : ( + <> + {store.showingDescription && ( +
+ {/* biome-ignore lint/security/noDangerouslySetInnerHtml: we need html here and it's sanitized */} +
+
+ )} + + )} + + {isDefined(store) && store.hasInterface("topbar") && } +
+ {newUIEnabled ? ( + isBulkMode || !store.hasInterface("side-column") ? ( + <> + {mainContent} + {store.hasInterface("topbar") && } + + ) : ( + + {mainContent} + {store.hasInterface("topbar") && } + + ) + ) : isBulkMode || !store.hasInterface("side-column") ? ( + mainContent ) : ( - {mainContent} - {store.hasInterface("topbar") && } - - ) - ) : isBulkMode || !store.hasInterface("side-column") ? ( - mainContent - ) : ( - - {mainContent} - - )} -
- - - - {store.hasInterface("debug") && } + + )} +
+ +
+
+ {store.hasInterface("debug") && } +
); } diff --git a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx index 7c7a5fa17caa..0166b97bc04f 100644 --- a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx +++ b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx @@ -48,10 +48,11 @@ export const AggregationCell = ({ isExpanded, }: { control: ControlTag; annotations: AnnotationSummary[]; isExpanded: boolean }) => { const allResults = annotations.flatMap((ann) => ann.results.filter((r) => r.from_name === control.name)); - const totalAnnotations = annotations.length; + // Exclude predictions for percentage denominator to match backend TaskDistributionAPI + const totalAnnotations = annotations.filter((a) => a.type === "annotation").length; if (!allResults.length) { - return No data; + return N/A; } // Handle labels-type controls (rectanglelabels, polygonlabels, labels, etc.) @@ -161,12 +162,12 @@ export const AggregationCell = ({ ); } - // Handle rating - calculate average rating across all annotations + // Handle rating - average over annotations that have a value (matches backend TaskDistributionAPI) if (control.type === "rating") { const ratings = allResults.map((r) => resultValue(r)).filter(Boolean); if (!ratings.length) return No ratings; - const avgRating = ratings.reduce((sum, val) => sum + val, 0) / totalAnnotations; + const avgRating = ratings.reduce((sum, val) => sum + val, 0) / ratings.length; return ( Avg: {avgRating.toFixed(1)} @@ -174,12 +175,12 @@ export const AggregationCell = ({ ); } - // Handle number - calculate average number value across all annotations + // Handle number - average over annotations that have a value (matches backend TaskDistributionAPI) if (control.type === "number") { const numbers = allResults.map((r) => resultValue(r)).filter((v) => v !== null && v !== undefined); if (!numbers.length) return No data; - const avg = numbers.reduce((sum, val) => sum + Number(val), 0) / totalAnnotations; + const avg = numbers.reduce((sum, val) => sum + Number(val), 0) / numbers.length; return ( Avg: {avg.toFixed(1)} @@ -219,7 +220,7 @@ const ApiAggregationCell = ({ ); } - return No data; + return N/A; } // Sort labels by count descending diff --git a/web/libs/editor/src/components/TaskSummary/LabelingSummary.tsx b/web/libs/editor/src/components/TaskSummary/LabelingSummary.tsx index d9745b83266b..a57a8f20b7dc 100644 --- a/web/libs/editor/src/components/TaskSummary/LabelingSummary.tsx +++ b/web/libs/editor/src/components/TaskSummary/LabelingSummary.tsx @@ -27,6 +27,7 @@ type Props = { controls: ControlTag[]; onSelect: (entity: AnnotationSummary) => void; hideInfo: boolean; + taskId?: number | string; }; const cellFn = (control: ControlTag, render: RendererType) => (props: { row: Row }) => { @@ -57,7 +58,7 @@ const convertPredictionResult = (result: MSTResult) => { const columnHelper = createColumnHelper(); -export const LabelingSummary = ({ hideInfo, annotations: all, controls, onSelect }: Props) => { +export const LabelingSummary = ({ hideInfo, annotations: all, controls, onSelect, taskId }: Props) => { const currentUser = window.APP_SETTINGS?.user; const [columnWidths, setColumnWidths] = useState>({}); const tableRef = useRef(null); @@ -218,6 +219,7 @@ export const LabelingSummary = ({ hideInfo, annotations: all, controls, onSelect headers={table.getHeaderGroups()[0]?.headers ?? []} controls={controls} annotations={annotations} + taskId={taskId} /> )} {/* Annotation Rows */} diff --git a/web/libs/editor/src/components/TaskSummary/TaskSummary.tsx b/web/libs/editor/src/components/TaskSummary/TaskSummary.tsx index e73c02bf910f..74aad025e407 100644 --- a/web/libs/editor/src/components/TaskSummary/TaskSummary.tsx +++ b/web/libs/editor/src/components/TaskSummary/TaskSummary.tsx @@ -117,6 +117,7 @@ const TaskSummary = ({ annotations: all, store: annotationStore }: TaskSummaryPr controls={controls} onSelect={onSelect} hideInfo={annotationStore.store.hasInterface("annotations:hide-info")} + taskId={task?.id} />
From a86dd72d78de8d7e33538c62ebd888ac9749cab6 Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 6 Feb 2026 15:54:44 -0500 Subject: [PATCH 12/28] fixing values when FF is on --- label_studio/tasks/api.py | 34 ++++++++++++------------- label_studio/tasks/tests/test_api.py | 38 +++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 18 deletions(-) diff --git a/label_studio/tasks/api.py b/label_studio/tasks/api.py index 0751a1dcfc60..63fc3396eb95 100644 --- a/label_studio/tasks/api.py +++ b/label_studio/tasks/api.py @@ -465,7 +465,6 @@ def get(self, request, pk): raise PermissionDenied('You do not have permission to view this task') # Get all annotations for this task with their results in a single query - # We only need the 'result' field, not the full annotation annotations = Annotation.objects.filter( task=task, was_cancelled=False, @@ -474,21 +473,17 @@ def get(self, request, pk): total_annotations = len(annotations) distributions = {} - # Process results to extract label distributions - for result in annotations: - if not result: - continue - - # result is a list of labeling results + 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', {}) - # Initialize distribution for this control if not exists if from_name not in distributions: distributions[from_name] = { 'type': result_type, @@ -496,9 +491,7 @@ def get(self, request, pk): 'values': [], } - # Extract values based on type if result_type.endswith('labels'): - # Labels type: rectanglelabels, polygonlabels, etc. labels = value.get(result_type, []) if isinstance(labels, list): for label in labels: @@ -507,7 +500,6 @@ def get(self, request, pk): distributions[from_name]['labels'][label] += 1 elif result_type == 'choices': - # Choices type choices = value.get('choices', []) if isinstance(choices, list): for choice in choices: @@ -516,36 +508,44 @@ def get(self, request, pk): distributions[from_name]['labels'][choice] += 1 elif result_type == 'rating': - # Rating type - collect values for averaging rating = value.get('rating') if rating is not None: distributions[from_name]['values'].append(rating) elif result_type == 'number': - # Number type - collect values for averaging number = value.get('number') if number is not None: distributions[from_name]['values'].append(number) elif result_type == 'taxonomy': - # Taxonomy type - extract leaf nodes taxonomy = value.get('taxonomy', []) if isinstance(taxonomy, list): for path in taxonomy: if isinstance(path, list) and path: - leaf = path[-1] # Get leaf node + 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': - # Pairwise type 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']: diff --git a/label_studio/tasks/tests/test_api.py b/label_studio/tasks/tests/test_api.py index 289e2da97297..3593ce397aed 100644 --- a/label_studio/tasks/tests/test_api.py +++ b/label_studio/tasks/tests/test_api.py @@ -3,7 +3,7 @@ from organizations.tests.factories import OrganizationFactory from projects.tests.factories import ProjectFactory from rest_framework.test import APITestCase -from tasks.tests.factories import AnnotationFactory, TaskFactory +from tasks.tests.factories import AnnotationFactory, PredictionFactory, TaskFactory class TestTaskAPI(APITestCase): @@ -559,3 +559,39 @@ def test_distribution_excludes_cancelled_annotations(self, mock_flag_set): data = response.json() assert data['total_annotations'] == 1 assert data['distributions']['label']['labels'] == {'Car': 1} + + @patch('tasks.api.flag_set') + def test_distribution_includes_predictions_in_label_counts(self, mock_flag_set): + """Predictions are merged into distributions so aggregate matches client-side (develop / FF off).""" + mock_flag_set.return_value = True + task = TaskFactory(project=self.project) + AnnotationFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'label', + 'to_name': 'image', + 'type': 'rectanglelabels', + 'value': {'rectanglelabels': ['Car', 'Car']}, + } + ], + ) + PredictionFactory( + task=task, + project=self.project, + result=[ + { + 'from_name': 'label', + 'to_name': 'image', + 'type': 'rectanglelabels', + 'value': {'rectanglelabels': ['Car']}, + } + ], + ) + self.client.force_authenticate(user=self.user) + response = self.client.get(f'/api/tasks/{task.id}/distribution/') + assert response.status_code == 200 + data = response.json() + assert data['total_annotations'] == 1 + assert data['distributions']['label']['labels'] == {'Car': 3} From 867de94461d6548398ccfc932dbb539d27cd87f0 Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 6 Feb 2026 15:59:56 -0500 Subject: [PATCH 13/28] making sure FF off works properly --- web/libs/editor/src/components/TaskSummary/Aggregation.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx index 0166b97bc04f..44e22dca48d2 100644 --- a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx +++ b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx @@ -355,9 +355,9 @@ export const AggregationTableRow = ({ className="px-4 py-2.5 overflow-hidden border-y-2 border-neutral-border-bold" style={{ width: header.getSize() }} > - {isLoading ? ( + {useApiData && isLoading ? ( - ) : error ? ( + ) : useApiData && error ? ( Failed to load ) : useApiData && distributionData ? ( Date: Fri, 6 Feb 2026 16:13:28 -0500 Subject: [PATCH 14/28] fixing unit test --- .../__tests__/TaskSummary.test.tsx | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/web/libs/editor/src/components/TaskSummary/__tests__/TaskSummary.test.tsx b/web/libs/editor/src/components/TaskSummary/__tests__/TaskSummary.test.tsx index bf0f46aa7c43..8e658bc984bf 100644 --- a/web/libs/editor/src/components/TaskSummary/__tests__/TaskSummary.test.tsx +++ b/web/libs/editor/src/components/TaskSummary/__tests__/TaskSummary.test.tsx @@ -1,7 +1,21 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactElement } from "react"; import { render, screen } from "@testing-library/react"; import type { MSTAnnotation, MSTStore } from "../../../stores/types"; import TaskSummary from "../TaskSummary"; +const createTestQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + +const renderWithQueryClient = (ui: ReactElement) => { + const queryClient = createTestQueryClient(); + return render({ui}); +}; + // Polyfill for Object.groupBy which may not be available in test environment if (!Object.groupBy) { Object.groupBy = ( @@ -165,7 +179,7 @@ describe("TaskSummary", () => { const annotations = [createMockAnnotation()]; const store = createMockStore(); - render(); + renderWithQueryClient(); expect(screen.getByText("Task Summary")).toBeInTheDocument(); expect(screen.getByText("Task Data")).toBeInTheDocument(); @@ -181,7 +195,7 @@ describe("TaskSummary", () => { }, }); - render(); + renderWithQueryClient(); expect(screen.getByText("Agreement")).toBeInTheDocument(); expect(screen.getByText("85.5%")).toBeInTheDocument(); @@ -197,7 +211,7 @@ describe("TaskSummary", () => { }, }); - render(); + renderWithQueryClient(); // Backend controls agreement visibility, so if we have a number, show it expect(screen.getByText("Agreement")).toBeInTheDocument(); @@ -210,7 +224,7 @@ describe("TaskSummary", () => { project: null, }); - render(); + renderWithQueryClient(); // Backend controls agreement visibility, so if we have a number, show it expect(screen.getByText("Agreement")).toBeInTheDocument(); @@ -226,7 +240,7 @@ describe("TaskSummary", () => { ]; const store = createMockStore(); - render(); + renderWithQueryClient(); expect(screen.getByText("Annotations")).toBeInTheDocument(); expect(screen.getByText("2")).toBeInTheDocument(); // Only submitted annotations @@ -241,7 +255,7 @@ describe("TaskSummary", () => { ]; const store = createMockStore(); - render(); + renderWithQueryClient(); expect(screen.getByText("Predictions")).toBeInTheDocument(); expect(screen.getByText("2")).toBeInTheDocument(); // Only submitted predictions @@ -266,7 +280,7 @@ describe("TaskSummary", () => { ]), }); - render(); + renderWithQueryClient(); expect(screen.getByText("Annotator")).toBeInTheDocument(); expect(screen.getByText("sentiment")).toBeInTheDocument(); @@ -288,7 +302,7 @@ describe("TaskSummary", () => { ]), }); - render(); + renderWithQueryClient(); // Object tags should appear in the data summary (as header and badge) expect(screen.getAllByText("text")).toHaveLength(2); // header + badge @@ -299,7 +313,7 @@ describe("TaskSummary", () => { const annotations: MSTAnnotation[] = []; const store = createMockStore(); - render(); + renderWithQueryClient(); // Should show 0 for both annotations and predictions expect(screen.getByText("Annotations")).toBeInTheDocument(); @@ -322,7 +336,7 @@ describe("TaskSummary", () => { }, }); - render(); + renderWithQueryClient(); // Should not display agreement when it's undefined expect(screen.queryByText("Agreement")).not.toBeInTheDocument(); @@ -351,7 +365,7 @@ describe("TaskSummary", () => { names: new Map([controlWithPerRegion]), }); - render(); + renderWithQueryClient(); expect(screen.getByText("regionLabel")).toBeInTheDocument(); }); @@ -367,7 +381,7 @@ describe("TaskSummary", () => { ]), }); - render(); + renderWithQueryClient(); // Only valid object tags with $ prefix should appear (as header and badge) expect(screen.getAllByText("text")).toHaveLength(2); // header + badge From 12cdeb47ce5430bed929a3845534f1e00acd5e5a Mon Sep 17 00:00:00 2001 From: robot-ci-heartex Date: Fri, 6 Feb 2026 21:26:12 +0000 Subject: [PATCH 15/28] Sync Follow Merge dependencies Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/21766395983 --- poetry.lock | 20 +++++++++++--------- pyproject.toml | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/poetry.lock b/poetry.lock index 09f0a6478a45..448a34a13871 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "annotated-types" @@ -291,7 +291,6 @@ description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" groups = ["main", "build", "test"] -markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -361,6 +360,7 @@ files = [ {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, ] +markers = {main = "platform_python_implementation != \"PyPy\"", build = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\"", test = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = "*" @@ -503,6 +503,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {test = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -643,6 +644,7 @@ files = [ {file = "cryptography-44.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:04abd71114848aa25edb28e225ab5f268096f44cf0127f3d36975bdf1bdf3390"}, {file = "cryptography-44.0.2.tar.gz", hash = "sha256:c63454aa261a0cf0c5b4718349629793e9e634993538db841165b3df74f37ec0"}, ] +markers = {build = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\""} [package.dependencies] cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} @@ -2092,7 +2094,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -2148,7 +2150,7 @@ optional = false python-versions = ">=3.10,<4" groups = ["main"] files = [ - {file = "c4b8cffdd50f2ca70f37a3504b1c6d4457c0b11e.zip", hash = "sha256:72b3fd5f93c23acfc8627f528aa33b3ba92c7bae6fca778d8385946de0e45b9c"}, + {file = "7dbfddaa31da32023b88441fe199ee6eb211f389.zip", hash = "sha256:33cba201d4abe5c063f926da7669537384ffb161676cccbf66a99230f958f637"}, ] [package.dependencies] @@ -2176,7 +2178,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/c4b8cffdd50f2ca70f37a3504b1c6d4457c0b11e.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/7dbfddaa31da32023b88441fe199ee6eb211f389.zip" [[package]] name = "launchdarkly-server-sdk" @@ -3371,11 +3373,11 @@ description = "C parser in Python" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" groups = ["main", "build", "test"] -markers = "platform_python_implementation != \"PyPy\"" files = [ {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, ] +markers = {main = "platform_python_implementation != \"PyPy\"", build = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\"", test = "platform_python_implementation != \"PyPy\""} [[package]] name = "pydantic" @@ -4429,10 +4431,10 @@ files = [ ] [package.dependencies] -botocore = ">=1.37.4,<2.0a.0" +botocore = ">=1.37.4,<2.0a0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] [[package]] name = "secretstorage" @@ -5146,4 +5148,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "c44130874f845c7565d9edd7b080de79a5d0eabbe3fffa9099933ed89c265624" +content-hash = "a3efdeb3abeefabf08ab3ced51ab6e98dbb57c41f2b3884b83443c2a7f2d1215" diff --git a/pyproject.toml b/pyproject.toml index 92adf12111d3..94bada682c74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "tldextract (>=5.1.3)", "uuid-utils (>=0.11.0,<1.0.0)", ## HumanSignal repo dependencies :start - "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/c4b8cffdd50f2ca70f37a3504b1c6d4457c0b11e.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/7dbfddaa31da32023b88441fe199ee6eb211f389.zip", ## HumanSignal repo dependencies :end ] From 52302c40ed7629b254c17fde88674804d48ee1f2 Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 6 Feb 2026 16:31:25 -0500 Subject: [PATCH 16/28] fixing pytest --- label_studio/tasks/api.py | 2 +- label_studio/tasks/tests/test_api.py | 34 +++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/label_studio/tasks/api.py b/label_studio/tasks/api.py index 63fc3396eb95..9bedefdca707 100644 --- a/label_studio/tasks/api.py +++ b/label_studio/tasks/api.py @@ -453,7 +453,7 @@ class TaskDistributionAPI(generics.RetrieveAPIView): 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): - return Response({'error': 'Feature not enabled'}, status=404) + raise PermissionDenied('Feature not enabled') try: task = Task.objects.get(pk=pk) diff --git a/label_studio/tasks/tests/test_api.py b/label_studio/tasks/tests/test_api.py index 3593ce397aed..259d4fbb1a2c 100644 --- a/label_studio/tasks/tests/test_api.py +++ b/label_studio/tasks/tests/test_api.py @@ -1,6 +1,9 @@ +import unittest from unittest.mock import patch +from core.feature_flags import flag_set from organizations.tests.factories import OrganizationFactory +from projects.models import Project from projects.tests.factories import ProjectFactory from rest_framework.test import APITestCase from tasks.tests.factories import AnnotationFactory, PredictionFactory, TaskFactory @@ -238,8 +241,8 @@ def test_get_task_resolve_uri_false_with_multiple_url_fields(self): assert response_data['text'] == 'Plain text field' -class TestTaskDistributionAPI(APITestCase): - """Tests for TaskDistributionAPI (GET /api/tasks//distribution/).""" +class TestTaskDistributionAPIFeatureOff(APITestCase): + """When feature flag is off, distribution endpoint returns 403. Always run this test.""" @classmethod def setUpTestData(cls): @@ -248,13 +251,27 @@ def setUpTestData(cls): cls.user = cls.organization.created_by @patch('tasks.api.flag_set') - def test_distribution_returns_404_when_feature_flag_disabled(self, mock_flag_set): + def test_distribution_returns_403_when_feature_flag_disabled(self, mock_flag_set): mock_flag_set.return_value = False task = TaskFactory(project=self.project) self.client.force_authenticate(user=self.user) response = self.client.get(f'/api/tasks/{task.id}/distribution/') - assert response.status_code == 404 - assert response.json() == {'error': 'Feature not enabled'} + assert response.status_code == 403 + assert 'detail' in response.json() or 'error' in response.json() + + +@unittest.skipUnless( + flag_set('fflag_fix_all_fit_720_lazy_load_annotations', user=None), + 'Distribution API tests require fflag_fix_all_fit_720_lazy_load_annotations to be on', +) +class TestTaskDistributionAPI(APITestCase): + """Tests for TaskDistributionAPI (GET /api/tasks//distribution/). Run only when feature flag is on.""" + + @classmethod + def setUpTestData(cls): + cls.organization = OrganizationFactory() + cls.project = ProjectFactory(organization=cls.organization) + cls.user = cls.organization.created_by @patch('tasks.api.flag_set') def test_distribution_returns_404_for_nonexistent_task(self, mock_flag_set): @@ -265,11 +282,16 @@ def test_distribution_returns_404_for_nonexistent_task(self, mock_flag_set): assert response.json() == {'error': 'Task not found'} @patch('tasks.api.flag_set') - def test_distribution_permission_denied_for_other_project(self, mock_flag_set): + @patch.object(Project, 'has_permission') + def test_distribution_permission_denied_for_other_project(self, mock_has_permission, mock_flag_set): mock_flag_set.return_value = True other_org = OrganizationFactory() other_project = ProjectFactory(organization=other_org) task = TaskFactory(project=other_project) + # In OSS Project.has_permission is a stub that always returns True; patch so other_project denies access + def has_perm(project, user): + return project.id != other_project.id + mock_has_permission.side_effect = has_perm self.client.force_authenticate(user=self.user) response = self.client.get(f'/api/tasks/{task.id}/distribution/') assert response.status_code == 403 From 0589d177f7e37d7fd3793a714dbf44f17f4462ed Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Fri, 6 Feb 2026 16:34:32 -0500 Subject: [PATCH 17/28] lint cleanup --- label_studio/tasks/tests/test_api.py | 1 + 1 file changed, 1 insertion(+) diff --git a/label_studio/tasks/tests/test_api.py b/label_studio/tasks/tests/test_api.py index 259d4fbb1a2c..26ea3735a149 100644 --- a/label_studio/tasks/tests/test_api.py +++ b/label_studio/tasks/tests/test_api.py @@ -291,6 +291,7 @@ def test_distribution_permission_denied_for_other_project(self, mock_has_permiss # In OSS Project.has_permission is a stub that always returns True; patch so other_project denies access def has_perm(project, user): return project.id != other_project.id + mock_has_permission.side_effect = has_perm self.client.force_authenticate(user=self.user) response = self.client.get(f'/api/tasks/{task.id}/distribution/') From 54bcb54b2c9fdfe36b84e7a7ca7b3f9d7de14e15 Mon Sep 17 00:00:00 2001 From: matt-bernstein Date: Mon, 9 Feb 2026 14:14:52 +0000 Subject: [PATCH 18/28] Sync Follow Merge dependencies Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/21828441303 From b488b7b1257207d866d660a5f078448121fef00b Mon Sep 17 00:00:00 2001 From: robot-ci-heartex Date: Mon, 9 Feb 2026 14:23:05 +0000 Subject: [PATCH 19/28] Sync Follow Merge dependencies Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/21828719776 From 1fb8cf76c8be94673e338b7fd29a3cd42e209923 Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Mon, 9 Feb 2026 09:24:50 -0500 Subject: [PATCH 20/28] registring new endpoint --- label_studio/core/all_urls.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/label_studio/core/all_urls.json b/label_studio/core/all_urls.json index ff41ce78f50f..bc1a004b13d7 100644 --- a/label_studio/core/all_urls.json +++ b/label_studio/core/all_urls.json @@ -575,6 +575,12 @@ "name": "tasks:api:task-annotations-drafts", "decorators": "" }, + { + "url": "/api/tasks//distribution/", + "module": "tasks.api.TaskDistributionAPI", + "name": "tasks:api:task-distribution", + "decorators": "" + }, { "url": "/api/annotations//", "module": "tasks.api.AnnotationAPI", From e33b2652fcf97d1d632ff8cb960dbbebab6fd476 Mon Sep 17 00:00:00 2001 From: robot-ci-heartex Date: Mon, 9 Feb 2026 14:27:10 +0000 Subject: [PATCH 21/28] Sync Follow Merge dependencies Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/21828861963 --- poetry.lock | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 448a34a13871..6b4023cc0c2d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2150,7 +2150,7 @@ optional = false python-versions = ">=3.10,<4" groups = ["main"] files = [ - {file = "7dbfddaa31da32023b88441fe199ee6eb211f389.zip", hash = "sha256:33cba201d4abe5c063f926da7669537384ffb161676cccbf66a99230f958f637"}, + {file = "4661c88b64a1064d8746116962195a8e42cad806.zip", hash = "sha256:ef4716e1cdb53d2d4667701026667e63d3f60158ac3bd1ae8868604a4a4b305c"}, ] [package.dependencies] @@ -2178,7 +2178,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/7dbfddaa31da32023b88441fe199ee6eb211f389.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/4661c88b64a1064d8746116962195a8e42cad806.zip" [[package]] name = "launchdarkly-server-sdk" @@ -5148,4 +5148,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "a3efdeb3abeefabf08ab3ced51ab6e98dbb57c41f2b3884b83443c2a7f2d1215" +content-hash = "95a57b5c3dc42b7b3b44f1813d074747749992c78a12c84e50a7bb592ca6bad1" diff --git a/pyproject.toml b/pyproject.toml index 94bada682c74..278642dd2642 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "tldextract (>=5.1.3)", "uuid-utils (>=0.11.0,<1.0.0)", ## HumanSignal repo dependencies :start - "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/7dbfddaa31da32023b88441fe199ee6eb211f389.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/4661c88b64a1064d8746116962195a8e42cad806.zip", ## HumanSignal repo dependencies :end ] From a74ba2c543314e653713c2a64a60087fc4d6504a Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Mon, 9 Feb 2026 14:41:56 -0500 Subject: [PATCH 22/28] updating to /agreement instead of /distribution --- label_studio/core/all_urls.json | 4 +-- label_studio/tasks/tests/test_api.py | 26 +++++++++---------- label_studio/tasks/urls.py | 4 +-- .../components/TaskSummary/Aggregation.tsx | 4 +-- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/label_studio/core/all_urls.json b/label_studio/core/all_urls.json index bc1a004b13d7..ce81c8673faa 100644 --- a/label_studio/core/all_urls.json +++ b/label_studio/core/all_urls.json @@ -576,9 +576,9 @@ "decorators": "" }, { - "url": "/api/tasks//distribution/", + "url": "/api/tasks//agreement/", "module": "tasks.api.TaskDistributionAPI", - "name": "tasks:api:task-distribution", + "name": "tasks:api:task-agreement", "decorators": "" }, { diff --git a/label_studio/tasks/tests/test_api.py b/label_studio/tasks/tests/test_api.py index 26ea3735a149..6d0219ba88d1 100644 --- a/label_studio/tasks/tests/test_api.py +++ b/label_studio/tasks/tests/test_api.py @@ -255,7 +255,7 @@ def test_distribution_returns_403_when_feature_flag_disabled(self, mock_flag_set mock_flag_set.return_value = False task = TaskFactory(project=self.project) self.client.force_authenticate(user=self.user) - response = self.client.get(f'/api/tasks/{task.id}/distribution/') + response = self.client.get(f'/api/tasks/{task.id}/agreement/') assert response.status_code == 403 assert 'detail' in response.json() or 'error' in response.json() @@ -265,7 +265,7 @@ def test_distribution_returns_403_when_feature_flag_disabled(self, mock_flag_set 'Distribution API tests require fflag_fix_all_fit_720_lazy_load_annotations to be on', ) class TestTaskDistributionAPI(APITestCase): - """Tests for TaskDistributionAPI (GET /api/tasks//distribution/). Run only when feature flag is on.""" + """Tests for TaskDistributionAPI (GET /api/tasks//agreement/). Run only when feature flag is on.""" @classmethod def setUpTestData(cls): @@ -277,7 +277,7 @@ def setUpTestData(cls): def test_distribution_returns_404_for_nonexistent_task(self, mock_flag_set): mock_flag_set.return_value = True self.client.force_authenticate(user=self.user) - response = self.client.get('/api/tasks/99999/distribution/') + response = self.client.get('/api/tasks/99999/agreement/') assert response.status_code == 404 assert response.json() == {'error': 'Task not found'} @@ -294,7 +294,7 @@ def has_perm(project, user): mock_has_permission.side_effect = has_perm self.client.force_authenticate(user=self.user) - response = self.client.get(f'/api/tasks/{task.id}/distribution/') + response = self.client.get(f'/api/tasks/{task.id}/agreement/') assert response.status_code == 403 @patch('tasks.api.flag_set') @@ -302,7 +302,7 @@ def test_distribution_empty_task_returns_zero_annotations(self, mock_flag_set): mock_flag_set.return_value = True task = TaskFactory(project=self.project) self.client.force_authenticate(user=self.user) - response = self.client.get(f'/api/tasks/{task.id}/distribution/') + response = self.client.get(f'/api/tasks/{task.id}/agreement/') assert response.status_code == 200 data = response.json() assert data['total_annotations'] == 0 @@ -337,7 +337,7 @@ def test_distribution_with_rectanglelabels(self, mock_flag_set): ], ) self.client.force_authenticate(user=self.user) - response = self.client.get(f'/api/tasks/{task.id}/distribution/') + response = self.client.get(f'/api/tasks/{task.id}/agreement/') assert response.status_code == 200 data = response.json() assert data['total_annotations'] == 2 @@ -387,7 +387,7 @@ def test_distribution_with_choices(self, mock_flag_set): ], ) self.client.force_authenticate(user=self.user) - response = self.client.get(f'/api/tasks/{task.id}/distribution/') + response = self.client.get(f'/api/tasks/{task.id}/agreement/') assert response.status_code == 200 data = response.json() assert data['total_annotations'] == 3 @@ -425,7 +425,7 @@ def test_distribution_with_rating(self, mock_flag_set): ], ) self.client.force_authenticate(user=self.user) - response = self.client.get(f'/api/tasks/{task.id}/distribution/') + response = self.client.get(f'/api/tasks/{task.id}/agreement/') assert response.status_code == 200 data = response.json() assert data['total_annotations'] == 2 @@ -463,7 +463,7 @@ def test_distribution_with_number(self, mock_flag_set): ], ) self.client.force_authenticate(user=self.user) - response = self.client.get(f'/api/tasks/{task.id}/distribution/') + response = self.client.get(f'/api/tasks/{task.id}/agreement/') assert response.status_code == 200 data = response.json() assert data['total_annotations'] == 2 @@ -500,7 +500,7 @@ def test_distribution_with_taxonomy(self, mock_flag_set): ], ) self.client.force_authenticate(user=self.user) - response = self.client.get(f'/api/tasks/{task.id}/distribution/') + response = self.client.get(f'/api/tasks/{task.id}/agreement/') assert response.status_code == 200 data = response.json() assert data['total_annotations'] == 2 @@ -538,7 +538,7 @@ def test_distribution_with_pairwise(self, mock_flag_set): ], ) self.client.force_authenticate(user=self.user) - response = self.client.get(f'/api/tasks/{task.id}/distribution/') + response = self.client.get(f'/api/tasks/{task.id}/agreement/') assert response.status_code == 200 data = response.json() assert data['total_annotations'] == 2 @@ -577,7 +577,7 @@ def test_distribution_excludes_cancelled_annotations(self, mock_flag_set): ], ) self.client.force_authenticate(user=self.user) - response = self.client.get(f'/api/tasks/{task.id}/distribution/') + response = self.client.get(f'/api/tasks/{task.id}/agreement/') assert response.status_code == 200 data = response.json() assert data['total_annotations'] == 1 @@ -613,7 +613,7 @@ def test_distribution_includes_predictions_in_label_counts(self, mock_flag_set): ], ) self.client.force_authenticate(user=self.user) - response = self.client.get(f'/api/tasks/{task.id}/distribution/') + response = self.client.get(f'/api/tasks/{task.id}/agreement/') assert response.status_code == 200 data = response.json() assert data['total_annotations'] == 1 diff --git a/label_studio/tasks/urls.py b/label_studio/tasks/urls.py index 7628456cbd34..f034d45b1d4c 100644 --- a/label_studio/tasks/urls.py +++ b/label_studio/tasks/urls.py @@ -21,8 +21,8 @@ api.AnnotationDraftListAPI.as_view(), name='task-annotations-drafts', ), - # Distribution endpoint for Summary view - path('/distribution/', api.TaskDistributionAPI.as_view(), name='task-distribution'), + # Agreement endpoint for Summary view + path('/agreement/', api.TaskDistributionAPI.as_view(), name='task-agreement'), ] _api_annotations_urlpatterns = [ diff --git a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx index 44e22dca48d2..4512d8dff90a 100644 --- a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx +++ b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx @@ -24,7 +24,7 @@ type DistributionData = { }; const fetchDistribution = async (taskId: number | string): Promise => { - const response = await fetch(`/api/tasks/${taskId}/distribution/`); + const response = await fetch(`/api/tasks/${taskId}/agreement/`); if (!response.ok) { throw new Error("Failed to load distribution"); } @@ -297,7 +297,7 @@ export const AggregationTableRow = ({ isLoading, error, } = useQuery({ - queryKey: ["task-distribution", taskId], + queryKey: ["task-agreement", taskId], queryFn: () => fetchDistribution(taskId!), enabled: useApiData && !!taskId, staleTime: 30000, // Consider data fresh for 30 seconds From c9a283787d532bed71414c9afec8d62d9d7fea22 Mon Sep 17 00:00:00 2001 From: robot-ci-heartex Date: Mon, 9 Feb 2026 20:53:41 +0000 Subject: [PATCH 23/28] Sync Follow Merge dependencies Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/21840166951 --- poetry.lock | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 6b4023cc0c2d..42d2cc167652 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2150,7 +2150,7 @@ optional = false python-versions = ">=3.10,<4" groups = ["main"] files = [ - {file = "4661c88b64a1064d8746116962195a8e42cad806.zip", hash = "sha256:ef4716e1cdb53d2d4667701026667e63d3f60158ac3bd1ae8868604a4a4b305c"}, + {file = "46a1ff6987e84894edf926310bbc825aab9296d2.zip", hash = "sha256:651e7708263d96ebec0639416b5f590c4865f7c69ec26303713bd9b1be61e309"}, ] [package.dependencies] @@ -2178,7 +2178,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/4661c88b64a1064d8746116962195a8e42cad806.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/46a1ff6987e84894edf926310bbc825aab9296d2.zip" [[package]] name = "launchdarkly-server-sdk" @@ -5148,4 +5148,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "95a57b5c3dc42b7b3b44f1813d074747749992c78a12c84e50a7bb592ca6bad1" +content-hash = "3f47dd10a72811c050f9818d62f8b8c7ad30f05d0d8e26a8ee49401c4a02ef40" diff --git a/pyproject.toml b/pyproject.toml index 278642dd2642..1611aecb1487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "tldextract (>=5.1.3)", "uuid-utils (>=0.11.0,<1.0.0)", ## HumanSignal repo dependencies :start - "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/4661c88b64a1064d8746116962195a8e42cad806.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/46a1ff6987e84894edf926310bbc825aab9296d2.zip", ## HumanSignal repo dependencies :end ] From 1722e88e722915f0b36ebcfdc667f071f229a453 Mon Sep 17 00:00:00 2001 From: robot-ci-heartex Date: Mon, 9 Feb 2026 20:56:13 +0000 Subject: [PATCH 24/28] Sync Follow Merge dependencies Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/21840248995 --- poetry.lock | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 42d2cc167652..8a21cd4801b0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2150,7 +2150,7 @@ optional = false python-versions = ">=3.10,<4" groups = ["main"] files = [ - {file = "46a1ff6987e84894edf926310bbc825aab9296d2.zip", hash = "sha256:651e7708263d96ebec0639416b5f590c4865f7c69ec26303713bd9b1be61e309"}, + {file = "2ab9b178b339c6c7721e67afd21a6c5a91e7c4cd.zip", hash = "sha256:27bfed032766883d456ffdb2567da769d10b26866969e06b955697b96ca058cb"}, ] [package.dependencies] @@ -2178,7 +2178,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/46a1ff6987e84894edf926310bbc825aab9296d2.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/2ab9b178b339c6c7721e67afd21a6c5a91e7c4cd.zip" [[package]] name = "launchdarkly-server-sdk" @@ -5148,4 +5148,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "3f47dd10a72811c050f9818d62f8b8c7ad30f05d0d8e26a8ee49401c4a02ef40" +content-hash = "f0c9b3975ebe325241980148487c6b5bb3471fca07220e80cde3db5cbfc8d2b7" diff --git a/pyproject.toml b/pyproject.toml index 1611aecb1487..aa5309a39dc0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "tldextract (>=5.1.3)", "uuid-utils (>=0.11.0,<1.0.0)", ## HumanSignal repo dependencies :start - "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/46a1ff6987e84894edf926310bbc825aab9296d2.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/2ab9b178b339c6c7721e67afd21a6c5a91e7c4cd.zip", ## HumanSignal repo dependencies :end ] From c2b36df50656e0495e488026fc42b8109a39ef3d Mon Sep 17 00:00:00 2001 From: yyassi-heartex Date: Mon, 9 Feb 2026 21:03:11 +0000 Subject: [PATCH 25/28] Sync Follow Merge dependencies Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/21840469345 --- poetry.lock | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8a21cd4801b0..e8f84bfb6bd6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2150,7 +2150,7 @@ optional = false python-versions = ">=3.10,<4" groups = ["main"] files = [ - {file = "2ab9b178b339c6c7721e67afd21a6c5a91e7c4cd.zip", hash = "sha256:27bfed032766883d456ffdb2567da769d10b26866969e06b955697b96ca058cb"}, + {file = "33fb130fc9c08783f687db36165c6b6d96836ab2.zip", hash = "sha256:51fd48da1a25e7bbc790e81ad2efa3f92cf7ce6fc929e2ed5e4a30390b1d16c0"}, ] [package.dependencies] @@ -2178,7 +2178,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/2ab9b178b339c6c7721e67afd21a6c5a91e7c4cd.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/33fb130fc9c08783f687db36165c6b6d96836ab2.zip" [[package]] name = "launchdarkly-server-sdk" @@ -5148,4 +5148,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "f0c9b3975ebe325241980148487c6b5bb3471fca07220e80cde3db5cbfc8d2b7" +content-hash = "7fdb5c861726b2fcf7f3fd55c69050847b80397125bcc546075cc549f338c062" diff --git a/pyproject.toml b/pyproject.toml index aa5309a39dc0..4c5b4bcf8603 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "tldextract (>=5.1.3)", "uuid-utils (>=0.11.0,<1.0.0)", ## HumanSignal repo dependencies :start - "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/2ab9b178b339c6c7721e67afd21a6c5a91e7c4cd.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/33fb130fc9c08783f687db36165c6b6d96836ab2.zip", ## HumanSignal repo dependencies :end ] From 6bb4a94c5d89a4c13f3420da7c12c633d263978b Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Mon, 9 Feb 2026 16:08:25 -0500 Subject: [PATCH 26/28] updating className to TaskAgreementAPI instead of TaskDistributionAPI --- label_studio/core/all_urls.json | 2 +- label_studio/tasks/api.py | 2 +- label_studio/tasks/tests/test_api.py | 10 +++++----- label_studio/tasks/urls.py | 2 +- .../editor/src/components/TaskSummary/Aggregation.tsx | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/label_studio/core/all_urls.json b/label_studio/core/all_urls.json index ce81c8673faa..903aad862a24 100644 --- a/label_studio/core/all_urls.json +++ b/label_studio/core/all_urls.json @@ -577,7 +577,7 @@ }, { "url": "/api/tasks//agreement/", - "module": "tasks.api.TaskDistributionAPI", + "module": "tasks.api.TaskAgreementAPI", "name": "tasks:api:task-agreement", "decorators": "" }, diff --git a/label_studio/tasks/api.py b/label_studio/tasks/api.py index 9bedefdca707..a03d7ba6c919 100644 --- a/label_studio/tasks/api.py +++ b/label_studio/tasks/api.py @@ -439,7 +439,7 @@ def put(self, request, *args, **kwargs): }, ), ) -class TaskDistributionAPI(generics.RetrieveAPIView): +class TaskAgreementAPI(generics.RetrieveAPIView): """ Efficient endpoint for getting label distribution without fetching all annotations. diff --git a/label_studio/tasks/tests/test_api.py b/label_studio/tasks/tests/test_api.py index 6d0219ba88d1..61c335011a66 100644 --- a/label_studio/tasks/tests/test_api.py +++ b/label_studio/tasks/tests/test_api.py @@ -241,8 +241,8 @@ def test_get_task_resolve_uri_false_with_multiple_url_fields(self): assert response_data['text'] == 'Plain text field' -class TestTaskDistributionAPIFeatureOff(APITestCase): - """When feature flag is off, distribution endpoint returns 403. Always run this test.""" +class TestTaskAgreementAPIFeatureOff(APITestCase): + """When feature flag is off, agreement endpoint returns 403. Always run this test.""" @classmethod def setUpTestData(cls): @@ -262,10 +262,10 @@ def test_distribution_returns_403_when_feature_flag_disabled(self, mock_flag_set @unittest.skipUnless( flag_set('fflag_fix_all_fit_720_lazy_load_annotations', user=None), - 'Distribution API tests require fflag_fix_all_fit_720_lazy_load_annotations to be on', + 'Agreement API tests require fflag_fix_all_fit_720_lazy_load_annotations to be on', ) -class TestTaskDistributionAPI(APITestCase): - """Tests for TaskDistributionAPI (GET /api/tasks//agreement/). Run only when feature flag is on.""" +class TestTaskAgreementAPI(APITestCase): + """Tests for TaskAgreementAPI (GET /api/tasks//agreement/). Run only when feature flag is on.""" @classmethod def setUpTestData(cls): diff --git a/label_studio/tasks/urls.py b/label_studio/tasks/urls.py index f034d45b1d4c..340b4b88b0d2 100644 --- a/label_studio/tasks/urls.py +++ b/label_studio/tasks/urls.py @@ -22,7 +22,7 @@ name='task-annotations-drafts', ), # Agreement endpoint for Summary view - path('/agreement/', api.TaskDistributionAPI.as_view(), name='task-agreement'), + path('/agreement/', api.TaskAgreementAPI.as_view(), name='task-agreement'), ] _api_annotations_urlpatterns = [ diff --git a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx index 4512d8dff90a..91e25d5f6098 100644 --- a/web/libs/editor/src/components/TaskSummary/Aggregation.tsx +++ b/web/libs/editor/src/components/TaskSummary/Aggregation.tsx @@ -48,7 +48,7 @@ export const AggregationCell = ({ isExpanded, }: { control: ControlTag; annotations: AnnotationSummary[]; isExpanded: boolean }) => { const allResults = annotations.flatMap((ann) => ann.results.filter((r) => r.from_name === control.name)); - // Exclude predictions for percentage denominator to match backend TaskDistributionAPI + // Exclude predictions for percentage denominator to match backend TaskAgreementAPI const totalAnnotations = annotations.filter((a) => a.type === "annotation").length; if (!allResults.length) { @@ -162,7 +162,7 @@ export const AggregationCell = ({ ); } - // Handle rating - average over annotations that have a value (matches backend TaskDistributionAPI) + // Handle rating - average over annotations that have a value (matches backend TaskAgreementAPI) if (control.type === "rating") { const ratings = allResults.map((r) => resultValue(r)).filter(Boolean); if (!ratings.length) return No ratings; @@ -175,7 +175,7 @@ export const AggregationCell = ({ ); } - // Handle number - average over annotations that have a value (matches backend TaskDistributionAPI) + // Handle number - average over annotations that have a value (matches backend TaskAgreementAPI) if (control.type === "number") { const numbers = allResults.map((r) => resultValue(r)).filter((v) => v !== null && v !== undefined); if (!numbers.length) return No data; From 3617534bbb7fca60359f449f41b07ebc477933fb Mon Sep 17 00:00:00 2001 From: robot-ci-heartex Date: Mon, 9 Feb 2026 22:46:24 +0000 Subject: [PATCH 27/28] Sync Follow Merge dependencies Workflow run: https://github.com/HumanSignal/label-studio-enterprise/actions/runs/21843723312 --- poetry.lock | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index e8f84bfb6bd6..619bba0d1417 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2150,7 +2150,7 @@ optional = false python-versions = ">=3.10,<4" groups = ["main"] files = [ - {file = "33fb130fc9c08783f687db36165c6b6d96836ab2.zip", hash = "sha256:51fd48da1a25e7bbc790e81ad2efa3f92cf7ce6fc929e2ed5e4a30390b1d16c0"}, + {file = "e3c6062e6fffa06e5af1755384522041f62ae8f1.zip", hash = "sha256:896d7493a90ae1cdbb33414841793d1ab861a36f10d43b6c520949d25c5d2141"}, ] [package.dependencies] @@ -2178,7 +2178,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/33fb130fc9c08783f687db36165c6b6d96836ab2.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/e3c6062e6fffa06e5af1755384522041f62ae8f1.zip" [[package]] name = "launchdarkly-server-sdk" @@ -5148,4 +5148,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "7fdb5c861726b2fcf7f3fd55c69050847b80397125bcc546075cc549f338c062" +content-hash = "0a49ff798675e8dde78e82d28974757303be838dc034f731feb1811a2b90cb9f" diff --git a/pyproject.toml b/pyproject.toml index 4c5b4bcf8603..5b37f262f5aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "tldextract (>=5.1.3)", "uuid-utils (>=0.11.0,<1.0.0)", ## HumanSignal repo dependencies :start - "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/33fb130fc9c08783f687db36165c6b6d96836ab2.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/e3c6062e6fffa06e5af1755384522041f62ae8f1.zip", ## HumanSignal repo dependencies :end ] From afe2978c1327f29e53779625c17fceb3a664b23b Mon Sep 17 00:00:00 2001 From: robot-ci-heartex Date: Mon, 9 Feb 2026 22:49:08 +0000 Subject: [PATCH 28/28] Sync Follow Merge dependencies Workflow run: https://github.com/HumanSignal/label-studio/actions/runs/21843836280 --- poetry.lock | 6 +++--- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 619bba0d1417..95079a1bd040 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2150,7 +2150,7 @@ optional = false python-versions = ">=3.10,<4" groups = ["main"] files = [ - {file = "e3c6062e6fffa06e5af1755384522041f62ae8f1.zip", hash = "sha256:896d7493a90ae1cdbb33414841793d1ab861a36f10d43b6c520949d25c5d2141"}, + {file = "ca54296fd090cf9b1da7f4b8cd60f85430d774c5.zip", hash = "sha256:bd260eabdbaf666bd0ea98935d496a8d289c10ca86ee9a2e78613477ac115bd5"}, ] [package.dependencies] @@ -2178,7 +2178,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/e3c6062e6fffa06e5af1755384522041f62ae8f1.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/ca54296fd090cf9b1da7f4b8cd60f85430d774c5.zip" [[package]] name = "launchdarkly-server-sdk" @@ -5148,4 +5148,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "0a49ff798675e8dde78e82d28974757303be838dc034f731feb1811a2b90cb9f" +content-hash = "be6cf8e314ceac5b5de695c635348879ac597cd190f71ff2c1c75f06f6e9876c" diff --git a/pyproject.toml b/pyproject.toml index 5b37f262f5aa..4b4f8156eed5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ dependencies = [ "tldextract (>=5.1.3)", "uuid-utils (>=0.11.0,<1.0.0)", ## HumanSignal repo dependencies :start - "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/e3c6062e6fffa06e5af1755384522041f62ae8f1.zip", + "label-studio-sdk @ https://github.com/HumanSignal/label-studio-sdk/archive/ca54296fd090cf9b1da7f4b8cd60f85430d774c5.zip", ## HumanSignal repo dependencies :end ]