lirony commited on
Commit
352a4b6
·
0 Parent(s):

Initial commit

Browse files
.dockerignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__/
2
+ env.list
.gitattributes ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ static/images/*.jpg filter=lfs diff=lfs merge=lfs -text
37
+ static/images/*.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ env.list
2
+ __pycache__
3
+ **/__pycache__
4
+ langextract/__pycache__
5
+ .git
Dockerfile ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ FROM python:3.10-slim
16
+
17
+ # Set an environment variable for unbuffered python output (good for logging)
18
+ # and define the cache directory path
19
+ ENV PYTHONUNBUFFERED=1
20
+ ENV CACHE_DIR=/data/cache
21
+
22
+ # Install system dependencies first, as they change less frequently
23
+ RUN apt-get update && \
24
+ apt-get install -y fonts-ocr-a fonts-ocr-b unzip --no-install-recommends && \
25
+ rm -rf /var/lib/apt/lists/*
26
+
27
+ # Set up a new user named "user" with user ID 1000
28
+ # -m creates the home directory /home/user
29
+ # -s /bin/bash sets a default shell (good practice for debugging or execing into the container)
30
+ RUN useradd -m -s /bin/bash -u 1000 user
31
+
32
+ WORKDIR /app
33
+
34
+ COPY --chown=user:user . .
35
+
36
+ RUN pip install --no-cache-dir -r requirements.txt
37
+
38
+ # Run tests after installing dependencies
39
+ RUN python -m unittest discover tests
40
+
41
+ RUN mkdir -p $CACHE_DIR
42
+ RUN chmod -R 777 $CACHE_DIR
43
+ RUN unzip -o ./default_cache/radexplain-cache.zip -d $CACHE_DIR
44
+
45
+ USER user
46
+
47
+ EXPOSE 7860
48
+
49
+ CMD ["gunicorn", \
50
+ "--bind", "0.0.0.0:7860", \
51
+ "--timeout", "600", \
52
+ "--worker-class", "gthread", \
53
+ "--workers", "1", \
54
+ "--threads", "4", \
55
+ "--preload", \
56
+ "app:app"]
README.md ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: MedGemma - Radiology Explainer Demo
3
+ emoji: 🩺
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: apache-2.0
10
+ short_description: Radiology Image & Report Explainer Demo - Buit with MedGemma
11
+ models:
12
+ - google/medgemma-4b-it
13
+ secrets:
14
+ - HF_TOKEN
15
+ ---
16
+
17
+ # Radiology Image & Report Explainer Demo - Buit with MedGemma
18
+
19
+ Consider an educational scenario where interacting with a radiology image can
20
+ substantially improve learning. This demonstration shows how MedGemma might be built upon to provide a useful tool for exploring radiology images and associated reports by translating them into simple language, with visual cues to highlight the relevant areas of the image.
21
+
22
+ Powered by AI (MedGemma-4B Multimodel), this space analyzes both a sample radiology report and its corresponding Chest X-Ray/CT image. Click on any sentence in the report, and you'll receive an AI-generated explanation tailored to that specific text and visual context. When relevant, the explanation will also pinpoint the corresponding area on the X-ray/CT image.
23
+
24
+ This demonstration is for illustrative purposes only and does not represent a finished or approved product. It is not representative of compliance to any harmonized regulations or standards for quality, safety or efficacy. Any real-world application would require additional development, training, and adaptation. The experience highlighted in this demo shows MedGemma's baseline capability for the displayed task and is intended to help developers and users explore possible applications and inspire further development.
25
+
26
+ **Note:** This space uses a HuggingFace endpoint that may scale down to zero due to inactivity. If this occurs, please allow approximately 10 minutes for the endpoint to restart. As an alternative, the model can be deployed on ModelGarden (see the link below).
27
+
28
+ # Links
29
+ * MedGemma HuggingFace - https://huggingface.co/collections/google/medgemma-release-680aade845f90bec6a3f60c4
30
+ * MedGemma DevSite - https://developers.google.com/health-ai-developer-foundations/medgemma
31
+ * MedGemma ModelGarden - https://console.cloud.google.com/vertex-ai/publishers/google/model-garden/medgemma
32
+ * HAI-DEF models - https://developers.google.com/health-ai-developer-foundations
app.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import os
16
+ import logging
17
+ import sys
18
+ from flask import Flask
19
+
20
+ # Import configurations and initializers first
21
+ import config # Use relative import assuming app.py is in the rad_explain package
22
+ import llm_client
23
+ from routes import main_bp
24
+ from cache_store import cache
25
+
26
+ def create_app():
27
+ """Creates and configures the Flask application."""
28
+ app = Flask(__name__, static_folder=config.STATIC_DIR)
29
+
30
+ # --- Configure Logging ---
31
+ # Basic config should be done before creating the app or registering blueprints
32
+ # if those modules rely on logging during import time.
33
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - [%(name)s] - %(message)s')
34
+ logger = logging.getLogger(__name__)
35
+
36
+ # --- Check Configuration and Initialize Services ---
37
+ if not config.HF_TOKEN:
38
+ logger.error("HF_TOKEN environment variable not set.")
39
+ sys.exit("Exiting: HF_TOKEN not set.")
40
+ if not config.MEDGEMMA_ENDPOINT_URL:
41
+ logger.error("MEDGEMMA_ENDPOINT_URL environment variable not set.")
42
+ sys.exit("Exiting: MEDGEMMA_ENDPOINT_URL not set.")
43
+ else:
44
+ logger.info(f"Using LLM API Base URL: {config.MEDGEMMA_ENDPOINT_URL}")
45
+
46
+ # Initialize LLM Client
47
+ llm_client.init_llm_client()
48
+ if not llm_client.is_initialized():
49
+ logger.warning("LLM client failed to initialize. API calls will fail.")
50
+ sys.exit("Exiting: LLM client initialization failed.")
51
+
52
+ # Register Blueprints
53
+ app.register_blueprint(main_bp)
54
+
55
+ return app
56
+
57
+ # Create the application instance using the factory function
58
+ # This makes the 'app' instance available at the module level for WSGI servers
59
+ app = create_app()
60
+
61
+ if __name__ == '__main__':
62
+ # This block now primarily focuses on running the app and pre-run checks/setup
63
+ logger = logging.getLogger(__name__)
64
+
65
+ # Run the Flask development server
66
+ app.run(host='0.0.0.0', port=7860, debug=True)
cache_store.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import diskcache
16
+ import os
17
+
18
+ # Use the CACHE_DIR environment variable, defaulting to /app/cache_dir if not set.
19
+ cache_directory = os.getenv('CACHE_DIR', '/app/cache_dir')
20
+ cache = diskcache.Cache(cache_directory)
config.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import os
16
+ from pathlib import Path
17
+ import csv # Import the csv module
18
+ import logging
19
+
20
+ # --- Configuration ---
21
+ # Configure basic logging (optional, adjust as needed)
22
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # API Configuration
26
+ HF_TOKEN = os.environ.get("HF_TOKEN", None)
27
+ MEDGEMMA_ENDPOINT_URL = os.environ.get("MEDGEMMA_ENDPOINT_URL", None)
28
+
29
+ # --- Paths using pathlib ---
30
+ # Determine the base directory of the application
31
+ BASE_DIR = Path(__file__).parent.resolve() # Use resolve() for absolute path
32
+ STATIC_DIR = BASE_DIR / 'static'
33
+
34
+ # --- Load available report/image pairs from CSV ---
35
+ AVAILABLE_REPORTS = []
36
+ MANIFEST_CSV_PATH = STATIC_DIR / 'reports_manifest.csv'
37
+
38
+ if MANIFEST_CSV_PATH.is_file():
39
+ try:
40
+ with open(MANIFEST_CSV_PATH, mode='r', encoding='utf-8') as csvfile:
41
+ reader = csv.DictReader(csvfile)
42
+ # Expected CSV headers: 'image_type', 'case_display_name', 'image_path', 'report_path'
43
+ required_headers = {'case_display_name', 'image_path', 'report_path'}
44
+ if not required_headers.issubset(reader.fieldnames):
45
+ logger.error(
46
+ f"CSV file {MANIFEST_CSV_PATH} is missing one or more required headers: {required_headers - set(reader.fieldnames)}"
47
+ )
48
+ else:
49
+ for row in reader:
50
+ case_name = row['case_display_name']
51
+ image_path_from_csv = row['image_path'] # e.g., static/images/report1.jpg
52
+ report_path_from_csv = row['report_path'] # e.g., static/reports/report1.txt or empty
53
+
54
+ # Validate image_path_from_csv (must not be empty)
55
+ if not image_path_from_csv:
56
+ logger.warning(f"Empty image_path in CSV for case '{case_name}'. Skipping this entry.")
57
+ continue
58
+
59
+ # Construct absolute path for image file validation (paths from CSV are relative to BASE_DIR)
60
+ abs_image_path_to_check = BASE_DIR / image_path_from_csv
61
+ if not abs_image_path_to_check.is_file():
62
+ logger.warning(f"Image file not found for case '{case_name}' at '{abs_image_path_to_check}'. Skipping this entry.")
63
+ continue
64
+
65
+
66
+ image_file_for_config = image_path_from_csv
67
+
68
+ report_file_for_config = "" # Default to empty if no report or error
69
+ if report_path_from_csv: # Report path is optional
70
+ # Construct absolute path for report file validation (paths from CSV are relative to BASE_DIR)
71
+ abs_report_path_to_check = BASE_DIR / report_path_from_csv
72
+ if not abs_report_path_to_check.is_file():
73
+ logger.warning(
74
+ f"Report file specified for case '{case_name}' at '{abs_report_path_to_check}' not found. "
75
+ f"Proceeding without report file for this entry."
76
+ )
77
+ # report_file_for_config remains ""
78
+ else:
79
+ # The file (BASE_DIR / report_path_from_csv) exists.
80
+ # Now, ensure report_path_from_csv string itself starts with "static/"
81
+ # as per the assumption about CSV content.
82
+ if report_path_from_csv.startswith('static/') or report_path_from_csv.startswith('static\\'):
83
+ # Path is well-formed (starts with static/) and file exists.
84
+ # Store the path as is (e.g., "static/reports/report1.txt").
85
+ report_file_for_config = report_path_from_csv
86
+ else:
87
+ logger.warning(
88
+ f"Report path '{report_path_from_csv}' for case '{case_name}' in CSV "
89
+ f"is malformed (does not start with 'static/'). Treating as if no report path was specified."
90
+ )
91
+ # report_file_for_config remains ""
92
+ AVAILABLE_REPORTS.append({
93
+ "name": case_name,
94
+ "image_file": image_file_for_config, # static/images/report1.jpg
95
+ "report_file": report_file_for_config, # static/reports/report1.txt or ""
96
+ "image_type": row['image_type']
97
+ })
98
+ AVAILABLE_REPORTS.sort(key=lambda x: x['name'])
99
+ logger.info(f"Loaded {len(AVAILABLE_REPORTS)} report/image pairs from CSV.")
100
+
101
+ except Exception as e:
102
+ logger.error(f"Error reading or processing CSV file {MANIFEST_CSV_PATH}: {e}", exc_info=True)
103
+ else:
104
+ logger.warning(f"Manifest CSV file not found at {MANIFEST_CSV_PATH}. AVAILABLE_REPORTS will be empty.")
105
+
106
+ # --- Optional: Define a default report if needed ---
107
+ DEFAULT_REPORT_INFO = AVAILABLE_REPORTS[0] if AVAILABLE_REPORTS else None
default_cache/radexplain-cache.zip ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7afdbd67d67fa2b4eaa505842758a9eaf99b47b5f07940a3c711cef844ff56d6
3
+ size 28427
llm_client.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import logging
16
+ import requests
17
+ import config
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _api_key = None
22
+ _endpoint_url = None
23
+ _initialized = False
24
+
25
+ def init_llm_client():
26
+ """Initializes LLM client configuration by loading from config."""
27
+ global _api_key, _endpoint_url, _initialized
28
+ if config.HF_TOKEN and config.MEDGEMMA_ENDPOINT_URL:
29
+ _api_key = config.HF_TOKEN
30
+ _endpoint_url = config.MEDGEMMA_ENDPOINT_URL
31
+ _initialized = True
32
+ logger.info("LLM client configured successfully.")
33
+ else:
34
+ _api_key = None
35
+ _endpoint_url = None
36
+ logger.error("LLM client could not be configured due to missing API key or endpoint URL.")
37
+
38
+ def is_initialized():
39
+ return _initialized
40
+
41
+ def make_chat_completion_request(
42
+ model: str,
43
+ messages: list,
44
+ temperature: float,
45
+ max_tokens: int,
46
+ stream: bool,
47
+ top_p: float | None = None,
48
+ seed: int | None = None,
49
+ stop: list[str] | str | None = None,
50
+ frequency_penalty: float | None = None,
51
+ presence_penalty: float | None = None
52
+ ):
53
+ """
54
+ Makes a chat completion request to the configured LLM API.
55
+ """
56
+ if not _initialized:
57
+ logger.error("LLM client not initialized.")
58
+ raise RuntimeError("LLM client not initialized.")
59
+
60
+ headers = {
61
+ "Authorization": f"Bearer {_api_key}",
62
+ "Content-Type": "application/json",
63
+ }
64
+ payload = {
65
+ "model": model,
66
+ "messages": messages,
67
+ "temperature": temperature,
68
+ "max_tokens": max_tokens,
69
+ "stream": stream,
70
+ }
71
+ if top_p is not None: payload["top_p"] = top_p
72
+ if seed is not None: payload["seed"] = seed
73
+ if stop is not None: payload["stop"] = stop
74
+ if frequency_penalty is not None: payload["frequency_penalty"] = frequency_penalty
75
+ if presence_penalty is not None: payload["presence_penalty"] = presence_penalty
76
+
77
+ temp_url = _endpoint_url.rstrip('/')
78
+ if temp_url.endswith("/v1/chat/completions"):
79
+ full_url = temp_url
80
+ elif temp_url.endswith("/v1"):
81
+ full_url = temp_url + "/chat/completions"
82
+ else:
83
+ full_url = temp_url + "/v1/chat/completions"
84
+
85
+ response = requests.post(full_url, headers=headers, json=payload, stream=stream, timeout=60)
86
+ response.raise_for_status()
87
+ return response
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ flask
2
+ gunicorn
3
+ Pillow
4
+ diskcache
5
+ requests
routes.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import logging
16
+ from flask import Blueprint, render_template, request, jsonify, send_from_directory
17
+ from pathlib import Path
18
+ import shutil # For zipping the cache directory
19
+ import json # For parsing streamed JSON data
20
+
21
+ import os
22
+ import config
23
+ import utils
24
+ from llm_client import make_chat_completion_request, is_initialized as llm_is_initialized
25
+ from cache_store import cache
26
+ from cache_store import cache_directory
27
+ import requests
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ main_bp = Blueprint('main', __name__)
32
+
33
+ # LLM client is initialized in app.py create_app()
34
+
35
+ # --- Serve the cache directory as a zip file ---
36
+ @main_bp.route('/download_cache')
37
+ def download_cache_zip():
38
+ """Zips the cache directory and serves it for download."""
39
+ zip_filename = "radexplain-cache.zip"
40
+ # Create the zip file in a temporary directory
41
+ # Using /tmp is common in containerized environments
42
+ temp_dir = "/tmp"
43
+ zip_base_path = os.path.join(temp_dir, "radexplain-cache") # shutil adds .zip
44
+ zip_filepath = zip_base_path + ".zip"
45
+
46
+ # Ensure the cache directory exists before trying to zip it
47
+ if not os.path.isdir(cache_directory):
48
+ logger.error(f"Cache directory not found at {cache_directory}")
49
+ return jsonify({"error": f"Cache directory not found on server: {cache_directory}"}), 500
50
+
51
+ try:
52
+ logger.info(f"Creating zip archive of cache directory: {cache_directory} to {zip_filepath}")
53
+ shutil.make_archive(
54
+ zip_base_path, # This is the base name, shutil adds the .zip extension
55
+ "zip",
56
+ cache_directory, # This is the root directory to archive
57
+ )
58
+ logger.info("Zip archive created successfully.")
59
+ # Send the file and then clean it up
60
+ return send_from_directory(temp_dir, zip_filename, as_attachment=True)
61
+ except Exception as e:
62
+ logger.error(f"Error creating or sending zip archive of cache directory: {e}", exc_info=True)
63
+ return jsonify({"error": f"Error creating or sending zip archive: {e}"}), 500
64
+ @main_bp.route('/')
65
+ def index():
66
+ """Serves the main HTML page."""
67
+ # The backend now only provides the list of available reports.
68
+ # The frontend will be responsible for selecting a report,
69
+ # fetching its details (text, image path), and managing the current state.
70
+ if not config.AVAILABLE_REPORTS:
71
+ logger.warning("No reports found in config. AVAILABLE_REPORTS is empty.")
72
+
73
+ return render_template(
74
+ 'index.html',
75
+ available_reports=config.AVAILABLE_REPORTS
76
+ )
77
+
78
+ @main_bp.route('/get_report_details/<report_name>')
79
+ def get_report_details(report_name):
80
+ """Fetches the text content and image path for a given report name."""
81
+ selected_report_info = next((item for item in config.AVAILABLE_REPORTS if item['name'] == report_name), None)
82
+
83
+ if not selected_report_info:
84
+ logger.error(f"Report '{report_name}' not found when fetching details.")
85
+ return jsonify({"error": f"Report '{report_name}' not found."}), 404
86
+
87
+ report_file = selected_report_info.get('report_file')
88
+ image_file = selected_report_info.get('image_file')
89
+
90
+ report_text_content = "" # Default to empty if no report file is configured.
91
+
92
+ if report_file:
93
+ actual_server_report_path = config.BASE_DIR / report_file
94
+
95
+ try:
96
+ report_text_content = actual_server_report_path.read_text(encoding='utf-8').strip()
97
+ except Exception as e:
98
+ logger.error(f"Error reading report file {actual_server_report_path} for report '{report_name}': {e}", exc_info=True)
99
+ return jsonify({"error": "Error reading report file."}), 500
100
+ # If report_file was empty, report_text_content remains "".
101
+
102
+ image_type_from_config = selected_report_info.get('image_type')
103
+ display_image_type = 'Chest X-Ray' if image_type_from_config == 'CXR' else ('CT' if image_type_from_config == 'CT' else 'Medical Image')
104
+
105
+ return jsonify({"text": report_text_content, "image_file": image_file, "image_type": display_image_type})
106
+
107
+
108
+
109
+ @main_bp.route('/explain', methods=['POST'])
110
+ def explain_sentence():
111
+ """Handles the explanation request using LLM API with base64 encoded image."""
112
+ if not llm_is_initialized():
113
+ logger.error("LLM client (REST API) not initialized. Cannot process request.")
114
+ return jsonify({"error": "LLM client (REST API) not initialized. Check API key and base URL."}), 500
115
+
116
+ data = request.get_json()
117
+ if not data or 'sentence' not in data or 'report_name' not in data:
118
+ logger.warning("Missing 'sentence' or 'report_name' in request payload.")
119
+ return jsonify({"error": "Missing 'sentence' or 'report_name' in request"}), 400
120
+
121
+ selected_sentence = data['sentence']
122
+ report_name = data['report_name']
123
+ logger.info(f"Received request to explain: '{selected_sentence}' for report: '{report_name}'")
124
+
125
+ # --- Find the selected report info ---
126
+ selected_report_info = next((item for item in config.AVAILABLE_REPORTS if item['name'] == report_name), None)
127
+
128
+ if not selected_report_info:
129
+ logger.error(f"Report '{report_name}' not found in available reports.")
130
+ return jsonify({"error": f"Report '{report_name}' not found."}), 404
131
+
132
+ image_file = selected_report_info.get('image_file')
133
+ report_file = selected_report_info.get('report_file')
134
+ image_type = selected_report_info.get('image_type')
135
+
136
+ if not image_file:
137
+ logger.error(f"Image or report file path (relative to static) missing in config for report '{report_name}'.")
138
+ return jsonify({"error": f"File configuration missing for report '{report_name}'."}), 500
139
+
140
+ # Construct absolute server paths using BASE_DIR as image_file and report_file include "static/"
141
+ server_image_path = config.BASE_DIR / image_file
142
+
143
+
144
+ # --- Prepare Base64 Image for API ---
145
+ if not server_image_path.is_file():
146
+ logger.error(f"Image file not found at {server_image_path}")
147
+ return jsonify({"error": f"Image file for report '{report_name}' not found on server."}), 500
148
+
149
+ base64_image_data_url = utils.image_to_base64_data_url(str(server_image_path))
150
+ if not base64_image_data_url:
151
+ logger.error("Failed to encode image to base64.")
152
+ return jsonify({"error": "Could not encode image for API request"}), 500
153
+
154
+ logger.info("Image successfully encoded to base64 data URL for API.")
155
+
156
+ full_report_text = ""
157
+ if report_file: # Only attempt to read if a report file is configured
158
+ server_report_path = config.BASE_DIR / report_file
159
+ try:
160
+ full_report_text = server_report_path.read_text(encoding='utf-8')
161
+ except FileNotFoundError:
162
+ logger.error(f"Report file not found at {server_report_path}")
163
+ return jsonify({"error": f"Report file for '{report_name}' not found on server."}), 500
164
+ except Exception as e:
165
+ logger.error(f"Error reading report file {server_report_path}: {e}", exc_info=True)
166
+ return jsonify({"error": "Error reading report file."}), 500
167
+ else: # If report_file is not configured (e.g. empty string from selected_report_info)
168
+ logger.info(f"No report file configured for report '{report_name}'. Proceeding without full report text for system prompt.")
169
+
170
+ system_prompt = (
171
+ "You are a public-facing clinician. "
172
+ f"A learning user has provided a sentence from a radiology report and is viewing the accompanying {image_type} image. "
173
+ "Your task is to explain the meaning of ONLY the provided sentence in simple, clear terms. Explain terminology and abbriviations. Keep it concise. "
174
+ "Directly address the meaning of the sentence. Do not use introductory phrases like 'Okay' or refer to the sentence itself or the report itself (e.g., 'This sentence means...'). " # noqa: E501
175
+ f"{f'Crucially, since the user is looking at their {image_type} image, provide guidance on where to look on the image to understand your explanation, if applicable. ' if image_type != 'CT' else ''}"
176
+ "Do not discuss any other part of the report or any sentences not explicitly provided by the user. Stick to facts in the text. Do not infer anything. \n"
177
+ "===\n"
178
+ f"For context, the full REPORT is:\n{full_report_text}"
179
+ )
180
+ user_prompt_text = f"Explain this sentence from the radiology report: '{selected_sentence}'"
181
+
182
+ messages_for_api = [
183
+ {"role": "system", "content": system_prompt},
184
+ {
185
+ "role": "user",
186
+ "content": [
187
+ {"type": "text", "text": user_prompt_text}
188
+ ]
189
+ }
190
+ ]
191
+
192
+ cache_key = f"explain::{report_name}::{selected_sentence}"
193
+ cached_result = cache.get(cache_key)
194
+ if cached_result:
195
+ logger.info("Returning cached explanation.")
196
+ return jsonify({"explanation": cached_result})
197
+
198
+ try:
199
+ logger.info("Sending request to LLM API (REST) with base64 image...")
200
+ response = make_chat_completion_request(
201
+ model="tgi",
202
+ messages=messages_for_api,
203
+ top_p=None,
204
+ temperature=0,
205
+ max_tokens=250,
206
+ stream=True,
207
+ seed=None,
208
+ stop=None,
209
+ frequency_penalty=None,
210
+ presence_penalty=None
211
+ )
212
+ logger.info("Received response stream from LLM API (REST).")
213
+
214
+ explanation_parts = []
215
+ for line in response.iter_lines():
216
+ if line:
217
+ decoded_line = line.decode('utf-8')
218
+ if decoded_line.startswith('data: '):
219
+ json_data_str = decoded_line[len('data: '):].strip()
220
+ if json_data_str == "[DONE]":
221
+ break
222
+ try:
223
+ chunk = json.loads(json_data_str)
224
+ if chunk.get("choices") and chunk["choices"][0].get("delta") and chunk["choices"][0]["delta"].get("content"):
225
+ explanation_parts.append(chunk["choices"][0]["delta"]["content"])
226
+ except json.JSONDecodeError:
227
+ logger.warning(f"Could not decode JSON from stream chunk: {json_data_str}")
228
+ # Depending on API, might need to handle partial JSON or other errors
229
+ elif decoded_line.strip() == "[DONE]": # Some APIs might send [DONE] without "data: "
230
+ break
231
+
232
+ explanation = "".join(explanation_parts).strip()
233
+ if explanation:
234
+ cache.set(cache_key, explanation, expire=None)
235
+
236
+ logger.info("Explanation generated successfully." if explanation else "Empty explanation from API.")
237
+ return jsonify({"explanation": explanation or "No explanation content received from the API."})
238
+ except requests.exceptions.RequestException as e:
239
+ logger.error(f"Error during LLM API (REST) call: {e}", exc_info=True)
240
+ user_error_message = ("Failed to generate explanation. The service might be temporarily unavailable "
241
+ "and is now likely starting up. Please try again in a few moments.")
242
+ return jsonify({"error": user_error_message}), 500
run_local.sh ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # Copyright 2025 Google LLC
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ set -e
17
+ APP_NAME="radiology-report-explain"
18
+ HOST_APP_DIR="$(pwd)"
19
+ CONTAINER_APP_DIR="/app"
20
+
21
+ docker rm $APP_NAME || true
22
+
23
+ # Build the Docker image
24
+ echo "Building Docker image..."
25
+ docker build -t radiology-report-explain .
26
+
27
+ # Check if the build was successful
28
+ if [ $? -ne 0 ]; then
29
+ echo "Docker build failed!"
30
+ exit 1
31
+ fi
32
+
33
+ # Run the Docker container with a volume
34
+ echo "Running Docker container with volume (attached)..."
35
+ docker run \
36
+ -p 7860:7860 \
37
+ --env-file env.list \
38
+ --name "$APP_NAME" \
39
+ -v "$HOST_APP_DIR:$CONTAINER_APP_DIR" \
40
+ -u $(id -u):$(id -g) \
41
+ radiology-report-explain
42
+
43
+ # The script will block here, showing the container's output.
44
+ # To stop the container, press Ctrl+C in this terminal.
45
+
46
+ # The following code will only run after the container exits (e.g., Ctrl+C)
47
+ echo "Docker container has exited."
48
+ echo "To remove the container, run: docker rm $APP_NAME"
static/css/style.css ADDED
@@ -0,0 +1,595 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Copyright 2025 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /* Basic Reset & Body Style */
18
+ html {
19
+ height: 100%;
20
+ --image-fixed-width: 280px;
21
+ }
22
+
23
+ body {
24
+ font-family: sans-serif;
25
+ line-height: 1.6;
26
+ background-color: #f4f4f4;
27
+ color: #333;
28
+ margin: 0;
29
+ display: flex;
30
+ flex-direction: column;
31
+ user-select: none;
32
+ }
33
+
34
+ .main {
35
+ display: none; /*display: grid;*/
36
+ grid-template-columns: 1fr minmax(500px, 800px) minmax(300px, 1fr);
37
+ grid-template-rows: auto;
38
+ column-gap: 20px;
39
+ row-gap: 10px;
40
+ padding: 30px;
41
+ }
42
+
43
+ h1,
44
+ h2 {
45
+ color: #333;
46
+ }
47
+
48
+ .page-header h1 {
49
+ margin: 0;
50
+ }
51
+
52
+ .container {
53
+ display: flex;
54
+ flex-wrap: wrap;
55
+ gap: 25px;
56
+ padding: 20px;
57
+ flex-grow: 1;
58
+ justify-content: center;
59
+ }
60
+
61
+ /* Top Navigation Styles */
62
+ .top-navigation {
63
+ display: flex;
64
+ align-items: center;
65
+ padding-top: 30px;
66
+ gap: 15px; /* Space between back button and case tabs */
67
+ border-bottom: 1px solid #E0E0E0; /* Optional: a subtle separator */
68
+ margin-bottom: 10px;
69
+ align-items: flex-start;
70
+ justify-content: center;
71
+ }
72
+
73
+ .nav-button {
74
+ display: inline-flex; /* Changed from inline-flex on parent to allow direct styling */
75
+ justify-content: center;
76
+ align-items: center;
77
+ cursor: pointer;
78
+ user-select: none;
79
+ }
80
+
81
+ .nav-button-inner {
82
+ overflow: hidden;
83
+ border-radius: 100px; /* Very rounded corners */
84
+ outline: 1px solid #C4C7C5; /* Outline color */
85
+ outline-offset: -1px; /* Outline inside */
86
+ display: flex;
87
+ justify-content: center;
88
+ align-items: center;
89
+ padding: 6px 12px;
90
+ gap: 8px; /* Increased gap slightly for better visual separation */
91
+ transition: background-color 0.2s ease;
92
+ }
93
+
94
+ .nav-button-icon {
95
+ font-size: 20px; /* Icon size */
96
+ color: #444746; /* Icon color */
97
+ display: flex;
98
+ align-items: center;
99
+ justify-content: center;
100
+ }
101
+
102
+ .nav-button-text {
103
+ color: #444746; /* Text color */
104
+ font-size: 14px;
105
+ font-family: 'Google Sans Text', sans-serif;
106
+ font-weight: 500;
107
+ line-height: 20px;
108
+ white-space: nowrap;
109
+ }
110
+
111
+ .nav-button-case.active .nav-button-inner,
112
+ .nav-button:hover .nav-button-inner {
113
+ background-color: rgba(68, 71, 70, 0.10); /* Focused/hover background */
114
+ }
115
+
116
+ .nav-button-info {
117
+ color: #004A77;
118
+ margin: 0 20px;
119
+ }
120
+
121
+
122
+ /* Column Styles */
123
+ .report-section,
124
+ .image-section,
125
+ .explanation-section {
126
+ position: relative;
127
+ float: right;
128
+ }
129
+
130
+ .image-section {
131
+ justify-content: center;
132
+ display: flex;
133
+ flex-direction: column;
134
+ justify-self: end;
135
+ }
136
+
137
+ .report-section {
138
+ flex: 1;
139
+ max-width: 800px;
140
+ }
141
+
142
+ .image-header {
143
+ text-align: center;
144
+ color: black;
145
+ font-size: 22px;
146
+ font-family: Google Sans;
147
+ font-weight: 400;
148
+ line-height: 28px;
149
+ word-wrap: break-word;
150
+ margin: 5px;
151
+ }
152
+
153
+ .case-selector-tabs-container {
154
+ display: grid;
155
+ justify-content: center;
156
+ align-items: center;
157
+ grid-template-columns: min-content auto;
158
+ column-gap: 20px;
159
+ row-gap: 10px;
160
+ padding-left: 20px;
161
+ white-space: nowrap;
162
+ }
163
+
164
+ .case-selector-tabs-modality-container {
165
+ display: flex;
166
+ align-items: center;
167
+ width: 100%;
168
+ justify-content: flex-start;
169
+ }
170
+
171
+ .case-selector-tabs {
172
+ display: flex;
173
+ flex-wrap: wrap;
174
+ }
175
+
176
+ /* Report Text & Sentences */
177
+ .report-text-area {
178
+ padding: 30px;
179
+ overflow-y: auto;
180
+ background-color: #fdfdfd;
181
+ border-radius: 28px;
182
+ border: 2px solid #E9E9E9;
183
+ }
184
+
185
+ /* Styles for clickable sentences within the report text area */
186
+ .report-sentence {
187
+ cursor: pointer;
188
+ padding: 2px 0;
189
+ transition: background-color 0.2s ease;
190
+ border-radius: 14.272px;
191
+ border: 1.359px solid #F1E161;
192
+ }
193
+
194
+ .report-sentence:hover {
195
+ background: #F1E161;
196
+ mix-blend-mode: multiply;
197
+ }
198
+
199
+ .report-sentence.selected-sentence {
200
+ background: #F1E161;
201
+ mix-blend-mode: multiply;
202
+ }
203
+
204
+ /* Image Section */
205
+ .image-container {
206
+ position: relative;
207
+ border: 1px solid #ccc;
208
+ min-height: 100px;
209
+ display: flex;
210
+ flex-direction: column;
211
+ background-color: #f0f0f0;
212
+ margin: 0 auto; /* Keep auto margin for centering if desired */
213
+ width: var(--image-fixed-width);
214
+ }
215
+
216
+ .image-container img {
217
+ display: block;
218
+ max-width: 100%;
219
+ height: auto;
220
+ }
221
+
222
+ #report-image {
223
+ width: var(--image-fixed-width);
224
+ min-width: var(--image-fixed-width); /* Ensure min-width also uses the variable if it's meant to be the same */
225
+ }
226
+
227
+ #image-loading {
228
+ width: var(--image-fixed-width);
229
+ }
230
+
231
+ /* Note specific to CT images, displayed under the image */
232
+ #ct-image-note {
233
+ display: none; /* Initially hidden, controlled by JavaScript */
234
+ text-align: center;
235
+ margin-top: 10px;
236
+ font-size: 0.9em;
237
+ color: #555;
238
+ padding: 0 10px; /* Add some padding so text doesn't touch edges if it wraps */
239
+ word-wrap: break-word; /* Ensures text wraps to prevent overflow */
240
+ width: var(--image-fixed-width); /* Same width as the image */
241
+ }
242
+
243
+ .marker {
244
+ position: absolute;
245
+ transform: translate(-50%, -50%);
246
+ pointer-events: none;
247
+ display: none;
248
+ transition: top 0.3s ease-in-out, left 0.3s ease-in-out;
249
+ width: max-content;
250
+ }
251
+
252
+ /* Explanation Section */
253
+ .explanation-box {
254
+ border: 1px solid #eee;
255
+ min-height: 50px;
256
+ background-color: #F1E161;
257
+ box-shadow: 0px 5px 5px 0px rgba(0, 0, 0, 0.25);
258
+ position: absolute;
259
+ left: -20px;
260
+ top: 0px;
261
+ width: 300px;
262
+ transition: top 0.3s ease-in-out, background-color 0.3s ease, height 0.3s ease;
263
+ }
264
+
265
+ .explanation-header {
266
+ font-size: 22px;
267
+ font-style: normal;
268
+ font-weight: 400;
269
+ padding: 10px 10px 0;
270
+ }
271
+
272
+ .loading .explanation-header {
273
+ display: none;
274
+ }
275
+
276
+ /* New style for the content wrapper inside explanation-box */
277
+ #explanation-content {
278
+ padding: 10px;
279
+ overflow-y: auto;
280
+ height: 100%;
281
+ box-sizing: border-box;
282
+ }
283
+
284
+ /* Style for when the box is loading */
285
+ .explanation-box.loading {
286
+ background-color: #cfcfcf;
287
+ }
288
+
289
+ /* Adjust #explanation-loading (the div element) to be an overlay */
290
+ #explanation-loading {
291
+ display: none;
292
+ font-style: italic;
293
+ color: #777;
294
+ text-align: center;
295
+ padding: 10px;
296
+ }
297
+
298
+ /* Show #explanation-loading when its parent #explanation-output (which is .explanation-box) has class 'loading' */
299
+ #explanation-output.loading > #explanation-loading {
300
+ display: flex;
301
+ }
302
+
303
+ /* Utility Classes */
304
+ .error-message {
305
+ color: #d9534f;
306
+ background-color: #f2dede;
307
+ border: 1px solid #ebccd1;
308
+ padding: 10px;
309
+ border-radius: 4px;
310
+ margin-top: 10px;
311
+ text-align: center;
312
+ }
313
+
314
+ /* Responsive Adjustments (Optional) */
315
+ @media (max-width: 768px) {
316
+ .container {
317
+ flex-direction: column;
318
+ }
319
+ }
320
+
321
+ /* Info Page Styles */
322
+ .info {
323
+ flex-grow: 1;
324
+ display: flex;
325
+ justify-content: center;
326
+ align-items: center;
327
+ }
328
+
329
+ .info-page-container {
330
+ display: flex;
331
+ flex-direction: row-reverse;
332
+ flex-wrap: wrap;
333
+ align-items: center;
334
+ justify-content: center;
335
+ gap: 40px;
336
+ padding: 40px;
337
+ background-color: #fff;
338
+ max-width: 1200px;
339
+ margin: 20px auto;
340
+ border-radius: 8px;
341
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
342
+ box-sizing: border-box;
343
+ }
344
+
345
+ .info-content {
346
+ flex: 1;
347
+ min-width: 350px;
348
+ max-width: 600px;
349
+ display: flex;
350
+ flex-direction: column;
351
+ gap: 20px;
352
+ font-size: 18px;
353
+ }
354
+
355
+ .info-header {
356
+ margin-bottom: 10px;
357
+ }
358
+
359
+ .info-title-med,
360
+ .info-title-demo {
361
+ font-family: 'Google Sans', sans-serif;
362
+ font-size: 48px;
363
+ font-weight: 400;
364
+ }
365
+
366
+ #info-button .nav-button-inner{
367
+ background-color: #C2E7FF;
368
+ }
369
+
370
+ .info-title-demo {
371
+ color: #969696;
372
+ margin-left: 10px;
373
+ }
374
+
375
+ .info-subtitle-demo {
376
+ color: #969696;
377
+ font-size: 28px;
378
+ }
379
+
380
+ .info-text {
381
+ font-family: 'Google Sans', sans-serif;
382
+ line-height: 1.6;
383
+ color: #333;
384
+ }
385
+
386
+ .info-button {
387
+ font-family: 'Google Sans Text', sans-serif;
388
+ background-color: #0B57D0;
389
+ color: white;
390
+ font-size: 14px;
391
+ font-weight: 500;
392
+ padding: 12px 24px;
393
+ border: none;
394
+ border-radius: 100px;
395
+ cursor: pointer;
396
+ text-align: center;
397
+ transition: background-color 0.3s ease;
398
+ align-self: flex-start;
399
+ }
400
+
401
+ /* Hover state for the button */
402
+ .info-button:hover {
403
+ background-color: #0a4db8;
404
+ /* Darker blue on hover */
405
+ }
406
+
407
+ .info-disclaimer-box {
408
+ border: 1px solid #CDCDCD;
409
+ border-radius: 15px;
410
+ padding: 15px 20px;
411
+ margin-top: 20px;
412
+ }
413
+
414
+ .info-disclaimer-text {
415
+ font-family: 'Google Sans', sans-serif;
416
+ color: #333;
417
+ line-height: 1.5;
418
+ margin: 0;
419
+ font-size: 14px;
420
+ }
421
+
422
+ .info-disclaimer-title {
423
+ border-radius: 14.272px;
424
+ border: 1.359px solid #F1E161;
425
+ background: #F1E161;
426
+ mix-blend-mode: multiply;
427
+ }
428
+
429
+ .info-disclaimer-link {
430
+ color: #0B57D0;
431
+ font-weight: 500;
432
+ text-decoration: none;
433
+ }
434
+
435
+ .info-disclaimer-link:hover {
436
+ text-decoration: underline;
437
+ }
438
+
439
+ .info-image-container {
440
+ flex: 1;
441
+ min-width: 300px;
442
+ max-width: 500px;
443
+ display: flex;
444
+ justify-content: center;
445
+ align-items: center;
446
+ }
447
+
448
+ .info-image {
449
+ max-width: 100%;
450
+ height: auto;
451
+ border-radius: 8px;
452
+ }
453
+
454
+ /* Responsive adjustments for info page */
455
+ @media (max-width: 768px) {
456
+ .info-page-container {
457
+ flex-direction: column;
458
+ padding: 20px;
459
+ margin: 10px;
460
+ }
461
+
462
+ .info-content {
463
+ max-width: 100%;
464
+ align-items: center;
465
+ text-align: center;
466
+ }
467
+
468
+ .info-button {
469
+ align-self: center;
470
+ }
471
+
472
+ .info-header {
473
+ text-align: center;
474
+ }
475
+
476
+ .info-title-med,
477
+ .info-title-demo {
478
+ font-size: 36px;
479
+ }
480
+
481
+ .info-text {
482
+ font-size: 16px;
483
+ }
484
+ }
485
+
486
+
487
+ /* --- Immersive Info Dialog Styles --- */
488
+ .dialog-overlay {
489
+ position: fixed;
490
+ top: 0;
491
+ left: 0;
492
+ width: 100%;
493
+ height: 100%;
494
+ background-color: rgba(0, 0, 0, 0.6); /* Semi-transparent backdrop */
495
+ display: flex;
496
+ align-items: center;
497
+ justify-content: center;
498
+ z-index: 1000; /* Ensure it's on top */
499
+ opacity: 0;
500
+ visibility: hidden;
501
+ transition: opacity 0.3s ease, visibility 0.3s ease;
502
+ }
503
+
504
+ .dialog-overlay.active {
505
+ opacity: 1;
506
+ visibility: visible;
507
+ }
508
+
509
+ .dialog-box {
510
+ background: #fff;
511
+ border-radius: 12px;
512
+ box-shadow: 0 8px 30px rgba(0,0,0,0.15);
513
+ width: 85%;
514
+ max-width: 800px; /* Adjust for desired largeness */
515
+ max-height: 85vh; /* Ensure it fits vertically and allows scrolling */
516
+ position: relative;
517
+ display: flex;
518
+ flex-direction: column;
519
+ overflow: hidden; /* Important for border-radius with scrollable content */
520
+ transform: scale(0.95);
521
+ transition: transform 0.3s ease;
522
+ }
523
+
524
+ .dialog-overlay.active .dialog-box {
525
+ transform: scale(1);
526
+ }
527
+
528
+ .dialog-title-text {
529
+ padding: 24px 24px 16px 24px;
530
+ margin: 0;
531
+ font-size: 1.6rem; /* Adjust as needed */
532
+ font-weight: 500; /* Using Google Sans weight */
533
+ color: #202124; /* Dark grey text color */
534
+ border-bottom: 1px solid #e0e0e0; /* Subtle separator */
535
+ flex-shrink: 0; /* Prevent title from shrinking */
536
+ }
537
+
538
+ .dialog-body-scrollable {
539
+ padding: 16px 24px 24px 24px;
540
+ overflow-y: auto; /* Enable scrolling for TBD content */
541
+ flex-grow: 1; /* Allow body to take available space */
542
+ color: #3c4043; /* Standard body text color */
543
+ line-height: 1.6;
544
+ }
545
+
546
+ .dialog-close-btn {
547
+ position: absolute;
548
+ top: 12px;
549
+ right: 12px;
550
+ background: transparent;
551
+ border: none;
552
+ cursor: pointer;
553
+ padding: 10px; /* Increase clickable area */
554
+ line-height: 0; /* Helps align icon if it's just an icon */
555
+ border-radius: 50%;
556
+ transition: background-color 0.2s ease-in-out;
557
+ z-index: 10; /* Ensure close button is above title/body */
558
+ }
559
+
560
+ .dialog-close-btn:hover {
561
+ background-color: rgba(0,0,0,0.08);
562
+ }
563
+
564
+ .dialog-close-btn .material-symbols-outlined {
565
+ font-size: 24px; /* Standard Material Icon size */
566
+ color: #5f6368; /* Standard icon color */
567
+ display: block; /* Better for layout */
568
+ }
569
+
570
+ .hf-logo {
571
+ vertical-align: middle;
572
+ width: 30px;
573
+ }
574
+
575
+ .floating-disclaimer {
576
+ padding: 5px 10px;
577
+ margin-top: 10px;
578
+ color: #333;
579
+ display: flex;
580
+ align-items: center;
581
+ font-size: 12px;
582
+ background-color: lightcyan;
583
+ text-align: justify;
584
+ }
585
+
586
+ .disclaimer-icon {
587
+ vertical-align: middle;
588
+ margin-right: 10px;
589
+ font-weight: 300 !important;
590
+ }
591
+
592
+ .nav-button-back {
593
+ /*justify-self: start;
594
+ margin-left: 30px;*/
595
+ }
static/images/.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.jpg filter=lfs diff=lfs merge=lfs -text
static/images/CT-Tumor.jpg ADDED

