Browse Source

Add annotations and disagreements models, serializers, and API endpoints

pull/2426/head
GONCALOUNI 6 months ago
parent
commit
139dca3ae1
19 changed files with 447 additions and 4 deletions
  1. 32
      backend/annotations/migrations/0001_initial.py
  2. 0
      backend/annotations/migrations/__init__.py
  3. 19
      backend/annotations/models.py
  4. 13
      backend/annotations/serializers.py
  5. 10
      backend/annotations/urls.py
  6. 65
      backend/annotations/views.py
  7. 2
      backend/config/settings/base.py
  8. 1
      backend/config/urls.py
  9. 28
      backend/disagreements/models.py
  10. 7
      backend/disagreements/serializers.py
  11. 10
      backend/disagreements/urls.py
  12. 21
      backend/disagreements/views.py
  13. 27
      frontend/components/example/DocumentList.vue
  14. 9
      frontend/domain/models/annotation/annotation.ts
  15. 12
      frontend/domain/models/disagreement/disagreement.ts
  16. 44
      frontend/pages/projects/_id/dataset/index.vue
  17. 83
      frontend/pages/projects/_id/disagreements/index.vue
  18. 41
      frontend/repositories/annotation/apiAnnotationRepository.ts
  19. 27
      frontend/repositories/disagreement/apiDisagreementRepository.ts

32
backend/annotations/migrations/0001_initial.py

@ -0,0 +1,32 @@
# Generated by Django 4.2.15 on 2025-04-02 08:33
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Annotation",
fields=[
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("dataset_item_id", models.IntegerField()),
("extracted_labels", models.JSONField(blank=True, null=True)),
("additional_info", models.JSONField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"annotator",
models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
],
),
]

0
backend/annotations/migrations/__init__.py

19
backend/annotations/models.py

@ -0,0 +1,19 @@
from django.db import models
from django.conf import settings
from django.db.models import JSONField
class Annotation(models.Model):
dataset_item_id = models.IntegerField()
annotator = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE
)
extracted_labels = JSONField(null=True, blank=True)
additional_info = JSONField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"Annotation on dataset item {self.dataset_item_id} by {self.annotator}"

13
backend/annotations/serializers.py

@ -0,0 +1,13 @@
from rest_framework import serializers
from .models import Annotation
class AnnotationSerializer(serializers.ModelSerializer):
class Meta:
model = Annotation
fields = '__all__'
read_only_fields = ('annotator', 'created_at', 'updated_at')
def update(self, instance, validated_data):
if "extracted_labels" in validated_data:
instance.extracted_labels = validated_data["extracted_labels"]
return super().update(instance, validated_data)

10
backend/annotations/urls.py

@ -0,0 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import AnnotationView
router = DefaultRouter()
router.register(r'', AnnotationView, basename='annotation')
urlpatterns = [
path('', include(router.urls)),
]

65
backend/annotations/views.py

@ -0,0 +1,65 @@
from rest_framework import viewsets
from rest_framework.permissions import IsAuthenticated
from .models import Annotation
from .serializers import AnnotationSerializer
from examples.models import Example
class AnnotationView(viewsets.ModelViewSet):
queryset = Annotation.objects.all()
serializer_class = AnnotationSerializer
permission_classes = [IsAuthenticated]
def aggregate_extracted_labels(self, dataset_item_id, request):
example = Example.objects.get(id=dataset_item_id)
project = example.project
project_type = getattr(project, 'projectType', None)
if project_type is None:
if project.__class__.__name__ == "SequenceLabelingProject":
project_type = "SequenceLabeling"
elif project.__class__.__name__ == "DocumentClassificationProject":
project_type = "DocumentClassification"
else:
project_type = "Unknown"
if project_type == "SequenceLabeling":
if hasattr(example, "get_spans_json") and callable(example.get_spans_json):
spans = example.get_spans_json()
elif hasattr(example, "spans"):
spans = list(example.spans.values("start_offset", "end_offset", "label"))
else:
spans = []
try:
span_types_qs = project.span_types.all()
except AttributeError:
span_types_qs = project.spantype_set.all()
label_types = list(span_types_qs.values("id", "text", "background_color"))
aggregated = {
"text": example.text,
"spans": spans,
"labelTypes": label_types
}
elif project_type == "DocumentClassification":
aggregated = {
"text": example.text,
"label": example.meta.get("category") if example.meta else ""
}
else:
aggregated = {"text": example.text}
return aggregated
def perform_create(self, serializer):
dataset_item_id = serializer.validated_data.get("dataset_item_id")
new_labels = self.aggregate_extracted_labels(dataset_item_id, self.request)
serializer.save(
annotator=self.request.user,
extracted_labels=new_labels
)
def perform_update(self, serializer):
dataset_item_id = serializer.validated_data.get("dataset_item_id")
new_labels = self.aggregate_extracted_labels(dataset_item_id, self.request)
serializer.save(extracted_labels=new_labels)

