diff --git a/label_studio/core/all_urls.json b/label_studio/core/all_urls.json index 04f6fb5872cb..bce973610759 100644 --- a/label_studio/core/all_urls.json +++ b/label_studio/core/all_urls.json @@ -527,6 +527,12 @@ "name": "current-user-whoami", "decorators": "" }, + { + "url": "/api/current-user/product-tour", + "module": "users.product_tours.api.ProductTourAPI", + "name": "product-tour", + "decorators": "" + }, { "url": "/data/avatars/", "module": "django.views.static.serve", diff --git a/label_studio/ml_models/models.py b/label_studio/ml_models/models.py index f40218dd9566..bd3e7c605966 100644 --- a/label_studio/ml_models/models.py +++ b/label_studio/ml_models/models.py @@ -115,6 +115,13 @@ class ThirdPartyModelVersion(ModelVersion): 'organizations.Organization', on_delete=models.CASCADE, related_name='third_party_model_versions', null=True ) + @property + def project(self): + # TODO: can it be just a property of the model version? + if self.parent_model and self.parent_model.associated_projects.exists(): + return self.parent_model.associated_projects.first() + return None + def has_permission(self, user): return user.active_organization == self.organization diff --git a/label_studio/projects/api.py b/label_studio/projects/api.py index c4763834f03c..397d0c8a861a 100644 --- a/label_studio/projects/api.py +++ b/label_studio/projects/api.py @@ -788,28 +788,34 @@ def perform_create(self, serializer): return instance +def read_templates_and_groups(): + annotation_templates_dir = find_dir('annotation_templates') + configs = [] + for config_file in pathlib.Path(annotation_templates_dir).glob('**/*.yml'): + config = read_yaml(config_file) + if settings.VERSION_EDITION == 'Community': + if settings.VERSION_EDITION.lower() != config.get('type', 'community'): + continue + if config.get('image', '').startswith('/static') and settings.HOSTNAME: + # if hostname set manually, create full image urls + config['image'] = settings.HOSTNAME + config['image'] + configs.append(config) + template_groups_file = find_file(os.path.join('annotation_templates', 'groups.txt')) + with open(template_groups_file, encoding='utf-8') as f: + groups = f.read().splitlines() + logger.debug(f'{len(configs)} templates found.') + return {'templates': configs, 'groups': groups} + + class TemplateListAPI(generics.ListAPIView): parser_classes = (JSONParser, FormParser, MultiPartParser) permission_required = all_permissions.projects_view swagger_schema = None + # load this once in memory for performance + templates_and_groups = read_templates_and_groups() def list(self, request, *args, **kwargs): - annotation_templates_dir = find_dir('annotation_templates') - configs = [] - for config_file in pathlib.Path(annotation_templates_dir).glob('**/*.yml'): - config = read_yaml(config_file) - if settings.VERSION_EDITION == 'Community': - if settings.VERSION_EDITION.lower() != config.get('type', 'community'): - continue - if config.get('image', '').startswith('/static') and settings.HOSTNAME: - # if hostname set manually, create full image urls - config['image'] = settings.HOSTNAME + config['image'] - configs.append(config) - template_groups_file = find_file(os.path.join('annotation_templates', 'groups.txt')) - with open(template_groups_file, encoding='utf-8') as f: - groups = f.read().splitlines() - logger.debug(f'{len(configs)} templates found.') - return Response({'templates': configs, 'groups': groups}) + return Response(self.templates_and_groups) class ProjectSampleTask(generics.RetrieveAPIView): diff --git a/label_studio/projects/models.py b/label_studio/projects/models.py index 8c10b25e8227..f1bb14b69fb1 100644 --- a/label_studio/projects/models.py +++ b/label_studio/projects/models.py @@ -46,6 +46,7 @@ annotate_useful_annotation_number, ) from projects.functions.utils import make_queryset_from_iterable +from projects.signals import ProjectSignals from tasks.models import ( Annotation, AnnotationDraft, @@ -659,6 +660,10 @@ def display_count(count: int, type: str) -> Optional[str]: def _label_config_has_changed(self): return self.label_config != self.__original_label_config + @property + def label_config_is_not_default(self): + return self.label_config != Project._meta.get_field('label_config').default + def should_none_model_version(self, model_version): """ Returns True if the model version provided matches the object's model version, @@ -728,7 +733,12 @@ def save(self, *args, update_fields=None, recalc=True, **kwargs): exists = True if self.pk else False project_with_config_just_created = not exists and self.label_config - if self._label_config_has_changed() or project_with_config_just_created: + label_config_has_changed = self._label_config_has_changed() + logger.debug( + f'Label config has changed: {label_config_has_changed}, original: {self.__original_label_config}, new: {self.label_config}' + ) + + if label_config_has_changed or project_with_config_just_created: self.data_types = extract_data_types(self.label_config) self.parsed_label_config = parse_config(self.label_config) self.label_config_hash = hash(str(self.parsed_label_config)) @@ -740,11 +750,20 @@ def save(self, *args, update_fields=None, recalc=True, **kwargs): if update_fields is not None: update_fields = {'control_weights'}.union(update_fields) - if self._label_config_has_changed(): - self.__original_label_config = self.label_config - super(Project, self).save(*args, update_fields=update_fields, **kwargs) + if label_config_has_changed: + # save the new label config for future comparison + self.__original_label_config = self.label_config + # if tasks are already imported, emit signal that project is configured and ready for labeling + if self.num_tasks > 0: + logger.debug(f'Sending post_label_config_and_import_tasks signal for project {self.id}') + ProjectSignals.post_label_config_and_import_tasks.send(sender=Project, project=self) + else: + logger.debug( + f'No tasks imported for project {self.id}, skipping post_label_config_and_import_tasks signal' + ) + if not exists: steps = ProjectOnboardingSteps.objects.all() objs = [ProjectOnboarding(project=self, step=step) for step in steps] diff --git a/label_studio/projects/signals.py b/label_studio/projects/signals.py new file mode 100644 index 000000000000..4e1b7a2256f3 --- /dev/null +++ b/label_studio/projects/signals.py @@ -0,0 +1,18 @@ +from django.dispatch import Signal + + +class ProjectSignals: + """ + Signals for project: implements observer pattern for custom signals. + Example: + + # publisher + ProjectSignals.my_signal.send(sender=self, project=project) + + # observer + @receiver(ProjectSignals.my_signal) + def my_observer(sender, **kwargs): + ... + """ + + post_label_config_and_import_tasks = Signal() diff --git a/label_studio/tasks/serializers.py b/label_studio/tasks/serializers.py index 1ec5c5f74535..1c0d6ad945d0 100644 --- a/label_studio/tasks/serializers.py +++ b/label_studio/tasks/serializers.py @@ -392,6 +392,7 @@ def create(self, validated_data): self.post_process_annotations(user, db_annotations, 'imported') self.post_process_tasks(self.project.id, [t.id for t in self.db_tasks]) + self.post_process_custom_callback(self.project.id, user) if flag_set('fflag_feat_back_lsdv_5307_import_reviews_drafts_29062023_short', user=ff_user): with transaction.atomic(): @@ -590,6 +591,10 @@ def post_process_tasks(user, db_tasks): def add_annotation_fields(body, user, action): return body + @staticmethod + def post_process_custom_callback(project_id, user): + pass + class Meta: model = Task fields = '__all__' diff --git a/label_studio/users/migrations/0010_userproducttour.py b/label_studio/users/migrations/0010_userproducttour.py new file mode 100644 index 000000000000..122fb137d49c --- /dev/null +++ b/label_studio/users/migrations/0010_userproducttour.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.15 on 2024-12-22 09:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_migration_linter as linter + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0009_auto_20231201_0001'), + ] + + operations = [ + linter.IgnoreMigration(), + migrations.CreateModel( + name='UserProductTour', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(help_text='Unique identifier for the product tour. Name must match the config name.', max_length=256, verbose_name='Name')), + ('state', models.CharField(choices=[('ready', 'Ready'), ('completed', 'Completed'), ('skipped', 'Skipped')], default='ready', help_text='Current state of the tour for this user. Available options: ready (Ready), completed (Completed), skipped (Skipped)', max_length=32, verbose_name='State')), + ('interaction_data', models.JSONField(blank=True, default=dict, help_text='Additional data about user interaction with the tour', verbose_name='Interaction Data')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='When this tour record was created')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='When this tour record was last updated')), + ('user', models.ForeignKey(help_text='User who interacted with the tour', on_delete=django.db.models.deletion.CASCADE, related_name='tours', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/label_studio/users/product_tours/api.py b/label_studio/users/product_tours/api.py new file mode 100644 index 000000000000..5643435ab81c --- /dev/null +++ b/label_studio/users/product_tours/api.py @@ -0,0 +1,44 @@ +import logging + +from rest_framework import generics +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAuthenticated +from users.product_tours.models import UserProductTour + +from .serializers import UserProductTourSerializer + +logger = logging.getLogger(__name__) + + +class ProductTourAPI(generics.RetrieveUpdateAPIView): + permission_classes = (IsAuthenticated,) + serializer_class = UserProductTourSerializer + swagger_schema = None + + def get_tour_name(self): + name = self.request.query_params.get('name') + if not name: + raise ValidationError('Name is required') + # normalize name for subsequent checks + return name.replace('-', '_').lower() + + def get_serializer_context(self): + context = super().get_serializer_context() + context['name'] = self.get_tour_name() + return context + + def get_object(self): + name = self.get_tour_name() + + # TODO: add additional checks, e.g. user agent, role, etc. + + tour = UserProductTour.objects.filter(user=self.request.user, name=name).first() + if not tour: + logger.debug(f'Product tour {name} not found for user {self.request.user.id}. Creating new tour.') + tour_serializer = self.get_serializer(data={'user': self.request.user.id, 'name': name}) + tour_serializer.is_valid(raise_exception=True) + tour = tour_serializer.save() + else: + logger.debug(f'Product tour {name} requested for user {self.request.user.id}.') + + return tour diff --git a/label_studio/users/product_tours/configs/create_prompt.yml b/label_studio/users/product_tours/configs/create_prompt.yml new file mode 100644 index 000000000000..bd1d1e8a9d79 --- /dev/null +++ b/label_studio/users/product_tours/configs/create_prompt.yml @@ -0,0 +1,22 @@ +steps: +- target: body + placement: center + title: '
Auto-Label with AI
' + content: '
We’ve set up an initial prompt and supplied some OpenAI credits to help you get started with auto-labeling your project.

