diff --git a/.github/workflows/medcat-trainer_ci.yml b/.github/workflows/medcat-trainer_ci.yml index 4b542f470..03845f85d 100644 --- a/.github/workflows/medcat-trainer_ci.yml +++ b/.github/workflows/medcat-trainer_ci.yml @@ -285,13 +285,15 @@ jobs: tags: cogstacksystems/medcat-trainer:test labels: ${{ steps.meta.outputs.labels }} cache-from: type=registry,ref=cogstacksystems/medcat-trainer:buildcache - cache-to: type=registry,ref=cogstacksystems/medcat-trainer:buildcache,mode=max - name: Run Django Tests run: | # run tests using the built image docker run --rm cogstacksystems/medcat-trainer:test /home/.venv/bin/python manage.py test + - name: Prune stale Docker build cache + run: docker system prune --all --force --volumes + - name: Push Docker MedCATtrainer image id: docker_build_push uses: docker/build-push-action@v7 diff --git a/medcat-trainer/webapp/frontend/src/components/common/ClinicalText.vue b/medcat-trainer/webapp/frontend/src/components/common/ClinicalText.vue index d036468cb..93777564d 100644 --- a/medcat-trainer/webapp/frontend/src/components/common/ClinicalText.vue +++ b/medcat-trainer/webapp/frontend/src/components/common/ClinicalText.vue @@ -74,8 +74,10 @@ export default { formattedText () { if (this.loading || !this.text || !this.ents) { return '' } if (this.ents.length === 0) { - let text = this.text.replace('&', '&').replace('<', '>').replace('>', '>') - text = text === 'nan' ? '' : text + // Must escape for vue3-runtime-template: raw '<' / '>' / '&' are compiled as Vue + // template HTML and throw (e.g. compiler-24 missing end tag), which can leave the + // annotator unable to render subsequent documents. + const text = this.text === 'nan' ? '' : _.escape(this.text) return this.addAnnos ? `
${text}
` : `
${text}
` } diff --git a/medcat-trainer/webapp/frontend/src/tests/components/ClinicalText.spec.ts b/medcat-trainer/webapp/frontend/src/tests/components/ClinicalText.spec.ts index 2253b3647..73f9a5eb0 100644 --- a/medcat-trainer/webapp/frontend/src/tests/components/ClinicalText.spec.ts +++ b/medcat-trainer/webapp/frontend/src/tests/components/ClinicalText.spec.ts @@ -387,5 +387,54 @@ describe('ClinicalText.vue', () => { const formattedText = vm.formattedText expect(formattedText).toContain('@contextmenu.prevent.stop') }) + + it('escapes HTML-like clinical markup when there are no annotations', async () => { + const { compile } = await import('@vue/compiler-dom') + const text = '18 >[ No Service Recorded ] [ LLAMS MDT ] status a & b' + const wrapper = mount(ClinicalText, { + props: { + ...defaultProps, + text, + ents: [] + }, + global: { + stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu'] + } + }) + + const formattedText = (wrapper.vm as any).formattedText + expect(formattedText).toContain('>') + expect(formattedText).toContain('<unknown>') + expect(formattedText).toContain('&') + expect(formattedText).not.toContain('') + expect(() => compile(formattedText)).not.toThrow() + }) + + it('escapes HTML-like markup in unannotated segments when annotations exist', async () => { + const { compile } = await import('@vue/compiler-dom') + const text = 'before Sample after >[ note ]' + const ents = [{ + id: 1, + start_ind: 13, + end_ind: 19, + assignedValues: { 'Concept Anno': 'Correct' }, + manually_created: false + }] + const wrapper = mount(ClinicalText, { + props: { + ...defaultProps, + text, + ents + }, + global: { + stubs: ['v-overlay', 'v-progress-circular', 'v-runtime-template', 'vue-simple-context-menu'] + } + }) + + const formattedText = (wrapper.vm as any).formattedText + expect(formattedText).toContain('<tag>') + expect(formattedText).toContain('>') + expect(() => compile(formattedText)).not.toThrow() + }) }) diff --git a/medcat-trainer/webapp/frontend/src/tests/views/TrainAnnotations.spec.ts b/medcat-trainer/webapp/frontend/src/tests/views/TrainAnnotations.spec.ts index 53b671350..061d5e59b 100644 --- a/medcat-trainer/webapp/frontend/src/tests/views/TrainAnnotations.spec.ts +++ b/medcat-trainer/webapp/frontend/src/tests/views/TrainAnnotations.spec.ts @@ -52,7 +52,7 @@ describe('TrainAnnotations.vue fetchData', () => { if (url.startsWith('/api/project-annotate-entities/')) { return Promise.reject({ response: { status: 500, data: { message: 'Database unavailable' } } }) } - return new Promise(() => {}) + return new Promise(() => { }) }) wrapper.vm.fetchData() @@ -68,7 +68,7 @@ describe('TrainAnnotations.vue fetchData', () => { if (url.startsWith('/api/project-annotate-entities/')) { return Promise.reject({ response: { status: 401, data: { detail: 'Invalid token.' } } }) } - return new Promise(() => {}) + return new Promise(() => { }) }) wrapper.vm.fetchData() @@ -85,7 +85,7 @@ describe('TrainAnnotations.vue fetchEntities', () => { return Promise.reject({ response: { data: { message: 'Invalid token.' } } }) } // Stall created() lifecycle requests so they don't interfere with the test. - return new Promise(() => {}) + return new Promise(() => { }) }) wrapper.vm.project = project @@ -134,7 +134,7 @@ describe('TrainAnnotations.vue fetchEntities', () => { if (url.startsWith('/api/annotated-entities/')) { return Promise.resolve({ data: { results: [], previous: null, next: null } }) } - return new Promise(() => {}) + return new Promise(() => { }) }) wrapper.vm.fetchData() @@ -157,7 +157,7 @@ describe('TrainAnnotations.vue fetchEntities', () => { } }) } - return new Promise(() => {}) + return new Promise(() => { }) }) wrapper.vm.project = project @@ -173,8 +173,36 @@ describe('TrainAnnotations.vue fetchEntities', () => { expect(wrapper.vm.currentEnt.id).toBe(10) }) + it('resets annotation pagination state when switching documents', async () => { + const { wrapper, mockGet } = mountView((url) => { + if (url.startsWith('/api/annotated-entities/')) { + return Promise.resolve({ + data: { results: [], previous: null, next: null } + }) + } + return new Promise(() => { }) + }) + + wrapper.vm.project = { ...project, prepared_documents: [123, 456] } + wrapper.vm.currentDoc = { id: 123, text: 'first doc' } + wrapper.vm.ents = [{ id: 1, start_ind: 0, end_ind: 1, assignedValues: {} }] + wrapper.vm.nextEntSetUrl = '/api/annotated-entities/?project=1&document=123&page=2' + wrapper.vm.loadingMsg = null + + wrapper.vm.loadDoc({ id: 456, text: 'second doc with markup' }) + await flushPromises() + + expect(wrapper.vm.currentDoc.id).toBe(456) + expect(wrapper.vm.loadingMsg).toBeNull() + // Must request the new document, not continue the previous page URL. + const entCalls = mockGet.mock.calls.filter(c => String(c[0]).startsWith('/api/annotated-entities/')) + expect(entCalls.some(c => String(c[0]).includes('document=456'))).toBe(true) + expect(entCalls.some(c => String(c[0]).includes('page=2'))).toBe(false) + + }) + it('does not load a document when the auth cookie is missing', async () => { - const { wrapper, mockGet } = mountView(() => new Promise(() => {}), {}) + const { wrapper, mockGet } = mountView(() => new Promise(() => { }), {}) mockGet.mockClear() wrapper.vm.project = project @@ -183,5 +211,6 @@ describe('TrainAnnotations.vue fetchEntities', () => { expect(mockGet).not.toHaveBeenCalled() expect(wrapper.vm.currentDoc).toBeNull() + }) }) diff --git a/medcat-trainer/webapp/frontend/src/views/TrainAnnotations.vue b/medcat-trainer/webapp/frontend/src/views/TrainAnnotations.vue index 24b6012b1..db112e268 100644 --- a/medcat-trainer/webapp/frontend/src/views/TrainAnnotations.vue +++ b/medcat-trainer/webapp/frontend/src/views/TrainAnnotations.vue @@ -529,7 +529,12 @@ export default { } }) } + // Clear prior doc annotation state so we never render the new text with old ents + // (or continue paginating the previous document's annotated-entities URL). this.currentEnt = null + this.ents = null + this.nextEntSetUrl = null + this.loadingMsg = 'Loading document...' this.prepareDoc() }, prepareDoc () { @@ -620,7 +625,8 @@ export default { this.nextEntSetUrl = null this.loadingMsg = null this.errors.modal = true - this.errors.message = 'Failed to load document annotations. Please try again by refreshing the page.' + this.errors.message = `Failed to load document annotations for project ID ${this.projectId || ''}${this.currentDoc && this.currentDoc.id ? ', document ID ' + this.currentDoc.id : ''}. Please try again by refreshing the page. If the problem persists, please contact your administrator and provide them with this page's URL: ${window.location.href}` + if (err.response) { this.errors.message = err.response.data?.message || this.errors.message this.errors.description = err.response.data?.description || ''