Browse Source

Correção Anotações lado a lado

pull/2438/head
lucasgalheto 3 months ago
parent
commit
7e4c175749
4 changed files with 450 additions and 316 deletions
  1. 64
      frontend/components/discrepancy/DiscrepancyList.vue
  2. 9
      frontend/components/layout/TheSideBar.vue
  3. 439
      frontend/pages/projects/_id/compare-annotations/index.vue
  4. 254
      frontend/pages/projects/_id/compare.vue

64
frontend/components/discrepancy/DiscrepancyList.vue

@ -141,32 +141,7 @@
</v-chip>
</template>
<template #[`item.action`]="{ item }">
<v-select
v-model="selectedUsersByItem[item.id]"
:items="getAvailableUsers(item)"
item-text="username"
item-value="id"
label="Select Users"
multiple
chips
small-chips
deletable-chips
dense
outlined
hide-details
class="mb-2"
:rules="[v => !v || v.length <= 2 || 'Select a maximum of 2 users']"
></v-select>
<v-btn
small
color="primary text-capitalize"
:disabled="!selectedUsersByItem[item.id] || selectedUsersByItem[item.id].length !== 2"
@click="compareUsersForDocument(item.id, selectedUsersByItem[item.id])"
>
Compare Annotations
</v-btn>
</template>
</v-data-table>
<!-- Dialog para mostrar todas as labels -->
@ -329,8 +304,7 @@ export default Vue.extend({
selectedItemText: '',
selectedItemUserVotes: null as { [label: string]: { users: Array<{ id: number, name: string }> } } | null,
dialogTab: 0,
selectedItems: [] as ExampleDTO[],
selectedUsersByItem: {} as { [itemId: number]: number[] }
selectedItems: [] as ExampleDTO[]
}
},
@ -341,8 +315,7 @@ export default Vue.extend({
{ text: 'Label Percentage', value: 'labelPercentages', sortable: false },
{ text: 'Participation', value: 'participation', sortable: false },
{ text: 'Discrepancy', value: 'discrepancyPercentage', sortable: true },
{ text: 'Status', value: 'status', sortable: false },
{ text: 'Action', value: 'action', sortable: false }
{ text: 'Status', value: 'status', sortable: false }
]
},
projectId(): string {
@ -869,38 +842,7 @@ loadLabelsIfNeeded() {
return mapping[projectType] || `/projects/${projectId}/text-classification`
},
getAvailableUsers(item: ExampleDTO): { id: number; username: string }[] {
const userIds = new Set<number>();
if (item.annotations) {
item.annotations.forEach(annotation => {
const userId = annotation.user ?? annotation.user_id ?? annotation.created_by;
if (userId) {
userIds.add(userId);
}
});
}
return Array.from(userIds).map(id => {
console.log(`DiscrepancyList - Buscando username para ID: ${id}. memberNames atual:`, JSON.stringify(this.memberNames, null, 2));
return {
id,
username: this.memberNames[id] || `User ${id}`,
};
});
},
compareUsersForDocument(exampleId: number, userIds: number[]) {
console.log(`Comparar exemplo ${exampleId} para usuários: ${userIds.join(', ')}`)
// Navegar para a página de comparação com os IDs como query parameters
this.$router.push({
path: this.$nuxt.localePath(`/projects/${this.projectId}/compare`),
query: {
exampleId: exampleId.toString(),
user1Id: userIds[0].toString(),
user2Id: userIds[1].toString()
}
});
}
}
})
</script>

9
frontend/components/layout/TheSideBar.vue

