Browse Source

Implement CRUD for sequence labeling

pull/341/head
Hironsan 5 years ago
parent
commit
ef48af60e0
8 changed files with 574 additions and 193 deletions
  1. 46
      frontend/api/routes/docs.js
  2. 61
      frontend/components/containers/EntityItemBox.vue
  3. 122
      frontend/components/containers/EntityItemContainer.vue
  4. 112
      frontend/components/molecules/EntityItem.vue
  5. 212
      frontend/components/organisms/EntityItemBox.vue
  6. 138
      frontend/pages/projects/_id/annotation/index.vue
  7. 25
      frontend/services/annotation.service.js
  8. 51
      frontend/store/documents.js

46
frontend/api/routes/docs.js

@ -81,4 +81,50 @@ router.delete('/:docId', (req, res, next) => {
res.status(404).json({ detail: 'Not found.' })
}
})
// Add an annotation.
router.post('/:docId/annotations', (req, res, next) => {
const doc = db.find(item => item.id === parseInt(req.params.docId))
if (doc) {
const annotation = {
id: Math.floor(Math.random() * 10000),
label: req.body.label,
start_offset: req.body.start_offset,
end_offset: req.body.end_offset,
user: 1,
document: parseInt(req.params.docId)
}
doc.annotations.push(annotation)
res.json(annotation)
} else {
res.status(404).json({ detail: 'Not found.' })
}
})
// Delete an annotation.
router.delete('/:docId/annotations/:annotationId', (req, res, next) => {
const doc = db.find(item => item.id === parseInt(req.params.docId))
const docIndex = db.findIndex(item => item.id === parseInt(req.params.docId))
if (doc) {
const annotation = doc.annotations.find(item => item.id === parseInt(req.params.annotationId))
doc.annotations = doc.annotations.filter(item => item.id !== parseInt(req.params.annotationId))
db[docIndex] = doc
res.json(annotation)
} else {
res.status(404).json({ detail: 'Not found.' })
}
})
// Update an annotation.
router.patch('/:docId/annotations/:annotationId', (req, res, next) => {
const docIndex = db.findIndex(item => item.id === parseInt(req.params.docId))
if (docIndex !== -1) {
const doc = db[docIndex]
const annotationIndex = doc.annotations.findIndex(item => item.id === parseInt(req.params.annotationId))
Object.assign(db[docIndex].annotations[annotationIndex], req.body)
res.json(db[docIndex].annotations[annotationIndex])
} else {
res.status(404).json({ detail: 'Not found.' })
}
})
module.exports = router

61
frontend/components/containers/EntityItemBox.vue

@ -0,0 +1,61 @@
<template>
<entity-item-box
v-if="currentDoc"
:labels="items"
:text="currentDoc.text"
:entities="currentDoc.annotations"
:delete-annotation="removeEntity"
:update-entity="updateEntity"
:add-entity="addEntity"
/>
</template>
<script>
import { mapActions, mapGetters, mapState } from 'vuex'
import EntityItemBox from '~/components/organisms/EntityItemBox'
export default {
components: {
EntityItemBox
},
computed: {
...mapState('labels', ['items']),
...mapGetters('documents', ['currentDoc'])
},
created() {
this.getLabelList()
this.getDocumentList()
},
methods: {
...mapActions('labels', ['getLabelList']),
...mapActions('documents', ['getDocumentList', 'deleteAnnotation', 'updateAnnotation', 'addAnnotation']),
removeEntity(annotationId) {
const payload = {
annotationId,
projectId: this.$route.params.id
}
this.deleteAnnotation(payload)
},
updateEntity(labelId, annotationId) {
const payload = {
annotationId,
label: labelId,
projectId: this.$route.params.id
}
this.updateAnnotation(payload)
},
addEntity(startOffset, endOffset, labelId) {
const payload = {
start_offset: startOffset,
end_offset: endOffset,
label: labelId,
projectId: this.$route.params.id
}
this.addAnnotation(payload)
}
}
}
</script>

122
frontend/components/containers/EntityItemContainer.vue

