From dff88a1b4289ad4d37e9e20f7ed394bf6e1f14b5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 30 Oct 2026 11:00:03 +0000 Subject: [PATCH] feat(medcat-trainer): add meta-task selection to Project Admin UI Allow attaching Meta Annotation and Relation tasks when creating or editing projects and project groups in /project-admin, including API persistence on create/update/clone and matching docs/tests. Co-authored-by: tomolopolis --- medcat-trainer/docs/meta_annotations.md | 6 +- medcat-trainer/docs/project_admin.md | 6 + .../webapp/api/api/tests/test_views.py | 103 ++++++++++++ medcat-trainer/webapp/api/api/views.py | 94 +++++------ .../src/tests/views/ProjectAdmin.spec.ts | 149 +++++++++++++++--- .../frontend/src/views/ProjectAdmin.vue | 80 +++++++++- 6 files changed, 368 insertions(+), 70 deletions(-) diff --git a/medcat-trainer/docs/meta_annotations.md b/medcat-trainer/docs/meta_annotations.md index be9671aab..23ff1a499 100644 --- a/medcat-trainer/docs/meta_annotations.md +++ b/medcat-trainer/docs/meta_annotations.md @@ -12,7 +12,7 @@ Examples: ## Configure meta tasks -Create and manage tasks in Django admin (`/admin`): +Create and manage task definitions in Django admin (`/admin`): 1. Create **Meta Task Values** (the allowed label options). 2. Create a **Meta Task**: @@ -21,7 +21,9 @@ Create and manage tasks in Django admin (`/admin`): - default value (optional) - description - ordering -3. Attach selected tasks to your project (`Project annotate entities`). +3. Attach selected tasks to your project from **Project Admin** (`/project-admin`) + using the **Meta Annotation Tasks** multi-select (also available for project groups). + Django admin (`Project annotate entities`) can still be used for the same attachment. ## Model-pack predictions vs manual labels diff --git a/medcat-trainer/docs/project_admin.md b/medcat-trainer/docs/project_admin.md index eaab994bd..ddd7fb642 100644 --- a/medcat-trainer/docs/project_admin.md +++ b/medcat-trainer/docs/project_admin.md @@ -65,6 +65,12 @@ Pick one of: | **Irrelevant available** | Shows irrelevant action in annotation toolbar. | | **Entity annotation comments** | Enables free-text comments per annotation. | +### Meta Annotation & Relation Tasks +| Field | Description | +|------------------------------|-----------------------------------------------------------------------------| +| **Meta Annotation Tasks** | Multi-select of Meta Tasks (for example Temporality, Assertion, Experiencer) shown in the annotator sidebar for Correct/Alternative concepts. Define task values in Django admin (`/admin`) first, or upload a model pack that includes MetaCAT models. Selecting a model pack with linked MetaCAT models pre-fills matching tasks. | +| **Relation Tasks** | Multi-select of relation types available between annotated concepts in the Relation tab. | + ### Concept Filtering | Field | Description | |------------------------------|-----------------------------------------------------------------------------| diff --git a/medcat-trainer/webapp/api/api/tests/test_views.py b/medcat-trainer/webapp/api/api/tests/test_views.py index 4e358e935..ddd860633 100644 --- a/medcat-trainer/webapp/api/api/tests/test_views.py +++ b/medcat-trainer/webapp/api/api/tests/test_views.py @@ -16,7 +16,9 @@ ConceptDB, Document, Entity, + MetaTask, ProjectAnnotateEntities, + Relation, Vocabulary, ) from ._helpers import ( @@ -333,6 +335,23 @@ def test_clone_default_name_when_unspecified(self): self.assertEqual(resp.status_code, 201) self.assertEqual(resp.json()['name'], 'clone-proj (Clone)') + def test_clone_copies_tasks_and_relations(self): + task = MetaTask.objects.create(name='Temporality') + relation = Relation.objects.create(label='is associated with') + self.project.tasks.add(task) + self.project.relations.add(relation) + + client = _auth_client(self.member) + resp = client.post( + f'/api/project-admin/projects/{self.project.id}/clone/', + {'name': 'clone-with-tasks'}, + format='json', + ) + self.assertEqual(resp.status_code, 201, msg=resp.content) + clone = ProjectAnnotateEntities.objects.get(name='clone-with-tasks') + self.assertIn(task, clone.tasks.all()) + self.assertIn(relation, clone.relations.all()) + def test_clone_returns_404_for_unknown_project(self): client = _auth_client(self.member) resp = client.post('/api/project-admin/projects/99999/clone/', {}, format='json') @@ -348,6 +367,90 @@ def test_outsider_cannot_clone(self): self.assertEqual(resp.status_code, 403) +@override_settings(MEDIA_ROOT='/tmp/mct-tests-views') +class ProjectAdminMetaTaskSelectionTests(TestCase): + def setUp(self): + self.user = create_user(username='task-admin') + self.client = _auth_client(self.user) + self.base = create_basic_project(name='task-base') + self.task = MetaTask.objects.create(name='Assertion') + self.relation = Relation.objects.create(label='causes') + + def test_create_persists_tasks_and_relations(self): + resp = self.client.post( + '/api/project-admin/projects/create/', + { + 'name': 'with-meta-tasks', + 'dataset': self.base.dataset_id, + 'concept_db': self.base.concept_db_id, + 'vocab': self.base.vocab_id, + 'cuis': '', + 'members': [self.user.id], + 'cdb_search_filter': [self.base.concept_db_id], + 'tasks': [self.task.id], + 'relations': [self.relation.id], + }, + format='json', + ) + self.assertEqual(resp.status_code, 201, msg=resp.content) + project = ProjectAnnotateEntities.objects.get(name='with-meta-tasks') + self.assertIn(self.task, project.tasks.all()) + self.assertIn(self.relation, project.relations.all()) + self.assertEqual(resp.json()['tasks'], [self.task.id]) + self.assertEqual(resp.json()['relations'], [self.relation.id]) + + def test_update_persists_tasks_and_relations(self): + project = create_basic_project(name='update-tasks-proj') + project.members.add(self.user) + other_task = MetaTask.objects.create(name='Experiencer') + + resp = self.client.put( + f'/api/project-admin/projects/{project.id}/', + { + 'name': project.name, + 'dataset': project.dataset_id, + 'concept_db': project.concept_db_id, + 'vocab': project.vocab_id, + 'cuis': '', + 'members': [self.user.id], + 'cdb_search_filter': [project.concept_db_id], + 'tasks': [self.task.id, other_task.id], + 'relations': [self.relation.id], + }, + format='json', + ) + self.assertEqual(resp.status_code, 200, msg=resp.content) + project.refresh_from_db() + self.assertEqual(set(project.tasks.values_list('id', flat=True)), {self.task.id, other_task.id}) + self.assertEqual(list(project.relations.values_list('id', flat=True)), [self.relation.id]) + + def test_update_can_clear_tasks_and_relations(self): + project = create_basic_project(name='clear-tasks-proj') + project.members.add(self.user) + project.tasks.add(self.task) + project.relations.add(self.relation) + + resp = self.client.put( + f'/api/project-admin/projects/{project.id}/', + { + 'name': project.name, + 'dataset': project.dataset_id, + 'concept_db': project.concept_db_id, + 'vocab': project.vocab_id, + 'cuis': '', + 'members': [self.user.id], + 'cdb_search_filter': [project.concept_db_id], + 'tasks': [], + 'relations': [], + }, + format='json', + ) + self.assertEqual(resp.status_code, 200, msg=resp.content) + project.refresh_from_db() + self.assertEqual(project.tasks.count(), 0) + self.assertEqual(project.relations.count(), 0) + + @override_settings(MEDIA_ROOT='/tmp/mct-tests-views') class ModelLoadedTests(TestCase): def setUp(self): diff --git a/medcat-trainer/webapp/api/api/views.py b/medcat-trainer/webapp/api/api/views.py index 25abe9596..d00f34f90 100644 --- a/medcat-trainer/webapp/api/api/views.py +++ b/medcat-trainer/webapp/api/api/views.py @@ -47,6 +47,34 @@ logger = logging.getLogger(__name__) + +def _parse_m2m_ids(data, key): + """Parse a many-to-many ID list from JSON or multipart form data.""" + try: + value = data.get(key) if hasattr(data, 'get') else data[key] + except (KeyError, TypeError): + return [] + + if isinstance(value, list): + raw = value + elif hasattr(data, 'getlist'): + raw = data.getlist(key) + elif value in (None, ''): + return [] + else: + raw = [value] + + ids = [] + for item in raw: + if item in (None, ''): + continue + try: + ids.append(int(item)) + except (TypeError, ValueError): + logger.warning('Skipping non-integer %s value: %r', key, item) + return ids + + # Get the basic version of MedCAT cat = None @@ -1315,24 +1343,17 @@ def project_admin_detail(request, project_id): data = request.data.copy() if hasattr(request.data, 'copy') else dict(request.data) # Extract many-to-many fields - handle both JSON (list) and FormData (getlist) - cdb_search_filter_ids = [] - try: - cdb_search_filter_ids = [int(x) for x in request.data['cdb_search_filter'] if x] - except (ValueError, TypeError) as e: - logger.warning(f"Error parsing cdb_search_filter: {e}") - cdb_search_filter_ids = [] - - members_ids = [] - try: - members_ids = [int(x) for x in request.data['members'] if x] - except (ValueError, TypeError) as e: - logger.warning(f"Error parsing members: {e}") - members_ids = [] + cdb_search_filter_ids = _parse_m2m_ids(request.data, 'cdb_search_filter') + members_ids = _parse_m2m_ids(request.data, 'members') + task_ids = _parse_m2m_ids(request.data, 'tasks') + relation_ids = _parse_m2m_ids(request.data, 'relations') # Set many-to-many fields to the extracted IDs (or empty list) # This satisfies serializer validation, then we'll set them properly after save - data['members'] = members_ids if members_ids else [] - data['cdb_search_filter'] = cdb_search_filter_ids if cdb_search_filter_ids else [] + data['members'] = members_ids + data['cdb_search_filter'] = cdb_search_filter_ids + data['tasks'] = task_ids + data['relations'] = relation_ids # Convert string booleans to actual booleans boolean_fields = ['project_locked', 'annotation_classification', 'require_entity_validation', @@ -1351,6 +1372,8 @@ def project_admin_detail(request, project_id): # Handle many-to-many fields manually after saving project.cdb_search_filter.set(cdb_search_filter_ids) project.members.set(members_ids) + project.tasks.set(task_ids) + project.relations.set(relation_ids) return Response(ProjectAnnotateEntitiesSerializer(project).data) except Exception as e: logger.error(f"Error saving project {project_id}: {e}", exc_info=e) @@ -1372,33 +1395,10 @@ def project_admin_create(request): """ # Handle both JSON and FormData # Extract many-to-many fields - handle both JSON (list) and FormData (getlist) - cdb_search_filter_ids = [] - try: - if isinstance(request.data.get('cdb_search_filter'), list): - # JSON request - already a list - cdb_search_filter_ids = [int(x) for x in request.data['cdb_search_filter'] if x] - elif hasattr(request.data, 'getlist'): - # FormData request - use getlist() - cdb_filter_list = request.data.getlist('cdb_search_filter') - if cdb_filter_list: - cdb_search_filter_ids = [int(x) for x in cdb_filter_list if x and str(x).strip()] - except (ValueError, TypeError) as e: - logger.warning(f"Error parsing cdb_search_filter: {e}") - cdb_search_filter_ids = [] - - members_ids = [] - try: - if isinstance(request.data.get('members'), list): - # JSON request - already a list - members_ids = [int(x) for x in request.data['members'] if x] - elif hasattr(request.data, 'getlist'): - # FormData request - use getlist() - members_list = request.data.getlist('members') - if members_list: - members_ids = [int(x) for x in members_list if x and str(x).strip()] - except (ValueError, TypeError) as e: - logger.warning(f"Error parsing members: {e}") - members_ids = [] + cdb_search_filter_ids = _parse_m2m_ids(request.data, 'cdb_search_filter') + members_ids = _parse_m2m_ids(request.data, 'members') + task_ids = _parse_m2m_ids(request.data, 'tasks') + relation_ids = _parse_m2m_ids(request.data, 'relations') # Build data dict - use the actual member IDs we extracted, or empty list if none # The serializer will validate with these, then we'll set them properly after save @@ -1409,8 +1409,10 @@ def project_admin_create(request): # Set many-to-many fields to the extracted IDs (or empty list) # This satisfies serializer validation, then we'll set them properly after save - data['members'] = members_ids if members_ids else [] - data['cdb_search_filter'] = cdb_search_filter_ids if cdb_search_filter_ids else [] + data['members'] = members_ids + data['cdb_search_filter'] = cdb_search_filter_ids + data['tasks'] = task_ids + data['relations'] = relation_ids serializer = ProjectAnnotateEntitiesSerializer(data=data) if serializer.is_valid(): @@ -1418,6 +1420,8 @@ def project_admin_create(request): # Handle many-to-many fields manually after saving project.cdb_search_filter.set(cdb_search_filter_ids) project.members.set(members_ids) + project.tasks.set(task_ids) + project.relations.set(relation_ids) # Add the creator as a member if not already included if request.user not in project.members.all(): project.members.add(request.user) @@ -1462,6 +1466,8 @@ def project_admin_clone(request, project_id): project_copy.cdb_search_filter.add(c) for t in project.tasks.all(): project_copy.tasks.add(t) + for r in project.relations.all(): + project_copy.relations.add(r) project_copy.save() serializer = ProjectAnnotateEntitiesSerializer(project_copy) diff --git a/medcat-trainer/webapp/frontend/src/tests/views/ProjectAdmin.spec.ts b/medcat-trainer/webapp/frontend/src/tests/views/ProjectAdmin.spec.ts index 5bdab466c..adbcab056 100644 --- a/medcat-trainer/webapp/frontend/src/tests/views/ProjectAdmin.spec.ts +++ b/medcat-trainer/webapp/frontend/src/tests/views/ProjectAdmin.spec.ts @@ -29,33 +29,52 @@ function mountAdmin(http: { get: ReturnType; post: ReturnType { + if (url === '/api/project-admin/projects/') { + return Promise.resolve({ data: [] }) + } + if (url === '/api/users/') { + return Promise.resolve({ + data: { results: [{ id: 2, username: 'annotator' }, { id: 3, username: 'admin-user' }] } + }) + } + if (url === '/api/datasets/') { + return Promise.resolve({ data: { results: [{ id: 10, name: 'Notes' }] } }) + } + if (url === '/api/modelpacks/') { + return Promise.resolve({ + data: { results: [{ id: 7, name: 'SNOMED', concept_db: 4, meta_cats: [11] }] } + }) + } + if (url === '/api/meta-tasks/') { + return Promise.resolve({ + data: { + results: [ + { id: 21, name: 'Temporality', prediction_model: 11 }, + { id: 22, name: 'Experiencer', prediction_model: null } + ] + } + }) + } + if (url === '/api/relations/') { + return Promise.resolve({ + data: { results: [{ id: 31, label: 'is treatment for' }] } + }) + } + if (url.startsWith('/api/concept-db-search-index-created/')) { + return Promise.resolve({ data: { results: { 4: true } } }) + } + return Promise.resolve(emptyList) + }) +} + describe('ProjectAdmin.vue project group create', () => { let mockGet: ReturnType let mockPost: ReturnType beforeEach(() => { - mockGet = vi.fn((url: string) => { - if (url === '/api/project-admin/projects/') { - return Promise.resolve({ data: [] }) - } - if (url === '/api/users/') { - return Promise.resolve({ - data: { results: [{ id: 2, username: 'annotator' }, { id: 3, username: 'admin-user' }] } - }) - } - if (url === '/api/datasets/') { - return Promise.resolve({ data: { results: [{ id: 10, name: 'Notes' }] } }) - } - if (url === '/api/modelpacks/') { - return Promise.resolve({ - data: { results: [{ id: 7, name: 'SNOMED', concept_db: 4 }] } - }) - } - if (url.startsWith('/api/concept-db-search-index-created/')) { - return Promise.resolve({ data: { results: { 4: true } } }) - } - return Promise.resolve(emptyList) - }) + mockGet = defaultMockGet() mockPost = vi.fn().mockResolvedValue({ data: { id: 99, name: 'Group A' } }) }) @@ -117,3 +136,89 @@ describe('ProjectAdmin.vue project group create', () => { expect(vm.validationErrors.annotators).toBeTruthy() }) }) + +describe('ProjectAdmin.vue meta-task selection', () => { + let mockGet: ReturnType + let mockPost: ReturnType + + beforeEach(() => { + mockGet = defaultMockGet() + mockPost = vi.fn().mockResolvedValue({ data: { id: 55, name: 'Proj' } }) + }) + + it('loads meta tasks and relations into the create form', async () => { + const wrapper = mountAdmin({ get: mockGet, post: mockPost }) + await flushPromises() + await wrapper.find('.btn-create').trigger('click') + expect(wrapper.text()).toContain('Meta Annotation Tasks') + expect(wrapper.text()).toContain('Temporality') + expect(wrapper.text()).toContain('Experiencer') + expect(wrapper.text()).toContain('is treatment for') + }) + + it('posts selected tasks and relations when creating a project', async () => { + const wrapper = mountAdmin({ get: mockGet, post: mockPost }) + await flushPromises() + const vm = wrapper.vm as unknown as { + openCreateForm: (mode: string) => void + formData: Record + saveProject: () => Promise + } + vm.openCreateForm('project') + vm.formData.name = 'Meta Proj' + vm.formData.dataset = 10 + vm.formData.model_pack = 7 + vm.formData.tasks = [21, 22] + vm.formData.relations = [31] + await vm.saveProject() + await flushPromises() + expect(mockPost).toHaveBeenCalledWith( + '/api/project-admin/projects/create/', + expect.objectContaining({ + name: 'Meta Proj', + tasks: [21, 22], + relations: [31] + }) + ) + }) + + it('pre-selects meta tasks linked to the chosen model pack', async () => { + const wrapper = mountAdmin({ get: mockGet, post: mockPost }) + await flushPromises() + const vm = wrapper.vm as unknown as { + openCreateForm: (mode: string) => void + formData: { model_pack: number | null; tasks: number[] } + onModelPackChange: () => void + } + vm.openCreateForm('project') + vm.formData.model_pack = 7 + vm.onModelPackChange() + expect(vm.formData.tasks).toEqual([21]) + }) + + it('includes tasks and relations when creating a project group', async () => { + const wrapper = mountAdmin({ get: mockGet, post: mockPost }) + await flushPromises() + const vm = wrapper.vm as unknown as { + openCreateForm: (mode: string) => void + formData: Record + saveProject: () => Promise + } + vm.openCreateForm('group') + vm.formData.name = 'Group Meta' + vm.formData.dataset = 10 + vm.formData.model_pack = 7 + vm.formData.annotators = [2] + vm.formData.tasks = [22] + vm.formData.relations = [31] + await vm.saveProject() + await flushPromises() + expect(mockPost).toHaveBeenCalledWith( + '/api/project-groups/', + expect.objectContaining({ + tasks: [22], + relations: [31] + }) + ) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue b/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue index 4111aa96a..de3bbc629 100644 --- a/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue +++ b/medcat-trainer/webapp/frontend/src/views/ProjectAdmin.vue @@ -236,7 +236,7 @@ class="form-control" :class="{ 'is-invalid': validationErrors.model_pack }" :disabled="useBackupOption || formData.use_model_service" - @change="clearValidationError('model_pack')" + @change="onModelPackChange" > @@ -420,6 +420,39 @@ > +
+

Meta Annotation & Relation Tasks

+
+
+ + + + Hold Ctrl/Cmd to select multiple. These appear in the annotator sidebar for Correct/Alternative concepts. + Model packs with MetaCAT models may auto-apply their linked tasks on save. + +
+
+ + + + Hold Ctrl/Cmd to select multiple. Available relation types between annotated concepts. + +
+
+

+ No meta or relation tasks are defined yet. Create them in Django admin + (/admin) under Meta Tasks / Relations, or upload a model pack that includes MetaCAT models. +

+
+

{{ isCreatingGroup ? 'Group Members' : 'Members' }}