@ -44,7 +44,8 @@ import {
mdiFileDocumentOutline,
mdiEyeOutline,
mdiVote,
mdiFileChartOutline
mdiFileChartOutline,
mdiCompareHorizontal
} from '@mdi/js'
import { getLinkToAnnotationPage } from '~/presenter/linkToAnnotationPage'
@ -136,6 +137,12 @@ export default {
link: 'discrepancies', // LEVA PARA /projects/:id/discrepancies
isVisible: this.isProjectAdmin
},
{
icon: mdiCompareHorizontal, // ÍCONE DE COMPARAÇÃO
text: 'Compare Annotations',
link: 'compare-annotations', // LEVA PARA /projects/:id/compare-annotations
isVisible: this.isProjectAdmin
},
{
icon: mdiRobotOutline, // ÍCONE DE DISCREPÂNCIAS AUTOMÁTICAS
text: 'Automatic Discrepancies',

439
frontend/pages/projects/_id/compare-annotations/index.vue

@ -0,0 +1,439 @@
<template>
<v-card>
<v-card-title>
<h2>Compare Annotations</h2>
<v-spacer />
</v-card-title>
<v-card-text>
<!-- Filtro para seleção de usuários -->
<v-row class="mb-4">
<v-col cols="12" md="6">
<v-select
v-model="selectedUsers"
:items="availableUsers"
item-text="username"
item-value="id"
label="Select Users to Compare"
multiple
chips
small-chips
deletable-chips
outlined
:rules="[v => !v || v.length >= 2 || 'Select at least 2 users', v => !v || v.length <= 5 || 'Select maximum 5 users']"
>
<template #selection="{ item, index }">
<v-chip
v-if="index < 3"
:key="item.id"
color="primary"
small
close
@click:close="removeUser(item.id)"
>
{{ item.username }}
</v-chip>
<span
v-if="index === 3"
:key="item.id"
class="grey--text caption"
>
(+{{ selectedUsers.length - 3 }} others)
</span>
</template>
</v-select>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="Search text..."
outlined
hide-details
clearable
/>
</v-col>
</v-row>
<!-- Tabela de comparação -->
<v-data-table
:items="processedItems"
:headers="dynamicHeaders"
:loading="isLoading"
:search="search"
loading-text="Loading comparison data..."
:no-data-text="noDataMessage"
:footer-props="{
showFirstLastPage: true,
'items-per-page-options': [10, 25, 50, 100],
'items-per-page-text': 'Items per page:',
'page-text': '{0}-{1} of {2}'
}"
item-key="id"
:item-class="getRowClass"
>
<!-- Template para texto (primeira coluna) -->
<template #[`item.text`]="{ item }">
<div class="text-truncate" style="max-width: 300px;">
<v-tooltip bottom>
<template #activator="{ on, attrs }">
<span v-bind="attrs" v-on="on">
{{ truncateText(item.text, 100) }}
</span>
</template>
<span>{{ item.text }}</span>
</v-tooltip>
</div>
</template>
<!-- Templates dinâmicos para colunas de usuários -->
<template v-for="user in selectedUsers" #[`item.user_${user}`]="{ item }">
<div :key="`user_${user}`">
<v-chip
v-for="label in item.userAnnotations[user] || []"
:key="`${user}_${label}`"
small
:color="getLabelColor(label)"
text-color="white"
class="ma-1"
>
{{ label }}
</v-chip>
<span v-if="!item.userAnnotations[user] || item.userAnnotations[user].length === 0" class="grey--text">
No annotation
</span>
</div>
</template>
<!-- Template para coluna de discrepância -->
<template #[`item.discrepancy`]="{ item }">
<v-chip
:color="getDiscrepancyColor(item.discrepancyStatus)"
text-color="white"
small
>
<v-icon left small>
{{ getDiscrepancyIcon(item.discrepancyStatus) }}
</v-icon>
{{ getDiscrepancyText(item.discrepancyStatus) }}
</v-chip>
</template>
</v-data-table>
</v-card-text>
</v-card>
</template>
<script lang="ts">
import Vue from 'vue'
import { mapGetters } from 'vuex'
type UserAnnotation = {
id: number
username: string
}
type ExampleItem = {
id: number
text: string
userAnnotations: { [userId: number]: string[] }
hasDiscrepancy: boolean
discrepancyStatus: 'agreement' | 'discrepancy' | 'no_annotations'
}
export default Vue.extend({
name: 'CompareAnnotations',
layout: 'project',
middleware: ['check-auth', 'auth', 'setCurrentProject'],
data() {
return {
isLoading: true,
selectedUsers: [] as number[],
availableUsers: [] as UserAnnotation[],
examples: [] as any[],
search: '' as string,
labelColors: {} as { [label: string]: string }
}
},
async fetch() {
this.isLoading = true
try {
const projectId = this.$route.params.id
// Buscar membros do projeto
const membersResponse = await this.$repositories.member.list(projectId)
this.availableUsers = membersResponse.map((member: any) => ({
id: member.user,
username: member.username
}))
// Buscar exemplos com anotações
await this.loadExamples()
// Auto-selecionar os primeiros 2 usuários se disponíveis (para facilitar teste)
if (this.availableUsers.length >= 2) {
this.selectedUsers = [this.availableUsers[0].id, this.availableUsers[1].id]
console.log('🎯 Auto-selected users:', this.selectedUsers)
}
console.log('✅ Loaded', this.availableUsers.length, 'users and', this.examples.length, 'examples')
} catch (error) {
console.error('Error loading comparison data:', error)
} finally {
this.isLoading = false
}
},
computed: {
...mapGetters('projects', ['project']),
dynamicHeaders() {
const headers = [
{ text: 'Text', value: 'text', sortable: true, width: '300px' }
]
// Adicionar colunas para cada usuário selecionado
this.selectedUsers.forEach(userId => {
const user = this.availableUsers.find(u => u.id === userId)
if (user) {
headers.push({
text: user.username,
value: `user_${userId}`,
sortable: false,
width: '200px'
})
}
})
// Adicionar coluna de discrepância
if (this.selectedUsers.length >= 2) {
headers.push({
text: 'Consensus',
value: 'discrepancy',
sortable: true,
width: '150px'
})
}
return headers
},
processedItems(): ExampleItem[] {
if (this.selectedUsers.length < 2) {
return []
}
const processed = this.examples
.map(example => {
const userAnnotations: { [userId: number]: string[] } = {}
// Processar anotações por usuário
this.selectedUsers.forEach(userId => {
userAnnotations[userId] = this.getUserAnnotations(example, userId)
})
// Verificar se algum usuário selecionado fez anotações
const hasAnyAnnotations = this.selectedUsers.some(userId =>
userAnnotations[userId] && userAnnotations[userId].length > 0
)
// Se nenhum usuário selecionado anotou, pular este exemplo
if (!hasAnyAnnotations) {
return null
}
// Verificar discrepâncias e determinar status
const discrepancyResult = this.checkDiscrepancyWithStatus(userAnnotations)
return {
id: example.id,
text: example.text,
userAnnotations,
hasDiscrepancy: discrepancyResult.hasDiscrepancy,
discrepancyStatus: discrepancyResult.status
}
})
.filter(item => item !== null) as ExampleItem[] // Remover itens nulos
console.log('✅ Processed', processed.length, 'examples with relevant annotations for', this.selectedUsers.length, 'users')
return processed
},
noDataMessage() {
if (this.selectedUsers.length < 2) {
return 'Please select at least 2 users to compare annotations'
}
if (this.examples.length === 0) {
return 'No examples found in this project'
}
return 'No examples found where the selected users have made annotations. Try selecting different users or check if they have annotated any examples.'
}
},
methods: {
async loadExamples() {
try {
const projectId = this.$route.params.id
// Usar a mesma abordagem EXATA da página de discrepâncias
const response = await this.$repositories.example.list(projectId, {
limit: '1000',
offset: '0',
include_annotation: 'true' // CHAVE PARA INCLUIR ANOTAÇÕES!
})
// Processar da mesma forma que a página de discrepâncias
this.examples = response.items.map((item: any) => {
return {
id: item.id,
text: item.text,
assignments: item.assignments || [],
annotations: (item.annotations || []).map((a: any) => ({
user: a.user ?? a.user_id ?? a.created_by,
label: a.label,
start_offset: a.start_offset,
end_offset: a.end_offset,
text: a.text,
type: a.type
}))
}
})
console.log('📄 Loaded', this.examples.length, 'examples with annotations')
} catch (error) {
console.error('Error loading examples:', error)
this.examples = []
}
},
getUserAnnotations(example: any, userId: number): string[] {
if (!example.annotations) {
return []
}
const userAnnotations = example.annotations
.filter((annotation: any) => {
const annotationUserId = annotation.user || annotation.user_id || annotation.created_by
return annotationUserId === userId
})
.map((annotation: any) => annotation.label || 'Unlabeled')
.filter((label: string, index: number, self: string[]) => self.indexOf(label) === index) // Remove duplicates
return userAnnotations
},
checkDiscrepancyWithStatus(userAnnotations: { [userId: number]: string[] }): { hasDiscrepancy: boolean, status: 'agreement' | 'discrepancy' | 'no_annotations' } {
const userIds = Object.keys(userAnnotations).map(id => parseInt(id))
// Verificar quantos usuários têm anotações
const usersWithAnnotations = userIds.filter(userId =>
userAnnotations[userId] && userAnnotations[userId].length > 0
)
// Se menos de 2 usuários anotaram, é neutro
if (usersWithAnnotations.length < 2) {
return { hasDiscrepancy: false, status: 'no_annotations' }
}
// Comparar as anotações dos usuários que anotaram
const firstUserAnnotations = userAnnotations[usersWithAnnotations[0]]
for (let i = 1; i < usersWithAnnotations.length; i++) {
const currentUserAnnotations = userAnnotations[usersWithAnnotations[i]]
// Verificar se as anotações são diferentes
if (!this.arraysEqual(firstUserAnnotations, currentUserAnnotations)) {
return { hasDiscrepancy: true, status: 'discrepancy' }
}
}
return { hasDiscrepancy: false, status: 'agreement' }
},
arraysEqual(arr1: string[], arr2: string[]): boolean {
if (arr1.length !== arr2.length) return false
const sorted1 = [...arr1].sort()
const sorted2 = [...arr2].sort()
return sorted1.every((val, index) => val === sorted2[index])
},
removeUser(userId: number) {
this.selectedUsers = this.selectedUsers.filter(id => id !== userId)
},
truncateText(text: string, length: number): string {
if (!text) return ''
return text.length > length ? text.substring(0, length) + '...' : text
},
getLabelColor(label: string): string {
if (!this.labelColors[label]) {
// Gerar uma cor consistente para cada label
const colors = ['primary', 'secondary', 'accent', 'info', 'warning', 'error', 'success']
const hash = this.hashString(label)
this.labelColors[label] = colors[hash % colors.length]
}
return this.labelColors[label]
},
hashString(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash // Convert to 32bit integer
}
return Math.abs(hash)
},
getRowClass(item: ExampleItem): string {
return item.hasDiscrepancy ? 'discrepancy-row' : ''
},
getDiscrepancyColor(status: 'agreement' | 'discrepancy' | 'no_annotations'): string {
switch (status) {
case 'agreement': return 'success'
case 'discrepancy': return 'error'
case 'no_annotations': return 'grey'
default: return 'grey'
}
},
getDiscrepancyIcon(status: 'agreement' | 'discrepancy' | 'no_annotations'): string {
switch (status) {
case 'agreement': return 'mdi-check-circle'
case 'discrepancy': return 'mdi-alert-circle'
case 'no_annotations': return 'mdi-minus-circle'
default: return 'mdi-minus-circle'
}
},
getDiscrepancyText(status: 'agreement' | 'discrepancy' | 'no_annotations'): string {
switch (status) {
case 'agreement': return 'Agreement'
case 'discrepancy': return 'Disagreement'
case 'no_annotations': return 'Insufficient Data'
default: return 'Unknown'
}
}
}
})
</script>
<style scoped>
.discrepancy-row {
background-color: #fff3e0 !important;
}
.text-truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

