diff --git a/medcat-service/README.md b/medcat-service/README.md index 4b726ae0f..791d58ae3 100644 --- a/medcat-service/README.md +++ b/medcat-service/README.md @@ -329,6 +329,8 @@ The following environment variables are available for tailoring the MedCAT Servi - `APP_BULK_NPROC` - the number of threads used in bulk processing (default: `8`), - `APP_MEDCAT_MODEL_PACK` - MedCAT Model Pack path, if this parameter has a value IT WILL BE LOADED FIRST OVER EVERYTHING ELSE (CDB, Vocab, MetaCATs, etc.) declared above. - `APP_ENABLE_METRICS` - Enable prometheus metrics collection served on the path /metrics +- `APP_ENABLE_DEMO_UI` - Enable the demo user interface to try models. (Default: `False`) +- `APP_DEMO_UI_PATH` - Customise the path of the demo UI. (Default: `/`) ### Shared Memory (`DOCKER_SHM_SIZE`) diff --git a/medcat-service/medcat_service/config.py b/medcat-service/medcat_service/config.py index febb84469..fed350dfd 100644 --- a/medcat-service/medcat_service/config.py +++ b/medcat-service/medcat_service/config.py @@ -52,6 +52,9 @@ class Settings(BaseSettings): description="Enable DEID redaction. Returns text like [***] instead of [ANNOTATION]", ) + 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") + # Model paths model_cdb_path: str | None = Field("/cat/models/medmen/cdb.dat", alias="APP_MODEL_CDB_PATH") model_vocab_path: str | None = Field("/cat/models/medmen/vocab.dat", alias="APP_MODEL_VOCAB_PATH") diff --git a/medcat-service/medcat_service/demo/demo_logic.py b/medcat-service/medcat_service/demo/demo_logic.py index 050dd66b7..c25ef3d37 100644 --- a/medcat-service/medcat_service/demo/demo_logic.py +++ b/medcat-service/medcat_service/demo/demo_logic.py @@ -7,7 +7,9 @@ """ import logging +from typing import Any +from opentelemetry import trace from pydantic import BaseModel from medcat_service.dependencies import get_medcat_processor, get_settings @@ -15,6 +17,7 @@ from medcat_service.types_entities import Entity logger = logging.getLogger(__name__) +tracer = trace.get_tracer("medcat_service") class EntityAnnotation(BaseModel): @@ -108,7 +111,9 @@ def convert_display_model_to_list_of_lists(entity_display_model: list[EntityAnno ] -def perform_named_entity_resolution(input_text: str, redact: bool | None = None): +def perform_named_entity_resolution( + input_text: str, redact: bool | None = None +) -> tuple[dict[str, Any], list[list[str]], str]: """ Performs clinical coding by processing the input text with MedCAT to extract and annotate medical concepts (entities). @@ -135,7 +140,7 @@ def perform_named_entity_resolution(input_text: str, redact: bool | None = None) """ logger.debug("Performing named entity resolution") if not input_text or not input_text.strip(): - return None, None, None + return {}, [], "" processor = get_medcat_processor(get_settings()) input = ProcessAPIInputContent(text=input_text) @@ -160,7 +165,8 @@ def perform_named_entity_resolution(input_text: str, redact: bool | None = None) return response_tuple -def medcat_demo_perform_named_entity_resolution(input_text: str): +@tracer.start_as_current_span("medcat_demo_perform_named_entity_resolution") +def medcat_demo_perform_named_entity_resolution(input_text: str) -> tuple[dict[str, Any], list[list[str]]]: """ Performs named entity resolution for the MedCAT demo. """ @@ -168,7 +174,8 @@ def medcat_demo_perform_named_entity_resolution(input_text: str): return result[0], result[1] -def anoncat_demo_perform_deidentification(input_text: str, redact: bool): +@tracer.start_as_current_span("anoncat_demo_perform_deidentification") +def anoncat_demo_perform_deidentification(input_text: str, redact: bool) -> tuple[dict[str, Any], list[list[str]], str]: """ Performs deidentification for the AnonCAT demo. """ diff --git a/medcat-service/medcat_service/demo/gradio_demo.py b/medcat-service/medcat_service/demo/gradio_demo.py index eb0da47d7..5daefdf55 100644 --- a/medcat-service/medcat_service/demo/gradio_demo.py +++ b/medcat-service/medcat_service/demo/gradio_demo.py @@ -1,4 +1,6 @@ import gradio as gr +import pandas as pd +from fastapi import FastAPI import medcat_service.demo.demo_content as demo_content from medcat_service.demo.demo_logic import ( @@ -22,7 +24,7 @@ annotation_details_placeholder_text = "Click on a highlighted entity to view its details" -def format_annotation_details(row, selected_text: str): +def format_annotation_details(row: pd.Series | None, selected_text: str) -> str: """Format a pandas Series row as markdown for display.""" if row is None: return "**No annotation selected**\n\nClick on a highlighted entity to view its details." @@ -52,7 +54,7 @@ def format_annotation_details(row, selected_text: str): return details -def on_select(value, annotation_details, dataframe, evt: gr.SelectData): +def on_select_annotation(value, annotation_details: str, dataframe: pd.DataFrame, evt: gr.SelectData) -> str: """ On select of annotations in the highlighted text component. @@ -71,42 +73,54 @@ def on_select(value, annotation_details, dataframe, evt: gr.SelectData): return annotation_details_placeholder_text -if settings.deid_mode: - with gr.Blocks(title="AnonCAT Demo", fill_width=True) as io: - gr.Markdown("# AnonCAT Demo") +def output_details_interface() -> tuple[gr.HighlightedText, gr.Markdown, gr.Dataframe]: + """ + Output details interface for the demo. + Based on gradio Namd-Entity Recognition Demo + https://www.gradio.app/guides/named-entity-recognition + """ + highlighted = gr.HighlightedText(label="Processed Text", elem_id="highlighted-text-output", interactive=False) + annotation_details = gr.Markdown(label="Annotation Details", value=annotation_details_placeholder_text) + with gr.Accordion(label="All Annotations", open=False): + dataframe = gr.Dataframe(label="All Annotations", headers=headers, interactive=False, max_chars=50) + + highlighted.select(on_select_annotation, [highlighted, annotation_details, dataframe], outputs=annotation_details) + return highlighted, annotation_details, dataframe + + +def anoncat_demo_interface() -> gr.Blocks: + def input_column(): + with gr.Tab("Input"): + with gr.Group(): + # Using a tab here just to make the input text box align with the output that is also tabbed + input_text = gr.Textbox( + label="Input Text", lines=3, placeholder="Enter some text and click Deidentify..." + ) + redact = gr.Checkbox(label="Redact", info="Replace sensitive information with ****") + examples = gr.Examples( # noqa + examples=[demo_content.short_example, demo_content.anoncat_example], + inputs=input_text, + example_labels=["Short Example", "Note with personally identifiable information"], + ) + with gr.Row(): + clear_btn = gr.Button("Clear", variant="secondary") + annotate_btn = gr.Button("Deidentify", variant="primary") + return input_text, redact, clear_btn, annotate_btn + + def output_column(): + with gr.Tab("Deidentification"): + deidentified_text = gr.Textbox(label="Deidentified Text", value="", lines=3, interactive=False) + with gr.Tab("Details"): + highlighted, annotation_details, dataframe = output_details_interface() + return highlighted, dataframe, deidentified_text, annotation_details + + with gr.Blocks(title="AnonCAT", fill_width=True) as io: + gr.Markdown("# AnonCAT") with gr.Row(): with gr.Column(): # noqa - with gr.Tab("Input"): - input_text = gr.Textbox( - label="Input Text", lines=3, placeholder="Enter some text and click Deidentify..." - ) - examples = gr.Examples( - examples=[demo_content.short_example, demo_content.anoncat_example], - inputs=input_text, - example_labels=["Short Example", "Note with personally identifiable information"], - ) - redact = gr.Checkbox(label="Redact") - with gr.Row(): - clear_btn = gr.Button("Clear", variant="secondary") - annotate_btn = gr.Button("Deidentify", variant="primary") - + input_text, redact, clear_btn, annotate_btn = input_column() with gr.Column(): - with gr.Tab("Deidentification"): - deidentified_text = gr.Textbox(label="Deidentified Text", value="", interactive=False) - with gr.Tab("Details"): - highlighted = gr.HighlightedText( - label="Processed Text", elem_id="highlighted-text-output", interactive=False - ) - annotation_details = gr.Markdown( - label="Annotation Details", value=annotation_details_placeholder_text - ) - with gr.Accordion(label="All Annotations", open=False): - dataframe = gr.Dataframe( - label="All Annotations", headers=headers, interactive=False, max_chars=50 - ) - - highlighted.select(on_select, [highlighted, annotation_details, dataframe], outputs=annotation_details) - + highlighted, dataframe, deidentified_text, annotation_details = output_column() annotate_btn.click( anoncat_demo_perform_deidentification, inputs=[input_text, redact], @@ -119,35 +133,34 @@ def on_select(value, annotation_details, dataframe, evt: gr.SelectData): outputs=[input_text, highlighted, dataframe, annotation_details], ) gr.Markdown(demo_content.anoncat_help_content) -else: - with gr.Blocks(title="MedCAT Demo", fill_width=True) as io: - gr.Markdown("# MedCAT Demo") + return io + + +def medcat_demo_interface() -> gr.Blocks: + def input_column(): + input_text = gr.Textbox(label="Input Text", lines=6, placeholder="Enter some text and click Annotate...") + with gr.Row(): + examples = gr.Examples( # noqa + examples=[demo_content.short_example, demo_content.long_example, demo_content.anoncat_example], + inputs=input_text, + example_labels=[ + "Short Example", + "Patient Discharge Summary in Neurology", + "Note with personally identifiable information", + ], + ) + with gr.Row(): + clear_btn = gr.Button("Clear", variant="secondary") + annotate_btn = gr.Button("Annotate", variant="primary") + return input_text, clear_btn, annotate_btn + + with gr.Blocks(title="MedCAT", fill_width=True) as io: + gr.Markdown("# MedCAT") with gr.Row(): with gr.Column(): - input_text = gr.Textbox( - label="Input Text", lines=6, placeholder="Enter some text and click Annotate..." - ) - with gr.Row(): - examples = gr.Examples( - examples=[demo_content.short_example, demo_content.long_example, demo_content.anoncat_example], - inputs=input_text, - example_labels=[ - "Short Example", - "Patient Discharge Summary in Neurology", - "Note with personally identifiable information", - ], - ) - with gr.Row(): - clear_btn = gr.Button("Clear", variant="secondary") - annotate_btn = gr.Button("Annotate", variant="primary") + input_text, clear_btn, annotate_btn = input_column() with gr.Column(): - highlighted = gr.HighlightedText( - label="Processed Text", elem_id="highlighted-text-output", interactive=False - ) - annotation_details = gr.Markdown(label="Annotation Details", value=annotation_details_placeholder_text) - with gr.Accordion(label="All Annotations", open=False): - dataframe = gr.Dataframe(label="All Annotations", headers=headers, interactive=False, max_chars=50) - highlighted.select(on_select, [highlighted, annotation_details, dataframe], outputs=annotation_details) + highlighted, annotation_details, dataframe = output_details_interface() annotate_btn.click(lambda: (annotation_details_placeholder_text), outputs=[annotation_details]) annotate_btn.click( @@ -159,9 +172,10 @@ def on_select(value, annotation_details, dataframe, evt: gr.SelectData): outputs=[input_text, highlighted, dataframe, annotation_details], ) gr.Markdown(demo_content.article_footer) + return io -def mount_gradio_app(app, path: str = "/demo") -> None: +def mount_gradio_app(app: FastAPI, path: str) -> None: """ Mount the Gradio interface to the FastAPI app with a custom theme. @@ -170,4 +184,7 @@ def mount_gradio_app(app, path: str = "/demo") -> None: path: The path at which to mount the Gradio app (default: "/demo") """ theme = gr.themes.Default(primary_hue="blue", secondary_hue="teal") + + io = anoncat_demo_interface() if settings.deid_mode else medcat_demo_interface() + gr.mount_gradio_app(app, io, path=path, theme=theme, css=highlighted_text_css) diff --git a/medcat-service/medcat_service/main.py b/medcat-service/medcat_service/main.py index 1a260f000..d6318e745 100644 --- a/medcat-service/medcat_service/main.py +++ b/medcat-service/medcat_service/main.py @@ -36,7 +36,9 @@ app.include_router(health.router) app.include_router(process.router) -mount_gradio_app(app, path="/demo") + +if settings.enable_demo_ui: + mount_gradio_app(app, path=settings.demo_ui_path) def configure_observability(settings: Settings, app: FastAPI): 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 4a55be15c..959570923 100644 --- a/medcat-service/medcat_service/test/demo/test_demo_logic.py +++ b/medcat-service/medcat_service/test/demo/test_demo_logic.py @@ -97,10 +97,10 @@ def test_perform_named_entity_resolution_with_empty_string(self, mock_get_proces # Execute result_dict, result_table, result_text = perform_named_entity_resolution("") - # Assert - self.assertIsNone(result_dict) - self.assertIsNone(result_table) - self.assertIsNone(result_text) + # Assert - should return empty objects, not None + self.assertEqual(result_dict, {}) + self.assertEqual(result_table, []) + self.assertEqual(result_text, "") @patch("medcat_service.demo.demo_logic.get_settings") @patch("medcat_service.demo.demo_logic.get_medcat_processor") @@ -113,10 +113,10 @@ def test_perform_named_entity_resolution_with_whitespace_only(self, mock_get_pro # Execute result_dict, result_table, result_text = perform_named_entity_resolution(" \n\t ") - # Assert - self.assertIsNone(result_dict) - self.assertIsNone(result_table) - self.assertIsNone(result_text) + # Assert - should return empty objects, not None + self.assertEqual(result_dict, {}) + self.assertEqual(result_table, []) + self.assertEqual(result_text, "") @patch("medcat_service.demo.demo_logic.get_settings") @patch("medcat_service.demo.demo_logic.get_medcat_processor") diff --git a/medcat-service/start_service_debug.sh b/medcat-service/start_service_debug.sh index 798d66d3f..f975ac382 100644 --- a/medcat-service/start_service_debug.sh +++ b/medcat-service/start_service_debug.sh @@ -12,6 +12,7 @@ if [ -z "${APP_MODEL_CDB_PATH}" ] && [ -z "${APP_MODEL_VOCAB_PATH}" ] && [ -z "$ fi export APP_ENABLE_METRICS=${APP_ENABLE_METRICS:-True} +export APP_ENABLE_DEMO_UI=${APP_ENABLE_DEMO_UI:-True} if [ "${HOT_MODULE_RELOADING}" = "True" ]; then # Experimental: Hot module reloading. Need to `pip install -r requirements-dev.txt`