From c113ea1b3cb096ca5322ecac73a5bfb5586b0803 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Wed, 24 Jun 2026 16:18:42 +0100 Subject: [PATCH 01/19] Update model pack to load meta cat info without loading full models --- medcat-trainer/webapp/api/api/models.py | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/medcat-trainer/webapp/api/api/models.py b/medcat-trainer/webapp/api/api/models.py index ec1c673da..520cf528d 100644 --- a/medcat-trainer/webapp/api/api/models.py +++ b/medcat-trainer/webapp/api/api/models.py @@ -13,9 +13,12 @@ from medcat.cat import CAT from medcat.cdb import CDB from medcat.vocab import Vocab -from medcat.components.addons.meta_cat.meta_cat import MetaCAT, MetaCATAddon +from medcat.components.addons.meta_cat.meta_cat import MetaCAT +from medcat.config.config_meta_cat import ConfigMetaCAT from polymorphic.models import PolymorphicModel +from .utils import load_meta_cat_info_from_model_folder + from core.settings import MEDIA_ROOT STATUS_CHOICES = [ @@ -114,19 +117,14 @@ def save(self, *args, skip_load=False, **kwargs): # load MetaCATs try: + meta_cat_addons = load_meta_cat_info_from_model_folder(unpacked_model_pack_path) metaCATmodels = [] - # should raise an error if there already is a MetaCAT model with this definition - addons = CAT.load_addons(unpacked_model_pack_path) - meta_cat_addons = [ - (addon_path, addon) for addon_path, addon in addons - if isinstance(addon, MetaCATAddon)] - for meta_cat_dir, meta_cat_addon in meta_cat_addons: - meta_cat = meta_cat_addon.mc + for meta_cat_dir, meta_cat_cnf in meta_cat_addons: mc_model = MetaCATModel() mc_model.meta_cat_dir = meta_cat_dir.replace(f'{MEDIA_ROOT}/', '') - mc_model.name = f'{meta_cat.config.general.category_name} - {meta_cat.config.model.model_name}' + mc_model.name = f'{meta_cat_cnf.config.general.category_name} - {meta_cat_cnf.model.model_name}' mc_model.save(unpack_load_meta_cat_dir=False) - mc_model.get_or_create_meta_tasks_and_values(meta_cat) + mc_model.get_or_create_meta_tasks_and_values(meta_cat_cnf) metaCATmodels.append(mc_model) self.meta_cats.set(metaCATmodels) # Use set() instead of add() for atomic operation except Exception as exc: @@ -211,8 +209,8 @@ class MetaCATModel(models.Model): 'is set via a model pack .zip upload', allow_folders=True, editable=False) - def get_or_create_meta_tasks_and_values(self, meta_cat: MetaCAT): - task = meta_cat.config.general.category_name + def get_or_create_meta_tasks_and_values(self, mc_config: ConfigMetaCAT): + task = mc_config.general.category_name mt = MetaTask.objects.filter(name=task).first() if not mt: mt = MetaTask() @@ -224,7 +222,7 @@ def get_or_create_meta_tasks_and_values(self, meta_cat: MetaCAT): mt.save() mt_vs = [] - for meta_task_value in meta_cat.config.general.category_value2id.keys(): + for meta_task_value in mc_config.general.category_value2id.keys(): mt_v = MetaTaskValue.objects.filter(name=meta_task_value).first() if not mt_v: mt_v = MetaTaskValue() From 98fa04e1914d527b957baca52b6954f827c7b973 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Wed, 24 Jun 2026 16:19:33 +0100 Subject: [PATCH 02/19] Add utility method that loads meta cat config for meta cat DB models --- medcat-trainer/webapp/api/api/utils.py | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/medcat-trainer/webapp/api/api/utils.py b/medcat-trainer/webapp/api/api/utils.py index 20c236276..69ba0aefd 100644 --- a/medcat-trainer/webapp/api/api/utils.py +++ b/medcat-trainer/webapp/api/api/utils.py @@ -2,6 +2,7 @@ import logging import os from typing import List +import dill import requests from background_task import background @@ -11,6 +12,7 @@ from django.dispatch import receiver from medcat.cat import CAT from medcat.cdb import CDB +from medcat.config.config_meta_cat import ConfigMetaCAT from medcat.components.ner.trf.deid import DeIdModel from medcat.tokenizing.tokens import UnregisteredDataPathException from opentelemetry import trace @@ -499,3 +501,40 @@ def env_str_to_bool(var: str, default: bool): elif val.lower() in ('0', 'false', 'f', 'n'): return False return val + + +# NOTE: meta cats are saved at +# saved_components/addon_meta_cat. +# however, their configs are (uniquely!) saved +# along with the config for the entire model pack +# at cdb/config/components/raw_dict.dat at 'addons' +# so we will load that directly + +_PATH_TO_CNF_ON_DISK = os.path.join( + "cdb", "config", "components", "raw_dict.dat" +) +_PATH_TO_META_CAT_START = os.path.join( + "saved_components", "addon_meta_cat." +) + +def _get_meta_cat_path(model_folder: str, cnf: ConfigMetaCAT) -> str: + return os.path.join( + model_folder, _PATH_TO_META_CAT_START) + cnf.category_name + + +def load_meta_cat_info_from_model_folder( + model_pack_path: str +) -> list[tuple[str, ConfigMetaCAT]]: + global_cnf_path = os.path.join(model_pack_path, _PATH_TO_CNF_ON_DISK) + with open(os.path.join(model_pack_path, '.serialised_by')) as f: + contents = f.read() + if contents.strip().lower() != "dill": + raise ValueError( + "Unable to load MetaCAT info without loading the meta cats from this model " + "because of its serialisation type (%s) - we only have implementation for 'dill'") + with open(global_cnf_path) as f: + cnf = dill.load(f) + return [ + (_get_meta_cat_path(model_pack_path, mc_cnf), mc_cnf) + for mc_cnf in cnf['addons'] + ] From 6e0032442cee77dba7edc0401d9abb00a0668181 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Wed, 24 Jun 2026 16:41:23 +0100 Subject: [PATCH 03/19] Fix circular import issue --- medcat-trainer/webapp/api/api/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/medcat-trainer/webapp/api/api/models.py b/medcat-trainer/webapp/api/api/models.py index 520cf528d..bdd596051 100644 --- a/medcat-trainer/webapp/api/api/models.py +++ b/medcat-trainer/webapp/api/api/models.py @@ -17,8 +17,6 @@ from medcat.config.config_meta_cat import ConfigMetaCAT from polymorphic.models import PolymorphicModel -from .utils import load_meta_cat_info_from_model_folder - from core.settings import MEDIA_ROOT STATUS_CHOICES = [ @@ -114,6 +112,8 @@ def save(self, *args, skip_load=False, **kwargs): # DeID model packs do not have a vocab.dat file logger.warn('Error loading the Vocab from this model pack - ' f'if this is a DeID model pack, this is expected: {vocab_path_abs}') + # load dynamically to avoid circular imports + from .utils import load_meta_cat_info_from_model_folder # load MetaCATs try: From e4255d251859d4e3d0b9de33ace3f486c69035ab Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Wed, 24 Jun 2026 16:52:06 +0100 Subject: [PATCH 04/19] Fix dill open mode --- medcat-trainer/webapp/api/api/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-trainer/webapp/api/api/utils.py b/medcat-trainer/webapp/api/api/utils.py index 69ba0aefd..047aef011 100644 --- a/medcat-trainer/webapp/api/api/utils.py +++ b/medcat-trainer/webapp/api/api/utils.py @@ -532,7 +532,7 @@ def load_meta_cat_info_from_model_folder( raise ValueError( "Unable to load MetaCAT info without loading the meta cats from this model " "because of its serialisation type (%s) - we only have implementation for 'dill'") - with open(global_cnf_path) as f: + with open(global_cnf_path, 'rb') as f: cnf = dill.load(f) return [ (_get_meta_cat_path(model_pack_path, mc_cnf), mc_cnf) From fefb61e9eb61b84e295e6c292a381e3b55441c3d Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Wed, 24 Jun 2026 17:11:27 +0100 Subject: [PATCH 05/19] Add new tests to avoid meta cat model loading at model import time --- .../api/api/tests/test_model_inference.py | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/medcat-trainer/webapp/api/api/tests/test_model_inference.py b/medcat-trainer/webapp/api/api/tests/test_model_inference.py index f2dbc395a..ae7636866 100644 --- a/medcat-trainer/webapp/api/api/tests/test_model_inference.py +++ b/medcat-trainer/webapp/api/api/tests/test_model_inference.py @@ -10,8 +10,10 @@ import medcat from medcat.cat import CAT +from medcat.components.addons.meta_cat import MetaCATAddon from api.models import Document, ProjectAnnotateEntities, ModelPack +from api.model_import import import_model_pack RAW_MODEL_PATH = os.path.join( @@ -27,12 +29,13 @@ MODEL_PATH = os.path.join( MEDIA_PATH, "fake_model_pack.zip" ) +MODEL_PATH_UNPACKED = MODEL_PATH.removesuffix(".zip") HAS_KNOWN_FAILURE = medcat.__version__ in ("2.8.0", "2.8.1", "2.8.2", "2.8.3") -class ModelInferenceTests(TestCase): +class BaseRealModelTests(TestCase): @classmethod def setUpClass(cls): @@ -55,6 +58,9 @@ def _copy_model(cls): MODEL_PATH ) + +class ModelInferenceTests(BaseRealModelTests): + def setUp(self): # A real user — the view reads request.user self.user = create_user(username="testuser", password="password", is_staff=True) @@ -108,3 +114,54 @@ def test_can_use_model_for_inference(self): # The document should now be in prepared_documents self.assertTrue(self.project.prepared_documents.all()) self.assertEqual(len(self.project.prepared_documents.all()), len(doc_ids)) + + +class ModelImportTests(BaseRealModelTests): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._create_meta_cat_for_model() + + @classmethod + def _create_meta_cat_for_model(cls): + # first, create the folder + from api.utils import _PATH_TO_META_CAT_START, _PATH_TO_CNF_ON_DISK + mc_path = os.path.join(MODEL_PATH_UNPACKED, _PATH_TO_META_CAT_START + "Subject") + os.mkdir(os.path.dirname(mc_path)) + os.mkdir(mc_path) + # then add .serialised_by + sb_path = os.path.join(mc_path, ".serialised_by") + with open(sb_path, 'w') as f: + f.write("dill") + # finally, add config + from medcat.config.config_meta_cat import ConfigMetaCAT + # create config + cnf = ConfigMetaCAT() + cnf.general.category_name = "Subject" + # load off disk + import dill + gcnf_path = os.path.join(MODEL_PATH_UNPACKED, _PATH_TO_CNF_ON_DISK) + with open(gcnf_path, 'rb') as f: + data = dill.load(f) + data['addons'] = [cnf] + # save to disk + with open(gcnf_path, 'wb') as f: + dill.dump(data, f) + + def setUp(self): + # A real user — the view reads request.user + self.user = create_user(username="testuser", password="password", is_staff=True) + + # NOTE: mocking dispatch just so it won't complain about missing sender + @patch('api.model_import.dispatch') + def test_model_import_does_not_load_addons(self, mock_dispatch): + with patch.object(MetaCATAddon, "__init__") as mock_init: + import_model_pack( + MODEL_PATH, + name='fake-test-model', + user=self.user, + description='Fake model!', + source_uri='https://some/fake/address', + ) + mock_init.assert_not_called() From 930360864379661856b86441f51e024a2dd67b04 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Wed, 24 Jun 2026 17:12:30 +0100 Subject: [PATCH 06/19] Add comment regarding test --- medcat-trainer/webapp/api/api/tests/test_model_inference.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/medcat-trainer/webapp/api/api/tests/test_model_inference.py b/medcat-trainer/webapp/api/api/tests/test_model_inference.py index ae7636866..fc10f0d07 100644 --- a/medcat-trainer/webapp/api/api/tests/test_model_inference.py +++ b/medcat-trainer/webapp/api/api/tests/test_model_inference.py @@ -123,6 +123,9 @@ def setUpClass(cls): super().setUpClass() cls._create_meta_cat_for_model() + # NOTE: if MetaCATAddons are going to be loaded, + # it would fail with this, but with previous + # code an attempt would have been made. @classmethod def _create_meta_cat_for_model(cls): # first, create the folder From dfdc99fc9c80080d9c2f0ead3878220445b42c31 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Wed, 24 Jun 2026 17:13:08 +0100 Subject: [PATCH 07/19] Rename inference tests module (tests with fake model) --- .../tests/{test_model_inference.py => test_with_fake_model.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename medcat-trainer/webapp/api/api/tests/{test_model_inference.py => test_with_fake_model.py} (100%) diff --git a/medcat-trainer/webapp/api/api/tests/test_model_inference.py b/medcat-trainer/webapp/api/api/tests/test_with_fake_model.py similarity index 100% rename from medcat-trainer/webapp/api/api/tests/test_model_inference.py rename to medcat-trainer/webapp/api/api/tests/test_with_fake_model.py From 16a242a951791fcf8d6571da935f0ab8aaae3460 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 25 Jun 2026 09:11:27 +0100 Subject: [PATCH 08/19] Fix incorrect pathing --- medcat-trainer/webapp/api/api/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/medcat-trainer/webapp/api/api/models.py b/medcat-trainer/webapp/api/api/models.py index bdd596051..eab137b41 100644 --- a/medcat-trainer/webapp/api/api/models.py +++ b/medcat-trainer/webapp/api/api/models.py @@ -122,7 +122,7 @@ def save(self, *args, skip_load=False, **kwargs): for meta_cat_dir, meta_cat_cnf in meta_cat_addons: mc_model = MetaCATModel() mc_model.meta_cat_dir = meta_cat_dir.replace(f'{MEDIA_ROOT}/', '') - mc_model.name = f'{meta_cat_cnf.config.general.category_name} - {meta_cat_cnf.model.model_name}' + mc_model.name = f'{meta_cat_cnf.general.category_name} - {meta_cat_cnf.model.model_name}' mc_model.save(unpack_load_meta_cat_dir=False) mc_model.get_or_create_meta_tasks_and_values(meta_cat_cnf) metaCATmodels.append(mc_model) From 63bf11b823788e89c8fbd48eaf6109be9e0de91d Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 25 Jun 2026 09:13:21 +0100 Subject: [PATCH 09/19] Update model pack / addon tests --- .../api/api/tests/test_model_pack_addons.py | 36 +++++++++---------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py b/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py index 9b2797e63..465a3e288 100644 --- a/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py +++ b/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py @@ -23,24 +23,22 @@ from django.core.files.base import ContentFile from django.test import TestCase, override_settings -from medcat.components.addons.meta_cat.meta_cat import MetaCATAddon -from medcat.components.addons.relation_extraction.rel_cat import RelCATAddon +from medcat.components.addons.meta_cat.meta_cat import ConfigMetaCAT +from medcat.components.addons.relation_extraction.rel_cat import ConfigRelCAT from ..models import ModelPack -def _make_meta_cat_addon(category_name="Status", model_name="bert"): - addon = MagicMock(spec=MetaCATAddon) - meta_cat = MagicMock() - meta_cat.config.general.category_name = category_name - meta_cat.config.model.model_name = model_name - meta_cat.config.general.category_value2id = {"True": 0, "False": 1} - addon.mc = meta_cat - return addon +def _make_meta_cat_addon_cnf(category_name="Status", model_name="bert"): + meta_cat_cnf = MagicMock(ConfigMetaCAT) + meta_cat_cnf.general.category_name = category_name + meta_cat_cnf.model.model_name = model_name + meta_cat_cnf.general.category_value2id = {"True": 0, "False": 1} + return meta_cat_cnf -def _make_rel_cat_addon(): - return MagicMock(spec=RelCATAddon) +def _make_rel_cat_addon_cnf(): + return MagicMock(spec=ConfigRelCAT) @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) @@ -60,7 +58,7 @@ def _register_model_pack(self, model_pack, addons): with patch("api.models.CAT.attempt_unpack"), \ patch("api.models.CDB.load"), \ patch("api.models.Vocab.load"), \ - patch("api.models.CAT.load_addons", return_value=addons) as load_addons: + patch("api.utils.load_meta_cat_info_from_model_folder", return_value=addons) as load_addons: model_pack.save() yield load_addons @@ -68,7 +66,7 @@ def test_register_model_pack_with_meta_cat_only(self): model_pack, unpacked = self._prepare_model_pack(name="meta-cat-pack") comps = os.path.join(unpacked, "saved_components") meta_cat_path = os.path.join(comps, "addon_meta_cat.Status") - addons = [(meta_cat_path, _make_meta_cat_addon())] + addons = [(meta_cat_path, _make_meta_cat_addon_cnf())] with self._register_model_pack(model_pack, addons) as load_addons: load_addons.assert_called_once_with(unpacked) @@ -84,7 +82,7 @@ def test_register_model_pack_with_rel_cat_only(self): model_pack, unpacked = self._prepare_model_pack(name="rel-cat-pack") comps = os.path.join(unpacked, "saved_components") rel_cat_path = os.path.join(comps, "addon_rel_cat.rel_cat") - addons = [(rel_cat_path, _make_rel_cat_addon())] + addons = [(rel_cat_path, _make_rel_cat_addon_cnf())] with self._register_model_pack(model_pack, addons) as load_addons: load_addons.assert_called_once_with(unpacked) @@ -98,9 +96,9 @@ def test_register_model_pack_registers_multiple_meta_cats(self): comps = os.path.join(unpacked, "saved_components") addons = [ (os.path.join(comps, "addon_meta_cat.Status"), - _make_meta_cat_addon(category_name="Status", model_name="bert")), + _make_meta_cat_addon_cnf(category_name="Status", model_name="bert")), (os.path.join(comps, "addon_meta_cat.Experiencer"), - _make_meta_cat_addon(category_name="Experiencer", model_name="roberta")), + _make_meta_cat_addon_cnf(category_name="Experiencer", model_name="roberta")), ] with self._register_model_pack(model_pack, addons): @@ -118,8 +116,8 @@ def test_register_model_pack_with_meta_cat_and_rel_cat(self): meta_cat_path = os.path.join(comps, "addon_meta_cat.Status") rel_cat_path = os.path.join(comps, "addon_rel_cat.rel_cat") addons = [ - (meta_cat_path, _make_meta_cat_addon()), - (rel_cat_path, _make_rel_cat_addon()), + (meta_cat_path, _make_meta_cat_addon_cnf()), + (rel_cat_path, _make_rel_cat_addon_cnf()), ] with self._register_model_pack(model_pack, addons) as load_addons: From 72a06678ee3f18af926267a14de784deb0fe2371 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 25 Jun 2026 09:16:15 +0100 Subject: [PATCH 10/19] Remove rel-cat related addon tests since only meta-cats are loaded --- .../api/api/tests/test_model_pack_addons.py | 43 +------------------ 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py b/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py index 465a3e288..b83376d25 100644 --- a/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py +++ b/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py @@ -23,24 +23,17 @@ from django.core.files.base import ContentFile from django.test import TestCase, override_settings -from medcat.components.addons.meta_cat.meta_cat import ConfigMetaCAT -from medcat.components.addons.relation_extraction.rel_cat import ConfigRelCAT - from ..models import ModelPack def _make_meta_cat_addon_cnf(category_name="Status", model_name="bert"): - meta_cat_cnf = MagicMock(ConfigMetaCAT) + meta_cat_cnf = MagicMock() meta_cat_cnf.general.category_name = category_name meta_cat_cnf.model.model_name = model_name meta_cat_cnf.general.category_value2id = {"True": 0, "False": 1} return meta_cat_cnf -def _make_rel_cat_addon_cnf(): - return MagicMock(spec=ConfigRelCAT) - - @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) class ModelPackAddonRegistrationTests(TestCase): def _prepare_model_pack(self, name="addon-pack-test"): @@ -78,19 +71,6 @@ def test_register_model_pack_with_meta_cat_only(self): self.assertEqual(meta_cat_model.name, "Status - bert") self.assertTrue(meta_cat_model.meta_cat_dir.endswith("addon_meta_cat.Status")) - def test_register_model_pack_with_rel_cat_only(self): - model_pack, unpacked = self._prepare_model_pack(name="rel-cat-pack") - comps = os.path.join(unpacked, "saved_components") - rel_cat_path = os.path.join(comps, "addon_rel_cat.rel_cat") - addons = [(rel_cat_path, _make_rel_cat_addon_cnf())] - - with self._register_model_pack(model_pack, addons) as load_addons: - load_addons.assert_called_once_with(unpacked) - - self.assertIsNotNone(model_pack.concept_db) - self.assertIsNotNone(model_pack.vocab) - self.assertEqual(model_pack.meta_cats.count(), 0) - def test_register_model_pack_registers_multiple_meta_cats(self): model_pack, unpacked = self._prepare_model_pack(name="multi-meta-cat-pack") comps = os.path.join(unpacked, "saved_components") @@ -110,27 +90,6 @@ def test_register_model_pack_registers_multiple_meta_cats(self): {"Status - bert", "Experiencer - roberta"}, ) - def test_register_model_pack_with_meta_cat_and_rel_cat(self): - model_pack, unpacked = self._prepare_model_pack(name="mixed-addon-pack") - comps = os.path.join(unpacked, "saved_components") - meta_cat_path = os.path.join(comps, "addon_meta_cat.Status") - rel_cat_path = os.path.join(comps, "addon_rel_cat.rel_cat") - addons = [ - (meta_cat_path, _make_meta_cat_addon_cnf()), - (rel_cat_path, _make_rel_cat_addon_cnf()), - ] - - with self._register_model_pack(model_pack, addons) as load_addons: - load_addons.assert_called_once_with(unpacked) - - self.assertIsNotNone(model_pack.concept_db) - self.assertIsNotNone(model_pack.vocab) - # All addons load; only MetaCAT rows are registered. - self.assertEqual(model_pack.meta_cats.count(), 1) - meta_cat_model = model_pack.meta_cats.get() - self.assertEqual(meta_cat_model.name, "Status - bert") - self.assertTrue(meta_cat_model.meta_cat_dir.endswith("addon_meta_cat.Status")) - def test_register_model_pack_without_addons(self): model_pack, unpacked = self._prepare_model_pack(name="no-addon-pack") From 3294b25ce508e1276af4d5d435143bd546bc5c29 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 25 Jun 2026 09:18:55 +0100 Subject: [PATCH 11/19] Separate global config loading in utils --- medcat-trainer/webapp/api/api/utils.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/medcat-trainer/webapp/api/api/utils.py b/medcat-trainer/webapp/api/api/utils.py index 047aef011..e3bf98799 100644 --- a/medcat-trainer/webapp/api/api/utils.py +++ b/medcat-trainer/webapp/api/api/utils.py @@ -522,9 +522,7 @@ def _get_meta_cat_path(model_folder: str, cnf: ConfigMetaCAT) -> str: model_folder, _PATH_TO_META_CAT_START) + cnf.category_name -def load_meta_cat_info_from_model_folder( - model_pack_path: str -) -> list[tuple[str, ConfigMetaCAT]]: +def _load_global_cnf(model_pack_path: str) -> dict: global_cnf_path = os.path.join(model_pack_path, _PATH_TO_CNF_ON_DISK) with open(os.path.join(model_pack_path, '.serialised_by')) as f: contents = f.read() @@ -533,7 +531,13 @@ def load_meta_cat_info_from_model_folder( "Unable to load MetaCAT info without loading the meta cats from this model " "because of its serialisation type (%s) - we only have implementation for 'dill'") with open(global_cnf_path, 'rb') as f: - cnf = dill.load(f) + return dill.load(f) + + +def load_meta_cat_info_from_model_folder( + model_pack_path: str +) -> list[tuple[str, ConfigMetaCAT]]: + cnf = _load_global_cnf(model_pack_path) return [ (_get_meta_cat_path(model_pack_path, mc_cnf), mc_cnf) for mc_cnf in cnf['addons'] From 39b87750451055b04353ccef1abb1466c4f3a634 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 25 Jun 2026 09:28:13 +0100 Subject: [PATCH 12/19] Fix some issues with global config addon load --- medcat-trainer/webapp/api/api/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/medcat-trainer/webapp/api/api/utils.py b/medcat-trainer/webapp/api/api/utils.py index e3bf98799..b3d2c6a6c 100644 --- a/medcat-trainer/webapp/api/api/utils.py +++ b/medcat-trainer/webapp/api/api/utils.py @@ -522,7 +522,7 @@ def _get_meta_cat_path(model_folder: str, cnf: ConfigMetaCAT) -> str: model_folder, _PATH_TO_META_CAT_START) + cnf.category_name -def _load_global_cnf(model_pack_path: str) -> dict: +def _load_global_cnf_addon_cnfs(model_pack_path: str) -> list[dict]: global_cnf_path = os.path.join(model_pack_path, _PATH_TO_CNF_ON_DISK) with open(os.path.join(model_pack_path, '.serialised_by')) as f: contents = f.read() @@ -531,14 +531,14 @@ def _load_global_cnf(model_pack_path: str) -> dict: "Unable to load MetaCAT info without loading the meta cats from this model " "because of its serialisation type (%s) - we only have implementation for 'dill'") with open(global_cnf_path, 'rb') as f: - return dill.load(f) + return dill.load(f)['components']['addons'] def load_meta_cat_info_from_model_folder( model_pack_path: str ) -> list[tuple[str, ConfigMetaCAT]]: - cnf = _load_global_cnf(model_pack_path) + addons = _load_global_cnf_addon_cnfs(model_pack_path) return [ (_get_meta_cat_path(model_pack_path, mc_cnf), mc_cnf) - for mc_cnf in cnf['addons'] + for mc_cnf in addons ] From 23cfc8a8f64229e2a622071882596eebb36a9fb8 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 25 Jun 2026 09:28:58 +0100 Subject: [PATCH 13/19] Filter addons down to meta-cat only --- medcat-trainer/webapp/api/api/utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/medcat-trainer/webapp/api/api/utils.py b/medcat-trainer/webapp/api/api/utils.py index b3d2c6a6c..530086225 100644 --- a/medcat-trainer/webapp/api/api/utils.py +++ b/medcat-trainer/webapp/api/api/utils.py @@ -537,8 +537,9 @@ def _load_global_cnf_addon_cnfs(model_pack_path: str) -> list[dict]: def load_meta_cat_info_from_model_folder( model_pack_path: str ) -> list[tuple[str, ConfigMetaCAT]]: - addons = _load_global_cnf_addon_cnfs(model_pack_path) + addon_cnfs = _load_global_cnf_addon_cnfs(model_pack_path) return [ (_get_meta_cat_path(model_pack_path, mc_cnf), mc_cnf) - for mc_cnf in addons + for mc_cnf in addon_cnfs + if mc_cnf['addon_type'] == "meta_cat" ] From 2233b131fb8aaef4cbdf04d5b5b2581c58db20da Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 25 Jun 2026 09:38:18 +0100 Subject: [PATCH 14/19] Fix deserialisation of meta cat config --- medcat-trainer/webapp/api/api/utils.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/medcat-trainer/webapp/api/api/utils.py b/medcat-trainer/webapp/api/api/utils.py index 530086225..a03855d4d 100644 --- a/medcat-trainer/webapp/api/api/utils.py +++ b/medcat-trainer/webapp/api/api/utils.py @@ -519,7 +519,7 @@ def env_str_to_bool(var: str, default: bool): def _get_meta_cat_path(model_folder: str, cnf: ConfigMetaCAT) -> str: return os.path.join( - model_folder, _PATH_TO_META_CAT_START) + cnf.category_name + model_folder, _PATH_TO_META_CAT_START) + cnf.general.category_name def _load_global_cnf_addon_cnfs(model_pack_path: str) -> list[dict]: @@ -531,7 +531,8 @@ def _load_global_cnf_addon_cnfs(model_pack_path: str) -> list[dict]: "Unable to load MetaCAT info without loading the meta cats from this model " "because of its serialisation type (%s) - we only have implementation for 'dill'") with open(global_cnf_path, 'rb') as f: - return dill.load(f)['components']['addons'] + cnf = dill.load(f) + return cnf['addons'] def load_meta_cat_info_from_model_folder( @@ -541,5 +542,5 @@ def load_meta_cat_info_from_model_folder( return [ (_get_meta_cat_path(model_pack_path, mc_cnf), mc_cnf) for mc_cnf in addon_cnfs - if mc_cnf['addon_type'] == "meta_cat" + if mc_cnf.comp_name == "meta_cat" ] From 02829222f8fcd7795d3b93fbe82b73dd03bad056 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 25 Jun 2026 10:09:55 +0100 Subject: [PATCH 15/19] Add commented real model test --- .../api/api/tests/test_with_fake_model.py | 17 +++++++++++++++++ medcat-trainer/webapp/api/api/utils.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/medcat-trainer/webapp/api/api/tests/test_with_fake_model.py b/medcat-trainer/webapp/api/api/tests/test_with_fake_model.py index fc10f0d07..6fc960eea 100644 --- a/medcat-trainer/webapp/api/api/tests/test_with_fake_model.py +++ b/medcat-trainer/webapp/api/api/tests/test_with_fake_model.py @@ -168,3 +168,20 @@ def test_model_import_does_not_load_addons(self, mock_dispatch): source_uri='https://some/fake/address', ) mock_init.assert_not_called() + + +# class RealModelTests(TestCase): +# MODEL_PATH = "../../../medcat-v2/.temp/20230227__kch_gstt_trained_model_f76d2121b77c3e9a/" + +# def test_can_read_from_real_model(self): +# from api.utils import load_meta_cat_info_from_model_folder +# infos = load_meta_cat_info_from_model_folder(self.MODEL_PATH) +# self.assertEqual(len(infos), 3) +# categories = { +# cnf.general.category_name +# for _, cnf in infos +# } +# self.assertEqual( +# categories, +# {"Presence", "Subject", "Time"} +# ) diff --git a/medcat-trainer/webapp/api/api/utils.py b/medcat-trainer/webapp/api/api/utils.py index a03855d4d..c786fa3b8 100644 --- a/medcat-trainer/webapp/api/api/utils.py +++ b/medcat-trainer/webapp/api/api/utils.py @@ -522,7 +522,7 @@ def _get_meta_cat_path(model_folder: str, cnf: ConfigMetaCAT) -> str: model_folder, _PATH_TO_META_CAT_START) + cnf.general.category_name -def _load_global_cnf_addon_cnfs(model_pack_path: str) -> list[dict]: +def _load_global_cnf_addon_cnfs(model_pack_path: str) -> list: global_cnf_path = os.path.join(model_pack_path, _PATH_TO_CNF_ON_DISK) with open(os.path.join(model_pack_path, '.serialised_by')) as f: contents = f.read() From 16a42dbf5f31cb6190ec2815a74271725d82df97 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 25 Jun 2026 10:18:15 +0100 Subject: [PATCH 16/19] Rework model pack addon tests --- .../api/api/tests/test_model_pack_addons.py | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py b/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py index b83376d25..cc77c533e 100644 --- a/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py +++ b/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py @@ -28,6 +28,7 @@ def _make_meta_cat_addon_cnf(category_name="Status", model_name="bert"): meta_cat_cnf = MagicMock() + meta_cat_cnf.comp_name = "meta_cat" meta_cat_cnf.general.category_name = category_name meta_cat_cnf.model.model_name = model_name meta_cat_cnf.general.category_value2id = {"True": 0, "False": 1} @@ -47,21 +48,19 @@ def _prepare_model_pack(self, name="addon-pack-test"): return model_pack, unpacked @contextmanager - def _register_model_pack(self, model_pack, addons): + def _register_model_pack(self, model_pack, addon_cnfs): with patch("api.models.CAT.attempt_unpack"), \ patch("api.models.CDB.load"), \ patch("api.models.Vocab.load"), \ - patch("api.utils.load_meta_cat_info_from_model_folder", return_value=addons) as load_addons: + patch("api.utils._load_global_cnf_addon_cnfs", return_value=addon_cnfs) as load_addons: model_pack.save() yield load_addons def test_register_model_pack_with_meta_cat_only(self): model_pack, unpacked = self._prepare_model_pack(name="meta-cat-pack") - comps = os.path.join(unpacked, "saved_components") - meta_cat_path = os.path.join(comps, "addon_meta_cat.Status") - addons = [(meta_cat_path, _make_meta_cat_addon_cnf())] + addon_cnfs = [_make_meta_cat_addon_cnf()] - with self._register_model_pack(model_pack, addons) as load_addons: + with self._register_model_pack(model_pack, addon_cnfs) as load_addons: load_addons.assert_called_once_with(unpacked) self.assertIsNotNone(model_pack.concept_db) @@ -73,15 +72,12 @@ def test_register_model_pack_with_meta_cat_only(self): def test_register_model_pack_registers_multiple_meta_cats(self): model_pack, unpacked = self._prepare_model_pack(name="multi-meta-cat-pack") - comps = os.path.join(unpacked, "saved_components") - addons = [ - (os.path.join(comps, "addon_meta_cat.Status"), - _make_meta_cat_addon_cnf(category_name="Status", model_name="bert")), - (os.path.join(comps, "addon_meta_cat.Experiencer"), - _make_meta_cat_addon_cnf(category_name="Experiencer", model_name="roberta")), + addon_cnfs = [ + _make_meta_cat_addon_cnf(category_name="Status", model_name="bert"), + _make_meta_cat_addon_cnf(category_name="Experiencer", model_name="roberta"), ] - with self._register_model_pack(model_pack, addons): + with self._register_model_pack(model_pack, addon_cnfs): pass self.assertEqual(model_pack.meta_cats.count(), 2) From eb8524cff5d5ff48dff952e49e5c32ab05776181 Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 25 Jun 2026 10:21:12 +0100 Subject: [PATCH 17/19] Add back rel-cat based addon tests --- .../api/api/tests/test_model_pack_addons.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py b/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py index cc77c533e..49fc8cde3 100644 --- a/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py +++ b/medcat-trainer/webapp/api/api/tests/test_model_pack_addons.py @@ -35,6 +35,10 @@ def _make_meta_cat_addon_cnf(category_name="Status", model_name="bert"): return meta_cat_cnf +def _make_rel_cat_addon_cnf(): + return MagicMock() + + @override_settings(MEDIA_ROOT=tempfile.mkdtemp()) class ModelPackAddonRegistrationTests(TestCase): def _prepare_model_pack(self, name="addon-pack-test"): @@ -70,6 +74,17 @@ def test_register_model_pack_with_meta_cat_only(self): self.assertEqual(meta_cat_model.name, "Status - bert") self.assertTrue(meta_cat_model.meta_cat_dir.endswith("addon_meta_cat.Status")) + def test_register_model_pack_with_rel_cat_only(self): + model_pack, unpacked = self._prepare_model_pack(name="rel-cat-pack") + addon_cnfs = [_make_rel_cat_addon_cnf()] + + with self._register_model_pack(model_pack, addon_cnfs) as load_addons: + load_addons.assert_called_once_with(unpacked) + + self.assertIsNotNone(model_pack.concept_db) + self.assertIsNotNone(model_pack.vocab) + self.assertEqual(model_pack.meta_cats.count(), 0) + def test_register_model_pack_registers_multiple_meta_cats(self): model_pack, unpacked = self._prepare_model_pack(name="multi-meta-cat-pack") addon_cnfs = [ @@ -86,6 +101,22 @@ def test_register_model_pack_registers_multiple_meta_cats(self): {"Status - bert", "Experiencer - roberta"}, ) + def test_register_model_pack_with_meta_cat_and_rel_cat(self): + model_pack, unpacked = self._prepare_model_pack(name="mixed-addon-pack") + addon_cnfs = [_make_meta_cat_addon_cnf(), _make_rel_cat_addon_cnf(),] + + with self._register_model_pack(model_pack, addon_cnfs) as load_addons: + load_addons.assert_called_once_with(unpacked) + + self.assertIsNotNone(model_pack.concept_db) + self.assertIsNotNone(model_pack.vocab) + # All addons load; only MetaCAT rows are registered. + self.assertEqual(model_pack.meta_cats.count(), 1) + meta_cat_model = model_pack.meta_cats.get() + self.assertEqual(meta_cat_model.name, "Status - bert") + self.assertTrue(meta_cat_model.meta_cat_dir.endswith("addon_meta_cat.Status")) + + def test_register_model_pack_without_addons(self): model_pack, unpacked = self._prepare_model_pack(name="no-addon-pack") From 0d319d3f3cdb95a1449e142e2aaf2ce88f5f779e Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 25 Jun 2026 12:08:37 +0100 Subject: [PATCH 18/19] Rename test file (fake -> dummy) --- .../tests/{test_with_fake_model.py => test_with_dummy_model.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename medcat-trainer/webapp/api/api/tests/{test_with_fake_model.py => test_with_dummy_model.py} (100%) diff --git a/medcat-trainer/webapp/api/api/tests/test_with_fake_model.py b/medcat-trainer/webapp/api/api/tests/test_with_dummy_model.py similarity index 100% rename from medcat-trainer/webapp/api/api/tests/test_with_fake_model.py rename to medcat-trainer/webapp/api/api/tests/test_with_dummy_model.py From 40b94de05f0b3c59060589a2deed8c7e93de930f Mon Sep 17 00:00:00 2001 From: Mart Ratas Date: Thu, 25 Jun 2026 12:10:08 +0100 Subject: [PATCH 19/19] Rename fake to dummy in tests --- .../webapp/api/api/tests/test_with_dummy_model.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/medcat-trainer/webapp/api/api/tests/test_with_dummy_model.py b/medcat-trainer/webapp/api/api/tests/test_with_dummy_model.py index 6fc960eea..1d760b0d9 100644 --- a/medcat-trainer/webapp/api/api/tests/test_with_dummy_model.py +++ b/medcat-trainer/webapp/api/api/tests/test_with_dummy_model.py @@ -27,7 +27,7 @@ "..", "..", "media" ) MODEL_PATH = os.path.join( - MEDIA_PATH, "fake_model_pack.zip" + MEDIA_PATH, "dummy_model_pack.zip" ) MODEL_PATH_UNPACKED = MODEL_PATH.removesuffix(".zip") @@ -68,7 +68,7 @@ def setUp(self): self.client.force_authenticate(self.user) self.model_pack = ModelPack.objects.create( - name='fake-model', + name='test-model', model_pack=MODEL_PATH, ) @@ -162,10 +162,10 @@ def test_model_import_does_not_load_addons(self, mock_dispatch): with patch.object(MetaCATAddon, "__init__") as mock_init: import_model_pack( MODEL_PATH, - name='fake-test-model', + name='test-model', user=self.user, description='Fake model!', - source_uri='https://some/fake/address', + source_uri='https://some/address', ) mock_init.assert_not_called()