diff --git a/.github/workflows/medcat-v2-tutorials_main.yml b/.github/workflows/medcat-v2-tutorials_main.yml index b16c132c8..ed2a7f162 100644 --- a/.github/workflows/medcat-v2-tutorials_main.yml +++ b/.github/workflows/medcat-v2-tutorials_main.yml @@ -24,7 +24,7 @@ jobs: python-version: "3.11" cache: 'pip' - - name: Update install targets in notebooks + - name: Skip notebook MedCAT installs (installed by CI) run: | python .ci/patch_notebook_installs.py . --expect-min-changes 4 @@ -71,7 +71,7 @@ jobs: with: python-version: "3.11" - - name: Update install targets in notebooks + - name: Skip notebook MedCAT installs (installed by CI) run: | python .ci/patch_notebook_installs.py . --expect-min-changes 4 diff --git a/medcat-service/env/app.env b/medcat-service/env/app.env index e59c7ad2f..657ded299 100755 --- a/medcat-service/env/app.env +++ b/medcat-service/env/app.env @@ -18,6 +18,10 @@ APP_MODEL_VOCAB_PATH=/cat/models/medmen/vocab.dat APP_MODEL_META_PATH_LIST=/cat/models/medmen/Status APP_MODEL_REL_PATH_LIST= +# optionally, restrict the MedCAT components that run by default. Comma-separated. +# Can be overridden per request with ?enabled_components=ner,linking,meta_cat +APP_ENABLED_COMPONENTS= + # MedCAT Model Pack path # IMPORTANT: if this parameter has value IT WILL BE LOADED FIRST OVER EVERYTHING ELSE (CDB, Vocab, MetaCATs, etc.) declared above. # Respect the same paths as above : /cat/models/model_pack_name.zip diff --git a/medcat-service/medcat_service/config.py b/medcat-service/medcat_service/config.py index a2033479e..08c50cfcc 100644 --- a/medcat-service/medcat_service/config.py +++ b/medcat-service/medcat_service/config.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Iterable from pathlib import Path from typing import Any @@ -20,6 +21,14 @@ def _coerce_loglevel(v: Any) -> int: return logging.INFO +def parse_disabled_components(v: str | Iterable[str] | None) -> tuple[str, ...]: + if v is None or not v: + return () + if isinstance(v, str): + return tuple(x.strip() for x in v.split(",") if x.strip()) + return tuple(v) + + class ObservabilitySettings(BaseSettings): model_config = SettingsConfigDict(frozen=True, env_prefix="APP_") @@ -53,6 +62,8 @@ class Settings(BaseSettings): description="Enable DEID redaction. Returns text like [***] instead of [ANNOTATION]", ) + disabled_components: str | tuple[str, ...] = Field(default=(), alias="APP_DISABLED_COMPONENTS") + enable_demo_ui: bool = Field(default=False, description="Enable the demo app", alias="APP_ENABLE_DEMO_UI") demo_ui_path: str = Field(default="", description="Path to the demo app", alias="APP_DEMO_UI_PATH") demo_ui_custom_markdown_path: str | None = Field( @@ -134,6 +145,11 @@ def _val_log_levels(cls, v: Any) -> int: def _lower_mode(cls, v: str) -> str: return v.lower().strip() + @field_validator("disabled_components", mode="before") + @classmethod + def _parse_disabled_components(cls, v: str | Iterable[str] | None) -> tuple[str, ...]: + return parse_disabled_components(v) + @field_validator("model_meta_path_list", "model_rel_path_list", mode="before") @classmethod def _split_paths(cls, v): diff --git a/medcat-service/medcat_service/demo/gradio_demo.py b/medcat-service/medcat_service/demo/gradio_demo.py index a80ab1d04..7d02b09b7 100644 --- a/medcat-service/medcat_service/demo/gradio_demo.py +++ b/medcat-service/medcat_service/demo/gradio_demo.py @@ -3,10 +3,8 @@ from fastapi import FastAPI import medcat_service.demo.demo_content as demo_content -from medcat_service.demo.demo_logic import ( - anoncat_demo_perform_deidentification, - medcat_demo_perform_named_entity_resolution, -) +from medcat_service.demo.demo_logic import (anoncat_demo_perform_deidentification, + medcat_demo_perform_named_entity_resolution) from medcat_service.dependencies import get_settings headers = ["Pretty Name", "Identifier", "Confidence Score", "Start Index", "End Index", "ID"] diff --git a/medcat-service/medcat_service/nlp_processor/medcat_processor.py b/medcat-service/medcat_service/nlp_processor/medcat_processor.py index b117bb32f..d791dc06d 100644 --- a/medcat-service/medcat_service/nlp_processor/medcat_processor.py +++ b/medcat-service/medcat_service/nlp_processor/medcat_processor.py @@ -3,6 +3,7 @@ import logging import time from datetime import datetime, timezone +from typing import cast import numpy as np import torch @@ -125,7 +126,7 @@ def process_entities(self, entities, *args, **kwargs): """Process entities for repsonse and serialisation """ if type(entities) is dict: - if "entities" in entities.keys(): + if "entities" in entities: entities = entities["entities"] self._fix_floats(entities) @@ -135,6 +136,57 @@ def process_entities(self, entities, *args, **kwargs): yield entities + @staticmethod + def _normalise_component_name(component_name: str) -> str: + return component_name.strip().lower() + + @classmethod + def _component_names(cls, component) -> set[str]: + names = { + cls._normalise_component_name(name) + for name in (getattr(component, "full_name", None), getattr(component, "name", None)) + if name + } + + if component.is_core(): + names.add(cls._normalise_component_name(component.get_type().name)) + elif getattr(component, "addon_type", None): + names.add(cls._normalise_component_name(component.addon_type)) + + return names + + def _filter_pipeline_components(self, components, disabled_names: set[str], matched_names: set[str]): + for component in components: + component_names = self._component_names(component) + if component_names & disabled_names: + matched_names.update(component_names & disabled_names) + continue + yield component + + def _get_entities(self, text: str, disabled_components: tuple[str, ...]): + if not disabled_components: + return self.cat.get_entities(text) + + cat = cast(CAT, self.cat) + pipeline = cat.pipe + disabled_names = {self._normalise_component_name(name) for name in disabled_components} + matched_names: set[str] = set() + doc = pipeline.tokenizer(text) + + for component in self._filter_pipeline_components( + pipeline.iter_all_components(), disabled_names, matched_names + ): + doc = component(doc) + + unknown_names = disabled_names - matched_names + if unknown_names: + self.log.warning("Requested MedCAT components were not found: %s", sorted(unknown_names)) + + if cat.usage_monitor.should_monitor: + cat.usage_monitor.log_inference(len(text), len(doc.linked_ents)) + + return cat._doc_to_out(doc, only_cui=False) + @tracer.start_as_current_span("process_content") def process_content(self, content, *args, redact=None, **kwargs): """Processes a single document extracting the annotations. @@ -177,31 +229,37 @@ def process_content(self, content, *args, redact=None, **kwargs): text, entities = self.cat.deid_text_with_entities(text, redact=redact_value) else: if text is not None and len(text.strip()) > 0: + disabled_components = kwargs.get("disabled_components") + if disabled_components is None: + disabled_components = self.service_settings.disabled_components with tracer.start_as_current_span("cat.get_entities"): - entities = self.cat.get_entities(text) + entities = self._get_entities(text, disabled_components=disabled_components) else: entities = [] elapsed_time = (time.time_ns() - start_time_ns) / 10e8 # nanoseconds to seconds + + relations = (entities.get("relations", []) if isinstance(entities, dict) else []) + meta_anns_filters = kwargs.get("meta_anns_filters") - if meta_anns_filters: - if isinstance(entities, dict): - entities = [ - e - for e in entities["entities"].values() - if isinstance(e, dict) - and all( - task in e.get("meta_anns", {}) - and e["meta_anns"][task]["value"] in filter_values - for task, filter_values in meta_anns_filters - ) - ] + if meta_anns_filters and isinstance(entities, dict): + entities = [ + e + for e in entities["entities"].values() + if isinstance(e, dict) + and all( + task in e.get("meta_anns", {}) + and e["meta_anns"][task]["value"] in filter_values + for task, filter_values in meta_anns_filters + ) + ] entities = list(self.process_entities(entities, **kwargs)) nlp_result = ProcessResult( text=str(text), annotations=entities, + relations=relations, success=True, timestamp=self._get_timestamp(), elapsed_time=elapsed_time, @@ -240,9 +298,9 @@ def process_content_bulk(self, content): ) elif isinstance(self.cat, CAT): ann_res = { - ann_id: res for ann_id, res in - self.cat.get_entities_multi_texts( - text_input, n_process=self.service_settings.bulk_nproc) + ann_id: res for ann_id, res in self.cat.get_entities_multi_texts( + text_input, n_process=self.service_settings.bulk_nproc + ) } except Exception as e: self.log.error("Unable to process data", exc_info=e) @@ -309,6 +367,8 @@ def _create_cat(self) -> DeIdModel | CAT: ) self._populate_model_card_info(cat.config) + + self.log.info("Loaded model pack: %s", self.service_settings.medcat_model_pack) return cat self.log.info(f"{Settings.env_name('medcat_model_pack')} not set, skipping...") @@ -404,14 +464,17 @@ def _generate_result(self, in_documents, annotations, elapsed_time): for i in range(len(in_documents)): in_ct = in_documents[i] - if not self.service_settings.deid_mode and i in annotations.keys(): + if not self.service_settings.deid_mode and i in annotations: # generate output for valid annotations entities = list(self.process_entities(annotations.get(i))) + relations = annotations[i].get("relations", []) + out_res = ProcessResult( text=str(in_ct["text"]), annotations=entities, + relations=relations, success=True, timestamp=self._get_timestamp(), elapsed_time=elapsed_time, @@ -458,8 +521,8 @@ def _get_medcat_version() -> str: import pkg_resources version = pkg_resources.require("medcat")[0].version return str(version) - except Exception: - raise Exception("Cannot read the MedCAT library version") + except Exception as e: + raise Exception("Cannot read the MedCAT library version") from e # NOTE: numpy uses np.float32 and those are not json serialisable # so we need to fix that diff --git a/medcat-service/medcat_service/routers/process.py b/medcat-service/medcat_service/routers/process.py index f19d8b819..c642962dd 100644 --- a/medcat-service/medcat_service/routers/process.py +++ b/medcat-service/medcat_service/routers/process.py @@ -1,13 +1,15 @@ import logging from typing import Annotated, Union -from fastapi import APIRouter, Body +from fastapi import APIRouter, Body, Query from fastapi.exceptions import RequestValidationError +from opentelemetry import trace from pydantic import ValidationError +from medcat_service.config import parse_disabled_components from medcat_service.dependencies import MedCatProcessorDep from medcat_service.types import BulkProcessAPIInput, BulkProcessAPIResponse, ProcessAPIInput, ProcessAPIResponse -from opentelemetry import trace + log = logging.getLogger("API") router = APIRouter(tags=["Process"]) @@ -46,6 +48,13 @@ async def process( ), ], medcat_processor: MedCatProcessorDep, + disabled_components: Annotated[ + str | None, + Query(description=( + "Comma-separated MedCAT component names to skip for this request. " + "Omit to use APP_DISABLED_COMPONENTS; pass an empty value to run all components." + )), + ] = None, ) -> ProcessAPIResponse: """ Returns the annotations extracted from a provided single document @@ -61,9 +70,18 @@ async def process( meta_filters = validated.meta_anns_filters except ValidationError as ve: log.error("Invalid payload", exc_info=ve) - raise RequestValidationError(errors=ve.errors()) + raise RequestValidationError(errors=ve.errors()) from ve + + parsed_disabled_components = ( + parse_disabled_components(disabled_components) if disabled_components is not None else None + ) + + process_result = medcat_processor.process_content( + content, + meta_anns_filters=meta_filters, + disabled_components=parsed_disabled_components, + ) - process_result = medcat_processor.process_content(content, meta_anns_filters=meta_filters) app_info = medcat_processor.get_app_info() return ProcessAPIResponse(result=process_result, medcat_info=app_info) except Exception as e: diff --git a/medcat-service/medcat_service/routers/swagger.py b/medcat-service/medcat_service/routers/swagger.py index 88ed866e4..f3db99cf7 100644 --- a/medcat-service/medcat_service/routers/swagger.py +++ b/medcat-service/medcat_service/routers/swagger.py @@ -1,11 +1,7 @@ from pathlib import Path from fastapi import FastAPI -from fastapi.openapi.docs import ( - get_redoc_html, - get_swagger_ui_html, - get_swagger_ui_oauth2_redirect_html, -) +from fastapi.openapi.docs import get_redoc_html, get_swagger_ui_html, get_swagger_ui_oauth2_redirect_html from fastapi.staticfiles import StaticFiles from medcat_service.config import Settings diff --git a/medcat-service/medcat_service/test/demo/test_demo_logic.py b/medcat-service/medcat_service/test/demo/test_demo_logic.py index 959570923..d930930e4 100644 --- a/medcat-service/medcat_service/test/demo/test_demo_logic.py +++ b/medcat-service/medcat_service/test/demo/test_demo_logic.py @@ -7,18 +7,11 @@ from unittest.mock import MagicMock, patch from medcat_service.config import Settings -from medcat_service.demo.demo_logic import ( - EntityResponse, - anoncat_demo_perform_deidentification, - medcat_demo_perform_named_entity_resolution, - perform_named_entity_resolution, -) +from medcat_service.demo.demo_logic import (EntityResponse, anoncat_demo_perform_deidentification, + medcat_demo_perform_named_entity_resolution, + perform_named_entity_resolution) from medcat_service.nlp_processor import MedCatProcessor -from medcat_service.test.common import ( - get_example_long_document, - get_example_short_document, - setup_medcat_processor, -) +from medcat_service.test.common import get_example_long_document, get_example_short_document, setup_medcat_processor class TestDemoLogic(unittest.TestCase): diff --git a/medcat-service/medcat_service/test/test_disabled_components.py b/medcat-service/medcat_service/test/test_disabled_components.py new file mode 100644 index 000000000..3ba90a97f --- /dev/null +++ b/medcat-service/medcat_service/test/test_disabled_components.py @@ -0,0 +1,225 @@ +"""Regression tests for component selection without loading model packs.""" + +import logging +import os +import unittest +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from fastapi import FastAPI +from fastapi.testclient import TestClient +from medcat.cat import CAT +from medcat.components.addons.addons import AddonComponent +from medcat.components.types import CoreComponent, CoreComponentType +from medcat.config import Config +from medcat.pipeline import Pipeline + +from medcat_service.config import Settings, parse_disabled_components +from medcat_service.dependencies import get_medcat_processor +from medcat_service.nlp_processor import MedCatProcessor +from medcat_service.routers.process import router +from medcat_service.types import ModelCardInfo + + +class TestDisabledComponentsSettings(unittest.TestCase): + def setUp(self): + self.enterContext(patch.dict(os.environ, {}, clear=True)) + + def test_default_runs_all_components(self): + self.assertEqual(Settings().disabled_components, ()) + + def test_environment_variable_parses_comma_separated_names(self): + with patch.dict(os.environ, {"APP_DISABLED_COMPONENTS": " rel_cat, , meta_cat.Status, "}): + self.assertEqual(Settings().disabled_components, ("rel_cat", "meta_cat.Status")) + + def test_parse_empty_and_iterable_values(self): + for value, expected in ( + (None, ()), + ("", ()), + (" , , ", ()), + ([], ()), + (["rel_cat", "meta_cat"], ("rel_cat", "meta_cat")), + (("rel_cat",), ("rel_cat",)), + ): + with self.subTest(value=value): + self.assertEqual(parse_disabled_components(value), expected) + + +class TestDisabledComponentsRequests(unittest.TestCase): + """Exercise the real router, processor and CAT pipeline with component spies.""" + + TEXT = "Patient was prescribed aspirin." + ALL_COMPONENTS = ["ner:dictionary", "meta_cat.Status", "meta_cat.Subject", "rel_cat.relations"] + RELATIONS = [{"label": "test_relation"}] + + def setUp(self): + self.enterContext(patch.dict(os.environ, {}, clear=True)) + self.executed = [] + + # Keep MedCAT's normal execution path, replacing only model inference + # and document serialization with lightweight fixtures. + self.ner = self._component("dictionary", "ner:dictionary", core=True) + self.ner.get_type.return_value = CoreComponentType.ner + self.status = self._component("Status", "meta_cat.Status", addon_type="meta_cat") + self.subject = self._component("Subject", "meta_cat.Subject", addon_type="meta_cat") + self.rel_cat = self._component("relations", "rel_cat.relations", addon_type="rel_cat") + + self.pipeline = Pipeline.__new__(Pipeline) + self.pipeline._tokenizer = Mock(side_effect=lambda text: SimpleNamespace(linked_ents=[], relations=[])) + self.pipeline._components = [self.ner] + self.pipeline._addons = [self.status, self.subject, self.rel_cat] + self.original_components = self.pipeline._components + self.original_addons = self.pipeline._addons + + self.cat = CAT.__new__(CAT) + self.cat.config = Config() + self.cat._pipeline = self.pipeline + self.cat.usage_monitor = Mock(should_monitor=False) + self.cat._doc_to_out = Mock(side_effect=lambda doc, only_cui: {"entities": {}, "relations": doc.relations}) + + self.processor = MedCatProcessor.__new__(MedCatProcessor) + self.processor.cat = self.cat + self.processor.service_settings = Settings() + self.processor.log = logging.getLogger(__name__) + self.processor.app_version = "test" + self.processor.model_card_info = ModelCardInfo( + ontologies=None, meta_cat_model_names=[], rel_cat_model_names=[], model_last_modified_on=None + ) + + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_medcat_processor] = lambda: self.processor + self.client = self.enterContext(TestClient(app, raise_server_exceptions=False)) + + def _component(self, name, full_name, *, core=False, addon_type=None): + component = Mock(spec=CoreComponent if core else AddonComponent) + component.name = name + component.full_name = full_name + component.is_core.return_value = core + component.addon_type = addon_type + + def run(doc): + self.executed.append(full_name) + if addon_type == "rel_cat": + doc.relations.extend(self.RELATIONS) + return doc + + component.side_effect = run + return component + + def _request(self, disabled_components=None): + self.executed.clear() + params = {} if disabled_components is None else {"disabled_components": disabled_components} + return self.client.post("/api/process", params=params, json={"content": {"text": self.TEXT}}) + + def _assert_success(self, response, expected_components): + self.assertEqual(response.status_code, 200, response.text) + result = response.json()["result"] + self.assertTrue(result["success"]) + self.assertEqual(self.executed, expected_components) + self.assertEqual(result["relations"], self.RELATIONS if "rel_cat.relations" in expected_components else []) + + def _assert_pipeline_unchanged(self): + self.assertIs(self.processor.cat, self.cat) + self.assertIs(self.cat.pipe, self.pipeline) + self.assertIs(self.pipeline._components, self.original_components) + self.assertIs(self.pipeline._addons, self.original_addons) + self.assertEqual(self.pipeline._components, [self.ner]) + self.assertEqual(self.pipeline._addons, [self.status, self.subject, self.rel_cat]) + + def _set_environment_default(self, value): + with patch.dict(os.environ, {"APP_DISABLED_COMPONENTS": value}): + self.processor.service_settings = Settings() + + def test_skipping_component_does_not_affect_next_request(self): + self._assert_success(self._request("rel_cat"), self.ALL_COMPONENTS[:-1]) + self.rel_cat.assert_not_called() + self._assert_pipeline_unchanged() + + self._assert_success(self._request(), self.ALL_COMPONENTS) + self.rel_cat.assert_called_once() + self._assert_pipeline_unchanged() + + def test_failed_request_does_not_affect_next_request(self): + normal_inference = self.ner.side_effect + self.ner.side_effect = RuntimeError("inference failed") + with self.assertLogs("API", "ERROR"): + response = self._request("rel_cat") + self.assertEqual(response.status_code, 500) + self.rel_cat.assert_not_called() + self._assert_pipeline_unchanged() + + self.ner.side_effect = normal_inference + self._assert_success(self._request(), self.ALL_COMPONENTS) + self.rel_cat.assert_called_once() + self._assert_pipeline_unchanged() + + def test_omitted_parameter_uses_environment_default(self): + self._set_environment_default("rel_cat") + + self._assert_success(self._request(), self.ALL_COMPONENTS[:-1]) + self.rel_cat.assert_not_called() + self._assert_pipeline_unchanged() + + def test_empty_parameter_overrides_environment_default_for_one_request(self): + self._set_environment_default("rel_cat") + + self._assert_success(self._request(""), self.ALL_COMPONENTS) + self._assert_success(self._request(), self.ALL_COMPONENTS[:-1]) + self.assertEqual(self.processor.service_settings.disabled_components, ("rel_cat",)) + self.rel_cat.assert_called_once() + self._assert_pipeline_unchanged() + + def test_explicit_selection_replaces_environment_default_for_one_request(self): + self._set_environment_default("rel_cat") + + self._assert_success(self._request("meta_cat"), ["ner:dictionary", "rel_cat.relations"]) + self._assert_success(self._request(), self.ALL_COMPONENTS[:-1]) + self.assertEqual(self.processor.service_settings.disabled_components, ("rel_cat",)) + self._assert_pipeline_unchanged() + + def test_component_names_types_and_full_names_are_case_insensitive(self): + for name, skipped in ( + ("ner", ["ner:dictionary"]), + ("DICTIONARY", ["ner:dictionary"]), + ("ner:dictionary", ["ner:dictionary"]), + ("rel_cat", ["rel_cat.relations"]), + ("relations", ["rel_cat.relations"]), + (" REL_CAT.RELATIONS ", ["rel_cat.relations"]), + ("meta_cat", ["meta_cat.Status", "meta_cat.Subject"]), + ("status", ["meta_cat.Status"]), + ("META_CAT.STATUS", ["meta_cat.Status"]), + ): + with self.subTest(name=name): + expected = [component for component in self.ALL_COMPONENTS if component not in skipped] + self._assert_success(self._request(name), expected) + self._assert_pipeline_unchanged() + + def test_multiple_names_ignore_whitespace_and_empty_entries(self): + self._assert_success(self._request(" rel_cat, , META_CAT.STATUS, "), ["ner:dictionary", "meta_cat.Subject"]) + self.status.assert_not_called() + self.rel_cat.assert_not_called() + + def test_unknown_name_warns_without_disabling_other_components(self): + with self.assertLogs(self.processor.log, "WARNING") as logs: + response = self._request("does_not_exist,rel_cat") + + self._assert_success(response, self.ALL_COMPONENTS[:-1]) + self.assertEqual(len(logs.records), 1) + self.assertIn("does_not_exist", logs.records[0].getMessage()) + self.assertNotIn("rel_cat", logs.records[0].getMessage()) + self._assert_pipeline_unchanged() + + def test_filtered_pipeline_logs_usage_once_when_enabled(self): + self.cat.usage_monitor.should_monitor = True + + self._assert_success(self._request("rel_cat"), self.ALL_COMPONENTS[:-1]) + self.cat.usage_monitor.log_inference.assert_called_once_with(len(self.TEXT), 0) + + def test_filtered_pipeline_does_not_log_usage_when_disabled(self): + self._assert_success(self._request("rel_cat"), self.ALL_COMPONENTS[:-1]) + self.cat.usage_monitor.log_inference.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/medcat-service/medcat_service/types.py b/medcat-service/medcat_service/types.py index 55beecb42..c616fb4c6 100644 --- a/medcat-service/medcat_service/types.py +++ b/medcat-service/medcat_service/types.py @@ -80,6 +80,8 @@ class ProcessResult(BaseModel): """ # e.g. [{"0": {...}}, {"1": {...}}] """ + + relations: list[dict[str, Any]] = Field(default_factory=list) success: bool timestamp: str elapsed_time: float diff --git a/medcat-service/scripts/download_medmen.sh b/medcat-service/scripts/download_medmen.sh index 4ea1d1c1f..e048722fd 100644 --- a/medcat-service/scripts/download_medmen.sh +++ b/medcat-service/scripts/download_medmen.sh @@ -7,4 +7,25 @@ export MODEL_CDB_URL=https://cogstack-medcat-example-models.s3.eu-west-2.amazona export MODEL_META_URL=https://cogstack-medcat-example-models.s3.eu-west-2.amazonaws.com/medcat-example-models/mc_status.zip SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -python ${SCRIPT_DIR}/download_model.py \ No newline at end of file +VENV_PYTHON="${SCRIPT_DIR}/../.venv/bin/python" + +if [[ -x "${VENV_PYTHON}" ]]; then + PYTHON_EXECUTABLE="${VENV_PYTHON}" +else + PYTHON_EXECUTABLE="" + + for PYTHON_CANDIDATE in python3.14 python3.13 python3.12 python3.11 python3; do + if command -v "${PYTHON_CANDIDATE}" >/dev/null 2>&1; then + PYTHON_EXECUTABLE="$(command -v "${PYTHON_CANDIDATE}")" + break + fi + done + + if [[ -z "${PYTHON_EXECUTABLE}" ]]; then + echo "Python 3 is required. Create .venv or install Python 3.11 through 3.14." >&2 + exit 1 + fi +fi + +echo "Using $("${PYTHON_EXECUTABLE}" --version 2>&1) (${PYTHON_EXECUTABLE})" +"${PYTHON_EXECUTABLE}" "${SCRIPT_DIR}/download_model.py" diff --git a/medcat-service/start_service_debug.sh b/medcat-service/start_service_debug.sh index f975ac382..f1a26dcf7 100644 --- a/medcat-service/start_service_debug.sh +++ b/medcat-service/start_service_debug.sh @@ -5,6 +5,14 @@ echo "Starting MedCAT Service" # export APP_MEDCAT_MODEL_PACK="models/examples/example-deid-model-pack.zip" # export DEID_MODE=True # export DEID_REDACT=True +# + +set -a + +source ./env/app.env +source ./env/medcat.env + +set +a if [ -z "${APP_MODEL_CDB_PATH}" ] && [ -z "${APP_MODEL_VOCAB_PATH}" ] && [ -z "${APP_MEDCAT_MODEL_PACK}" ]; then export APP_MEDCAT_MODEL_PACK="models/examples/example-medcat-v2-model-pack.zip" diff --git a/medcat-tutorials/.ci/patch_notebook_installs.py b/medcat-tutorials/.ci/patch_notebook_installs.py index 545751192..6aef8a8b5 100644 --- a/medcat-tutorials/.ci/patch_notebook_installs.py +++ b/medcat-tutorials/.ci/patch_notebook_installs.py @@ -5,9 +5,6 @@ import argparse -rel_install_path = "../medcat/" -abs_install_path = str(pathlib.Path(rel_install_path).resolve()) - # Matches either: # 1. `! pip install medcat[extras]~=version` # 2. `! pip install medcat[extras] @ git+...` @@ -30,8 +27,9 @@ def repl_nb(m, file_path: pathlib.Path): - extras = m[3] or "" - to_write = f'! pip install \\"{abs_install_path}{extras}\\"' + # CI installs the checkout and all tutorial extras before running nbmake. + # Reinstalling from parallel notebooks races on their shared site-packages. + to_write = '# MedCAT is already installed by the CI workflow.' print(f"[PATCHED] {file_path}\n with: '{to_write}'") return to_write diff --git a/medcat-tutorials/notebooks/introductory/relcat/1._Supervised_Training_Relation_Extraction.ipynb b/medcat-tutorials/notebooks/introductory/relcat/1._Supervised_Training_Relation_Extraction.ipynb index f167ca639..fc95bafc4 100644 --- a/medcat-tutorials/notebooks/introductory/relcat/1._Supervised_Training_Relation_Extraction.ipynb +++ b/medcat-tutorials/notebooks/introductory/relcat/1._Supervised_Training_Relation_Extraction.ipynb @@ -101,7 +101,7 @@ ], "source": [ "# Install medcat\n", - "! pip install \"medcat[spacy,rel-cat,meta-cat]~=2.4.0\" # NOTE: VERSION-STRING" + "! pip install \"medcat[spacy,rel-cat,meta-cat]~=2.9.0\" # NOTE: VERSION-STRING" ] }, { @@ -475,7 +475,9 @@ "cell_type": "code", "execution_count": 17, "id": "1fc17a58", - "metadata": {}, + "metadata": { + "lines_to_next_cell": 2 + }, "outputs": [ { "name": "stderr", @@ -828,7 +830,7 @@ "source": [ "relCAT.train(train_csv_path=\"./data/rel_cat_ADE_V2.tsv\", checkpoint_path=\"./ade_relcat_model\")\n", "\n", - "# for MedCAT Trainer Exports, use the export_path argument : relCAT.train(export_data_path=\"./data/MedCAT_Export_relation_extraction.json\")\n" + "# for MedCAT Trainer Exports, use the export_path argument : relCAT.train(export_data_path=\"./data/MedCAT_Export_relation_extraction.json\")" ] }, { diff --git a/medcat-tutorials/notebooks/introductory/relcat/2._Infering_relations_from_annotations_with_Relation_toolkit.ipynb b/medcat-tutorials/notebooks/introductory/relcat/2._Infering_relations_from_annotations_with_Relation_toolkit.ipynb index 24e961ceb..8619bf9dd 100644 --- a/medcat-tutorials/notebooks/introductory/relcat/2._Infering_relations_from_annotations_with_Relation_toolkit.ipynb +++ b/medcat-tutorials/notebooks/introductory/relcat/2._Infering_relations_from_annotations_with_Relation_toolkit.ipynb @@ -92,7 +92,7 @@ ], "source": [ "# Install medcat\n", - "! pip install \"medcat[spacy,rel-cat]~=2.4.0\" # NOTE: VERSION-STRING" + "! pip install \"medcat[spacy,rel-cat]~=2.9.0\" # NOTE: VERSION-STRING" ] }, { diff --git a/medcat-tutorials/requirements.txt b/medcat-tutorials/requirements.txt index ac2ba74e1..05fc5085f 100644 --- a/medcat-tutorials/requirements.txt +++ b/medcat-tutorials/requirements.txt @@ -3,3 +3,4 @@ ipykernel pytest-xdist~=3.6.0 nbmake<1.6 nbconvert<8 +pandas diff --git a/medcat/medcat/cat.py b/medcat/medcat/cat.py index b45f04a9d..8bf6be818 100644 --- a/medcat/medcat/cat.py +++ b/medcat/medcat/cat.py @@ -640,7 +640,8 @@ def _doc_to_out(self, out_with_text: bool = False ) -> Union[Entities, OnlyCUIEntities]: out: Union[Entities, OnlyCUIEntities] = {'entities': {}, - 'tokens': []} # type: ignore + 'tokens': [], + 'relations': []} # type: ignore cnf_annotation_output = self.config.annotation_output _ents = doc.linked_ents @@ -652,8 +653,13 @@ def _doc_to_out(self, for _, ent in enumerate(_ents): ent_id, ent_dict = self._doc_to_out_entity(ent, doc_tokens, only_cui) + # NOTE: the types match - not sure why mypy is having issues out['entities'][ent_id] = ent_dict # type: ignore + + out["relations"] = [] + if "relations" in doc.get_available_addon_paths(): + out['relations'] = doc.get_addon_data("relations") # type: ignore if cnf_annotation_output.include_text_in_output or out_with_text: out['text'] = doc.base.text diff --git a/medcat/medcat/components/addons/relation_extraction/rel_cat.py b/medcat/medcat/components/addons/relation_extraction/rel_cat.py index a960f3aee..002b0f057 100644 --- a/medcat/medcat/components/addons/relation_extraction/rel_cat.py +++ b/medcat/medcat/components/addons/relation_extraction/rel_cat.py @@ -83,6 +83,11 @@ def load_existing(cls, cnf: ConfigRelCAT, # set the correct base tokenizer and redo data paths rc.base_tokenizer = base_tokenizer rc._init_data_paths() + + # make sure not to override cdb from RelCAT with CAT one... + if cdb is not None: + if rc.cdb is None or not rc.cdb.cui2info: + rc.cdb = cdb return cls(cnf, rc) def serialise_to(self, folder_path: str) -> None: @@ -166,7 +171,7 @@ def deserialise_from(cls, folder_path: str, **init_kwargs load_path=folder_path, cnf=cnf, base_tokenizer=tokenizer, - cdb=None) + cdb=init_kwargs.get("cdb")) def get_strategy(self) -> SerialisingStrategy: return SerialisingStrategy.MANUAL @@ -904,6 +909,7 @@ def pipe(self, stream: Iterable[MutableDocument], *args, **kwargs "relations") out_rels = predict_rel_dataset.dataset[ "output_relations"][rel_idx] + relations.append( { "relation": rc_cnf.general.idx2labels[ @@ -919,6 +925,10 @@ def pipe(self, stream: Iterable[MutableDocument], *args, **kwargs "end_ent2_char_pos": out_rels[21], "start_entity_id": out_rels[8], "end_entity_id": out_rels[9], + "ent1_types": out_rels[6], + "ent2_types": out_rels[7], + "ent1_cui": out_rels[10], + "ent2_cui": out_rels[11] }) pbar.update(len(token_ids)) pbar.close() diff --git a/medcat/medcat/components/addons/relation_extraction/rel_dataset.py b/medcat/medcat/components/addons/relation_extraction/rel_dataset.py index 6130f34f3..0c42b0e22 100644 --- a/medcat/medcat/components/addons/relation_extraction/rel_dataset.py +++ b/medcat/medcat/components/addons/relation_extraction/rel_dataset.py @@ -205,6 +205,12 @@ def _create_relation_validation(self, ent2_token_end_pos: int = -1, is_spacy_doc: bool = False, is_mct_export: bool = False, + ent1_id: int = -1, + ent2_id: int = -1, + ent1_type: list[str] = [], + ent2_type: list[str] = [], + ent1_cui: Union[str, int] = -1, + ent2_cui: Union[str, int] = -1 ) -> list: """ This function checks if the relation is actually valid by distance @@ -345,7 +351,7 @@ def _create_relation_validation(self, assert _ent1_token_end_pos assert _ent2_token_end_pos except Exception as exception: - logger.error( + logger.info( "document id : %s failed to process relation", str(doc_id), exc_info=exception) return [] @@ -371,7 +377,8 @@ def _create_relation_validation(self, return [window_tokenizer_data["input_ids"], ent1_ent2_new_start, ent1_token, ent2_token, "UNK", self.config.model.padding_idx, - None, None, None, None, None, None, doc_id, "", + ent1_type, ent2_type, ent1_id, ent2_id, + ent1_cui, ent2_cui, doc_id, "", s1_start, e1_end, s2_start, e2_end, ent1_start_char_pos, ent1_end_char_pos, ent2_start_char_pos, ent2_end_char_pos] @@ -390,7 +397,7 @@ def _get_token_type_and_start_end( start_char_pos = entity.base.start_char_index end_char_pos = entity.base.end_char_index - + token_start_pos = [ i for i in range(0, doc_length_tokens) if start_char_pos in range( @@ -409,7 +416,8 @@ def _create_base_relation_for_ents( self, doc_text: str, doc_id: str, ent1_token: MutableEntity, ent2_token: MutableEntity, tokenized_text_data: dict[str, Any], - chars_to_exclude: str, doc_length_tokens: int + chars_to_exclude: str, + doc_length_tokens: int ) -> Optional[list]: tmp_ent1 = ent1_token @@ -432,6 +440,10 @@ def _create_base_relation_for_ents( ent2_token_end_pos)) = self._get_token_type_and_start_end( ent2_token, doc_length_tokens, tokenized_text_data) + ent1_id = ent1_token.id + ent2_id = ent2_token.id + + tkn1_str = str(ent1_token) tkn2_str = str(ent2_token) if (tkn2_str not in chars_to_exclude and @@ -457,6 +469,12 @@ def _create_base_relation_for_ents( ent2_token_start_pos=ent2_token_start_pos, ent1_token_end_pos=ent1_token_end_pos, ent2_token_end_pos=ent2_token_end_pos, + ent1_id=ent1_id, + ent2_id=ent2_id, + ent1_type=ent1_types, + ent2_type=ent2_types, + ent1_cui=ent1_token.cui, + ent2_cui=ent2_token.cui, is_spacy_doc=True )) else: @@ -473,6 +491,12 @@ def _create_base_relation_for_ents( ent2_token_start_pos=ent2_token_start_pos, ent1_token_end_pos=ent1_token_end_pos, ent2_token_end_pos=ent2_token_end_pos, + ent1_id=ent1_id, + ent2_id=ent2_id, + ent1_type=ent1_types, + ent2_type=ent2_types, + ent2_cui=ent2_token.cui, + ent1_cui=ent1_token.cui, is_spacy_doc=True )) return None @@ -499,6 +523,7 @@ def _create_base_relations_from_mutable_doc( doc_length_tokens) if relation is not None: relation_instances.append(relation) + return relation_instances def create_base_relations_from_doc( @@ -553,7 +578,7 @@ def create_base_relations_from_doc( doc_text = doc.base.text tokenized_text_data = cast(dict[str, Any], - self.tokenizer(doc_text, truncation=False)) + self.tokenizer(doc_text, truncation=False)) doc_length_tokens = len(tokenized_text_data["tokens"]) @@ -612,7 +637,7 @@ def create_base_relations_from_doc( "labels2idx": {}, "idx2label": {} } - def _create_relations_for_doc( + def _create_relations_for_mcexport_doc( self, document: MedCATTrainerExportDocument, data: dict, ) -> list[list]: @@ -842,7 +867,7 @@ def create_relations_from_export(self, data: dict): for project in data["projects"]: for _doc_id, document in enumerate(project["documents"]): - relation_instances = self._create_relations_for_doc( + relation_instances = self._create_relations_for_mcexport_doc( document, data) output_relations.extend(relation_instances) @@ -859,8 +884,7 @@ def create_relations_from_export(self, data: dict): logger.info("MCT export dataset | nclasses: %d | idx2label: %s", nclasses, str(idx2label)) logger.info("Samples per class: ") - - logger.error(str(idx2label)) + logger.info(str(idx2label)) for label_num in list(idx2label.keys()): sample_count = 0 diff --git a/medcat/medcat/data/entities.py b/medcat/medcat/data/entities.py index d2c299aaf..d40c27591 100644 --- a/medcat/medcat/data/entities.py +++ b/medcat/medcat/data/entities.py @@ -1,4 +1,4 @@ -from typing import TypedDict, Optional +from typing import TypedDict, Optional, Any class MetaAnnotation(TypedDict): @@ -34,10 +34,12 @@ class Entities(TypedDict): # is defined because the keys in entities are dynamic and can change. entities: dict[int, Entity] tokens: list[str] # TODO - do we need this + relations: list[dict[str, Any]] text: Optional[str] class OnlyCUIEntities(TypedDict): entities: dict[int, str] tokens: list[str] # TODO - do we need this + relations: list[dict[str, Any]] text: Optional[str] diff --git a/medcat/medcat/pipeline/pipeline.py b/medcat/medcat/pipeline/pipeline.py index e22423580..30cc3c28c 100644 --- a/medcat/medcat/pipeline/pipeline.py +++ b/medcat/medcat/pipeline/pipeline.py @@ -377,7 +377,11 @@ def pipe_until( "instead of the document." ) for addon in self._addons: - doc = addon(doc) + try: + doc = addon(doc) + except Exception: + logger.exception("Addon error %s", addon.full_name) + if doc is None: raise IncorrectAddonComponent( f"Addon component {addon.full_name} returned None "