mirror of https://github.com/doccano/doccano.git
Browse Source
Add annotations and disagreements models, serializers, and API endpoints
pull/2426/head
Add annotations and disagreements models, serializers, and API endpoints
pull/2426/head
19 changed files with 447 additions and 4 deletions
Split View
Diff Options
-
32backend/annotations/migrations/0001_initial.py
-
0backend/annotations/migrations/__init__.py
-
19backend/annotations/models.py
-
13backend/annotations/serializers.py
-
10backend/annotations/urls.py
-
65backend/annotations/views.py
-
2backend/config/settings/base.py
-
1backend/config/urls.py
-
28backend/disagreements/models.py
-
7backend/disagreements/serializers.py
-
10backend/disagreements/urls.py
-
21backend/disagreements/views.py
-
27frontend/components/example/DocumentList.vue
-
9frontend/domain/models/annotation/annotation.ts
-
12frontend/domain/models/disagreement/disagreement.ts
-
44frontend/pages/projects/_id/dataset/index.vue
-
83frontend/pages/projects/_id/disagreements/index.vue
-
41frontend/repositories/annotation/apiAnnotationRepository.ts
-
27frontend/repositories/disagreement/apiDisagreementRepository.ts
@ -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,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}" |
@ -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) |
@ -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)), |
|||
] |
@ -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) |
@ -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})" |
@ -0,0 +1,7 @@ |
|||
from rest_framework import serializers |
|||
from .models import Disagreement |
|||
|
|||
class DisagreementSerializer(serializers.ModelSerializer): |
|||
class Meta: |
|||
model = Disagreement |
|||
fields = '__all__' |
@ -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)), |
|||
] |
@ -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) |
@ -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; |
|||
} |
@ -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; |
|||
} |
@ -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> |
@ -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 |
|||
} |
|||
} |
@ -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) |
|||
} |
|||
} |
Write
Preview
Loading…
Cancel
Save