254
frontend/pages/projects/_id/compare.vue

@ -1,254 +0,0 @@
<template>
<v-card>
<v-card-title>
<h2>Comparação de Anotações</h2>
<v-spacer />
</v-card-title>
<v-card-text>
<div v-if="isLoading">Carregando dados para comparação...</div>
<div v-else-if="!documentText">Não foi possível carregar o documento ou anotações.</div>
<div v-else>
<div class="mb-4">
<h3>Legenda:</h3>
<div class="d-flex flex-wrap">
<div
v-for="labelName in uniqueLabelsForLegend"
:key="labelName"
:style="{ backgroundColor: getLabelColor(labelName), color: 'black', padding: '4px 8px', margin: '4px', borderRadius: '4px' }"
>
{{ labelName }}
</div>
</div>
</div>
<v-row>
<v-col cols="12" md="6">
<h4>Anotações do {{ user1Name }}:</h4>
<div v-if="user1Annotations.length === 0">Nenhuma anotação encontrada para este usuário.</div>
<div v-else>
<AnnotatedText :text="documentText" :annotations="user1Annotations" :label-colors="labelColorsDict" />
</div>
</v-col>
<v-col cols="12" md="6">
<h4>Anotações do {{ user2Name }}:</h4>
<div v-if="user2Annotations.length === 0">Nenhuma anotação encontrada para este usuário.</div>
<div v-else>
<AnnotatedText :text="documentText" :annotations="user2Annotations" :label-colors="labelColorsDict" />
</div>
</v-col>
</v-row>
</div>
</v-card-text>
</v-card>
</template>
<script lang="ts">
import Vue from 'vue'
import { mapGetters } from 'vuex'
import AnnotatedText from '@/components/AnnotatedText.vue'
// Tipos para anotações de span
type SpanAnnotation = {
id?: number
prob?: number
user: number
example?: number
created_at?: string
updated_at?: string
label: string; // Corrigido para string
start_offset: number
end_offset: number
}
// Tipo para Label
// type Label = {
// id: number
// text: string
// shortcut: string | null
// background_color: string
// text_color: string
// }
export default Vue.extend({
name: 'AnnotationCompare',
components: {
AnnotatedText
},
layout: 'project',
middleware: ['check-auth', 'auth', 'setCurrentProject'],
data() {
return {
isLoading: true,
projectId: '' as string,
exampleId: '' as string,
user1Id: '' as string,
user2Id: '' as string,
documentText: '' as string,
user1Annotations: [] as SpanAnnotation[],
user2Annotations: [] as SpanAnnotation[],
user1Name: '' as string,
user2Name: '' as string,
discrepancyReport: null as string | null,
labelColors: {} as { [key: string]: string },
}
},
async fetch() {
this.isLoading = true
try {
this.projectId = this.$route.params.id
this.exampleId = this.$route.query.exampleId as string
this.user1Id = this.$route.query.user1Id as string
this.user2Id = this.$route.query.user2Id as string
if (!this.projectId || !this.exampleId || !this.user1Id || !this.user2Id) {
console.error('Parâmetros de rota ausentes.')
this.isLoading = false
return
}
console.log('this.$repositories:', this.$repositories);
const [exampleResponse, membersResponse] = await Promise.all([
this.$repositories.example.findById(this.projectId, parseInt(this.exampleId)),
this.$repositories.member.list(this.projectId),
]);
this.documentText = exampleResponse.text
// As anotações (spans) já vêm na resposta do documento
// Não é mais necessário mapear label ID para nome, pois já vem como string.
const initialUser1Annotations = exampleResponse.annotations.filter((annotation: any) => annotation.user.toString() === this.user1Id);
const initialUser2Annotations = exampleResponse.annotations.filter((annotation: any) => annotation.user.toString() === this.user2Id);
// Marcar discrepâncias nos spans
const { markedAnnotations1, markedAnnotations2 } = this.markDiscrepantSpans(
initialUser1Annotations,
initialUser2Annotations
);
this.user1Annotations = markedAnnotations1;
this.user2Annotations = markedAnnotations2;
const user1Member = membersResponse.find((m: any) => m.user.toString() === this.user1Id);
const user2Member = membersResponse.find((m: any) => m.user.toString() === this.user2Id);
this.user1Name = user1Member ? user1Member.username : `Usuário ${this.user1Id}`;
this.user2Name = user2Member ? user2Member.username : `Usuário ${this.user2Id}`;
// this.discrepancyReport = this.compareAnnotations(this.user1Annotations, this.user2Annotations); // Removido: não exibe mais o relatório textual
} catch (e) {
console.error('Erro ao buscar dados para comparação:', e)
} finally {
this.isLoading = false
}
},
computed: {
...mapGetters('projects', ['project']),
compareAnnotations(): (annotations1: any[], annotations2: any[]) => string | null {
return (annotations1: any[], annotations2: any[]) => {
const differences: string[] = []
const spans1 = annotations1.filter(a => a.start_offset !== undefined).sort((a, b) => a.start_offset - b.start_offset)
const spans2 = annotations2.filter(a => a.start_offset !== undefined).sort((a, b) => a.start_offset - b.start_offset)
if (spans1.length !== spans2.length) {
differences.push(`Número diferente de spans: ${spans1.length} vs ${spans2.length}`)
} else {
for (let i = 0; i < spans1.length; i++) {
if (spans1[i].label !== spans2[i].label ||
spans1[i].start_offset !== spans2[i].start_offset ||
spans1[i].end_offset !== spans2[i].end_offset) {
differences.push(`Span diferente: "${spans1[i].start_offset}-${spans1[i].end_offset}:${spans1[i].label}" vs "${spans2[i].start_offset}-${spans2[i].end_offset}:${spans2[i].label}"`)
}
}
}
return differences.length > 0 ? differences.join('\n') : null
}
},
uniqueLabelsForLegend(): string[] {
const allLabels = [
...this.user1Annotations.map(ann => ann.label),
...this.user2Annotations.map(ann => ann.label),
];
return Array.from(new Set(allLabels));
},
labelColorsDict(): { [key: string]: string } {
// Gera as cores de forma determinística para todos os labels únicos
// Cores mais claras/pastéis para melhor contraste com sublinhado vermelho
const colors = [
'#FFB6C1', // Light Pink
'#87CEEB', // Sky Blue
'#98FB98', // Pale Green
'#F0E68C', // Khaki
'#DDA0DD', // Plum
'#AFEEEE', // Pale Turquoise
'#FFE4E1', // Misty Rose
'#E0FFFF', // Light Cyan
'#FFEFD5', // Papaya Whip
'#F5DEB3', // Wheat
'#D8BFD8', // Thistle
'#B0E0E6', // Powder Blue
'#FAFAD2', // Light Goldenrod Yellow
'#FFE4B5', // Moccasin
'#E6E6FA', // Lavender
'#F0FFFF', // Azure
'#FFF8DC', // Cornsilk
'#F5F5DC', // Beige
'#FFFACD', // Lemon Chiffon
'#F0F8FF', // Alice Blue
];
const dict: { [key: string]: string } = {};
this.uniqueLabelsForLegend.forEach(label => {
const hash = label.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
dict[label] = colors[hash % colors.length];
});
return dict;
},
},
methods: {
getLabelColor(label: string): string {
return this.labelColorsDict[label] || '#FFF';
},
markDiscrepantSpans(
annotations1: SpanAnnotation[],
annotations2: SpanAnnotation[]
): { markedAnnotations1: SpanAnnotation[]; markedAnnotations2: SpanAnnotation[] } {
const markedAnnotations1: SpanAnnotation[] = annotations1.map(ann => ({ ...ann, isDiscrepant: false }))
const markedAnnotations2: SpanAnnotation[] = annotations2.map(ann => ({ ...ann, isDiscrepant: false }))
// Função auxiliar para verificar se uma anotação existe em uma lista
const existsInList = (annotation: SpanAnnotation, list: SpanAnnotation[]) => {
return list.some(
a =>
a.start_offset === annotation.start_offset &&
a.end_offset === annotation.end_offset &&
a.label === annotation.label
)
}
// Marcar discrepâncias na primeira lista
markedAnnotations1.forEach(ann1 => {
if (!existsInList(ann1, markedAnnotations2)) {
ann1.isDiscrepant = true
}
})
// Marcar discrepâncias na segunda lista
markedAnnotations2.forEach(ann2 => {
if (!existsInList(ann2, markedAnnotations1)) {
ann2.isDiscrepant = true
}
})
return { markedAnnotations1, markedAnnotations2 }
}
}
})
</script>
<style scoped>
/* Estilos futuros para a visualização lado a lado */
</style>
Loading…
Cancel
Save