Git LFS Details

  • SHA256: 1cec21735cbe0221a23c6e1b95010d9210500ed9fae56f3c675af8c0b3e57672
  • Pointer size: 131 Bytes
  • Size of remote file: 508 kB
static/images/Effusion2.jpg ADDED

Git LFS Details

  • SHA256: de607bdea2ae675df89c1dc853a39616dec5e53ab5d5106f352769f1e663bf32
  • Pointer size: 131 Bytes
  • Size of remote file: 674 kB
static/images/Infection.jpg ADDED

Git LFS Details

  • SHA256: 659b8ffceae7f623baf5fcf9a775e19a9561fbcc796337cc28c1e38812642211
  • Pointer size: 131 Bytes
  • Size of remote file: 105 kB
static/images/Lymphadenopathy2.jpg ADDED

Git LFS Details

  • SHA256: 04efbc2b28f0c5224bd3b67bb798799c26fd204b3f089cbcc40e71aeb8f6322b
  • Pointer size: 131 Bytes
  • Size of remote file: 107 kB
static/images/Nodule3.jpg ADDED

Git LFS Details

  • SHA256: d42e5a916f5791015c6da5e6cc2c0dfae9b46e56d759e6077e5d9e8af2f0da20
  • Pointer size: 131 Bytes
  • Size of remote file: 763 kB