2
backend/config/settings/base.py

@ -73,6 +73,7 @@ INSTALLED_APPS = [
"health_check.contrib.celery",
"django_cleanup",
"perspectives",
"annotations",
]
@ -248,6 +249,7 @@ CSRF_TRUSTED_ORIGINS = [
"http://10.20.85.44:3000",
"http://10.20.81.58:3000",
"http://172.24.64.1:3000",
"http://10.20.80.25:3000",
]
CSRF_TRUSTED_ORIGINS += env.list("CSRF_TRUSTED_ORIGINS", [])

1
backend/config/urls.py

@ -68,6 +68,7 @@ urlpatterns += [
path("v1/projects/<int:project_id>/", include("labels.urls")),
path("v1/projects/<int:project_id>/", include("label_types.urls")),
path("v1/", include("perspectives.urls")),
path('v1/annotations/', include('annotations.urls')),
path("v1/projects/<int:project_id>/perspectives/", include("perspectives.urls")),
path("swagger/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"),
path("v1/projects/<int:project_id>/category-types/", CategoryTypeList.as_view(), name="project_category_types"),

28
backend/disagreements/models.py

@ -0,0 +1,28 @@
from django.db import models
from django.conf import settings
from django.db.models import JSONField
from annotations.models import Annotation
class Disagreement(models.Model):
dataset_item_id = models.IntegerField()
annotations = models.ManyToManyField(Annotation)
disagreement_details = JSONField(null=True, blank=True)
status = models.CharField(max_length=20, default="open")
resolved_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
null=True,
blank=True,
on_delete=models.SET_NULL
)
resolution_comments = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
resolved_at = models.DateTimeField(null=True, blank=True)
def __str__(self):
return f"Disagreement for dataset item {self.dataset_item_id} (Status: {self.status})"

7
backend/disagreements/serializers.py

@ -0,0 +1,7 @@
from rest_framework import serializers
from .models import Disagreement
class DisagreementSerializer(serializers.ModelSerializer):
class Meta:
model = Disagreement
fields = '__all__'

10
backend/disagreements/urls.py

@ -0,0 +1,10 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import DisagreementViewSet
router = DefaultRouter()
router.register(r'disagreements', DisagreementViewSet, basename='disagreement')
urlpatterns = [
path('', include(router.urls)),
]

21
backend/disagreements/views.py

@ -0,0 +1,21 @@
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .models import Disagreement
from .serializers import DisagreementSerializer
class DisagreementViewSet(viewsets.ModelViewSet):
serializer_class = DisagreementSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
dataset_item_id = self.request.query_params.get('dataset_item_id')
if (dataset_item_id):
return Disagreement.objects.filter(dataset_item_id=dataset_item_id)
return Disagreement.objects.all()
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
disagreement = serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)

27
frontend/components/example/DocumentList.vue

