From 3b447136f665028dd250ca8b1fba0a5754c3fb0c Mon Sep 17 00:00:00 2001 From: Tom Searle Date: Fri, 16 Jan 2026 00:09:17 +0000 Subject: [PATCH 1/4] feat(medcat-trainer): improved demo screen, model pack selection, new clinical text component, improved demo filtering concept picker --- medcat-trainer/webapp/api/api/model_cache.py | 28 +- medcat-trainer/webapp/api/api/views.py | 91 ++++- medcat-trainer/webapp/api/core/urls.py | 3 +- medcat-trainer/webapp/frontend/env.d.ts | 6 + .../src/components/anns/AddAnnotation.vue | 4 +- .../components/common/AnnotateTextEntry.vue | 336 +++++++++++++++++ .../src/components/common/ClinicalText.vue | 5 +- .../src/components/common/ConceptSummary.vue | 8 + .../common/MetaAnnotationsSummary.vue | 90 +++++ .../src/components/common/ProjectList.vue | 8 +- .../frontend/src/tests/views/Demo.spec.ts | 60 ++- .../webapp/frontend/src/views/Demo.vue | 353 +++++++++++++++--- .../frontend/src/views/TrainAnnotations.vue | 2 +- .../webapp/frontend/tsconfig.vitest.json | 1 + 14 files changed, 899 insertions(+), 96 deletions(-) create mode 100644 medcat-trainer/webapp/frontend/src/components/common/AnnotateTextEntry.vue create mode 100644 medcat-trainer/webapp/frontend/src/components/common/MetaAnnotationsSummary.vue diff --git a/medcat-trainer/webapp/api/api/model_cache.py b/medcat-trainer/webapp/api/api/model_cache.py index e3e2a9a84..977cccc0f 100644 --- a/medcat-trainer/webapp/api/api/model_cache.py +++ b/medcat-trainer/webapp/api/api/model_cache.py @@ -11,7 +11,7 @@ from medcat.vocab import Vocab from medcat.utils.legacy.convert_cdb import get_cdb_from_old -from api.models import ConceptDB +from api.models import ConceptDB, ModelPack """ Module level caches for CDBs, Vocabs and CAT instances. @@ -163,6 +163,22 @@ def get_medcat_from_model_pack(project, cat_map: Dict[str, CAT]=CAT_MAP) -> CAT: return cat +def get_medcat_from_model_pack_id(modelpack_id: int, cat_map: Dict[str, CAT]=CAT_MAP) -> CAT: + """ + Load (and cache) a MedCAT model pack directly from a ModelPack id. + """ + cat_id = f'mp{modelpack_id}' + if cat_id in cat_map: + return cat_map[cat_id] + + model_pack_obj = ModelPack.objects.get(id=modelpack_id) + logger.info('Loading model pack from:%s', model_pack_obj.model_pack.path) + cat = CAT.load_model_pack(model_pack_obj.model_pack.path) + cat_map[cat_id] = cat + _clear_models(cat_map=cat_map) + return cat + + def get_medcat(project, cdb_map: Dict[str, CDB]=CDB_MAP, vocab_map: Dict[str, Vocab]=VOCAB_MAP, @@ -201,6 +217,16 @@ def clear_cached_medcat(project, cat_map: Dict[str, CAT]=CAT_MAP): del cat_map[cat_id] +def is_model_pack_loaded(modelpack_id: int, cat_map: Dict[str, CAT]=CAT_MAP) -> bool: + return f'mp{modelpack_id}' in cat_map + + +def clear_cached_medcat_by_model_pack_id(modelpack_id: int, cat_map: Dict[str, CAT]=CAT_MAP) -> None: + cat_id = f'mp{modelpack_id}' + if cat_id in cat_map: + del cat_map[cat_id] + + def get_cached_cdb(cdb_id: str, cdb_map: Dict[str, CDB]=CDB_MAP) -> CDB: from api.utils import clear_cdb_cnf_addons if cdb_id not in cdb_map: diff --git a/medcat-trainer/webapp/api/api/views.py b/medcat-trainer/webapp/api/api/views.py index 713938e52..52f293de2 100644 --- a/medcat-trainer/webapp/api/api/views.py +++ b/medcat-trainer/webapp/api/api/views.py @@ -24,7 +24,7 @@ import_concepts_from_cdb from .data_utils import upload_projects_export from .metrics import calculate_metrics -from .model_cache import get_medcat, get_cached_cdb, VOCAB_MAP, clear_cached_medcat, CAT_MAP, CDB_MAP, is_model_loaded +from .model_cache import get_medcat, get_medcat_from_model_pack_id, get_cached_cdb, VOCAB_MAP, clear_cached_medcat, clear_cached_medcat_by_model_pack_id, is_model_pack_loaded, CAT_MAP, CDB_MAP, is_model_loaded from .permissions import * from .serializers import * from .solr_utils import collections_available, search_collection, ensure_concept_searchable @@ -623,16 +623,46 @@ def update_meta_annotation(request): @api_view(http_method_names=['POST']) def annotate_text(request): - p_id = request.data['project_id'] - message = request.data['message'] - cuis = request.data['cuis'] - if message is None or p_id is None: - return HttpResponseBadRequest('No message to annotate') + message = request.data.get('message') + cuis = request.data.get('cuis', []) + p_id = request.data.get('project_id') + modelpack_id = request.data.get('modelpack_id') + include_sub_concepts = request.data.get('include_sub_concepts', False) - project = ProjectAnnotateEntities.objects.get(id=p_id) + if message is None or (p_id is None and modelpack_id is None): + return HttpResponseBadRequest('No message to annotate') - cat = get_medcat(project=project) - cat.config.components.linking.filters.cuis = set(cuis) + if modelpack_id is not None: + try: + cat = get_medcat_from_model_pack_id(int(modelpack_id)) + except (ValueError, TypeError): + return HttpResponseBadRequest('Invalid modelpack_id') + except ModelPack.DoesNotExist: + return HttpResponseBadRequest('ModelPack does not exist') + else: + project = ProjectAnnotateEntities.objects.get(id=p_id) + cat = get_medcat(project=project) + + # Normalise cuis to a set[str] + if isinstance(cuis, str): + cuis_set = {c.strip() for c in cuis.split(',') if c.strip()} + elif isinstance(cuis, (list, tuple, set)): + cuis_set = {str(c).strip() for c in cuis if str(c).strip()} + else: + cuis_set = set() + + # Expand CUIs to include sub-concepts if requested + if include_sub_concepts and cuis_set and cat.cdb: + expanded_cuis = set(cuis_set) + for parent_cui in cuis_set: + try: + child_cuis = get_all_ch(parent_cui, cat.cdb) + expanded_cuis.update(child_cuis) + except Exception as e: + logger.warning(f'Failed to get children for CUI {parent_cui}: {e}') + cuis_set = expanded_cuis + + cat.config.components.linking.filters.cuis = cuis_set spacy_doc = cat(message) ents = [] @@ -641,6 +671,26 @@ def annotate_text(request): cnt = Entity.objects.filter(label=ent.cui).count() inc_ent = all(tkn not in anno_tkns for tkn in ent) if inc_ent and cnt != 0: + meta_annotations = [] + if 'meta_cat_meta_anns' in ent.get_available_addon_paths(): + meta_anns = ent.get_addon_data('meta_cat_meta_anns') + for meta_ann_task, pred in meta_anns.items(): + # Extract value and confidence from pred + # pred can be a dict, object, or string + if isinstance(pred, dict): + pred_value = pred.get('value', str(pred)) + pred_confidence = pred.get('confidence', None) + elif hasattr(pred, 'value'): + pred_value = pred.value + pred_confidence = getattr(pred, 'confidence', None) + else: + pred_value = str(pred) + pred_confidence = None + meta_annotations.append({ + 'task': meta_ann_task, + 'value': pred_value, + 'confidence': pred_confidence + }) anno_tkns.extend([tkn for tkn in ent]) entity = Entity.objects.get(label=ent.cui) ents.append({ @@ -648,7 +698,8 @@ def annotate_text(request): 'value': ent.base.text, 'start_ind': ent.base.start_char_index, 'end_ind': ent.base.end_char_index, - 'acc': ent.context_similarity + 'acc': ent.context_similarity, + 'meta_annotations': meta_annotations }) ents.sort(key=lambda e: e['start_ind']) @@ -738,7 +789,7 @@ def upload_deployment(request): @api_view(http_method_names=['GET', 'DELETE']) -def cache_model(request, project_id): +def cache_project_model(request, project_id): try: project = ProjectAnnotateEntities.objects.get(id=project_id) is_loaded = is_model_loaded(project) @@ -758,6 +809,24 @@ def cache_model(request, project_id): return Response({'message': f'{str(e)}'}, 500) +@api_view(http_method_names=['GET', 'DELETE']) +def cache_modelpack(request, modelpack_id: int): + try: + if request.method == 'GET': + if not is_model_pack_loaded(modelpack_id): + get_medcat_from_model_pack_id(modelpack_id) + return Response('success', 200) + elif request.method == 'DELETE': + clear_cached_medcat_by_model_pack_id(modelpack_id) + return Response('success', 200) + else: + return Response(f'Invalid method', 404) + except ModelPack.DoesNotExist: + return Response(f'ModelPack with id:{modelpack_id} does not exist', 404) + except Exception as e: + return Response({'message': f'{str(e)}'}, 500) + + @api_view(http_method_names=['GET']) def model_loaded(_): diff --git a/medcat-trainer/webapp/api/core/urls.py b/medcat-trainer/webapp/api/core/urls.py index e4165a52f..f9ee2296b 100644 --- a/medcat-trainer/webapp/api/core/urls.py +++ b/medcat-trainer/webapp/api/core/urls.py @@ -50,7 +50,8 @@ path('api/project-progress/', api.views.project_progress), path('api/concept-db-search-index-created/', api.views.concept_search_index_available), path('api/model-loaded/', api.views.model_loaded), - path('api/cache-model//', api.views.cache_model), + path('api/cache-project-model//', api.views.cache_project_model), + path('api/cache-modelpack//', api.views.cache_modelpack), path('api/upload-deployment/', api.views.upload_deployment), path('api/model-concept-children//', api.views.cdb_cui_children), path('api/metrics//', api.views.view_metrics), diff --git a/medcat-trainer/webapp/frontend/env.d.ts b/medcat-trainer/webapp/frontend/env.d.ts index 11f02fe2a..356c5d67a 100644 --- a/medcat-trainer/webapp/frontend/env.d.ts +++ b/medcat-trainer/webapp/frontend/env.d.ts @@ -1 +1,7 @@ /// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +} diff --git a/medcat-trainer/webapp/frontend/src/components/anns/AddAnnotation.vue b/medcat-trainer/webapp/frontend/src/components/anns/AddAnnotation.vue index 4b00abf44..1ecd50283 100644 --- a/medcat-trainer/webapp/frontend/src/components/anns/AddAnnotation.vue +++ b/medcat-trainer/webapp/frontend/src/components/anns/AddAnnotation.vue @@ -125,7 +125,7 @@ export default { cui: this.selectedCUI.cui } this.loading = true - this.$http.get(`/api/cache-model/${this.project.id}/`).then(_ => { + this.$http.get(`/api/cache-project-model/${this.project.id}/`).then(_ => { this.loading = false this.$http.post('/api/add-annotation/', payload).then(resp => { this.$emit('request:addAnnotationComplete', resp.data.id) @@ -134,7 +134,7 @@ export default { }).catch(err => { this.errorMessage = err.response.data.message || 'Error loading model.' }) - + }, cancel () { this.$emit('request:addAnnotationComplete') diff --git a/medcat-trainer/webapp/frontend/src/components/common/AnnotateTextEntry.vue b/medcat-trainer/webapp/frontend/src/components/common/AnnotateTextEntry.vue new file mode 100644 index 000000000..a39381fea --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/components/common/AnnotateTextEntry.vue @@ -0,0 +1,336 @@ + + + + + + diff --git a/medcat-trainer/webapp/frontend/src/components/common/ClinicalText.vue b/medcat-trainer/webapp/frontend/src/components/common/ClinicalText.vue index 61872d4d1..d036468cb 100644 --- a/medcat-trainer/webapp/frontend/src/components/common/ClinicalText.vue +++ b/medcat-trainer/webapp/frontend/src/components/common/ClinicalText.vue @@ -160,9 +160,10 @@ export default { styleClass = `highlight-task-${btnIndex}` } - if (ent.id === this.currentRelStartEnt.id) { + // Only add relation markers if currentRelStartEnt/EndEnt have valid IDs + if (this.currentRelStartEnt && this.currentRelStartEnt.id && ent.id === this.currentRelStartEnt.id) { styleClass += ' current-rel-start' - } else if (ent.id === this.currentRelEndEnt.id) { + } else if (this.currentRelEndEnt && this.currentRelEndEnt.id && ent.id === this.currentRelEndEnt.id) { styleClass += ' current-rel-end' } diff --git a/medcat-trainer/webapp/frontend/src/components/common/ConceptSummary.vue b/medcat-trainer/webapp/frontend/src/components/common/ConceptSummary.vue index e51d46983..5c0f6bc54 100644 --- a/medcat-trainer/webapp/frontend/src/components/common/ConceptSummary.vue +++ b/medcat-trainer/webapp/frontend/src/components/common/ConceptSummary.vue @@ -188,6 +188,14 @@ export default { diff --git a/medcat-trainer/webapp/frontend/src/components/common/ProjectList.vue b/medcat-trainer/webapp/frontend/src/components/common/ProjectList.vue index c79e8eafd..e6152b7e8 100644 --- a/medcat-trainer/webapp/frontend/src/components/common/ProjectList.vue +++ b/medcat-trainer/webapp/frontend/src/components/common/ProjectList.vue @@ -299,13 +299,13 @@ export default { }, confirmClearLoadedModel (projectId) { this.clearModelModal = false - this.$http.delete(`/api/cache-model/${projectId}/`).then(_ => { + this.$http.delete(`/api/cache-project-model/${projectId}/`).then(_ => { this.fetchModelsLoaded() }) }, loadProjectCDB (projectId) { this.loadingModel = projectId - this.$http.get(`/api/cache-model/${projectId}/`).then(_ => { + this.$http.get(`/api/cache-project-model/${projectId}/`).then(_ => { this.loadingModel = false this.fetchModelsLoaded() }).catch(_ => { @@ -334,10 +334,10 @@ export default { if (this.selectedProjects.length === 0) { return {class: ''} } else { - let disabled = !(this.selectedProjects[0].concept_db === data.item.concept_db && + let disabled = !(this.selectedProjects[0].concept_db === data.item.concept_db && this.selectedProjects[0].vocab === data.item.vocab) || this.selectedProjects[0].model_pack !== data.item.model_pack - return {class: disabled ? ' disabled-row' : ''} + return {class: disabled ? ' disabled-row' : ''} } }, submitMetricsReportReq () { diff --git a/medcat-trainer/webapp/frontend/src/tests/views/Demo.spec.ts b/medcat-trainer/webapp/frontend/src/tests/views/Demo.spec.ts index 606c4d5c6..6213e30a5 100644 --- a/medcat-trainer/webapp/frontend/src/tests/views/Demo.spec.ts +++ b/medcat-trainer/webapp/frontend/src/tests/views/Demo.spec.ts @@ -4,30 +4,62 @@ import Demo from '@/views/Demo.vue' describe('Demo.vue', () => { it('posts to /api/annotate-text/ with correct payload when annotate is clicked', async () => { + vi.useFakeTimers() const mockPost = vi.fn().mockResolvedValue({ data: { entities: [], message: 'annotated!' } }) - const mockGet = vi.fn().mockResolvedValue({ data: { count: 1, results: [{ id: 42, name: 'Test Project', cdb_search_filter: [] }], next: null } }) + const mockGet = vi.fn((url: string) => { + if (url === '/api/project-annotate-entities/') { + return Promise.resolve({ + data: { + count: 1, + results: [{ id: 42, name: 'Test Project', model_pack: 7, cdb_search_filter: [] }], + next: null + } + }) + } + if (url === '/api/modelpacks/') { + return Promise.resolve({ + data: { + count: 1, + results: [{ id: 7, name: 'Test ModelPack' }], + next: null + } + }) + } + if (url === '/api/cache-modelpack/7/') { + return Promise.resolve({ data: 'success' }) + } + return Promise.resolve({ data: { count: 0, results: [], next: null } }) + }) const wrapper = mount(Demo, { global: { mocks: { $http: { get: mockGet, post: mockPost } }, - stubs: ['clinical-text', 'concept-summary'] + stubs: { + 'clinical-text': true, + 'concept-summary': true, + 'concept-picker': true, + 'meta-annotations-summary': true + } } }) await flushPromises() - // Set up form values - await wrapper.setData({ - selectedProject: { id: 42, name: 'Test Project', cdb_search_filter: [] }, - exampleText: 'Some text to annotate', - cuiFilters: 'C1234,C5678' - }) - // Find and click the annotate button - await wrapper.find('button.btn-primary').trigger('click') + // Paste CUIs (optional box) still drives payload + await wrapper.setData({ cuiFilters: 'C1234,C5678' }) + + // Enter message in the annotate component textarea + const textarea = wrapper.find('textarea[name="message"]') + expect(textarea.exists()).toBe(true) + await textarea.setValue('Some text to annotate') + // Debounced auto-annotate after 1500ms of inactivity + vi.advanceTimersByTime(1500) await flushPromises() - expect(mockPost).toHaveBeenCalledWith('/api/annotate-text/', { - project_id: 42, + expect(mockPost).toHaveBeenCalledWith('/api/annotate-text/', expect.objectContaining({ + modelpack_id: 7, message: 'Some text to annotate', - cuis: 'C1234,C5678' - }) + cuis: 'C1234,C5678', + include_sub_concepts: false + })) + vi.useRealTimers() }) }) diff --git a/medcat-trainer/webapp/frontend/src/views/Demo.vue b/medcat-trainer/webapp/frontend/src/views/Demo.vue index 2a3c28ce6..b7d825c75 100644 --- a/medcat-trainer/webapp/frontend/src/views/Demo.vue +++ b/medcat-trainer/webapp/frontend/src/views/Demo.vue @@ -3,40 +3,91 @@
- - +
-
- - +
+ {{ toast.message }}
- - + +
+ + +
+ +
+
+ Selected ModelPack does not have a Concept DB. +
+ +
+ +
+ + {{ item.cui }} - {{ item.name }} + + +
+
-
- +
diff --git a/medcat-trainer/webapp/frontend/src/views/TrainAnnotations.vue b/medcat-trainer/webapp/frontend/src/views/TrainAnnotations.vue index 257569fe6..087acb455 100644 --- a/medcat-trainer/webapp/frontend/src/views/TrainAnnotations.vue +++ b/medcat-trainer/webapp/frontend/src/views/TrainAnnotations.vue @@ -492,7 +492,7 @@ export default { this.fetchEntities() } else { this.loadingMsg = "Loading MedCAT model..." - this.$http.get(`/api/cache-model/${this.project.id}/`).then(_ => { + this.$http.get(`/api/cache-project-model/${this.project.id}/`).then(_ => { this.loadingMsg = "Preparing Document..." let payload = { project_id: this.project.id, diff --git a/medcat-trainer/webapp/frontend/tsconfig.vitest.json b/medcat-trainer/webapp/frontend/tsconfig.vitest.json index 571995d11..1f7f6ce5f 100644 --- a/medcat-trainer/webapp/frontend/tsconfig.vitest.json +++ b/medcat-trainer/webapp/frontend/tsconfig.vitest.json @@ -1,5 +1,6 @@ { "extends": "./tsconfig.app.json", + "include": ["env.d.ts", "src/tests/**/*.ts"], "exclude": [], "compilerOptions": { "composite": true, From f8f46d4f4fcbc485bf491b8d261dc55867e980b9 Mon Sep 17 00:00:00 2001 From: Tom Searle Date: Fri, 16 Jan 2026 10:45:06 +0000 Subject: [PATCH 2/4] fix(medcat-trainer): CU-8699ryke4: test type fixes --- .../src/tests/views/ConceptDatabase.spec.ts | 4 +- .../frontend/src/tests/views/Demo.spec.ts | 130 +++++++++--------- .../src/tests/views/MetricsHome.spec.ts | 12 +- 3 files changed, 73 insertions(+), 73 deletions(-) diff --git a/medcat-trainer/webapp/frontend/src/tests/views/ConceptDatabase.spec.ts b/medcat-trainer/webapp/frontend/src/tests/views/ConceptDatabase.spec.ts index 579d8c339..d33616c0b 100644 --- a/medcat-trainer/webapp/frontend/src/tests/views/ConceptDatabase.spec.ts +++ b/medcat-trainer/webapp/frontend/src/tests/views/ConceptDatabase.spec.ts @@ -11,9 +11,9 @@ const mockCdbsPage1 = { } describe('ConceptDatabase.vue', () => { - let mockGet + let mockGet: ReturnType beforeEach(() => { - mockGet = vi.fn((url) => { + mockGet = vi.fn((url: string) => { if (url === '/api/concept-dbs/') { return Promise.resolve({ data: mockCdbsPage1 }) } diff --git a/medcat-trainer/webapp/frontend/src/tests/views/Demo.spec.ts b/medcat-trainer/webapp/frontend/src/tests/views/Demo.spec.ts index 6213e30a5..a0ea47f19 100644 --- a/medcat-trainer/webapp/frontend/src/tests/views/Demo.spec.ts +++ b/medcat-trainer/webapp/frontend/src/tests/views/Demo.spec.ts @@ -1,65 +1,65 @@ -import { describe, it, expect, vi } from 'vitest' -import { mount, flushPromises } from '@vue/test-utils' -import Demo from '@/views/Demo.vue' - -describe('Demo.vue', () => { - it('posts to /api/annotate-text/ with correct payload when annotate is clicked', async () => { - vi.useFakeTimers() - const mockPost = vi.fn().mockResolvedValue({ data: { entities: [], message: 'annotated!' } }) - const mockGet = vi.fn((url: string) => { - if (url === '/api/project-annotate-entities/') { - return Promise.resolve({ - data: { - count: 1, - results: [{ id: 42, name: 'Test Project', model_pack: 7, cdb_search_filter: [] }], - next: null - } - }) - } - if (url === '/api/modelpacks/') { - return Promise.resolve({ - data: { - count: 1, - results: [{ id: 7, name: 'Test ModelPack' }], - next: null - } - }) - } - if (url === '/api/cache-modelpack/7/') { - return Promise.resolve({ data: 'success' }) - } - return Promise.resolve({ data: { count: 0, results: [], next: null } }) - }) - const wrapper = mount(Demo, { - global: { - mocks: { - $http: { get: mockGet, post: mockPost } - }, - stubs: { - 'clinical-text': true, - 'concept-summary': true, - 'concept-picker': true, - 'meta-annotations-summary': true - } - } - }) - await flushPromises() - // Paste CUIs (optional box) still drives payload - await wrapper.setData({ cuiFilters: 'C1234,C5678' }) - - // Enter message in the annotate component textarea - const textarea = wrapper.find('textarea[name="message"]') - expect(textarea.exists()).toBe(true) - await textarea.setValue('Some text to annotate') - // Debounced auto-annotate after 1500ms of inactivity - vi.advanceTimersByTime(1500) - await flushPromises() - expect(mockPost).toHaveBeenCalledWith('/api/annotate-text/', expect.objectContaining({ - modelpack_id: 7, - message: 'Some text to annotate', - cuis: 'C1234,C5678', - include_sub_concepts: false - })) - vi.useRealTimers() - }) -}) +import { describe, it, expect, vi } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import Demo from '@/views/Demo.vue' + +describe('Demo.vue', () => { + it('posts to /api/annotate-text/ with correct payload when annotate is clicked', async () => { + vi.useFakeTimers() + const mockPost = vi.fn().mockResolvedValue({ data: { entities: [], message: 'annotated!' } }) + const mockGet = vi.fn((url: string) => { + if (url === '/api/project-annotate-entities/') { + return Promise.resolve({ + data: { + count: 1, + results: [{ id: 42, name: 'Test Project', model_pack: 7, cdb_search_filter: [] }], + next: null + } + }) + } + if (url === '/api/modelpacks/') { + return Promise.resolve({ + data: { + count: 1, + results: [{ id: 7, name: 'Test ModelPack' }], + next: null + } + }) + } + if (url === '/api/cache-modelpack/7/') { + return Promise.resolve({ data: 'success' }) + } + return Promise.resolve({ data: { count: 0, results: [], next: null } }) + }) + const wrapper = mount(Demo, { + global: { + mocks: { + $http: { get: mockGet, post: mockPost } + }, + stubs: { + 'clinical-text': true, + 'concept-summary': true, + 'concept-picker': true, + 'meta-annotations-summary': true + } + } + }) + await flushPromises() + // Paste CUIs (optional box) still drives payload + await wrapper.setData({ cuiFilters: 'C1234,C5678' }) + + // Enter message in the annotate component textarea + const textarea = wrapper.find('textarea[name="message"]') + expect(textarea.exists()).toBe(true) + await textarea.setValue('Some text to annotate') + // Debounced auto-annotate after 1500ms of inactivity + vi.advanceTimersByTime(1500) + await flushPromises() + expect(mockPost).toHaveBeenCalledWith('/api/annotate-text/', expect.objectContaining({ + modelpack_id: 7, + message: 'Some text to annotate', + cuis: 'C1234,C5678', + include_sub_concepts: false + })) + vi.useRealTimers() + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/views/MetricsHome.spec.ts b/medcat-trainer/webapp/frontend/src/tests/views/MetricsHome.spec.ts index ece0ccd6d..15a1358a6 100644 --- a/medcat-trainer/webapp/frontend/src/tests/views/MetricsHome.spec.ts +++ b/medcat-trainer/webapp/frontend/src/tests/views/MetricsHome.spec.ts @@ -20,9 +20,9 @@ const mockProjects = { } describe('MetricsHome.vue', () => { - let mockGet + let mockGet: ReturnType beforeEach(() => { - mockGet = vi.fn((url) => { + mockGet = vi.fn((url: string) => { if (url === '/api/metrics-job/') { return Promise.resolve({ data: { reports: mockReports } }) } @@ -40,7 +40,7 @@ describe('MetricsHome.vue', () => { mount(MetricsHome, { global: { mocks: { $http: { get: mockGet } }, - stubs: ['v-data-table', 'v-overlay', 'v-progress-circular', 'modal', 'font-awesome-icon', 'router-link'] + stubs: ['v-data-table', 'v-overlay', 'v-progress-circular', 'v-tooltip', 'v-runtime-template', 'modal', 'font-awesome-icon', 'router-link'] } }) await flushPromises() @@ -51,7 +51,7 @@ describe('MetricsHome.vue', () => { mount(MetricsHome, { global: { mocks: { $http: { get: mockGet } }, - stubs: ['v-data-table', 'v-overlay', 'v-progress-circular', 'modal', 'font-awesome-icon', 'router-link'] + stubs: ['v-data-table', 'v-overlay', 'v-progress-circular', 'v-tooltip', 'v-runtime-template', 'modal', 'font-awesome-icon', 'router-link'] } }) await flushPromises() @@ -63,11 +63,11 @@ describe('MetricsHome.vue', () => { const wrapper = mount(MetricsHome, { global: { mocks: { $http: { get: mockGet } }, - stubs: ['v-data-table', 'v-overlay', 'v-progress-circular', 'modal', 'font-awesome-icon', 'router-link'] + stubs: ['v-data-table', 'v-overlay', 'v-progress-circular', 'v-tooltip', 'v-runtime-template', 'modal', 'font-awesome-icon', 'router-link'] } }) await flushPromises() - const headers = wrapper.vm.reports.headers.map(h => h.title) + const headers = wrapper.vm.reports.headers.map((h: { title: string }) => h.title) expect(headers).toEqual([ 'ID', 'Report Name', From 03c10d9ef79471cb9639cb8c35cb1219646ac931 Mon Sep 17 00:00:00 2001 From: Tom Searle Date: Tue, 20 Jan 2026 12:33:15 +0000 Subject: [PATCH 3/4] fix(medcat-trainer): pr review changes --- medcat-trainer/webapp/api/api/views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/medcat-trainer/webapp/api/api/views.py b/medcat-trainer/webapp/api/api/views.py index 4fcb65c78..3c90b876e 100644 --- a/medcat-trainer/webapp/api/api/views.py +++ b/medcat-trainer/webapp/api/api/views.py @@ -650,9 +650,9 @@ def annotate_text(request): try: cat = get_medcat_from_model_pack_id(int(modelpack_id)) except (ValueError, TypeError): - return HttpResponseBadRequest('Invalid modelpack_id') + return HttpResponseBadRequest(f'Invalid modelpack_id:{modelpack_id} for project:{p_id}') except ModelPack.DoesNotExist: - return HttpResponseBadRequest('ModelPack does not exist') + return HttpResponseBadRequest(f'ModelPack does not exist:{modelpack_id} for project:{p_id}') else: project = ProjectAnnotateEntities.objects.get(id=p_id) cat = get_medcat(project=project) @@ -676,8 +676,10 @@ def annotate_text(request): logger.warning(f'Failed to get children for CUI {parent_cui}: {e}') cuis_set = expanded_cuis + curr_cuis = cat.config.components.linking.filters cat.config.components.linking.filters.cuis = cuis_set spacy_doc = cat(message) + cat.config.components.linking.filters = curr_cuis ents = [] anno_tkns = [] From 0833e05bdc123fa62be2cf691b0add69201fd492 Mon Sep 17 00:00:00 2001 From: Tom Searle Date: Tue, 20 Jan 2026 13:23:41 +0000 Subject: [PATCH 4/4] fix(medcat-trainer): address auto feedback --- medcat-trainer/webapp/api/api/views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/medcat-trainer/webapp/api/api/views.py b/medcat-trainer/webapp/api/api/views.py index 3c90b876e..1c5354c74 100644 --- a/medcat-trainer/webapp/api/api/views.py +++ b/medcat-trainer/webapp/api/api/views.py @@ -650,9 +650,11 @@ def annotate_text(request): try: cat = get_medcat_from_model_pack_id(int(modelpack_id)) except (ValueError, TypeError): - return HttpResponseBadRequest(f'Invalid modelpack_id:{modelpack_id} for project:{p_id}') + logger.warning(f'Invalid modelpack_id received for project:{p_id}') + return HttpResponseBadRequest('Invalid modelpack_id for project') except ModelPack.DoesNotExist: - return HttpResponseBadRequest(f'ModelPack does not exist:{modelpack_id} for project:{p_id}') + logger.warning(f'ModelPack does not exist received for project:{p_id}') + return HttpResponseBadRequest('ModelPack does not exist for project') else: project = ProjectAnnotateEntities.objects.get(id=p_id) cat = get_medcat(project=project)