Let us show you around!
' + +- target: .cm-editor + placement: right + title: '
Step 1 of 3
' + content: '
We’ve gone ahead and generated a prompt for you based on your project’s labeling configuration.

Feel free to adjust it!
' + +- target: '[data-testid="evaluate-model-button"]' + placement: top + title: '
Step 2 of 3
' + content: '
That''s it! After saving, just click here to start getting predictions for your tasks.
' + +- target: body + placement: center + title: '
🎉
That’s it!
' + content: 'Watch the outputs stream in, and click on any task to review the full prediction.
Once you''re ready, run your prompt on "All Project Tasks" to get predictions for all your tasks!' + locale: + last: 'Finish' diff --git a/label_studio/users/product_tours/configs/prompts_page.yml b/label_studio/users/product_tours/configs/prompts_page.yml new file mode 100644 index 000000000000..dd44d8994e42 --- /dev/null +++ b/label_studio/users/product_tours/configs/prompts_page.yml @@ -0,0 +1,9 @@ +steps: + - target: body + placement: center + title: '
Welcome to Prompts!
' + content: '
Set up Prompts to help you rapidly pre-label projects.
' + + - target: ".lsf-data-table__body-row[data-index='0'] .lsf-models-list__model-name" + content: '
Click on this sample Prompt to get you started.
' + isFixed: true diff --git a/label_studio/users/product_tours/configs/show_autolabel_button.yml b/label_studio/users/product_tours/configs/show_autolabel_button.yml new file mode 100644 index 000000000000..b88ce2959dfb --- /dev/null +++ b/label_studio/users/product_tours/configs/show_autolabel_button.yml @@ -0,0 +1,14 @@ +steps: + - target: '[data-testid="auto-labeling-button"]' + placement: bottom + title: 'Great news!' + content: > + You can now rapidly label this project using Prompts. +