@ -84,6 +84,7 @@ import Vue from 'vue'
import { DataOptions } from 'vuetify/types'
import { ExampleDTO } from '~/services/application/example/exampleData'
import { MemberItem } from '~/domain/models/member/member'
import { APIAnnotationRepository } from '@/repositories/annotation/apiAnnotationRepository'
export default Vue.extend({
props: {
@ -167,9 +168,30 @@ export default Vue.extend({
},
methods: {
toLabeling(item: ExampleDTO) {
async toLabeling(item: ExampleDTO) {
const annotationData = {
dataset_item_id: item.id,
extracted_labels: {
label: (item.meta as any)?.category || '',
text_snippet: item.text ? item.text.substring(0, 50) : ''
},
additional_info: {}
}
const repo = new APIAnnotationRepository()
try {
const existing = await repo.getByDatasetItem(item.id)
if (existing) {
await repo.update(existing.id, annotationData)
} else {
await repo.create(annotationData)
}
} catch (err) {
console.error('Error creating/updating annotation:', err.response || err.message)
}
// Proceed with your usual labeling navigation
const index = this.items.indexOf(item)
const offset = (this.options.page - 1) * this.options.itemsPerPage
const offset = ((this.options?.page || 1) - 1) * (this.options?.itemsPerPage || 10)
const page = (offset + index + 1).toString()
this.$emit('click:labeling', { page, q: this.search })
},
@ -197,7 +219,6 @@ export default Vue.extend({
}
},
// Método para truncar textos
truncate(value: string, length: number): string {
if (!value) return ''
if (value.length <= length) return value

9
frontend/domain/models/annotation/annotation.ts

@ -0,0 +1,9 @@
export interface Annotation {
id: number;
dataset_item_id: number;
annotator: number; // or string, depending on your user identity
extracted_labels?: any;
additional_info?: any;
created_at: string;
updated_at: string;
}

12
frontend/domain/models/disagreement/disagreement.ts

@ -0,0 +1,12 @@
export interface Disagreement {
id: number;
dataset_item_id: number;
annotations: number[];
disagreement_details?: any;
status: string;
resolved_by?: number | null;
resolution_comments?: string;
created_at: string;
updated_at: string;
resolved_at?: string | null;
}

44
frontend/pages/projects/_id/dataset/index.vue

@ -101,6 +101,7 @@ import ImageList from '~/components/example/ImageList.vue'
import { getLinkToAnnotationPage } from '~/presenter/linkToAnnotationPage'
import { ExampleDTO, ExampleListDTO } from '~/services/application/example/exampleData'
import { MemberItem } from '~/domain/models/member/member'
import { APIAnnotationRepository } from '@/repositories/annotation/apiAnnotationRepository'
export default Vue.extend({
components: {
@ -133,7 +134,14 @@ export default Vue.extend({
members: [] as MemberItem[],
user: {} as MemberItem,
isLoading: false,
isProjectAdmin: false
isProjectAdmin: false,
datasetItem: {
id: 0,
text: '',
category: '',
meta: {} as any,
spans: [] as Array<{ startOffset: number; endOffset: number; label: string }>
}
}
},
@ -150,6 +158,10 @@ export default Vue.extend({
computed: {
...mapGetters('projects', ['project']),
projectType(): string {
return (this.project && this.project.projectType) || "DocumentClassification"
},
canDelete(): boolean {
return this.selected.length > 0
},
@ -229,6 +241,36 @@ export default Vue.extend({
this.dialogReset = false
await this.$repositories.assignment.reset(this.projectId)
this.item = await this.$services.example.list(this.projectId, this.$route.query)
},
async annotate() {
const extractedLabels: { text: string; spans?: any[]; label?: string } = {
text: this.datasetItem.text
}
if (this.projectType === "SequenceLabeling") {
extractedLabels.spans = this.datasetItem.spans || []
} else if (this.projectType === "DocumentClassification") {
extractedLabels.label = (this.datasetItem.meta as any)?.category || ""
}
const annotationData = {
dataset_item_id: this.datasetItem.id,
extracted_labels: extractedLabels,
additional_info: {}
}
const repo = new APIAnnotationRepository()
try {
const existing = await repo.getByDatasetItem(this.datasetItem.id)
if (existing) {
await repo.update(existing.id, annotationData, { method: 'put' })
} else {
await repo.create(annotationData)
}
} catch (err) {
console.error('Error annotating dataset item:', err.response || err.message)
}
}
}
})

83
frontend/pages/projects/_id/disagreements/index.vue

@ -0,0 +1,83 @@
<template>
<v-card>
<v-card-title>
Disagreements
</v-card-title>
<v-card-text>
<v-progress-circular v-if="isLoading" indeterminate color="primary" />
<v-alert v-if="error" type="error" dense outlined>
{{ error }}
</v-alert>
<div v-if="!isLoading && disagreements.length === 0">
<p>No disagreements found.</p>
</div>
<v-list v-else>
<v-list-item v-for="disagreement in disagreements" :key="disagreement.id">
<v-list-item-content>
<v-list-item-title>
Disagreement on Dataset Item {{ disagreement.dataset_item_id }}
</v-list-item-title>
<v-list-item-subtitle>
Status: {{ disagreement.status }}
</v-list-item-subtitle>
<v-list-item-subtitle v-if="disagreement.disagreement_details">
Details: {{ disagreement.disagreement_details | json }}
</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action>
<v-btn icon @click="resolveDisagreement(disagreement)">
<v-icon>mdi-check</v-icon>
</v-btn>
</v-list-item-action>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</template>
<script lang="ts">
import Vue from 'vue'
import { APIDisagreementRepository } from '@/repositories/disagreement/apiDisagreementRepository'
export default Vue.extend({
name: 'DisagreementsPage',
data() {
return {
disagreements: [] as any[],
isLoading: false,
error: ''
}
},
mounted() {
this.fetchDisagreements()
},
methods: {
async fetchDisagreements() {
this.isLoading = true
const projectId = Number(this.$route.params.id)
try {
const repo = new APIDisagreementRepository()
this.disagreements = await repo.list(projectId)
} catch (err: any) {
console.error('Error fetching disagreements:', err.response || err.message)
this.error = "Failed to load disagreements."
} finally {
this.isLoading = false
}
},
resolveDisagreement(disagreement: any) {
const projectId = Number(this.$route.params.id)
this.$router.push({
path: this.localePath(`/projects/${projectId}/disagreements/${disagreement.id}/resolve`)
})
}
},
filters: {
json(value: any) {
return JSON.stringify(value, null, 2)
}
}
})
</script>
<style scoped>
</style>

41
frontend/repositories/annotation/apiAnnotationRepository.ts

@ -0,0 +1,41 @@
import { Annotation } from '@/domain/models/annotation/annotation'
import ApiService from '@/services/api.service'
export class APIAnnotationRepository {
async list(query?: any): Promise<Annotation[]> {
const url = `/annotations/`
const response = await ApiService.get(url, { params: query })
return response.data.results || response.data
}
async create(data: any): Promise<Annotation> {
const url = `/annotations/`
const response = await ApiService.post(url, data)
return response.data
}
async update(annotationId: number, data: any, options?: { method?: 'patch' | 'put' }): Promise<Annotation> {
const url = `/annotations/${annotationId}/`
if (options && options.method === 'put') {
const response = await ApiService.put(url, data)
return response.data
} else {
const response = await ApiService.patch(url, data)
return response.data
}
}
async delete(annotationId: number): Promise<void> {
const url = `/annotations/${annotationId}/`
await ApiService.delete(url)
}
async getByDatasetItem(dataset_item_id: number): Promise<Annotation | null> {
const url = `/annotations/`
const response = await ApiService.get(url, {
params: { dataset_item_id }
})
const annotations: Annotation[] = response.data.results || response.data
return annotations.length > 0 ? annotations[0] : null
}
}

27
frontend/repositories/disagreement/apiDisagreementRepository.ts

@ -0,0 +1,27 @@
import { Disagreement } from '@/domain/models/disagreement/disagreement'
import ApiService from '@/services/api.service'
export class APIDisagreementRepository {
async list(projectId: number, query?: any): Promise<Disagreement[]> {
const url = `/projects/${projectId}/disagreements`
const response = await ApiService.get(url, { params: query })
return response.data.results || response.data
}
async create(projectId: number, data: any): Promise<Disagreement> {
const url = `/projects/${projectId}/disagreements`
const response = await ApiService.post(url, data)
return response.data
}
async update(projectId: number, disagreementId: number, data: any): Promise<Disagreement> {
const url = `/projects/${projectId}/disagreements/${disagreementId}/`
const response = await ApiService.patch(url, data)
return response.data
}
async delete(projectId: number, disagreementId: number): Promise<void> {
const url = `/projects/${projectId}/disagreements/${disagreementId}/`
await ApiService.delete(url)
}
}
Loading…
Cancel
Save