@ -1,6 +1,40 @@
<template>
<div class="highlight-container highlight-container--bottom-labels" @mouseup="open">
<entity-item v-for="(chunk, i) in chunks" :key="i" :content="chunk.text" :label="chunk.label" :color="chunk.color" />
<div class="highlight-container highlight-container--bottom-labels" @click="open">
<entity-item
v-for="(chunk, i) in chunks"
:key="i"
:content="chunk.text"
:label="chunk.label"
:color="chunk.color"
:labels="labels"
/>
<v-menu
v-model="showMenu"
:position-x="x"
:position-y="y"
absolute
offset-y
>
<v-list
dense
min-width="150"
max-height="400"
class="overflow-y-auto"
>
<v-list-item
v-for="(label, i) in labels"
:key="i"
@click="add"
>
<v-list-item-content>
<v-list-item-title v-text="label.name" />
</v-list-item-content>
<v-list-item-action>
<v-list-item-action-text v-text="label.shortcut" />
</v-list-item-action>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
@ -28,6 +62,15 @@ export default {
required: true
}
},
data() {
return {
showMenu: false,
x: 0,
y: 0,
start: 0,
end: 0
}
},
computed: {
sortedEntities() {
return this.entities.slice().sort((a, b) => a.start_offset - b.start_offset)
@ -72,7 +115,19 @@ export default {
}
},
methods: {
open() {
show(e) {
e.preventDefault()
this.showMenu = false
this.x = e.clientX
this.y = e.clientY
this.$nextTick(() => {
this.showMenu = true
})
},
add() {
},
getSpanInfo() {
let selection
// Modern browsers.
if (window.getSelection) {
@ -80,16 +135,43 @@ export default {
} else if (document.selection) {
selection = document.selection
}
// If something is selected.
if (selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
const preSelectionRange = range.cloneRange()
preSelectionRange.selectNodeContents(this.$el)
preSelectionRange.setEnd(range.startContainer, range.startOffset)
const start = [...preSelectionRange.toString()].length
const end = start + [...range.toString()].length
alert(start + ' ' + end)
return end
// If nothing is selected.
if (selection.rangeCount <= 0) {
return
}
const range = selection.getRangeAt(0)
const preSelectionRange = range.cloneRange()
preSelectionRange.selectNodeContents(this.$el)
preSelectionRange.setEnd(range.startContainer, range.startOffset)
const start = [...preSelectionRange.toString()].length
const end = start + [...range.toString()].length
return { start, end }
},
validateSpan(span, entities) {
if (!span) {
return false
}
if (span.start === span.end) {
return false
}
for (const entity of this.entities) {
if ((entity.start_offset <= span.start) && (span.start < entity.end_offset)) {
return false
}
if ((entity.start_offset < span.end) && (span.end <= entity.end_offset)) {
return false
}
if ((span.start < entity.start_offset) && (entity.end_offset < span.end)) {
return false
}
}
return true
},
open(e) {
const span = this.getSpanInfo()
const isValid = this.validateSpan(span, this.entities)
if (isValid) {
this.show(e)
}
}
}
@ -98,16 +180,16 @@ export default {
<style scoped>
.highlight-container.highlight-container--bottom-labels {
align-items: flex-start;
align-items: flex-start;
}
.highlight-container {
line-height: 42px!important;
display: flex;
flex-wrap: wrap;
white-space: pre-wrap;
cursor: default;
line-height: 42px!important;
display: flex;
flex-wrap: wrap;
white-space: pre-wrap;
cursor: default;
}
.highlight-container.highlight-container--bottom-labels .highlight.bottom {
margin-top: 6px;
margin-top: 6px;
}
</style>

112
frontend/components/molecules/EntityItem.vue

@ -1,7 +1,34 @@
<template>
<span v-if="label" class="highlight bottom" :style="{ borderColor: color }">
<span class="highlight__content">{{ content }}<v-icon class="delete" @click="remove">mdi-close-circle</v-icon></span><span class="highlight__label" :data-label="label" :style="{backgroundColor: color}" @click="open" />
</span>
<v-menu
v-if="label"
v-model="showMenu"
offset-y
>
<template v-slot:activator="{ on }">
<span class="highlight bottom" :style="{ borderColor: color }" v-on="on">
<span class="highlight__content">{{ content }}<v-icon class="delete" @click.stop="remove">mdi-close-circle</v-icon></span><span class="highlight__label" :data-label="label" :style="{ backgroundColor: color, color: textColor }" />
</span>
</template>
<v-list
dense
min-width="150"
max-height="400"
class="overflow-y-auto"
>
<v-list-item
v-for="(item, i) in labels"
:key="i"
@click="update(item)"
>
<v-list-item-content>
<v-list-item-title v-text="item.text" />
</v-list-item-content>
<v-list-item-action>
<v-list-item-action-text v-text="item.suffix_key" />
</v-list-item-action>
</v-list-item>
</v-list>
</v-menu>
<span v-else>{{ content }}</span>
</template>
@ -22,6 +49,16 @@ export default {
color: {
type: String,
default: '#64FFDA'
},
labels: {
type: Array,
default: () => [],
required: true
}
},
data() {
return {
showMenu: false
}
},
computed: {
@ -30,11 +67,11 @@ export default {
}
},
methods: {
open() {
alert('hello')
update(label) {
this.$emit('update', label)
},
remove() {
alert('remove')
this.$emit('remove')
}
}
}
@ -42,26 +79,25 @@ export default {
<style scoped>
.highlight.blue {
background: #edf4fa !important;
background: #edf4fa !important;
}
.highlight.bottom {
display: block;
white-space: normal;
display: block;
white-space: normal;
}
.highlight:first-child {
margin-left: 0;
margin-left: 0;
}
.highlight {
border: 2px solid;
color: #232323;
margin: 4px 6px 4px 3px;
vertical-align: middle;
box-shadow: 2px 4px 20px rgba(0,0,0,.1);
position: relative;
cursor: default;
min-width: 26px;
line-height: 22px;
display: flex;
border: 2px solid;
margin: 4px 6px 4px 3px;
vertical-align: middle;
box-shadow: 2px 4px 20px rgba(0,0,0,.1);
position: relative;
cursor: default;
min-width: 26px;
line-height: 22px;
display: flex;
}
.highlight .delete {
top:-15px;
@ -73,28 +109,28 @@ export default {
display: block;
}
.highlight__content {
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 2px 2px 0px 6px;
display: flex;
flex-wrap: wrap;
align-items: center;
padding: 2px 2px 0px 6px;
}
.highlight.bottom .highlight__content:after {
content: " ";
padding-right: 3px;
content: " ";
padding-right: 3px;
}
.highlight__label {
line-height: 14px;
padding-top: 1px;
align-items: center;
justify-content: center;
display: flex;
padding: 0 8px;
text-align: center;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
color: white;
line-height: 14px;
padding-top: 1px;
align-items: center;
justify-content: center;
display: flex;
padding: 0 8px;
text-align: center;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
color: white;
}
.highlight__label::after {
content: attr(data-label);

212
frontend/components/organisms/EntityItemBox.vue

@ -0,0 +1,212 @@
<template>
<div class="highlight-container highlight-container--bottom-labels" @click="open">
<entity-item
v-for="(chunk, i) in chunks"
:key="i"
:content="chunk.text"
:label="chunk.label"
:color="chunk.color"
:labels="labels"
@remove="deleteAnnotation(chunk.id)"
@update="updateEntity($event.id, chunk.id)"
/>
<v-menu
v-model="showMenu"
:position-x="x"
:position-y="y"
absolute
offset-y
>
<v-list
dense
min-width="150"
max-height="400"
class="overflow-y-auto"
>
<v-list-item
v-for="(label, i) in labels"
:key="i"
@click="addEntity(start, end, label.id)"
>
<v-list-item-content>
<v-list-item-title v-text="label.text" />
</v-list-item-content>
<v-list-item-action>
<v-list-item-action-text v-text="label.suffix_key" />
</v-list-item-action>
</v-list-item>
</v-list>
</v-menu>
</div>
</template>
<script>
import EntityItem from '~/components/molecules/EntityItem'
export default {
components: {
EntityItem
},
props: {
text: {
type: String,
default: '',
required: true
},
labels: {
type: Array,
default: () => ([]),
required: true
},
entities: {
type: Array,
default: () => ([]),
required: true
},
deleteAnnotation: {
type: Function,
default: () => ([]),
required: true
},
updateEntity: {
type: Function,
default: () => ([]),
required: true
},
addEntity: {
type: Function,
default: () => ([]),
required: true
}
},
data() {
return {
showMenu: false,
x: 0,
y: 0,
start: 0,
end: 0
}
},
computed: {
sortedEntities() {
return this.entities.slice().sort((a, b) => a.start_offset - b.start_offset)
},
chunks() {
const chunks = []
const entities = this.sortedEntities
let startOffset = 0
for (const entity of entities) {
// add non-entities to chunks.
chunks.push({
label: null,
color: null,
text: this.text.slice(startOffset, entity.start_offset)
})
startOffset = entity.end_offset
// add entities to chunks.
const label = this.labelObject[entity.label]
chunks.push({
id: entity.id,
label: label.text,
color: label.background_color,
text: this.text.slice(entity.start_offset, entity.end_offset)
})
}
// add the rest of text.
chunks.push({
label: null,
color: null,
text: this.text.slice(startOffset, this.text.length)
})
return chunks
},
labelObject() {
const obj = {}
for (const label of this.labels) {
obj[label.id] = label
}
return obj
}
},
methods: {
show(e) {
e.preventDefault()
this.showMenu = false
this.x = e.clientX
this.y = e.clientY
this.$nextTick(() => {
this.showMenu = true
})
},
getSpanInfo() {
let selection
// Modern browsers.
if (window.getSelection) {
selection = window.getSelection()
} else if (document.selection) {
selection = document.selection
}
// If nothing is selected.
if (selection.rangeCount <= 0) {
return
}
const range = selection.getRangeAt(0)
const preSelectionRange = range.cloneRange()
preSelectionRange.selectNodeContents(this.$el)
preSelectionRange.setEnd(range.startContainer, range.startOffset)
const start = [...preSelectionRange.toString()].length
const end = start + [...range.toString()].length
return { start, end }
},
validateSpan(span, entities) {
if (!span) {
return false
}
if (span.start === span.end) {
return false
}
for (const entity of this.entities) {
if ((entity.start_offset <= span.start) && (span.start < entity.end_offset)) {
return false
}
if ((entity.start_offset < span.end) && (span.end <= entity.end_offset)) {
return false
}
if ((span.start < entity.start_offset) && (entity.end_offset < span.end)) {
return false
}
}
return true
},
open(e) {
const span = this.getSpanInfo()
const isValid = this.validateSpan(span, this.entities)
if (isValid) {
this.start = span.start
this.end = span.end
this.show(e)
}
}
}
}
</script>
<style scoped>
.highlight-container.highlight-container--bottom-labels {
align-items: flex-start;
}
.highlight-container {
line-height: 42px!important;
display: flex;
flex-wrap: wrap;
white-space: pre-wrap;
cursor: default;
}
.highlight-container.highlight-container--bottom-labels .highlight.bottom {
margin-top: 6px;
}
</style>

138
frontend/pages/projects/_id/annotation/index.vue

@ -11,7 +11,7 @@
<v-flex>
<v-card>
<v-card-text class="title">
<entity-item-container :content="text" :labels="labels" :entities="annotations" />
<entity-item-box />
</v-card-text>
</v-card>
</v-flex>
@ -22,23 +22,19 @@
</template>
<script>
import EntityItemContainer from '~/components/containers/EntityItemContainer'
import EntityItemBox from '~/components/containers/EntityItemBox'
import SideBarLabeling from '~/components/organisms/SideBarLabeling'
export default {
layout: 'annotation',
components: {
EntityItemContainer,
EntityItemBox,
SideBarLabeling
},
data: () => ({
progress: 30,
metadata: '{"wikiPageId":2}',
search: '',
content: 'Sony',
text:
'Barack Hussein Obama II (born August 4, 1961) is an American attorney and politician who served as the 44th president of the United States from 2009 to 2017. A member of the Democratic Party, he was the first African American to be elected to the presidency. He previously served as a U.S. senator from Illinois from 2005 to 2008 and an Illinois state senator from 1997 to 2004.',
labelName: 'ORG',
labels: [
{
id: 1,
@ -70,134 +66,8 @@ export default {
color: '#333333',
shortcut: 't'
}
],
annotations: [
{
id: 2,
prob: 0.0,
label: 3,
start_offset: 0,
end_offset: 23,
user: 1,
document: 1
},
{
id: 3,
prob: 0.0,
label: 4,
start_offset: 30,
end_offset: 44,
user: 1,
document: 1
},
{
id: 2,
prob: 0.0,
label: 1,
start_offset: 125,
end_offset: 138,
user: 1,
document: 1
},
{
id: 2,
prob: 0.0,
label: 4,
start_offset: 144,
end_offset: 148,
user: 1,
document: 1
},
{
id: 2,
prob: 0.0,
label: 4,
start_offset: 152,
end_offset: 156,
user: 1,
document: 1
},
{
id: 2,
prob: 0.0,
label: 2,
start_offset: 174,
end_offset: 190,
user: 1,
document: 1
},
{
id: 2,
prob: 0.0,
label: 1,
start_offset: 285,
end_offset: 289,
user: 1,
document: 1
},
{
id: 2,
prob: 0.0,
label: 1,
start_offset: 303,
end_offset: 311,
user: 1,
document: 1
},
{
id: 2,
prob: 0.0,
label: 4,
start_offset: 317,
end_offset: 321,
user: 1,
document: 1
},
{
id: 2,
prob: 0.0,
label: 4,
start_offset: 325,
end_offset: 329,
user: 1,
document: 1
},
{
id: 2,
prob: 0.0,
label: 1,
start_offset: 337,
end_offset: 345,
user: 1,
document: 1
},
{
id: 2,
prob: 0.0,
label: 4,
start_offset: 365,
end_offset: 369,
user: 1,
document: 1
},
{
id: 2,
prob: 0.0,
label: 4,
start_offset: 373,
end_offset: 377,
user: 1,
document: 1
}
]
}),
methods: {
save() {},
cancel() {},
open() {},
close() {}
}
})
}
</script>

25
frontend/services/annotation.service.js

@ -0,0 +1,25 @@
import ApiService from '@/services/api.service'
class AnnotationService {
constructor() {
this.request = new ApiService()
}
getAnnotationList(projectId, docId) {
return this.request.get(`/projects/${projectId}/docs/${docId}/annotations`)
}
addAnnotation(projectId, docId, payload) {
return this.request.post(`/projects/${projectId}/docs/${docId}/annotations`, payload)
}
deleteAnnotation(projectId, docId, annotationId) {
return this.request.delete(`/projects/${projectId}/docs/${docId}/annotations/${annotationId}`)
}
updateAnnotation(projectId, docId, annotationId, payload) {
return this.request.patch(`/projects/${projectId}/docs/${docId}/annotations/${annotationId}`, payload)
}
}
export default new AnnotationService()

51
frontend/store/documents.js

@ -1,4 +1,5 @@
import DocumentService from '@/services/document.service'
import AnnotationService from '@/services/annotation.service'
import CSVParser from '@/services/parsers/csv.service'
export const state = () => ({
@ -6,7 +7,8 @@ export const state = () => ({
selected: [],
loading: false,
selectedFormat: null,
parsed: {}
parsed: {},
current: 0
})
export const getters = {
@ -52,6 +54,9 @@ export const getters = {
} else {
return []
}
},
currentDoc(state) {
return state.items[state.current]
}
}
@ -81,6 +86,16 @@ export const mutations = {
parseFile(state, text) {
const parser = new CSVParser()
state.parsed = parser.parse(text)
},
addAnnotation(state, payload) {
state.items[state.current].annotations.push(payload)
},
deleteAnnotation(state, annotationId) {
state.items[state.current].annotations = state.items[state.current].annotations.filter(item => item.id !== annotationId)
},
updateAnnotation(state, payload) {
const item = state.items[state.current].annotations.find(item => item.id === payload.id)
Object.assign(item, payload)
}
}
@ -128,6 +143,10 @@ export const actions = {
}
commit('resetSelected')
},
nextPage({ commit }) {
},
prevPage({ commit }) {
},
parseFile({ commit }, data) {
const reader = new FileReader()
reader.readAsText(data, 'UTF-8')
@ -137,5 +156,35 @@ export const actions = {
reader.onerror = (e) => {
alert(e)
}
},
addAnnotation({ commit, state }, payload) {
const documentId = state.items[state.current].id
AnnotationService.addAnnotation(payload.projectId, documentId, payload)
.then((response) => {
commit('addAnnotation', response)
})
.catch((error) => {
alert(error)
})
},
updateAnnotation({ commit, state }, payload) {
const documentId = state.items[state.current].id
AnnotationService.updateAnnotation(payload.projectId, documentId, payload.annotationId, payload)
.then((response) => {
commit('updateAnnotation', response)
})
.catch((error) => {
alert(error)
})
},
deleteAnnotation({ commit, state }, payload) {
const documentId = state.items[state.current].id
AnnotationService.deleteAnnotation(payload.projectId, documentId, payload.annotationId)
.then((response) => {
commit('deleteAnnotation', payload.annotationId)
})
.catch((error) => {
alert(error)
})
}
}
Loading…
Cancel
Save