diff --git a/medcat-trainer/client/README.md b/medcat-trainer/client/README.md index ff716dce1..cfebcc7d9 100644 --- a/medcat-trainer/client/README.md +++ b/medcat-trainer/client/README.md @@ -8,7 +8,7 @@ A Python client for interacting with a MedCATTrainer web application instance. T ## Features - Manage datasets, concept databases, vocabularies, and model packs -- Create and manage users and projects +- Create and manage users, projects, and project groups - Retrieve and upload project annotations - Command-line interface (CLI) for automation @@ -87,6 +87,8 @@ session = MedCATTrainerSession( ### MedCATTrainerSession Methods - `create_project(name, description, members, dataset, cuis=[], cuis_file=None, concept_db=None, vocab=None, cdb_search_filter=None, modelpack=None, meta_tasks=[], rel_tasks=[])` +- `create_project_group(name, description, annotators, dataset, administrators=None, cuis=None, cuis_file=None, create_associated_projects=True, concept_db=None, vocab=None, cdb_search_filter=None, modelpack=None, meta_tasks=None, rel_tasks=None)` +- `get_project_groups()` - `create_dataset(name, dataset_file)` - `create_user(username, password)` - `create_medcat_model(cdb, vocab)` diff --git a/medcat-trainer/client/mctclient.py b/medcat-trainer/client/mctclient.py index 278ed61be..e38eb2841 100644 --- a/medcat-trainer/client/mctclient.py +++ b/medcat-trainer/client/mctclient.py @@ -240,6 +240,88 @@ def __str__(self): +@dataclass +class MCTProjectGroup(MCTObj): + """A project group in the MedCATTrainer instance. + + A group shares one annotation setup across annotators. With + ``create_associated_projects`` enabled, saving the group creates one + ``ProjectAnnotateEntities`` per annotator, named ``{group name} - {username}``. + + Attributes: + name (str): The name of the project group. Must be unique. + description (str): The description copied onto each generated project. + create_time (datetime): The date and time the group was created. + last_modified (datetime): The latest modification time of an associated project. + cuis (str): Comma-separated CUIs used to filter concepts. + dataset (MCTDataset): The dataset annotated by the group. + annotation_classification (bool): Whether annotations contribute to a general-purpose model. + project_locked (bool): Whether annotation collection is complete. + project_status (str): Annotating (A), Discontinued (D), or Complete (C). + deid_model_annotation (bool): Whether the group collects de-identification annotations. + concept_db (MCTConceptDB): The concept database used for annotation. + vocab (MCTVocab): The vocabulary used for annotation. + model_pack (MCTModelPack): The model pack used for annotation. + cdb_search_filter (List[MCTConceptDB]): Concept databases used for concept lookup. + administrators (List[MCTUser]): Users who can see every project in the group. + annotators (List[MCTUser]): Users who each receive an annotation project. + meta_tasks (List[MCTMetaTask]): Meta annotation tasks copied onto generated projects. + rel_tasks (List[MCTRelTask]): Relation tasks copied onto generated projects. + create_associated_projects (bool): Create one project per annotator when the group is created. + """ + name: str=None + description: str=None + create_time: Union[datetime, str]=None + last_modified: Union[datetime, str]=None + cuis: str=None + dataset: MCTDataset=None + annotation_classification: bool=None + project_locked: bool=None + project_status: str='A' + deid_model_annotation: bool=False + concept_db: MCTConceptDB=None + vocab: MCTVocab=None + model_pack: MCTModelPack=None + cdb_search_filter: List[MCTConceptDB]=None + administrators: List[MCTUser]=None + annotators: List[MCTUser]=None + meta_tasks: List[MCTMetaTask]=None + rel_tasks: List[MCTRelTask]=None + create_associated_projects: bool=True + + def __str__(self): + return f'{self.id} : {self.name} \t {self.description} \t {self.dataset}' + + +def _project_group_from_api(payload: Dict[str, Any]) -> MCTProjectGroup: + """Build an MCTProjectGroup from a ProjectGroup serializer payload.""" + def _cdb(cdb_id): + return MCTConceptDB(id=cdb_id) if cdb_id else None + + return MCTProjectGroup( + id=payload.get('id'), + name=payload.get('name'), + description=payload.get('description'), + cuis=payload.get('cuis'), + create_time=payload.get('create_time'), + last_modified=payload.get('last_modified'), + annotation_classification=payload.get('annotation_classification'), + project_locked=payload.get('project_locked'), + project_status=payload.get('project_status'), + deid_model_annotation=payload.get('deid_model_annotation'), + create_associated_projects=payload.get('create_associated_projects', True), + dataset=MCTDataset(id=payload['dataset']) if payload.get('dataset') else None, + concept_db=_cdb(payload.get('concept_db')), + vocab=MCTVocab(id=payload['vocab']) if payload.get('vocab') else None, + model_pack=MCTModelPack(id=payload['model_pack']) if payload.get('model_pack') else None, + cdb_search_filter=[_cdb(cdb_id) for cdb_id in payload.get('cdb_search_filter') or []], + administrators=[MCTUser(id=user_id) for user_id in payload.get('administrators') or []], + annotators=[MCTUser(id=user_id) for user_id in payload.get('annotators') or []], + meta_tasks=[MCTMetaTask(id=task_id) for task_id in payload.get('tasks') or []], + rel_tasks=[MCTRelTask(id=rel_id) for rel_id in payload.get('relations') or []], + ) + + class MedCATTrainerSession: """Wrapper for the MedCATTrainer API. This class provides a wrapper around the MedCATTrainer API, allowing for easy creation of projects, datasets, users, and models. @@ -271,6 +353,10 @@ class MedCATTrainerSession: >>> session.create_project(name='test-project', description='test-description', members=[MCTUser(username='test-user')], dataset=ds, concept_db=MCTConceptDB(name='test_cdb'), vocab=MCTVocab(name='test_vocab')) + Create a project group that provisions one annotation project per annotator. + >>> session.create_project_group(name='test-group', description='test-description', + annotators=[MCTUser(username='test-user')], dataset=ds, concept_db=cdb, vocab=vocab) + To download annotations for a project: >>> projects = session.get_projects() >>> annotations = session.get_project_annos(projects[0]) @@ -465,6 +551,167 @@ def create_project(self, name: str, else: raise MCTUtilsException(f'Failed to create project with name: {name}', resp.text) + def create_project_group(self, name: str, + description: str='', + annotators: Union[List[MCTUser], List[str]]=None, + dataset: Union[MCTDataset, str]=None, + administrators: Union[List[MCTUser], List[str]]=None, + cuis: List[str]=None, + cuis_file: str=None, + create_associated_projects: bool=True, + deid_model_annotation: bool=False, + annotation_classification: bool=False, + concept_db: Union[MCTConceptDB, str]=None, + vocab: Union[MCTVocab, str]=None, + cdb_search_filter: Union[MCTConceptDB, str, List[Union[MCTConceptDB, str]]]=None, + modelpack: Union[MCTModelPack, str]=None, + meta_tasks: Union[List[MCTMetaTask], List[str]]=None, + rel_tasks: Union[List[MCTRelTask], List[str]]=None): + """Create a project group, and one annotation project per annotator by default. + + Users, models, and datasets can be referred to by their wrapper object or by name. + The authenticated user is added as an administrator by the server. + + Only a concept_db and vocab pair, or a modelpack, needs to be specified. + With ``create_associated_projects`` left at its default, at least one annotator is required. + Each generated project is named ``{group name} - {username}``. + + Args: + name (str): The name of the project group. + description (str): The description copied onto each generated project. + annotators (Union[List[MCTUser], List[str]]): Users who each receive an annotation project. + dataset (Union[MCTDataset, str]): The dataset to be annotated. + administrators (Union[List[MCTUser], List[str]], optional): Users who can see every project in the group. + cuis (List[str]): CUIs used to filter concepts. + cuis_file (str): A file of extra CUIs, appended to ``cuis``. + create_associated_projects (bool): Create one project per annotator. Defaults to True. + deid_model_annotation (bool): Whether the group collects de-identification annotations. + annotation_classification (bool): Whether annotations contribute to a general-purpose model. + concept_db (Union[MCTConceptDB, str], optional): The concept database used for annotation. + vocab (Union[MCTVocab, str], optional): The vocabulary used for annotation. + cdb_search_filter (Union[MCTConceptDB, str, List], optional): Concept databases used for lookup. + modelpack (Union[MCTModelPack, str], optional): The model pack used for annotation. + meta_tasks (Union[List[MCTMetaTask], List[str]], optional): Meta tasks copied onto generated projects. + rel_tasks (Union[List[MCTRelTask], List[str]], optional): Relation tasks copied onto generated projects. + + Raises: + MCTUtilsException: If the project group creation fails. + + Returns: + MCTProjectGroup: The created project group. + """ + def unresolved_name(value): + """Return a name to look up, or None when value already has an id.""" + if isinstance(value, str): + return value + if value is not None and not getattr(value, 'id', None) and getattr(value, 'name', None): + return value.name + return None + + def resolve_named(items, value, label): + name = unresolved_name(value) + if name is None: + return value + try: + return [item for item in items if item.name == name].pop() + except IndexError: + raise MCTUtilsException(f'{label} not found in MedCATTrainer instance: {name}') + + def resolve_users(users, label): + if not users: + return [] if users is not None else None + names = [] + for user in users: + if isinstance(user, str): + names.append(user) + elif isinstance(user, MCTUser) and not user.valid() and user.username: + names.append(user.username) + else: + return list(users) + found = [user for user in self.get_users() if user.username in names] + if len(found) != len(set(names)): + raise MCTUtilsException( + f'Not all {label} found in MedCATTrainer instance: {users} requested, ' + f'trainer users found: {found}' + ) + return found + + def resolve_named_list(values, loader, label): + values = list(values or []) + if not any(unresolved_name(item) for item in values): + return values + known = loader() + return [resolve_named(known, item, label) for item in values] + + annotators = resolve_users(annotators, 'annotators') or [] + administrators = resolve_users(administrators, 'administrators') + cuis = list(cuis or []) + + if unresolved_name(dataset): + dataset = resolve_named(self.get_datasets(), dataset, 'Dataset') + if dataset is None or not dataset.valid(): + raise MCTUtilsException('A dataset is required to create a project group') + + if unresolved_name(concept_db): + concept_db = resolve_named(self.get_concept_dbs(), concept_db, 'Concept DB') + if unresolved_name(vocab): + vocab = resolve_named(self.get_vocabs(), vocab, 'Vocab') + if unresolved_name(modelpack): + modelpack = resolve_named(self.get_model_packs(), modelpack, 'Model pack') + + if cdb_search_filter is not None: + filters = cdb_search_filter if isinstance(cdb_search_filter, list) else [cdb_search_filter] + cdb_search_filter = resolve_named_list(filters, self.get_concept_dbs, 'Concept DB') + + meta_tasks = resolve_named_list(meta_tasks, self.get_meta_tasks, 'Meta task') + rel_tasks = resolve_named_list(rel_tasks, self.get_rel_tasks, 'Rel task') + + if (concept_db or vocab) and modelpack: + raise MCTUtilsException('Cannot specify both concept_db/vocab and modelpack') + + payload = { + 'name': name, + 'description': description or '', + 'cuis': ','.join(cuis), + 'dataset': dataset.id, + 'annotators': [annotator.id for annotator in annotators], + 'tasks': [task.id for task in meta_tasks], + 'relations': [task.id for task in rel_tasks], + 'annotation_classification': annotation_classification, + 'deid_model_annotation': deid_model_annotation, + 'create_associated_projects': create_associated_projects, + } + if administrators is not None: + payload['administrators'] = [admin.id for admin in administrators] + if concept_db and vocab: + payload['concept_db'] = concept_db.id + payload['vocab'] = vocab.id + elif modelpack: + payload['model_pack'] = modelpack.id + if cdb_search_filter: + payload['cdb_search_filter'] = [cdb.id for cdb in cdb_search_filter] + + url = f'{self.server}/api/project-groups/' + if cuis_file: + with open(cuis_file, 'rb') as cui_upload: + resp = requests.post(url, data=payload, files={'cuis_file': cui_upload}, headers=self.headers) + else: + resp = requests.post(url, json=payload, headers=self.headers) + if 200 <= resp.status_code < 300: + resp_json = resp.json() + return MCTProjectGroup( + id=resp_json['id'], name=name, description=description, cuis=','.join(cuis), + dataset=dataset, concept_db=concept_db, vocab=vocab, model_pack=modelpack, + cdb_search_filter=cdb_search_filter, administrators=administrators, annotators=annotators, + meta_tasks=meta_tasks, rel_tasks=rel_tasks, + annotation_classification=annotation_classification, + deid_model_annotation=deid_model_annotation, + create_associated_projects=create_associated_projects, + create_time=resp_json.get('create_time'), + last_modified=resp_json.get('last_modified'), + ) + raise MCTUtilsException(f'Failed to create project group with name: {name}', resp.text) + def create_dataset(self, name: str, dataset_file: str): """Create a new dataset in the MedCATTrainer session. @@ -670,6 +917,29 @@ def get_projects(self) -> List[MCTProject]: rel_tasks=[MCTRelTask(id=rt) for rt in p['relations']]) for p in resp] return mct_projects + def get_project_groups(self) -> List[MCTProjectGroup]: + """Get all project groups in the MedCATTrainer instance. + + Returns: + List[MCTProjectGroup]: A list of all project groups in the MedCATTrainer instance. + """ + resp = requests.get(f'{self.server}/api/project-groups/', headers=self.headers) + if not (200 <= resp.status_code < 300): + raise MCTUtilsException( + f'Failed to get project groups from MedCATTrainer instance running at: {self.server}', + f'HTTP {resp.status_code}: {resp.text[:500]}', + ) + try: + payload = resp.json() + if not isinstance(payload, dict): + payload = json.loads(resp.text or '{}') + except Exception as e: + raise MCTUtilsException( + f'Failed to parse project groups response from MedCATTrainer instance running at: {self.server}', + e, + ) + return [_project_group_from_api(group) for group in payload.get('results', [])] + def get_datasets(self) -> List[MCTDataset]: """Get all datasets that have been created in the MedCATTrainer instance. diff --git a/medcat-trainer/client/tests/test_mctclient.py b/medcat-trainer/client/tests/test_mctclient.py index e1d8a91fe..128264a87 100644 --- a/medcat-trainer/client/tests/test_mctclient.py +++ b/medcat-trainer/client/tests/test_mctclient.py @@ -3,8 +3,9 @@ import unittest from unittest.mock import patch, MagicMock from mctclient import ( - MedCATTrainerSession, KeycloakSettings, - MCTDataset, MCTConceptDB, MCTVocab, MCTModelPack, MCTMetaTask, MCTRelTask, MCTUser, MCTProject + MedCATTrainerSession, KeycloakSettings, MCTUtilsException, + MCTDataset, MCTConceptDB, MCTVocab, MCTModelPack, MCTMetaTask, MCTRelTask, + MCTUser, MCTProject, MCTProjectGroup ) class TestMCTClient(unittest.TestCase): @@ -803,6 +804,193 @@ def post_side_effect(url, *args, **kwargs): self.assertEqual(call_count[0], 1) self.assertEqual(session.headers['Authorization'], 'Bearer token-1') + @patch('mctclient.requests.post') + def test_create_project_group(self, mock_post): + def post_side_effect(url, *args, **kwargs): + if url.endswith('/api/api-token-auth/'): + return MagicMock(status_code=200, text='{"token": "abc"}') + if url.endswith('/api/project-groups/'): + self.assertEqual(kwargs['json']['name'], 'Neuro Group') + self.assertEqual(kwargs['json']['annotators'], ['1']) + self.assertEqual(kwargs['json']['administrators'], ['9']) + self.assertEqual(kwargs['json']['dataset'], '2') + self.assertEqual(kwargs['json']['concept_db'], '20') + self.assertEqual(kwargs['json']['vocab'], '30') + self.assertEqual(kwargs['json']['cuis'], '84757009,91175000') + self.assertEqual(kwargs['json']['tasks'], ['200']) + self.assertEqual(kwargs['json']['relations'], ['300']) + self.assertTrue(kwargs['json']['create_associated_projects']) + return MagicMock( + status_code=201, + json=lambda: { + 'id': 7, + 'name': 'Neuro Group', + 'create_time': '2021-01-01T00:00:00Z', + 'last_modified': None, + }, + ) + return MagicMock(status_code=404, text='') + + mock_post.side_effect = post_side_effect + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + group = session.create_project_group( + name='Neuro Group', + description='one project per annotator', + annotators=[MCTUser(id='1', username='ann')], + administrators=[MCTUser(id='9', username='boss')], + dataset=MCTDataset(id='2', name='Neuro DS'), + concept_db=MCTConceptDB(id='20', name='cdb', conceptdb_file='cdb.dat'), + vocab=MCTVocab(id='30', name='vocab', vocab_file='vocab.dat'), + cuis=['84757009', '91175000'], + meta_tasks=[MCTMetaTask(id='200', name='Temporality')], + rel_tasks=[MCTRelTask(id='300', name='Has_Finding')], + ) + self.assertIsInstance(group, MCTProjectGroup) + self.assertEqual(group.id, 7) + self.assertEqual(group.name, 'Neuro Group') + self.assertEqual(group.annotators[0].username, 'ann') + self.assertEqual(group.dataset.name, 'Neuro DS') + self.assertTrue(group.create_associated_projects) + + @patch('mctclient.requests.post') + @patch('mctclient.requests.get') + def test_create_project_group_resolves_names(self, mock_get, mock_post): + def json_response(body): + return MagicMock(status_code=200, text=json.dumps(body), json=lambda: body) + + def get_side_effect(url, *args, **kwargs): + if url.endswith('/api/users/'): + return json_response({'results': [{'id': 1, 'username': 'ann'}, {'id': 9, 'username': 'boss'}]}) + if url.endswith('/api/datasets/'): + return json_response({'results': [{'id': 2, 'name': 'Neuro DS', 'original_file': 'neuro.csv'}]}) + if url.endswith('/api/concept-dbs/'): + return json_response({'results': [{'id': 20, 'name': 'example_cdb', 'cdb_file': 'cdb.dat'}]}) + if url.endswith('/api/vocabs/'): + return json_response({'results': [{'id': 30, 'name': 'example_vocab', 'vocab_file': 'vocab.dat'}]}) + if url.endswith('/api/meta-tasks/'): + return json_response({'results': [{'id': 200, 'name': 'Temporality'}]}) + if url.endswith('/api/relations/'): + return json_response({'results': [{'id': 300, 'label': 'Has_Finding'}]}) + return json_response({'results': []}) + + def post_side_effect(url, *args, **kwargs): + if url.endswith('/api/api-token-auth/'): + return MagicMock(status_code=200, text='{"token": "abc"}') + if url.endswith('/api/project-groups/'): + body = kwargs['json'] + self.assertEqual(body['annotators'], [1]) + self.assertEqual(body['administrators'], [9]) + self.assertEqual(body['dataset'], 2) + self.assertEqual(body['concept_db'], 20) + self.assertEqual(body['vocab'], 30) + self.assertEqual(body['tasks'], [200]) + self.assertEqual(body['relations'], [300]) + self.assertNotIn('model_pack', body) + return MagicMock(status_code=201, json=lambda: {'id': 8, 'name': 'Named Group'}) + return MagicMock(status_code=404, text='') + + mock_get.side_effect = get_side_effect + mock_post.side_effect = post_side_effect + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + group = session.create_project_group( + name='Named Group', + annotators=[MCTUser(username='ann')], + administrators=['boss'], + dataset='Neuro DS', + concept_db=MCTConceptDB(name='example_cdb'), + vocab='example_vocab', + meta_tasks=['Temporality'], + rel_tasks=['Has_Finding'], + ) + self.assertEqual(group.id, 8) + self.assertEqual(group.annotators[0].id, 1) + self.assertEqual(group.dataset.id, 2) + self.assertEqual(group.concept_db.id, 20) + self.assertEqual(group.meta_tasks[0].id, 200) + self.assertEqual(group.rel_tasks[0].id, 300) + + @patch('mctclient.requests.post') + def test_create_project_group_rejects_cdb_and_modelpack(self, mock_post): + mock_post.return_value = MagicMock(status_code=200, text='{"token": "abc"}') + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + with self.assertRaises(MCTUtilsException): + session.create_project_group( + name='Bad Group', + annotators=[MCTUser(id=1, username='ann')], + dataset=MCTDataset(id=2, name='Neuro DS'), + concept_db=MCTConceptDB(id=20, name='cdb'), + vocab=MCTVocab(id=30, name='vocab'), + modelpack=MCTModelPack(id=4, name='pack'), + ) + self.assertEqual(mock_post.call_count, 1) + + @patch('mctclient.requests.post') + def test_create_project_group_raises_on_api_error(self, mock_post): + def post_side_effect(url, *args, **kwargs): + if url.endswith('/api/api-token-auth/'): + return MagicMock(status_code=200, text='{"token": "abc"}') + return MagicMock(status_code=400, text='{"annotators": ["Select at least one annotator"]}') + + mock_post.side_effect = post_side_effect + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + with self.assertRaises(MCTUtilsException) as raised: + session.create_project_group( + name='Empty Group', + annotators=[], + dataset=MCTDataset(id=2, name='Neuro DS'), + modelpack=MCTModelPack(id=4, name='pack'), + create_associated_projects=True, + ) + self.assertIn('annotators', str(raised.exception)) + + @patch('mctclient.requests.post') + @patch('mctclient.requests.get') + def test_get_project_groups(self, mock_get, mock_post): + mock_post.return_value = MagicMock(status_code=200, text='{"token": "abc"}') + mock_get.return_value = MagicMock( + status_code=200, + text='', + json=lambda: { + 'results': [{ + 'id': 7, + 'name': 'Neuro Group', + 'description': 'shared setup', + 'cuis': '84757009', + 'create_time': '2021-01-01T00:00:00Z', + 'last_modified': '2021-02-01T00:00:00Z', + 'annotation_classification': False, + 'project_locked': False, + 'project_status': 'A', + 'deid_model_annotation': False, + 'create_associated_projects': True, + 'dataset': 2, + 'concept_db': 20, + 'vocab': 30, + 'model_pack': None, + 'cdb_search_filter': [20], + 'administrators': [9], + 'annotators': [1], + 'tasks': [200], + 'relations': [], + }] + }, + ) + session = MedCATTrainerSession(server='http://localhost', username='u', password='p') + groups = session.get_project_groups() + self.assertEqual(len(groups), 1) + group = groups[0] + self.assertIsInstance(group, MCTProjectGroup) + self.assertEqual(group.name, 'Neuro Group') + self.assertEqual(group.dataset.id, 2) + self.assertEqual(group.concept_db.id, 20) + self.assertIsNone(group.model_pack) + self.assertEqual(group.cdb_search_filter[0].id, 20) + self.assertEqual([user.id for user in group.annotators], [1]) + self.assertEqual([user.id for user in group.administrators], [9]) + self.assertEqual(group.meta_tasks[0].id, 200) + self.assertEqual(group.rel_tasks, []) + mock_get.assert_called_with('http://localhost/api/project-groups/', headers=session.headers) + if __name__ == '__main__': unittest.main() \ No newline at end of file diff --git a/medcat-trainer/docker-compose-prod.yml b/medcat-trainer/docker-compose-prod.yml index 9e6294906..fa355a981 100644 --- a/medcat-trainer/docker-compose-prod.yml +++ b/medcat-trainer/docker-compose-prod.yml @@ -5,7 +5,7 @@ services: container_name: medcattrainer hostname: medcat restart: always - image: cogstacksystems/medcat-trainer:medcat-trainer-v3.8.1 + image: cogstacksystems/medcat-trainer:medcat-trainer-v3.9.0 volumes: - api-media:/home/api/media - api-static:/home/api/static @@ -16,7 +16,7 @@ services: env_file: - ./envs/env-prod environment: - - MCT_VERSION=v3.8.1 + - MCT_VERSION=v3.9.0 command: /usr/bin/supervisord -c /etc/supervisord.conf nginx: diff --git a/medcat-trainer/docker-compose.yml b/medcat-trainer/docker-compose.yml index 6374a3369..b0be75716 100644 --- a/medcat-trainer/docker-compose.yml +++ b/medcat-trainer/docker-compose.yml @@ -4,7 +4,7 @@ services: # medattrainer services medcattrainer: - image: cogstacksystems/medcat-trainer:medcat-trainer-v3.8.1 + image: cogstacksystems/medcat-trainer:medcat-trainer-v3.9.0 restart: always volumes: - api-media:/home/api/media @@ -16,7 +16,7 @@ services: env_file: - ./envs/env environment: - - MCT_VERSION=v3.8.1 + - MCT_VERSION=v3.9.0 command: /usr/bin/supervisord -c /etc/supervisord.conf nginx: diff --git a/medcat-trainer/notebook_docs/Client_API_Tutorials.ipynb b/medcat-trainer/notebook_docs/Client_API_Tutorials.ipynb index c3a13172f..c8974e7cb 100644 --- a/medcat-trainer/notebook_docs/Client_API_Tutorials.ipynb +++ b/medcat-trainer/notebook_docs/Client_API_Tutorials.ipynb @@ -11,7 +11,7 @@ "1. Setting up a MedCATTrainer session\n", "2. Exploring available resources (users, datasets, models)\n", "3. Creating new resources (datasets, models, users)\n", - "4. Creating annotation projects with different approaches\n", + "4. Creating annotation projects and project groups\n", "5. Downloading and saving annotations\n", "\n", "These steps provide a complete workflow for programmatically managing medical text annotation projects with MedCATTrainer." @@ -58,7 +58,7 @@ "import json\n", "import sys\n", "sys.path.append('../client')\n", - "from mctclient import MedCATTrainerSession, MCTDataset, MCTConceptDB, MCTVocab, MCTModelPack, MCTUser, MCTProject" + "from mctclient import MedCATTrainerSession, MCTDataset, MCTConceptDB, MCTVocab, MCTModelPack, MCTUser, MCTProject, MCTProjectGroup" ] }, { @@ -137,7 +137,14 @@ "rel_tasks = session.get_rel_tasks()\n", "print(\"Relation Tasks:\")\n", "for i, task in enumerate(rel_tasks):\n", - " print(f\"{i+1} : {task.name}\")" + " print(f\"{i+1} : {task.name}\")\n", + "print()\n", + "\n", + "# Get project groups\n", + "project_groups = session.get_project_groups()\n", + "print(\"Project Groups:\")\n", + "for group in project_groups:\n", + " print(group)" ] }, { @@ -351,6 +358,50 @@ "print(f\"Created project with model pack: {general_project}\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 4.1 Creating a Project Group\n", + "\n", + "A project group shares one annotation setup across annotators. With `create_associated_projects` left on (the default), MedCATTrainer creates one annotation project per annotator, named `{group name} - {username}`. The signed-in user is added as an administrator, and as a member of each generated project.\n", + "\n", + "Annotators, datasets, and models can be wrapper objects or names, the same as `create_project`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# One neurology project per annotator, using the CDB and vocab uploaded above.\n", + "neuro_group = session.create_project_group(\n", + " name=\"Neurology Annotation Group\",\n", + " description=\"One neurology project per annotator\",\n", + " annotators=[user for user in users],\n", + " dataset=datasets[-1],\n", + " concept_db=concept_dbs[-1],\n", + " vocab=vocabs[-1],\n", + " cuis=[\"84757009\", \"91175000\"],\n", + ")\n", + "print(f\"Created project group: {neuro_group}\")\n", + "\n", + "# The same call accepts a model pack instead of a CDB and vocab, and names instead of objects:\n", + "# session.create_project_group(\n", + "# name=\"Neurology Annotation Group (model pack)\",\n", + "# description=\"One neurology project per annotator\",\n", + "# annotators=[user.username for user in users],\n", + "# dataset=datasets[-1].name,\n", + "# modelpack=model_packs[-1],\n", + "# )\n", + "\n", + "groups = session.get_project_groups()\n", + "print(\"Project groups:\")\n", + "for group in groups:\n", + " print(group)" + ] + }, { "cell_type": "markdown", "metadata": {},