+ Click "Auto-Label Tasks" to set up LLM powered labeling in under a minute. +

+ We've provided some OpenAI credits to get you started. + disableBeacon: true + locale: + last: "OK" + skip: "Don't show this message again." \ No newline at end of file diff --git a/label_studio/users/product_tours/models.py b/label_studio/users/product_tours/models.py new file mode 100644 index 000000000000..a8970109e179 --- /dev/null +++ b/label_studio/users/product_tours/models.py @@ -0,0 +1,57 @@ +from typing import Any, Dict, Optional + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from pydantic import BaseModel, Field + + +class ProductTourState(models.TextChoices): + READY = 'ready', _('Ready') + COMPLETED = 'completed', _('Completed') + SKIPPED = 'skipped', _('Skipped') + + +class ProductTourInteractionData(BaseModel): + """Pydantic model for validating tour interaction data""" + + index: Optional[int] = Field(None, description='Step number where tour was completed') + action: Optional[str] = Field(None, description='Action taken during the tour') + type: Optional[str] = Field(None, description='Type of interaction') + status: Optional[str] = Field(None, description='Status of the interaction') + additional_data: Optional[Dict[str, Any]] = Field( + default_factory=dict, description='Extensible field for additional interaction data' + ) + + +class UserProductTour(models.Model): + """Stores product tour state and interaction data for users""" + + user = models.ForeignKey( + 'User', on_delete=models.CASCADE, related_name='tours', help_text='User who interacted with the tour' + ) + + name = models.CharField( + _('Name'), max_length=256, help_text='Unique identifier for the product tour. Name must match the config name.' + ) + + state = models.CharField( + _('State'), + max_length=32, + choices=ProductTourState.choices, + default=ProductTourState.READY, + help_text=f'Current state of the tour for this user. Available options: {", ".join(f"{k} ({v})" for k,v in ProductTourState.choices)}', + ) + + interaction_data = models.JSONField( + _('Interaction Data'), + default=dict, + blank=True, + help_text='Additional data about user interaction with the tour', + ) + + created_at = models.DateTimeField(auto_now_add=True, help_text='When this tour record was created') + + updated_at = models.DateTimeField(auto_now=True, help_text='When this tour record was last updated') + + def __str__(self): + return f'{self.user.email} - {self.name} ({self.state})' diff --git a/label_studio/users/product_tours/serializers.py b/label_studio/users/product_tours/serializers.py new file mode 100644 index 000000000000..a5500178e9fe --- /dev/null +++ b/label_studio/users/product_tours/serializers.py @@ -0,0 +1,48 @@ +import pathlib +from functools import cached_property + +import yaml +from rest_framework import serializers + +from .models import ProductTourInteractionData, UserProductTour + +PRODUCT_TOURS_CONFIGS_DIR = pathlib.Path(__file__).parent / 'configs' + + +class UserProductTourSerializer(serializers.ModelSerializer): + steps = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = UserProductTour + fields = '__all__' + + @cached_property + def available_tours(self): + return {pathlib.Path(f).stem for f in PRODUCT_TOURS_CONFIGS_DIR.iterdir()} + + def validate_name(self, value): + + if value not in self.available_tours: + raise serializers.ValidationError( + f'Product tour {value} not found. Available tours: {self.available_tours}' + ) + + return value + + def load_tour_config(self): + # TODO: get product tour from yaml file. Later we move it to remote storage, e.g. S3 + filepath = PRODUCT_TOURS_CONFIGS_DIR / f'{self.context["name"]}.yml' + with open(filepath, 'r') as f: + return yaml.safe_load(f) + + def get_steps(self, obj): + config = self.load_tour_config() + return config.get('steps', []) + + def validate_interaction_data(self, value): + try: + # Validate interaction data using pydantic model + ProductTourInteractionData(**value) + return value + except Exception as e: + raise serializers.ValidationError(f'Invalid interaction data format: {str(e)}') diff --git a/label_studio/users/urls.py b/label_studio/users/urls.py index e5110aa626ee..34325eedc0eb 100644 --- a/label_studio/users/urls.py +++ b/label_studio/users/urls.py @@ -8,6 +8,7 @@ from django.views.static import serve from rest_framework import routers from users import api, views +from users.product_tours import api as product_tours_api router = routers.DefaultRouter() router.register(r'users', api.UserAPI, basename='user') @@ -23,6 +24,8 @@ path('api/current-user/reset-token/', api.UserResetTokenAPI.as_view(), name='current-user-reset-token'), path('api/current-user/token', api.UserGetTokenAPI.as_view(), name='current-user-token'), path('api/current-user/whoami', api.UserWhoAmIAPI.as_view(), name='current-user-whoami'), + # Product tours + path('api/current-user/product-tour', product_tours_api.ProductTourAPI.as_view(), name='product-tour'), ] # When CLOUD_FILE_STORAGE_ENABLED is set, avatars are uploaded to cloud storage with a different URL pattern. diff --git a/poetry.lock b/poetry.lock index d8eb0ed9a538..b6f3e77c0baa 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.0.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -2133,14 +2133,14 @@ testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", [[package]] name = "label-studio-sdk" -version = "1.0.9.dev0" +version = "1.0.9" description = "" optional = false python-versions = ">=3.9,<4" groups = ["main"] markers = "python_version >= \"3.12\" or python_version <= \"3.11\"" files = [ - {file = "250af500905b654b208741cc9d6b34ba3b4c28a0.zip", hash = "sha256:b0616e2f257b8c8abaf9f12d7fdc1f8501914d8f55986b03fbaac17c56b40d80"}, + {file = "abf1ea4207e22d3d3cdfb8f4bb12ffb4d59384e4.zip", hash = "sha256:95cda8afbbb56d11b79f8b6d34f0f20dd8d1bf7119878322fc0d7fb6c2d00a39"}, ] [package.dependencies] @@ -2148,7 +2148,6 @@ appdirs = ">=1.4.3" datamodel-code-generator = "0.26.1" httpx = ">=0.21.2" ijson = ">=3.2.3" -jinja2 = ">=3.1.5" jsf = ">=0.11.2,<0.12.0" jsonschema = ">=4.23.0" lxml = ">=4.2.5" @@ -2157,6 +2156,7 @@ numpy = ">=1.26.4,<2.0.0" pandas = ">=0.24.0" Pillow = ">=10.0.1" pydantic = ">=1.9.2" +pydantic-core = ">=2.18.2,<3.0.0" requests = ">=2.22.0" requests-mock = "1.12.1" typing_extensions = ">=4.0.0" @@ -2165,7 +2165,7 @@ xmljson = "0.2.1" [package.source] type = "url" -url = "https://github.com/HumanSignal/label-studio-sdk/archive/250af500905b654b208741cc9d6b34ba3b4c28a0.zip" +url = "https://github.com/HumanSignal/label-studio-sdk/archive/abf1ea4207e22d3d3cdfb8f4bb12ffb4d59384e4.zip" [[package]] name = "launchdarkly-server-sdk" @@ -4484,15 +4484,15 @@ files = [ [[package]] name = "smart-open" -version = "7.0.5" +version = "7.1.0" description = "Utils for streaming large files (S3, HDFS, GCS, Azure Blob Storage, gzip, bz2...)" optional = false python-versions = "<4.0,>=3.7" groups = ["main"] markers = "python_version >= \"3.12\" or python_version <= \"3.11\"" files = [ - {file = "smart_open-7.0.5-py3-none-any.whl", hash = "sha256:8523ed805c12dff3eaa50e9c903a6cb0ae78800626631c5fe7ea073439847b89"}, - {file = "smart_open-7.0.5.tar.gz", hash = "sha256:d3672003b1dbc85e2013e4983b88eb9a5ccfd389b0d4e5015f39a9ee5620ec18"}, + {file = "smart_open-7.1.0-py3-none-any.whl", hash = "sha256:4b8489bb6058196258bafe901730c7db0dcf4f083f316e97269c66f45502055b"}, + {file = "smart_open-7.1.0.tar.gz", hash = "sha256:a4f09f84f0f6d3637c6543aca7b5487438877a21360e7368ccf1f704789752ba"}, ] [package.dependencies] @@ -5034,4 +5034,4 @@ uwsgi = ["pyuwsgi", "uwsgitop"] [metadata] lock-version = "2.1" python-versions = ">=3.10,<4" -content-hash = "2b28804cecb02cb73e270fc6f368a14ce2cc4136bbd3661fa9ef10179f321f8b" +content-hash = "3eb25be02165cb9964ae5ec1f8c106ac849ec5ed77bf4d33b068b4c3e9bffcff" diff --git a/pyproject.toml b/pyproject.toml index 7c66aab533ac..e9132e3d600b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -203,7 +203,7 @@ django-migration-linter = "^5.1.0" setuptools = ">=75.4.0" # Humansignal repo dependencies -label-studio-sdk = {url = "https://github.com/HumanSignal/label-studio-sdk/archive/250af500905b654b208741cc9d6b34ba3b4c28a0.zip"} +label-studio-sdk = {url = "https://github.com/HumanSignal/label-studio-sdk/archive/abf1ea4207e22d3d3cdfb8f4bb12ffb4d59384e4.zip"} [tool.poetry.group.test.dependencies] pytest = "7.2.2"