static/images/Nodule5.jpg ADDED

Git LFS Details

  • SHA256: c2c9fb13f52a402966ea93f464ec447fc20d42b5a8f3214676394c58748c4330
  • Pointer size: 131 Bytes
  • Size of remote file: 611 kB
static/js/demo.js ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Copyright 2025 Google LLC
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ document.addEventListener('DOMContentLoaded', () => {
18
+ const infoDiv = document.querySelector('.info');
19
+ const mainDiv = document.querySelector('.main');
20
+ const reportSectionDiv = document.querySelector('.report-section');
21
+ const viewDemoButton = document.getElementById('view-demo-button');
22
+ const backToInfoButton = document.getElementById('back-to-info-button');
23
+ const caseSelectorTabsContainer = document.getElementById('case-selector-tabs-container');
24
+ const reportTextDisplay = document.getElementById('report-text-display');
25
+ const explanationOutput = document.getElementById('explanation-output');
26
+ const explanationContent = document.getElementById('explanation-content');
27
+ const explanationError = document.getElementById('explanation-error');
28
+ const imageContainer = document.getElementById('image-container');
29
+ const reportImage = document.getElementById('report-image');
30
+ const imageLoading = document.getElementById('image-loading');
31
+ const imageError = document.getElementById('image-error');
32
+ const imageModalityHeader = document.getElementById('image-modality-header'); // Get reference to the header
33
+ const ctImageNote = document.getElementById('ct-image-note');
34
+ const appLoading = document.getElementById('app-loading');
35
+ const appError = document.getElementById('app-error');
36
+
37
+ let availableReports = [];
38
+ let currentReportName = null;
39
+ let currentReportDetails = null;
40
+
41
+ let explainAbortController = null;
42
+ let reportLoadAbortController = null;
43
+ let appLoadingTimeout = null;
44
+ let explanationLoadingTimer = null;
45
+
46
+ function initialize() {
47
+ try {
48
+ const reportsDataElement = document.getElementById('reports-data');
49
+ if (reportsDataElement) {
50
+ availableReports = JSON.parse(reportsDataElement.textContent);
51
+ } else {
52
+ displayAppError("Failed to load report list.");
53
+ return;
54
+ }
55
+ } catch (e) {
56
+ displayAppError("Failed to parse report list.");
57
+ return;
58
+ }
59
+
60
+ if (availableReports.length === 0) {
61
+ displayAppError("No reports available.");
62
+ return;
63
+ }
64
+
65
+ if (viewDemoButton && infoDiv && mainDiv) {
66
+ viewDemoButton.addEventListener('click', () => {
67
+ infoDiv.style.display = 'none';
68
+ mainDiv.style.display = 'grid';
69
+ if (currentReportName) {
70
+ loadReportDetails(currentReportName);
71
+ }
72
+ });
73
+ }
74
+
75
+ if (backToInfoButton && infoDiv && mainDiv) {
76
+ backToInfoButton.addEventListener('click', () => {
77
+ abortOngoingRequests();
78
+ mainDiv.style.display = 'none';
79
+ infoDiv.style.display = 'flex';
80
+ clearAllOutputs();
81
+ currentReportDetails = null;
82
+ reportImage.src = '';
83
+ document.title = "Radiology Report Explainer";
84
+ });
85
+ }
86
+
87
+ if (caseSelectorTabsContainer) {
88
+ caseSelectorTabsContainer.addEventListener('click', handleCaseSelectionClick);
89
+ }
90
+
91
+ reportTextDisplay.addEventListener('click', handleSentenceClick);
92
+
93
+ const firstCaseButton = caseSelectorTabsContainer?.querySelector('.nav-button-case');
94
+ if (firstCaseButton) {
95
+ currentReportName = firstCaseButton.dataset.reportName;
96
+ setActiveCaseButton(firstCaseButton);
97
+ loadReportDetails(currentReportName);
98
+ } else {
99
+ displayAppError("No cases found to load initially.");
100
+ }
101
+ }
102
+
103
+ function handleCaseSelectionClick(event) {
104
+ const clickedButton = event.target.closest('.nav-button-case');
105
+ if (!clickedButton) return;
106
+
107
+ const selectedName = clickedButton.dataset.reportName;
108
+ if (selectedName && selectedName !== currentReportName) {
109
+ abortOngoingRequests();
110
+ currentReportName = selectedName;
111
+ setActiveCaseButton(clickedButton);
112
+ loadReportDetails(currentReportName);
113
+ }
114
+ }
115
+
116
+ async function handleSentenceClick(event) {
117
+ const clickedElement = event.target;
118
+ if (!clickedElement.classList.contains('report-sentence') || clickedElement.tagName !== 'SPAN') return;
119
+
120
+ const sentenceText = clickedElement.dataset.sentence;
121
+ if (!sentenceText || !currentReportName) return;
122
+
123
+ abortOngoingRequests(['report']);
124
+ explainAbortController = new AbortController();
125
+
126
+ document.querySelectorAll('#report-text-display .selected-sentence').forEach(el => el.classList.remove('selected-sentence'));
127
+ clickedElement.classList.add('selected-sentence');
128
+
129
+ adjustExplanationPosition(clickedElement);
130
+
131
+ try {
132
+ await Promise.all([
133
+ fetchExplanation(sentenceText, explainAbortController.signal),
134
+ ]);
135
+ } catch (error) {
136
+ if (error.name !== 'AbortError') {
137
+ console.error("Error during sentence processing:", error);
138
+ }
139
+ }
140
+ }
141
+
142
+ async function loadReportDetails(reportName) {
143
+ abortOngoingRequests();
144
+ reportLoadAbortController = new AbortController();
145
+ const signal = reportLoadAbortController.signal;
146
+
147
+ setLoadingState(true, 'report');
148
+ clearAllOutputs(true);
149
+
150
+ try {
151
+ const response = await fetch(`/get_report_details/${encodeURIComponent(reportName)}`, { signal });
152
+ if (signal.aborted) return;
153
+
154
+ if (!response.ok) {
155
+ const errorData = await response.json().catch(() => ({ error: `HTTP error ${response.status}` }));
156
+ throw new Error(errorData.error || `HTTP error ${response.status}`);
157
+ }
158
+
159
+ currentReportDetails = await response.json();
160
+ if (signal.aborted) return;
161
+
162
+ document.title = `${reportName} - Radiology Explainer`;
163
+
164
+ // Update the image modality header
165
+ if (imageModalityHeader && currentReportDetails.image_type) {
166
+ imageModalityHeader.textContent = currentReportDetails.image_type;
167
+ }
168
+
169
+ // Show/hide CT note based on image_type and image_file presence
170
+ if (ctImageNote) {
171
+ if (currentReportDetails.image_type === 'CT' && currentReportDetails.image_file) {
172
+ ctImageNote.style.display = 'block';
173
+ } else {
174
+ ctImageNote.style.display = 'none';
175
+ }
176
+ }
177
+
178
+
179
+ if (currentReportDetails.image_file) {
180
+ const imageUrl = `${currentReportDetails.image_file}`;
181
+
182
+ reportImage.onload = null;
183
+ reportImage.onerror = null;
184
+
185
+ reportImage.onload = () => {
186
+ imageLoading.style.display = 'none';
187
+ reportImage.style.display = 'block';
188
+ imageError.style.display = 'none';
189
+ };
190
+ reportImage.onerror = () => {
191
+ imageLoading.style.display = 'none';
192
+ reportImage.style.display = 'none';
193
+ displayImageError("Failed to load image file.");
194
+ };
195
+
196
+ reportImage.src = imageUrl;
197
+ reportImage.alt = `Radiology Image for ${reportName}`;
198
+ } else {
199
+ displayImageError("Image path not configured for this report.");
200
+ if (ctImageNote) ctImageNote.style.display = 'none'; // Ensure note is hidden if no image path
201
+ }
202
+
203
+ renderReportTextWithLineBreaks(currentReportDetails.text || '');
204
+ } catch (error) {
205
+ if (error.name !== 'AbortError') {
206
+ displayReportTextError(`Failed to load report: ${error.message}`);
207
+ // Clean up UI elements on report load error.
208
+ if (reportImage) {
209
+ reportImage.style.display = 'none';
210
+ reportImage.src = '';
211
+ reportImage.onload = null;
212
+ reportImage.onerror = null;
213
+ }
214
+ if (imageLoading) imageLoading.style.display = 'none';
215
+ if (imageError) imageError.style.display = 'none';
216
+ if (ctImageNote) ctImageNote.style.display = 'none';
217
+ clearExplanationAndLocationUI();
218
+ }
219
+ } finally {
220
+ if (reportLoadAbortController?.signal === signal) {
221
+ reportLoadAbortController = null;
222
+ }
223
+ if (!signal.aborted) {
224
+ setLoadingState(false, 'report');
225
+ }
226
+ }
227
+ }
228
+
229
+ function renderReportTextWithLineBreaks(text) {
230
+ reportTextDisplay.innerHTML = '';
231
+ reportTextDisplay.classList.remove('loading', 'error');
232
+
233
+ const lines = text.split('\n');
234
+ if (lines.length === 0 || (lines.length === 1 && !lines[0].trim())) {
235
+ reportTextDisplay.textContent = 'Report text is empty or could not be processed.';
236
+ return;
237
+ }
238
+
239
+ lines.forEach((line, index) => {
240
+ const trimmedLine = line.trim();
241
+ if (trimmedLine !== '') {
242
+ const sentences = splitSentences(trimmedLine);
243
+ sentences.forEach(sentence => {
244
+ if (sentence) {
245
+ const span = document.createElement('span');
246
+ span.textContent = sentence + ' ';
247
+ if (!sentence.includes('Image source: ') && sentence.includes(' ') || !sentence.includes(':')) {
248
+ span.classList.add('report-sentence');
249
+ }
250
+ span.dataset.sentence = sentence;
251
+ reportTextDisplay.appendChild(span);
252
+ }
253
+ });
254
+ }
255
+ if (index < lines.length - 1) {
256
+ reportTextDisplay.appendChild(document.createElement('br'));
257
+ }
258
+ });
259
+ }
260
+
261
+ async function fetchExplanation(sentence, signal) {
262
+ explanationError.style.display = 'none';
263
+ if (!currentReportName) {
264
+ displayExplanationError("No report selected.");
265
+ return;
266
+ }
267
+
268
+ if (explanationLoadingTimer) {
269
+ clearTimeout(explanationLoadingTimer);
270
+ }
271
+
272
+ explanationLoadingTimer = setTimeout(() => {
273
+ if (!signal.aborted) { // Only add if not already aborted
274
+ explanationOutput.classList.add('loading');
275
+ explanationContent.textContent = '';
276
+ }
277
+ explanationLoadingTimer = null; // Timer has done its job or been cleared
278
+ }, 150); // 150ms delay, adjust as needed
279
+
280
+ try {
281
+ const response = await fetch('/explain', {
282
+ method: 'POST',
283
+ headers: { 'Content-Type': 'application/json' }, // prettier-ignore
284
+ body: JSON.stringify({ sentence, report_name: currentReportName }),
285
+ signal
286
+ });
287
+
288
+ if (signal.aborted) return;
289
+
290
+ if (!response.ok) {
291
+ if (explanationLoadingTimer) clearTimeout(explanationLoadingTimer);
292
+ explanationLoadingTimer = null;
293
+ explanationOutput.classList.remove('loading');
294
+ const errorData = await response.json().catch(() => ({ error: `HTTP error ${response.status}` }));
295
+ throw new Error(errorData.error || `HTTP error ${response.status}`);
296
+ }
297
+
298
+ const data = await response.json();
299
+ if (signal.aborted) return;
300
+
301
+ if (explanationLoadingTimer) clearTimeout(explanationLoadingTimer);
302
+ explanationLoadingTimer = null;
303
+ explanationOutput.classList.remove('loading');
304
+ requestAnimationFrame(() => {
305
+ explanationContent.textContent = data.explanation || "No explanation content received.";
306
+ adjustExplanationPosition();
307
+ });
308
+ } catch (error) {
309
+ if (explanationLoadingTimer) clearTimeout(explanationLoadingTimer);
310
+ explanationLoadingTimer = null;
311
+ explanationOutput.classList.remove('loading');
312
+ if (error.name !== 'AbortError') {
313
+ displayExplanationError(`Explanation Error: ${error.message}`);
314
+ }
315
+ }
316
+ }
317
+
318
+ function setLoadingState(isLoading, type = 'all') {
319
+ if (type === 'all' || type === 'report') {
320
+ if (isLoading) {
321
+ if (appLoadingTimeout) {
322
+ clearTimeout(appLoadingTimeout);
323
+ appLoadingTimeout = null;
324
+ }
325
+ // Cleanup immediate error/content states
326
+ if (appError) appError.style.display = 'none';
327
+ if (reportImage) reportImage.style.display = 'none';
328
+ if (imageError) imageError.style.display = 'none';
329
+ if (ctImageNote) ctImageNote.style.display = 'none';
330
+ clearExplanationAndLocationUI();
331
+
332
+ appLoadingTimeout = setTimeout(() => {
333
+ if (appLoading) appLoading.style.display = 'block';
334
+
335
+ // Show content-specific loaders only if timeout fires
336
+ if (reportTextDisplay) {
337
+ reportTextDisplay.innerHTML = 'Loading...';
338
+ reportTextDisplay.classList.add('loading');
339
+ reportTextDisplay.classList.remove('error');
340
+ }
341
+ if (imageLoading) imageLoading.style.display = 'block';
342
+ }, 200); // Delay for app and content loading indicators (e.g., 200ms)
343
+
344
+ } else {
345
+ if (appLoadingTimeout) {
346
+ clearTimeout(appLoadingTimeout);
347
+ appLoadingTimeout = null;
348
+ }
349
+ if (appLoading) appLoading.style.display = 'none';
350
+ }
351
+ }
352
+ }
353
+ function clearAllOutputs(keepReportTextLoading = false) {
354
+ if (!keepReportTextLoading && reportTextDisplay) {
355
+ reportTextDisplay.innerHTML = 'Select a report to view its text.';
356
+ reportTextDisplay.classList.remove('loading', 'error');
357
+ }
358
+ if (reportImage) {
359
+ reportImage.style.display = 'none';
360
+ reportImage.onload = null;
361
+ reportImage.onerror = null;
362
+ reportImage.src = '';
363
+ }
364
+ if (imageError) imageError.style.display = 'none';
365
+ if (ctImageNote) ctImageNote.style.display = 'none';
366
+ clearExplanationAndLocationUI();
367
+ if (appError) appError.style.display = 'none';
368
+ // Reset image modality header on clear
369
+ if (imageModalityHeader) {
370
+ imageModalityHeader.textContent = 'Medical Image'; // Reset to default
371
+ }
372
+ }
373
+
374
+ function clearExplanationAndLocationUI() {
375
+ if (explanationContent) {
376
+ explanationContent.textContent = 'Click a sentence to see the explanation here.';
377
+ }
378
+ if (explanationLoadingTimer) { // Clear any pending explanation loading timer
379
+ clearTimeout(explanationLoadingTimer);
380
+ explanationLoadingTimer = null;
381
+ }
382
+ explanationOutput.classList.remove('loading');
383
+ explanationError.style.display = 'none';
384
+ document.querySelectorAll('#report-text-display .selected-sentence').forEach(el => el.classList.remove('selected-sentence'));
385
+ }
386
+
387
+ function displayReportTextError(message) {
388
+ reportTextDisplay.innerHTML = `<span class="error-message">${message}</span>`;
389
+ reportTextDisplay.classList.add('error');
390
+ reportTextDisplay.classList.remove('loading');
391
+ }
392
+
393
+ function displayAppError(message) {
394
+ appError.textContent = `Error: ${message}`;
395
+ appError.style.display = 'block';
396
+ appLoading.style.display = 'none';
397
+ }
398
+
399
+ function displayImageError(message) {
400
+ imageError.textContent = message;
401
+ imageError.style.display = 'block';
402
+ imageLoading.style.display = 'none';
403
+ reportImage.style.display = 'none';
404
+ if (ctImageNote) ctImageNote.style.display = 'none';
405
+ }
406
+
407
+ function displayExplanationError(message) {
408
+ explanationError.textContent = message;
409
+ explanationError.style.display = 'block';
410
+ explanationOutput.classList.remove('loading');
411
+ if (explanationContent) explanationContent.textContent = '';
412
+ }
413
+
414
+ function setActiveCaseButton(activeButton) {
415
+ if (!caseSelectorTabsContainer) return;
416
+ caseSelectorTabsContainer.querySelectorAll('.nav-button-case').forEach(btn => btn.classList.remove('active'));
417
+ if (activeButton) activeButton.classList.add('active');
418
+ }
419
+
420
+ function splitSentences(text) {
421
+ if (!text) return [];
422
+ try {
423
+ if (typeof nlp !== 'function') {
424
+ const basicSentences = text.match(/[^.?!]+[.?!]['"]?(\s+|$)/g);
425
+ return basicSentences ? basicSentences.map(s => s.trim()).filter(s => s.length > 0) : [];
426
+ }
427
+ const doc = nlp(text);
428
+ return doc.sentences().out('array').map(s => s.trim()).filter(s => s.length > 0);
429
+ } catch (e) {
430
+ const basicSentences = text.match(/[^.?!]+[.?!]['"]?(\s+|$)/g);
431
+ return basicSentences ? basicSentences.map(s => s.trim()).filter(s => s.length > 0) : [];
432
+ }
433
+ }
434
+
435
+ function adjustExplanationPosition(clickedSentenceElement) {
436
+ const targetSentenceElement = clickedSentenceElement || document.querySelector('#report-text-display .selected-sentence');
437
+ if (!targetSentenceElement) return;
438
+
439
+ const explanationSection = explanationOutput.closest('.explanation-section');
440
+
441
+ if (explanationOutput && explanationSection && reportSectionDiv) {
442
+ const sentenceRect = targetSentenceElement.getBoundingClientRect();
443
+ const explanationSectionRect = explanationSection.getBoundingClientRect();
444
+
445
+ // Use actual offsetHeight, fallback if not rendered. Accurate after rAF.
446
+ const explanationHeight = explanationOutput.offsetHeight || 200;
447
+
448
+ // Initial top: align with sentence, relative to explanationSection.
449
+ let newTop = sentenceRect.top - explanationSectionRect.top;
450
+
451
+ // Absolute bottom of explanation box if placed at newTop.
452
+ const explanationBoxAbsoluteBottom = explanationSectionRect.top + newTop + explanationHeight + 15; // 15px margin
453
+
454
+ const viewportHeight = window.innerHeight;
455
+ const pageBottomOverflow = explanationBoxAbsoluteBottom - viewportHeight;
456
+
457
+ if (pageBottomOverflow > 0) {
458
+ // Adjust newTop upwards if overflowing viewport bottom.
459
+ newTop -= pageBottomOverflow;
460
+ }
461
+
462
+ // Prevent top from being negative (relative to its container).
463
+ newTop = Math.max(0, newTop);
464
+
465
+ explanationOutput.style.top = `${newTop}px`;
466
+ }
467
+ }
468
+
469
+ function abortOngoingRequests(excludeTypes = []) {
470
+ if (!excludeTypes.includes('report') && reportLoadAbortController) {
471
+ reportLoadAbortController.abort();
472
+ reportLoadAbortController = null;
473
+ }
474
+ if (!excludeTypes.includes('explain') && explainAbortController) {
475
+ explainAbortController.abort();
476
+ explainAbortController = null;
477
+ }
478
+ }
479
+
480
+ initialize();
481
+ });
482
+
483
+
484
+ // Make sure this is within your existing DOMContentLoaded listener,
485
+ // or wrap it in one if demo.js doesn't have a global one.
486
+ document.addEventListener('DOMContentLoaded', () => {
487
+
488
+ // ... (any existing JavaScript code in demo.js)
489
+
490
+ // --- BEGIN: Immersive Info Dialog Logic ---
491
+ const infoButton = document.getElementById('info-button');
492
+ const immersiveDialogOverlay = document.getElementById('immersive-info-dialog');
493
+ const dialogCloseButton = document.getElementById('dialog-close-button');
494
+
495
+ if (infoButton && immersiveDialogOverlay && dialogCloseButton) {
496
+ const openDialog = () => {
497
+ immersiveDialogOverlay.style.display = 'flex'; // Use flex as per CSS
498
+ // Timeout to allow display:flex to apply before triggering transition
499
+ setTimeout(() => {
500
+ immersiveDialogOverlay.classList.add('active');
501
+ }, 10); // Small delay
502
+ document.body.style.overflow = 'hidden'; // Prevent background scroll
503
+ };
504
+
505
+ const closeDialog = () => {
506
+ immersiveDialogOverlay.classList.remove('active');
507
+ // Wait for opacity transition to finish before setting display to none
508
+ setTimeout(() => {
509
+ immersiveDialogOverlay.style.display = 'none';
510
+ }, 300); // Must match CSS transition duration
511
+ document.body.style.overflow = ''; // Restore background scroll
512
+ };
513
+
514
+ infoButton.addEventListener('click', openDialog);
515
+ dialogCloseButton.addEventListener('click', closeDialog);
516
+
517
+ // Dismissible: Close when clicking on the overlay (backdrop)
518
+ immersiveDialogOverlay.addEventListener('click', (event) => {
519
+ if (event.target === immersiveDialogOverlay) {
520
+ closeDialog();
521
+ }
522
+ });
523
+
524
+ // Dismissible: Close with Escape key
525
+ document.addEventListener('keydown', (event) => {
526
+ if (event.key === 'Escape' && immersiveDialogOverlay.classList.contains('active')) {
527
+ closeDialog();
528
+ }
529
+ });
530
+ } else {
531
+ // Log errors if elements are not found, helps in debugging
532
+ if (!infoButton) console.error('Dialog trigger button (#info-button) not found.');
533
+ if (!immersiveDialogOverlay) console.error('Immersive dialog (#immersive-info-dialog) not found.');
534
+ if (!dialogCloseButton) console.error('Dialog close button (#dialog-close-button) not found.');
535
+ }
536
+ // --- END: Immersive Info Dialog Logic ---
537
+
538
+ });
static/reports/CT-Tumor.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FINDINGS:
2
+ Mass effect and endobronchial tumor invasion result in severe focal stenosis of right middle lobar bronchus. Subsegmental mucus plugging in lateral segment right middle lobe, peripheral to central right middle lobe lung mass (described below).
3
+ No pneumothorax. No pleural effusion.
4
+ Right middle lobe hyperlucency, compatible with lobar air trapping. Solid, well-circumscribed, enhancing 33 x 25 mm central right middle lobe lung mass. No consolidation elsewhere. Very mild dependent atelectasis in lower lobes. Subsolid 3 mm nodule in posteromedial right upper lobe.
5
+ Enlargement of right heart chambers. No pericardial effusion. No thoracic aortic aneurysm. Mild thymic hyperplasia.
6
+ No bulky thoracic lymphadenopathy.
7
+ Cholelithiasis.
8
+ No aggressive destructive skeletal focus. Mild thoracic spine degenerative disk disease.
9
+
10
+
11
+ IMPRESSION:
12
+ 1. Solid, enhancing 33 x 25 mm central right middle lobe lung mass with apparent endobronchial invasion and associated right middle lobe air trapping; imaging features favor carcinoid tumor.
13
+ 2. No bulky thoracic lymphadenopathy.
14
+ 3. Nonspecific 3 mm right upper lobe lung nodule.
15
+
16
+ RECOMMENDATION:
17
+ >> Pulmonary consultation.
static/reports/Effusion2.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FINDINGS:
2
+ No pneumothorax. Small to medium right pleural effusion with adjacent right basilar atelectasis. No substantial left pleural effusion.
3
+
4
+ Normal heart size. Mediastinal shadow within normal limits.
5
+
6
+ No acute skeletal abnormality is apparent.
7
+
8
+ IMPRESSION:
9
+ Small to medium right pleural effusion.
10
+
11
+ Image source: LIDC-IDRI
static/reports/Infection.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FINDINGS:
2
+ No pneumothorax. Small right pleural effusion. No substantial left pleural effusion. Heterogeneous opacities with mixed interstitial opacities are present in mid and lower lungs bilateral, most pronounced in lower right lung.
3
+
4
+ Normal heart size. Mediastinal shadow within normal limits.
5
+
6
+ No acute skeletal abnormality is apparent.
7
+
8
+ IMPRESSION:
9
+ Bilateral heterogeneous lung opacities, most likely lung infection, most pronounced in lower right lung. Small right pleural effusion.
10
+
11
+ Image source: NIH Chest X-ray14 database
static/reports/Lymphadenopathy2.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FINDINGS:
2
+ No pneumothorax. No substantial pleural effusion. No consolidation.
3
+
4
+ Normal heart size. Soft tissue bulge near aortopulmonary window. Multiple calcified, enlarged mediastinal lymph nodes.
5
+
6
+ No acute skeletal abnormality is apparent.
7
+
8
+ IMPRESSION:
9
+ Soft tissue bulge near aortopulmonary window; bulky lymphadenopathy suspected. Multiple calcified, enlarged mediastinal lymph nodes may correspond to either treated malignancy or sequelae of remote endemic fungal infection.
10
+
11
+ Image source: NIH Chest X-ray14 database
static/reports/Nodule3.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FINDINGS:
2
+ No pneumothorax. No substantial pleural effusion. No consolidation. Approximately 1.5 cm nodule in lateral upper/mid left lung.
3
+
4
+ Normal heart size. Slightly unfolded thoracic aorta. Mediastinal shadow otherwise within normal limits.
5
+
6
+ No acute skeletal abnormality is apparent.
7
+
8
+ IMPRESSION:
9
+ Approximately 1.5 cm lateral upper/mid left lung nodule; malignancy needs to be excluded.
10
+
11
+ RECOMMENDATION:
12
+ Chest CT without contrast.
13
+
14
+ Image source: LIDC-IDRI
static/reports/Nodule5.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FINDINGS:
2
+ No pneumothorax. No substantial pleural effusion. Well-circumscribed, approximately 2 cm round nodule in medial mid right lung. No consolidation otherwise.
3
+
4
+ Normal heart size. Mediastinal shadow within normal limits.
5
+
6
+ Skeletal structures unremarkable.
7
+
8
+ IMPRESSION:
9
+ Well circumscribed, approximately 2 cm medial mid right lung nodule; malignancy needs to be excluded.
10
+
11
+ RECOMMENDATION:
12
+ Chest CT without contrast.
13
+
14
+ Image source: LIDC-IDRI
static/reports_manifest.csv ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ image_type,case_display_name,image_path,report_path
2
+ CT,Tumor,static/images/CT-Tumor.jpg,static/reports/CT-Tumor.txt
3
+ CXR,Effusion,static/images/Effusion2.jpg,static/reports/Effusion2.txt
4
+ CXR,Infection,static/images/Infection.jpg,static/reports/Infection.txt
5
+ CXR,Lymphadenopathy,static/images/Lymphadenopathy2.jpg,static/reports/Lymphadenopathy2.txt
6
+ CXR,Nodule A,static/images/Nodule3.jpg,static/reports/Nodule3.txt
7
+ CXR,Nodule B,static/images/Nodule5.jpg,static/reports/Nodule5.txt
templates/index.html ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <!--
3
+ Copyright 2025 Google LLC
4
+
5
+ Licensed under the Apache License, Version 2.0 (the "License");
6
+ you may not use this file except in compliance with the License.
7
+ You may obtain a copy of the License at
8
+
9
+ http://www.apache.org/licenses/LICENSE-2.0
10
+
11
+ Unless required by applicable law or agreed to in writing, software
12
+ distributed under the License is distributed on an "AS IS" BASIS,
13
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ See the License for the specific language governing permissions and
15
+ limitations under the License.
16
+ -->
17
+
18
+ <html lang="en">
19
+
20
+ <head>
21
+ <meta charset="UTF-8">
22
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
23
+ <title>Radiology Report Explainer</title>
24
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
25
+ <link rel="preconnect" href="https://fonts.googleapis.com">
26
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
27
+ <link
28
+ href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500&family=Google+Sans+Text:wght@500&display=swap"
29
+ rel="stylesheet">
30
+ <link
31
+ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,[email protected],100..700,0..1,-50..200"
32
+ rel="stylesheet">
33
+ <!-- Link to the correct JS file -->
34
+ <script src="https://unpkg.com/compromise" defer></script>
35
+ <script src="{{ url_for('static', filename='js/demo.js') }}" defer></script>
36
+ </head>
37
+
38
+ <body>
39
+ <div class="info">
40
+ <!-- New Info Page Content -->
41
+ <div class="info-page-container">
42
+ <div class="info-content">
43
+ <div class="info-header">
44
+ <span class="info-title-demo">Radiology Explainer Demo</span><br>
45
+ <span class="info-title-demo info-subtitle-demo">Built with</span>
46
+ <span class="info-title-med info-subtitle-demo">MedGemma</span>
47
+ </div>
48
+ <div class="info-text">Consider an educational scenario where interacting with a radiology image can
49
+ substantially improve learning. This demonstration shows how MedGemma might be built upon to provide
50
+ a useful tool for exploring radiology images and associated reports by translating them into
51
+ simple language, with visual cues to highlight the relevant areas of the image.</div>
52
+ <div class="info-disclaimer-text"><span class="info-disclaimer-title">Disclaimer</span> This
53
+ demonstration is for illustrative purposes only and does not represent a finished or approved
54
+ product. It is not representative of compliance to any regulations or standards for
55
+ quality, safety or efficacy. Any real-world application would require additional development,
56
+ training, and adaptation. The experience highlighted in this demo shows MedGemma's baseline
57
+ capability for the displayed task and is intended to help developers and users explore possible
58
+ applications and inspire further development.</div>
59
+ <button class="info-button" id="view-demo-button">View Demo</button>
60
+ </div>
61
+ </div>
62
+ </div>
63
+ <div class="main">
64
+ <div class="nav-button nav-button-back" id="back-to-info-button">
65
+ <div class="nav-button-inner">
66
+ <span class="material-symbols-outlined nav-button-icon">keyboard_arrow_left</span>
67
+ <span class="nav-button-text">Back</span>
68
+ </div>
69
+ </div>
70
+ <div class="case-selector-tabs-container" id="case-selector-tabs-container">
71
+ <div>X-Ray</div>
72
+ <div class="case-selector-tabs" id="case-selector-tabs">
73
+ {% if available_reports %}
74
+ {% for report in available_reports %}
75
+ {% if report.image_type == 'CXR' %}
76
+ <div class="nav-button nav-button-case" data-report-name="{{ report.name }}">
77
+ <div class="nav-button-inner">
78
+ <span class="nav-button-text">{{ report.name }}</span>
79
+ </div>
80
+ </div>
81
+ {% endif %}
82
+ {% endfor %}
83
+ {% else %}
84
+ <span class="no-cases-available">No cases available</span>
85
+ {% endif %}
86
+ </div>
87
+
88
+ <div>CT</div>
89
+ <div class="case-selector-tabs" id="case-selector-tabs2">
90
+ {% if available_reports %}
91
+ {% for report in available_reports %}
92
+ {% if report.image_type == 'CT' %}
93
+ <div class="nav-button nav-button-case" data-report-name="{{ report.name }}">
94
+ <div class="nav-button-inner">
95
+ <span class="nav-button-text">{{ report.name }}</span>
96
+ </div>
97
+ </div>
98
+ {% endif %}
99
+ {% endfor %}
100
+ {% else %}
101
+ <span class="no-cases-available">No cases available</span>
102
+ {% endif %}
103
+ </div>
104
+
105
+ </div>
106
+ <div class="nav-button nav-button-info" id="info-button">
107
+ <div class="nav-button-inner">
108
+ <span class="material-symbols-outlined nav-button-icon">code_blocks</span>
109
+ <span class="nav-button-text">Details about this Demo</span>
110
+ </div>
111
+ </div>
112
+
113
+
114
+
115
+ <div class="image-section">
116
+ <div id="image-modality-header" class="image-header">{{ image_type | default('Medical Image', true) }}
117
+ </div> <!-- Updated: ID added, initial text changed -->
118
+ <div id="image-container" class="image-container"> <!-- Container for relative positioning -->
119
+ <img id="report-image" src="" alt="Medical Image" style="display: none;">
120
+ <div id="image-loading" class="loading" style="display: none;">Loading image...</div>
121
+ <div id="image-error" class="error-message" style="display: none;"></div>
122
+ </div>
123
+ <div id="ct-image-note" class="image-note">
124
+ This shows a single slice of the CT. Not all elements in the report can be visualized.
125
+ </div>
126
+ </div>
127
+
128
+ <div class="report-section">
129
+ <div id="app-loading" class="loading" style="display: none;">Loading report details...</div>
130
+ <div id="app-error" class="error-message" style="display: none;"></div>
131
+ <div class="explanation-section">
132
+ <div id="explanation-output" class="explanation-box">
133
+ <div id="explanation-loading" class="loading">Generating explanation... Please wait.</div>
134
+ <div class="explanation-header">What this means
135
+ </div>
136
+ <div id="explanation-content">
137
+ Click a sentence to see the explanation here.
138
+ </div>
139
+ </div>
140
+ </div>
141
+ <div class="report-text-area">
142
+ <div id="report-text-display" >
143
+ <!-- Report text will be loaded here -->
144
+ Select a report to view its text.
145
+ </div>
146
+ <div class="floating-disclaimer">
147
+ <span class="material-symbols-outlined disclaimer-icon">warning</span>
148
+ <span class="disclaimer-text">This demonstration is for illustrative purposes of MedGemma's baseline
149
+ capability only. It does not represent a finished or approved product, is not intended to
150
+ diagnose or suggest treatment of any disease or condition, and should not be used for medical
151
+ advice.</span>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ <!-- Embed available reports data for JavaScript -->
156
+ <script id="reports-data" type="application/json">
157
+ {{ available_reports | tojson | safe }}
158
+ </script>
159
+ <div id="explanation-error" class="error-message" style="display: none;"></div>
160
+ </div>
161
+
162
+ <!-- Immersive Info Dialog -->
163
+ <div id="immersive-info-dialog" class="dialog-overlay" style="display: none;" role="dialog" aria-modal="true"
164
+ aria-labelledby="dialog-title">
165
+ <div class="dialog-box">
166
+ <button id="dialog-close-button" class="dialog-close-btn" aria-label="Close dialog">
167
+ <span class="material-symbols-outlined">close</span>
168
+ </button>
169
+ <h2 id="dialog-title" class="dialog-title-text">Details About This Demo</h2>
170
+ <div class="dialog-body-scrollable">
171
+ <p><b>The Model:</b> This demo exclusively features Google's MedGemma-4B, a Gemma 3-based model
172
+ fine-tuned for
173
+ comprehending medical text and images, such as chest X-rays. It demonstrates MedGemma's ability to
174
+ accelerate the development of AI-powered healthcare applications by offering advanced
175
+ interpretation of medical data.</p>
176
+ <p><b>Accessing and Using the Model:</b> Google's MedGemma-4B is available on <a
177
+ href="https://huggingface.co/google/medgemma-4b-it" target="_blank">HuggingFace<img
178
+ class="hf-logo"
179
+ src="https://huggingface.co/datasets/huggingface/brand-assets/resolve/main/hf-logo.svg">
180
+ </a> and
181
+ <a href="https://console.cloud.google.com/vertex-ai/publishers/google/model-garden/medgemma" target="_blank">Model
182
+ Garden <img class="hf-logo"
183
+ src="https://www.gstatic.com/cloud/images/icons/apple-icon.png"></a>.
184
+ Learn more about using the model and its limitations on the <a
185
+ href="https://developers.google.com/health-ai-developer-foundations?referral=rad_explain"
186
+ target="_blank">HAI-DEF
187
+ developer site</a>.
188
+ </p>
189
+ <p><b>Health AI Developer Foundations (HAI-DEF)</b> provides a collection of open-weight models and
190
+ companion resources to empower developers in building AI models for healthcare.</p>
191
+ <p><b>Enjoying the Demo?</b> We'd love your feedback! If you found this demo helpful, please show
192
+ your appreciation by clicking the ♡ button on the HuggingFace page, linked at the top.</p>
193
+ <p><b>Explore More Demos:</b> Discover additional demonstrations on HuggingFace Spaces or via Colabs:
194
+ </p>
195
+ <ul>
196
+ <li><a href="https://huggingface.co/spaces/google/cxr-foundation-demo?referral=rad_explain"
197
+ target="_blank">
198
+ CXR Foundations Demo <img class="hf-logo"
199
+ src="https://huggingface.co/datasets/huggingface/brand-assets/resolve/main/hf-logo.svg"></a>
200
+ -
201
+ Showcases on-browser, data-efficient, and zero-shot classification of CXR images.</li>
202
+ <li><a href="https://huggingface.co/spaces/google/path-foundation-demo?referral=rad_explain"
203
+ target="_blank">
204
+ Path Foundations Demo <img class="hf-logo"
205
+ src="https://huggingface.co/datasets/huggingface/brand-assets/resolve/main/hf-logo.svg"></a>
206
+ -
207
+ Highlights on-browser, data-efficient classification and outlier detection within pathology
208
+ slides.</li>
209
+ <li><a href="https://github.com/Google-Health/medgemma/tree/main/notebooks/fine_tune_with_hugging_face.ipynb" target="_blank">
210
+ Finetune MedGemma Colab <img class="hf-logo"
211
+ src="https://upload.wikimedia.org/wikipedia/commons/d/d0/Google_Colaboratory_SVG_Logo.svg"></a>
212
+ -
213
+ See an example of how to fine-tune this model.</li>
214
+ </ul>
215
+ </div>
216
+ </div>
217
+ </div>
218
+ </body>
219
+
220
+ </html>
tests/test_cache_store.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import unittest
16
+ from unittest.mock import patch
17
+ import importlib
18
+ import os
19
+ # Import module under test. Initial load uses actual or globally patched dependencies.
20
+ import cache_store
21
+
22
+ class TestCacheStore(unittest.TestCase):
23
+
24
+ @patch('diskcache.Cache')
25
+ @patch('os.getenv')
26
+ def test_cache_directory_env_var_set(self, mock_os_getenv, mock_DiskCache_class):
27
+ expected_dir = '/custom/cache/path'
28
+
29
+ # Mock os.getenv for this test.
30
+ def getenv_side_effect_set(key, default=None):
31
+ if key == 'CACHE_DIR':
32
+ return expected_dir
33
+ return os.environ.get(key, default) # Fallback for other env var calls.
34
+ mock_os_getenv.side_effect = getenv_side_effect_set
35
+
36
+ # Reload cache_store to apply method-specific mocks.
37
+ importlib.reload(cache_store)
38
+
39
+ # Check os.getenv call by cache_store.py.
40
+ mock_os_getenv.assert_any_call('CACHE_DIR', '/app/cache_dir')
41
+ # Check diskcache.Cache initialization with the expected directory.
42
+ mock_DiskCache_class.assert_called_once_with(expected_dir)
43
+
44
+ @patch('diskcache.Cache')
45
+ @patch('os.getenv')
46
+ def test_cache_directory_env_var_not_set(self, mock_os_getenv, mock_DiskCache_class):
47
+ # Mock os.getenv: simulate 'CACHE_DIR' not set, so it returns the default.
48
+ def getenv_side_effect_not_set(key, default=None):
49
+ if key == 'CACHE_DIR':
50
+ # 'default' will be '/app/cache_dir' from cache_store.py's call
51
+ return default
52
+ return os.environ.get(key, default) # Fallback for other env vars
53
+ mock_os_getenv.side_effect = getenv_side_effect_not_set
54
+
55
+ # Reload cache_store to apply method-specific mocks.
56
+ importlib.reload(cache_store)
57
+
58
+ # Check os.getenv call by cache_store.py.
59
+ mock_os_getenv.assert_any_call('CACHE_DIR', '/app/cache_dir')
60
+ # Check diskcache.Cache initialization with the default directory.
61
+ mock_DiskCache_class.assert_called_once_with('/app/cache_dir')
62
+
63
+ if __name__ == '__main__':
64
+ unittest.main()
utils.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import base64
16
+ import mimetypes
17
+ import logging # Used for logging
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # --- Helper Function for Base64 Encoding ---
22
+ def image_to_base64_data_url(image_path):
23
+ """Reads an image file, encodes it to base64, and returns a data URL."""
24
+ try:
25
+ mime_type, _ = mimetypes.guess_type(image_path)
26
+ if not mime_type:
27
+ # Fallback or raise error if MIME type can't be guessed
28
+ mime_type = "image/jpeg" # Defaulting to image/jpeg if MIME type detection fails
29
+ logger.warning(f"Could not determine MIME type for {image_path}, defaulting to {mime_type}")
30
+
31
+ with open(image_path, "rb") as image_file:
32
+ encoded_string = base64.b64encode(image_file.read()).decode('utf-8')
33
+ return f"data:{mime_type};base64,{encoded_string}"
34
+ except FileNotFoundError:
35
+ logger.error(f"Image file not found at {image_path} for base64 encoding.")
36
+ return None
37
+ except Exception as e:
38
+ logger.error(f"Error encoding image to base64: {e}", exc_info=True)
39
+ return None