goelak commited on
Commit
fab8051
·
0 Parent(s):

Initial commit for RadExtract

Browse files
.dockerignore ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ venv/
8
+ env/
9
+ ENV/
10
+ .venv
11
+ .env
12
+
13
+ # Testing
14
+ .pytest_cache/
15
+ .coverage
16
+ htmlcov/
17
+ .tox/
18
+ *.cover
19
+ *.log
20
+
21
+ # IDE
22
+ .vscode/
23
+ .idea/
24
+ *.swp
25
+ *.swo
26
+ *~
27
+
28
+ # OS
29
+ .DS_Store
30
+ Thumbs.db
31
+
32
+ # Git
33
+ .git/
34
+ .gitignore
35
+
36
+ # Documentation
37
+ *.md
38
+ docs/
39
+ README.md
40
+ LICENSE
41
+
42
+ # Build artifacts
43
+ dist/
44
+ build/
45
+ *.egg-info/
46
+
47
+ # Development scripts
48
+ run_local.sh
49
+ run_docker.sh
50
+ test_*.py
51
+
52
+ # Temporary files
53
+ *.tmp
54
+ *.bak
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
.gitignore ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ env.list
2
+ **/__pycache__
3
+ *.pyc
4
+ *.pyo
5
+ *.pyd
6
+ __pycache__/
7
+ .pytest_cache/
8
+ .DS_Store
9
+ *.log
10
+ venv/
11
+ .venv/
12
+ .env
13
+ .vscode/
14
+ .idea/
15
+ *.egg-info/
16
+ build/
17
+ dist/
18
+ .coverage
19
+ htmlcov/
20
+ .mypy_cache/
21
+ .ruff_cache/
22
+ *.so
23
+ *.dylib
24
+
25
+ # Local developer documentation
26
+ run_docker_dev.sh
27
+ notes/
28
+
29
+ # Video assets for demo
30
+ video_assets/
.prettierrc.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "printWidth": 80,
3
+ "tabWidth": 2,
4
+ "useTabs": false,
5
+ "semi": true,
6
+ "singleQuote": true,
7
+ "quoteProps": "as-needed",
8
+ "jsxSingleQuote": false,
9
+ "trailingComma": "all",
10
+ "bracketSpacing": true,
11
+ "bracketSameLine": false,
12
+ "arrowParens": "always",
13
+ "htmlWhitespaceSensitivity": "css",
14
+ "endOfLine": "lf"
15
+ }
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use a base image with Python
2
+ FROM python:3.11-slim
3
+
4
+ # Set the working directory
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies
8
+ RUN apt-get update && apt-get install -y --no-install-recommends git procps libmagic1 curl \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Copy the project configuration
12
+ COPY pyproject.toml /app/
13
+
14
+ # Environment variables
15
+ ENV HOME=/tmp
16
+ ENV APP_MODULE=app:app
17
+
18
+ # Install dependencies
19
+ RUN pip install --no-cache-dir -e .
20
+
21
+ # Copy all application files
22
+ COPY . /app/
23
+
24
+ # Expose the ports
25
+ EXPOSE 7870
26
+
27
+ # Health check endpoint
28
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
29
+ CMD curl -f http://localhost:7870/cache/stats || exit 1
30
+
31
+ # Copy and use the startup script
32
+ COPY start.sh /app/
33
+ RUN chmod +x /app/start.sh
34
+
35
+ CMD ["/app/start.sh"]
LICENSE ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
176
+
177
+ END OF TERMS AND CONDITIONS
178
+
179
+ APPENDIX: How to apply the Apache License to your work.
180
+
181
+ To apply the Apache License to your work, attach the following
182
+ boilerplate notice, with the fields enclosed by brackets "[]"
183
+ replaced with your own identifying information. (Don't include
184
+ the brackets!) The text should be enclosed in the appropriate
185
+ comment syntax for the file format. We also recommend that a
186
+ file or class name and description of purpose be included on the
187
+ same "printed page" as the copyright notice for easier
188
+ identification within third-party archives.
189
+
190
+ Copyright [yyyy] [name of copyright owner]
191
+
192
+ Licensed under the Apache License, Version 2.0 (the "License");
193
+ you may not use this file except in compliance with the License.
194
+ You may obtain a copy of the License at
195
+
196
+ http://www.apache.org/licenses/LICENSE-2.0
197
+
198
+ Unless required by applicable law or agreed to in writing, software
199
+ distributed under the License is distributed on an "AS IS" BASIS,
200
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201
+ See the License for the specific language governing permissions and
202
+ limitations under the License.
README.md ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: RadExtract
3
+ emoji: 🗂️
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ license: apache-2.0
9
+ header: mini
10
+ app_port: 7870
11
+ tags:
12
+ - medical
13
+ - nlp
14
+ - radiology
15
+ - langextract
16
+ - gemini
17
+ - structured-data
18
+ ---
19
+
20
+ # RadExtract: Radiology Report Structuring Demo
21
+
22
+ [![🤗 Hugging Face Spaces](https://img.shields.io/badge/🤗%20Hugging%20Face-Spaces-blue)](https://huggingface.co/spaces/google/radextract)
23
+ [![LangExtract](https://img.shields.io/badge/Powered%20by-LangExtract-green)](https://github.com/google/langextract)
24
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
25
+
26
+ A demonstration application powered by [LangExtract](https://github.com/google/langextract) that structures radiology reports using Gemini models. Transform unstructured radiology text into organized, interactive segments with clinical significance annotations.
27
+
28
+ ## Try the Demo
29
+
30
+ **[Launch RadExtract Demo](https://huggingface.co/spaces/google/radextract)**
31
+
32
+ Transform unstructured radiology reports into structured data with highlighted findings that are precisely mapped back to the original source text.
33
+
34
+ ## Key Features
35
+
36
+ - **Structured Output**: Organizes reports into anatomical sections with clinical significance
37
+ - **Interactive Highlighting**: Click any finding to see its exact source in the original text
38
+ - **Clinical Significance**: Annotates findings as minor, significant, or grounding
39
+ - **Character-Level Mapping**: Precise attribution back to source text
40
+ - **Multi-Model Support**: Gemini 2.5 Flash (fast) and Pro (comprehensive)
41
+
42
+ ## Quick Start
43
+
44
+ ### Setup
45
+
46
+ ```bash
47
+ git clone https://huggingface.co/spaces/google/radextract
48
+ cd radextract
49
+ python -m venv venv
50
+ source venv/bin/activate
51
+ pip install -e ".[dev]"
52
+ cp env.list.example env.list
53
+ # Edit env.list and set KEY=your_gemini_api_key_here
54
+ ```
55
+
56
+ ### Local Development
57
+
58
+ ```bash
59
+ source venv/bin/activate
60
+ export KEY=your_gemini_api_key_here
61
+ python app.py
62
+ ```
63
+
64
+ Access at: http://localhost:7870
65
+
66
+ ## API Usage
67
+
68
+ ### Example Request
69
+ ```bash
70
+ curl -X POST \
71
+ -H 'X-Model-ID: gemini-2.5-flash' \
72
+ -H 'X-Use-Cache: true' \
73
+ -d 'FINDINGS: Normal heart and lungs. IMPRESSION: Normal study.' \
74
+ http://localhost:7870/predict
75
+ ```
76
+
77
+ ### Response Format
78
+ ```json
79
+ {
80
+ "segments": [{
81
+ "type": "body",
82
+ "label": "Chest",
83
+ "content": "Normal heart and lungs",
84
+ "intervals": [{"startPos": 10, "endPos": 32}],
85
+ "significance": "minor"
86
+ }],
87
+ "text": "Chest:\n- Normal heart and lungs",
88
+ "annotated_document_json": {...}
89
+ }
90
+ ```
91
+
92
+ ## Architecture
93
+
94
+ - **Backend**: Flask + Python 3.10+ with full type safety
95
+ - **NLP Engine**: [LangExtract](https://github.com/google/langextract) for structured extraction
96
+ - **AI Models**: Google Gemini 2.5 (Flash/Pro)
97
+ - **Frontend**: Vanilla JavaScript with interactive UI
98
+ - **Deployment**: Docker + Hugging Face Spaces
99
+ - **Package Details**: See [pyproject.toml](https://huggingface.co/spaces/google/radextract/blob/main/pyproject.toml) for dependencies, metadata, and tooling
100
+
101
+ ## Project Structure
102
+
103
+ ```
104
+ radextract/
105
+ ├── app.py # Flask API endpoints
106
+ ├── structure_report.py # Core structuring logic
107
+ ├── sanitize.py # Text preprocessing & normalization
108
+ ├── prompt_instruction.py # LangExtract prompt
109
+ ├── cache_manager.py # Response caching
110
+ ├── static/ # Frontend assets
111
+ └── templates/ # HTML templates
112
+ ```
113
+
114
+ ## Development
115
+
116
+ ### Setup
117
+ ```bash
118
+ git clone https://huggingface.co/spaces/google/radextract
119
+ cd radextract
120
+ python -m venv venv
121
+ source venv/bin/activate
122
+ pip install -e ".[dev]"
123
+ ```
124
+
125
+ ### Code Quality
126
+ ```bash
127
+ # Format code
128
+ pyink . && isort .
129
+
130
+ # Type checking
131
+ mypy . --ignore-missing-imports
132
+
133
+ # Run tests
134
+ pytest
135
+ ```
136
+
137
+ ### Docker
138
+ ```bash
139
+ # Build and run
140
+ docker build -t radextract .
141
+ docker run -p 7870:7870 --env-file env.list radextract
142
+ ```
143
+
144
+ ## License
145
+
146
+ Apache License 2.0 - see [LICENSE](LICENSE) for details.
147
+
148
+ ## Related Projects
149
+
150
+ - **[LangExtract](https://github.com/google/langextract)**: Core NLP library
151
+
152
+ ---
153
+
154
+ **Built for the medical AI community** | **Hosted on Hugging Face Spaces**
155
+
156
+ ## Disclaimer
157
+
158
+ This is not an officially supported Google product. If you use RadExtract or LangExtract in production or publications, please cite accordingly and acknowledge usage. Use is subject to the [Apache 2.0 License](LICENSE). For health-related applications, use of LangExtract is also subject to the [Health AI Developer Foundations Terms of Use](https://developers.google.com/health-ai-foundations/terms).
159
+
app.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flask web application for radiology report structuring using Gemini models.
2
+
3
+ This module provides a web API that structures radiology reports into
4
+ semantic sections using LangExtract and Google's Gemini language models.
5
+ The application supports caching, multiple model configurations, and
6
+ provides both a web interface and REST API endpoints.
7
+
8
+ Typical usage example:
9
+
10
+ # Set environment variables
11
+ export KEY=your_gemini_api_key_here
12
+ export MODEL_ID=gemini-2.5-flash
13
+
14
+ # Run the application
15
+ python app.py
16
+ """
17
+
18
+ import logging
19
+ import os
20
+ import shutil
21
+ import tempfile
22
+ import time
23
+ import json
24
+ import hashlib
25
+
26
+ from flask import Flask, jsonify, render_template, request
27
+ from flask_limiter import Limiter
28
+ from flask_limiter.util import get_remote_address
29
+
30
+ from cache_manager import CacheManager
31
+ from sanitize import preprocess_report
32
+ from social_sharing import SocialSharingConfig
33
+ from structure_report import RadiologyReportStructurer, ResponseDict
34
+
35
+ # Configuration constants
36
+ MAX_INPUT_LENGTH = 3000
37
+
38
+ logging.basicConfig(
39
+ level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s"
40
+ )
41
+ logger = logging.getLogger(__name__)
42
+
43
+
44
+ class Model:
45
+ """Manages RadiologyReportStructurer instances for different Gemini model IDs.
46
+
47
+ This class handles initialization, caching, and coordination
48
+ of structurer instances for various model configurations, ensuring
49
+ efficient resource usage and consistent API key management.
50
+ """
51
+
52
+ def __init__(self):
53
+ """Initializes the Model manager with default structurer.
54
+
55
+ Sets up the Gemini API key from environment variables
56
+ and creates a default structurer instance for the configured model.
57
+
58
+ Raises:
59
+ ValueError: If the KEY environment variable is not set.
60
+ """
61
+ self.gemini_api_key = os.environ.get("KEY")
62
+ if not self.gemini_api_key:
63
+ logger.error("KEY environment variable not set.")
64
+ raise ValueError("KEY environment variable not set.")
65
+
66
+ self._structurers: dict[str, RadiologyReportStructurer] = {}
67
+
68
+ default_model_id = os.environ.get("MODEL_ID", "gemini-2.5-flash")
69
+ self._structurers[default_model_id] = RadiologyReportStructurer(
70
+ api_key=self.gemini_api_key,
71
+ model_id=default_model_id,
72
+ )
73
+
74
+ logger.info(
75
+ f"RadExtract ready [Worker {os.getpid()}] with model: {default_model_id}"
76
+ )
77
+
78
+ def _get_structurer(self, model_id: str) -> RadiologyReportStructurer:
79
+ """Returns a cached or newly created structurer for the given model ID.
80
+
81
+ Args:
82
+ model_id: Identifier for the specific model configuration.
83
+
84
+ Returns:
85
+ RadiologyReportStructurer instance for the specified model.
86
+ """
87
+ if model_id not in self._structurers:
88
+ logger.info(f"Creating structurer for model: {model_id}")
89
+ self._structurers[model_id] = RadiologyReportStructurer(
90
+ api_key=self.gemini_api_key,
91
+ model_id=model_id,
92
+ )
93
+ return self._structurers[model_id]
94
+
95
+ def predict(self, data: str, model_id: str) -> ResponseDict:
96
+ """Processes prediction request using the specified model.
97
+
98
+ Args:
99
+ data: Input text data to be processed.
100
+ model_id: Identifier for the model to use for processing.
101
+
102
+ Returns:
103
+ Dictionary containing the structured prediction results.
104
+ """
105
+ logger.info(f"Processing prediction with model: {model_id}")
106
+ structurer = self._get_structurer(model_id)
107
+ result = structurer.predict(data)
108
+ logger.info(f"Result preview: {str(result)[:500]}...")
109
+ return result
110
+
111
+
112
+ model = Model()
113
+
114
+
115
+ # Copy prebuilt cache to writable location if it exists
116
+ def setup_cache():
117
+ """Sets up the cache directory and copies prebuilt cache files.
118
+
119
+ Creates a writable cache directory in /tmp and copies any existing
120
+ prebuilt cache files to ensure the latest version is available.
121
+
122
+ Returns:
123
+ Path to the configured cache directory.
124
+ """
125
+ cache_dir = tempfile.gettempdir() + "/cache"
126
+ os.makedirs(cache_dir, exist_ok=True)
127
+
128
+ source_cache = "cache/sample_cache.json"
129
+ target_cache = os.path.join(cache_dir, "sample_cache.json")
130
+
131
+ if os.path.exists(source_cache) and not os.path.exists(target_cache):
132
+ shutil.copy2(source_cache, target_cache)
133
+ logger.info(f"Initialized cache with {os.path.getsize(target_cache)} bytes")
134
+
135
+ return cache_dir
136
+
137
+
138
+ cache_dir = setup_cache()
139
+ cache_manager = CacheManager(cache_dir=cache_dir)
140
+
141
+ app = Flask(
142
+ __name__,
143
+ static_url_path="/static",
144
+ static_folder="static",
145
+ template_folder="templates",
146
+ )
147
+
148
+ # Initialize rate limiter
149
+ limiter = Limiter(
150
+ get_remote_address,
151
+ app=app,
152
+ default_limits=[
153
+ os.environ.get("RATE_LIMIT_DAY", "200 per day"),
154
+ os.environ.get("RATE_LIMIT_HOUR", "50 per hour"),
155
+ ],
156
+ storage_uri="memory://",
157
+ )
158
+
159
+
160
+ @app.route("/")
161
+ def index():
162
+ """Renders the main application interface.
163
+
164
+ Returns:
165
+ Rendered HTML template for the application index page.
166
+ """
167
+ # Get social sharing context
168
+ social_context = SocialSharingConfig.get_sharing_context(request.url_root)
169
+
170
+ return render_template("index.html", **social_context)
171
+
172
+
173
+ @app.route("/cache/stats")
174
+ def cache_stats():
175
+ """Returns cache performance statistics.
176
+
177
+ Returns:
178
+ JSON response containing cache usage and performance statistics.
179
+ """
180
+ return jsonify(cache_manager.get_cache_stats())
181
+
182
+
183
+ @app.route("/predict", methods=["POST"])
184
+ @limiter.limit(os.environ.get("RATE_LIMIT_PREDICT", "100 per hour"))
185
+ def predict():
186
+ """Processes radiology report text and returns structured results.
187
+
188
+ Accepts raw text via POST request body with optional headers
189
+ for caching, sample identification, and model selection. Supports
190
+ both cached and real-time processing modes.
191
+
192
+ Returns:
193
+ JSON response containing structured report segments, annotations,
194
+ and formatted text. Includes cache status when applicable.
195
+
196
+ Raises:
197
+ 500: If processing fails due to invalid input or model errors.
198
+ """
199
+ start_time = time.time()
200
+
201
+ try:
202
+ data = request.get_data(as_text=True)
203
+
204
+ # Validate input to ensure it meets API requirements
205
+ if not data or not data.strip():
206
+ return (
207
+ jsonify(
208
+ {
209
+ "error": "Empty input",
210
+ "message": "Input text is required",
211
+ "max_length": MAX_INPUT_LENGTH,
212
+ }
213
+ ),
214
+ 400,
215
+ )
216
+
217
+ if len(data) > MAX_INPUT_LENGTH:
218
+ return (
219
+ jsonify(
220
+ {
221
+ "error": "Input too long",
222
+ "message": f"Input length ({len(data)} characters) exceeds maximum allowed length of {MAX_INPUT_LENGTH} characters",
223
+ "max_length": MAX_INPUT_LENGTH,
224
+ }
225
+ ),
226
+ 400,
227
+ )
228
+
229
+ use_cache = request.headers.get("X-Use-Cache", "true").lower() == "true"
230
+ sample_id = request.headers.get("X-Sample-ID")
231
+ model_id = request.headers.get(
232
+ "X-Model-ID", os.environ.get("MODEL_ID", "gemini-2.5-flash")
233
+ )
234
+ processed_data = preprocess_report(data)
235
+
236
+ if use_cache:
237
+ cached_result = cache_manager.get_cached_result(processed_data, sample_id)
238
+ if cached_result:
239
+ req_id = hashlib.md5(
240
+ f"{request.remote_addr}{int(time.time()/3600)}".encode()
241
+ ).hexdigest()[:8]
242
+ logger.info(
243
+ f"🟢 CACHE HIT [Req {req_id}] [Worker {os.getpid()}] - Returning cached result (no API call)"
244
+ )
245
+ return jsonify({"from_cache": True, **cached_result})
246
+
247
+ try:
248
+ req_id = hashlib.md5(
249
+ f"{request.remote_addr}{int(time.time()/3600)}".encode()
250
+ ).hexdigest()[:8]
251
+ logger.info(
252
+ f"🔴 API CALL [Req {req_id}] [Worker {os.getpid()}] - Processing with Gemini model: {model_id}"
253
+ )
254
+ result = model.predict(processed_data, model_id=model_id)
255
+
256
+ if use_cache:
257
+ cache_manager.cache_result(processed_data, result, sample_id)
258
+
259
+ result["sanitized_input"] = processed_data
260
+
261
+ return jsonify(result)
262
+
263
+ except TypeError as te:
264
+ error_msg = str(te)
265
+ logger.error(f"TypeError in prediction: {error_msg}", exc_info=True)
266
+
267
+ return (
268
+ jsonify({"error": "Processing error. Please try a different input."}),
269
+ 500,
270
+ )
271
+ except Exception as e:
272
+ logger.error(f"Prediction error: {str(e)}", exc_info=True)
273
+
274
+ return jsonify({"error": str(e)}), 500
275
+
276
+
277
+ @app.errorhandler(429)
278
+ def ratelimit_handler(e):
279
+ """Handle rate limit exceeded errors."""
280
+ return (
281
+ jsonify(
282
+ {
283
+ "error": "Rate limit exceeded. Please try again later.",
284
+ "message": str(e.description),
285
+ }
286
+ ),
287
+ 429,
288
+ )
289
+
290
+
291
+ if __name__ == "__main__":
292
+ logger.info("Starting development server")
293
+ app.run(host="0.0.0.0", port=7870, debug=True)
cache/sample_cache.json ADDED
The diff for this file is too large to render. See raw diff
 
cache_manager.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cache management for radiology report structuring results.
2
+
3
+ This module provides the CacheManager class that handles caching of
4
+ structured radiology report results to improve performance and reduce
5
+ API calls. Supports both sample-based and custom text caching with
6
+ JSON file persistence.
7
+
8
+ Example usage:
9
+
10
+ cache_manager = CacheManager(cache_dir="cache")
11
+ cached_result = cache_manager.get_cached_result(report_text, sample_id)
12
+ if not cached_result:
13
+ result = process_report(report_text)
14
+ cache_manager.cache_result(report_text, result, sample_id)
15
+ """
16
+
17
+ import hashlib
18
+ import json
19
+ import logging
20
+ import os
21
+ import time
22
+ from typing import Any
23
+
24
+ from langextract.data import AnnotatedDocument, CharInterval, Extraction
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class CacheManager:
30
+ """Manages caching of radiology report structuring results.
31
+
32
+ This class provides efficient caching capabilities for structured
33
+ radiology report results, supporting both file-based persistence
34
+ and in-memory access with automatic cache key generation and management.
35
+
36
+ Attributes:
37
+ cache_dir: Directory path for cache storage.
38
+ cache_file: Full path to the cache JSON file.
39
+ """
40
+
41
+ def __init__(self, cache_dir: str = "cache"):
42
+ """Initializes the CacheManager with specified cache directory.
43
+
44
+ Args:
45
+ cache_dir: Directory path for cache storage. Defaults to "cache".
46
+ """
47
+ self.cache_dir = cache_dir
48
+ self.cache_file = os.path.join(cache_dir, "sample_cache.json")
49
+ self._cache_data: dict[str, Any] = {}
50
+ self._load_cache()
51
+
52
+ def _ensure_cache_dir(self):
53
+ """Ensures the cache directory exists, creating it if necessary."""
54
+ os.makedirs(self.cache_dir, exist_ok=True)
55
+
56
+ def _load_cache(self):
57
+ """Loads existing cache data from file into memory.
58
+
59
+ Attempts to load cache from the JSON file. If the file doesn't
60
+ exist or cannot be loaded, initializes with an empty cache.
61
+ """
62
+ try:
63
+ if os.path.exists(self.cache_file):
64
+ with open(self.cache_file, "r", encoding="utf-8") as f:
65
+ self._cache_data = json.load(f)
66
+ logger.info(f"Loaded cache with {len(self._cache_data)} entries")
67
+ else:
68
+ self._cache_data = {}
69
+ logger.info("No existing cache file found, starting with empty cache")
70
+ except Exception as e:
71
+ logger.error(f"Error loading cache: {e}")
72
+ self._cache_data = {}
73
+
74
+ def _save_cache(self):
75
+ """Saves current cache data to the JSON file.
76
+
77
+ Ensures the cache directory exists before writing the cache data
78
+ to the JSON file with proper formatting.
79
+ """
80
+ try:
81
+ self._ensure_cache_dir()
82
+ with open(self.cache_file, "w", encoding="utf-8") as f:
83
+ json.dump(self._cache_data, f, indent=2, ensure_ascii=False)
84
+ logger.info(f"Saved cache with {len(self._cache_data)} entries")
85
+ except Exception as e:
86
+ logger.error(f"Error saving cache: {e}")
87
+
88
+ def _get_cache_key(self, text: str, sample_id: str | None = None) -> str:
89
+ """Generates a cache key for the given text and optional sample ID.
90
+
91
+ Args:
92
+ text: The input text to generate a key for.
93
+ sample_id: Optional sample identifier for predefined samples.
94
+
95
+ Returns:
96
+ A string cache key, either sample-based or hash-based.
97
+ """
98
+ if sample_id:
99
+ # Avoid double "sample_" prefix if sample_id already starts with "sample_"
100
+ if sample_id.startswith("sample_"):
101
+ return sample_id
102
+ else:
103
+ return f"sample_{sample_id}"
104
+ else:
105
+ return f"custom_{hashlib.md5(text.encode('utf-8')).hexdigest()}"
106
+
107
+ def get_cached_result(self, text: str, sample_id: str | None = None) -> dict | None:
108
+ """Gets cached result for given text.
109
+
110
+ Args:
111
+ text: The input text to look up.
112
+ sample_id: Optional sample identifier for predefined samples.
113
+
114
+ Returns:
115
+ The cached result dictionary if found, None otherwise.
116
+ """
117
+ cache_key = self._get_cache_key(text, sample_id)
118
+ result = self._cache_data.get(cache_key)
119
+ if result:
120
+ logger.info(f"Cache hit for key: {cache_key}")
121
+ return result
122
+
123
+ def _dict_to_extraction(self, extraction_dict: dict[str, Any]) -> Extraction:
124
+ """Converts a cached extraction dictionary to an Extraction object."""
125
+ char_interval = None
126
+ if extraction_dict.get("char_interval"):
127
+ interval_data = extraction_dict["char_interval"]
128
+ char_interval = CharInterval(
129
+ start_pos=interval_data.get("start_pos"),
130
+ end_pos=interval_data.get("end_pos"),
131
+ )
132
+
133
+ return Extraction(
134
+ extraction_text=extraction_dict.get("extraction_text", ""),
135
+ extraction_class=extraction_dict.get("extraction_class", ""),
136
+ attributes=extraction_dict.get("attributes", {}),
137
+ char_interval=char_interval,
138
+ alignment_status=extraction_dict.get("alignment_status"),
139
+ )
140
+
141
+ def convert_cached_response_to_annotated_document(
142
+ self, cached_response: dict[str, Any]
143
+ ) -> AnnotatedDocument:
144
+ """Converts a cached response to an AnnotatedDocument with proper Extraction objects."""
145
+ extractions = []
146
+ if (
147
+ "annotated_document_json" in cached_response
148
+ and "extractions" in cached_response["annotated_document_json"]
149
+ ):
150
+ for extraction_dict in cached_response["annotated_document_json"][
151
+ "extractions"
152
+ ]:
153
+ extractions.append(self._dict_to_extraction(extraction_dict))
154
+
155
+ return AnnotatedDocument(text="", extractions=extractions)
156
+
157
+ def cache_result(
158
+ self, text: str, result: dict[str, Any] | Any, sample_id: str | None = None
159
+ ) -> None:
160
+ """Caches result for given text.
161
+
162
+ Args:
163
+ text: The input text to cache results for.
164
+ result: The structured result dictionary to cache.
165
+ sample_id: Optional sample identifier for predefined samples.
166
+ """
167
+ cache_key = self._get_cache_key(text, sample_id)
168
+ self._cache_data[cache_key] = result
169
+ self._save_cache()
170
+ logger.info(f"Cached result for key: {cache_key}")
171
+
172
+ def clear_cache(self) -> None:
173
+ """Clears all cached results and saves the empty cache to file."""
174
+ self._cache_data = {}
175
+ self._save_cache()
176
+ logger.info("Cache cleared")
177
+
178
+ def remove_sample(self, sample_id: str) -> bool:
179
+ """Removes a specific sample from cache.
180
+
181
+ Args:
182
+ sample_id: The sample identifier to remove.
183
+
184
+ Returns:
185
+ True if the sample was found and removed, False otherwise.
186
+ """
187
+ cache_key = f"sample_{sample_id}"
188
+ if cache_key in self._cache_data:
189
+ del self._cache_data[cache_key]
190
+ self._save_cache()
191
+ logger.info(f"Removed sample {sample_id} from cache")
192
+ return True
193
+ else:
194
+ logger.warning(f"Sample {sample_id} not found in cache")
195
+ return False
196
+
197
+ def prepopulate_cache_with_samples(
198
+ self,
199
+ sample_reports: list[dict[str, Any]],
200
+ structurer_callable,
201
+ force_refresh: bool = False,
202
+ ) -> None:
203
+ """Prepopulates cache with sample reports.
204
+
205
+ Processes a list of sample reports and caches their structured
206
+ results to improve initial application performance. Includes rate
207
+ limiting and error handling for robust cache population.
208
+
209
+ Args:
210
+ sample_reports: List of sample report dictionaries with 'id' and 'text'.
211
+ structurer_callable: Function to call for structuring reports.
212
+ force_refresh: If True, reprocesses samples even if already cached.
213
+ """
214
+ if not sample_reports:
215
+ logger.info("No sample reports provided for cache prepopulation")
216
+ return
217
+
218
+ logger.info(f"Starting cache prepopulation with {len(sample_reports)} samples")
219
+
220
+ lock_file = os.path.join(self.cache_dir, ".cache_lock")
221
+ if os.path.exists(lock_file) and not force_refresh:
222
+ logger.info("Cache prepopulation already in progress or recently completed")
223
+ return
224
+
225
+ try:
226
+ self._ensure_cache_dir()
227
+ with open(lock_file, "w") as f:
228
+ f.write(str(os.getpid()))
229
+
230
+ for i, sample in enumerate(sample_reports):
231
+ sample_id = sample.get("id")
232
+ sample_text = sample.get("text", "")
233
+
234
+ if not sample_id or not sample_text:
235
+ logger.warning(f"Sample {i} missing id or text, skipping")
236
+ continue
237
+
238
+ if not force_refresh and self.get_cached_result(sample_text, sample_id):
239
+ logger.info(f"Sample {sample_id} already cached, skipping")
240
+ continue
241
+
242
+ logger.info(
243
+ f"Processing sample {sample_id} ({i+1}/{len(sample_reports)})"
244
+ )
245
+
246
+ try:
247
+ result = structurer_callable(sample_text)
248
+ self.cache_result(sample_text, result, sample_id)
249
+ logger.info(f"Successfully cached sample {sample_id}")
250
+ except Exception as e:
251
+ logger.error(f"Error processing sample {sample_id}: {e}")
252
+ continue
253
+
254
+ time.sleep(6)
255
+
256
+ logger.info("Cache prepopulation completed")
257
+ self._save_cache()
258
+
259
+ except Exception as e:
260
+ logger.error(f"Error during cache prepopulation: {e}")
261
+ finally:
262
+ if os.path.exists(lock_file):
263
+ os.remove(lock_file)
264
+
265
+ def get_cache_stats(self) -> dict[str, Any]:
266
+ """Gets cache statistics.
267
+
268
+ Returns:
269
+ Dictionary containing cache statistics including entry counts,
270
+ file information, and cache status details.
271
+ """
272
+ sample_count = sum(
273
+ 1 for key in self._cache_data.keys() if key.startswith("sample_")
274
+ )
275
+ custom_count = sum(
276
+ 1 for key in self._cache_data.keys() if key.startswith("custom_")
277
+ )
278
+
279
+ return {
280
+ "total_entries": len(self._cache_data),
281
+ "sample_entries": sample_count,
282
+ "custom_entries": custom_count,
283
+ "cache_file": self.cache_file,
284
+ "cache_file_exists": os.path.exists(self.cache_file),
285
+ }
env.list.example ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Copy this file to env.list and fill in your actual API key
2
+ KEY=your_gemini_api_key_here
3
+ MODEL_ID=gemini-2.5-flash
prompt_instruction.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core prompt template for radiology report structuring.
2
+
3
+ This module provides the main prompt template used to guide the LangExtract
4
+ system in categorizing radiology report text into semantic sections
5
+ (prefix, body, suffix) with appropriate clinical significance annotations.
6
+
7
+ The prompt includes comprehensive instruction templates with detailed guidelines
8
+ for handling different report formats and edge cases, ensuring consistent and
9
+ accurate structuring across various radiology report types.
10
+ """
11
+
12
+ import textwrap
13
+
14
+ PROMPT_INSTRUCTION = textwrap.dedent(
15
+ """\
16
+ # RadExtract Prompt
17
+
18
+ ## Task Description
19
+
20
+ You are a medical assistant specialized in categorizing radiology text into sections:
21
+
22
+ - **findings_prefix** -- All text that appears before the actual "findings" content.
23
+ - **findings_body** -- The main 'Findings' section. Each finding is classified into a possible section through a list of attributes, some of which may also be assigned to a subheader.
24
+ - **findings_suffix** -- Any text that appears after the "findings" portion (such as "Impression" or other concluding content).
25
+
26
+ ### Section Categories:
27
+ - **findings_prefix**: Use only for header information before clinical findings (examination details, clinical indication, technique). Never use for actual clinical observations or pathological findings.
28
+ - **findings_body**: Use for all clinical findings, observations, and pathological descriptions.
29
+ - **findings_suffix**: Use only for conclusions, impressions, or recommendations that appear after the main findings.
30
+
31
+ ### Critical Rule:
32
+ If a report contains only clinical findings without any header information, do not create a findings_prefix extraction. Start directly with findings_body extractions for the clinical content.
33
+
34
+ **Example of findings-only content (NO prefix needed):**
35
+ Input: "There is a small joint effusion. The cartilage shows thinning."
36
+ Correct: Create only findings_body extractions for each clinical finding.
37
+ Incorrect: Do not categorize clinical findings as findings_prefix.
38
+
39
+ ### Professional Output Standards:
40
+ All extracted text must maintain the grammatical correctness and professional coherence expected in radiology reports. Ensure that:
41
+ - All sentences are complete and grammatically correct
42
+ - Medical terminology is used appropriately and consistently
43
+ - The language remains professional and clinical in tone
44
+ - Correct obvious typos (e.g., "splen" → "spleen", "kidny" → "kidney")
45
+ - Any modifications to the original text preserve the intended medical meaning
46
+ - Minor typos are corrected and optimal punctuation is used
47
+
48
+ ### Empty prefix or suffix sections:
49
+ Only create extractions for sections that actually exist in the text. Do not create empty prefix or suffix sections if there is no corresponding content in the source text. If the text is findings-only without any impression/conclusion, do not create a findings_suffix extraction.
50
+
51
+ ### Section Usage Guidelines:
52
+
53
+ **findings_prefix**: Reserved exclusively for header information that appears before clinical findings, such as:
54
+ - Examination details (type of study, technique)
55
+ - Clinical indication or history
56
+ - Comparison studies referenced
57
+ - Technical parameters
58
+
59
+ **findings_body**: Contains the actual clinical findings and observations from the imaging study.
60
+
61
+ **findings_suffix**: Reserved for concluding content that follows the findings, such as impressions or recommendations.
62
+
63
+ **Critical Rule**: Clinical findings should never be categorized as prefix content. If a report begins directly with clinical observations without any header information, create only findings_body and findings_suffix extractions as appropriate.
64
+
65
+ ### Special guidance for findings_prefix organization:
66
+ When the report has detailed prefix information with clear section headers (like EXAMINATION, CLINICAL INDICATION, COMPARISON, TECHNIQUE), create separate extractions for each section rather than one large block. Use the "section" attribute to label each part:
67
+ - "Examination" for exam type/title
68
+ - "Clinical Indication" for clinical history/reason for study
69
+ - "Comparison" for prior studies referenced
70
+ - "Technique" for imaging parameters and acquisition details
71
+
72
+ **Important:** Even when examination information appears at the beginning without an explicit "EXAMINATION:" header, it should still be labeled with section:"Examination". This includes standalone exam descriptions that identify the type of imaging study being performed.
73
+
74
+ Always recognize examination-type content and use section:"Examination" regardless of whether it has an explicit header.
75
+
76
+ This structured approach provides better organization and readability.
77
+
78
+ ### Critical for findings_suffix:
79
+ Do NOT include headers like "IMPRESSION:", "CONCLUSION:", etc. in the extraction_text. Only extract the actual content that follows these headers. The formatting system will add appropriate headers automatically.
80
+
81
+ **Example:** If the text contains "IMPRESSION: 1. Severe arthritis. 2. Labral tear.", extract only "1. Severe arthritis. 2. Labral tear." as the extraction_text.
82
+
83
+ ### Additional Notes for findings_body:
84
+ - If a single sentence references multiple structures with a shared status (e.g., "liver, gallbladder, spleen appear unremarkable"), please split them into separate extraction lines, each referencing the relevant structure.
85
+ - If the text mentions subheaders like "CT ABDOMEN" or "CERVICAL SPINE," only create/retain that subheader if it clearly organizes multiple organ-structure findings under it. Do not force subheaders if only 1 or 2 lines belong there. A subheader should ideally group 3+ sections to be meaningful.
86
+
87
+ ### Special guidance for spine reports:
88
+ - For spine imaging (MRI, CT), organize findings by anatomical level using the format: "Lumbar Spine Levels: L1-L2", "Lumbar Spine Levels: L2-L3", "Cervical Spine Levels: C5-C6", etc.
89
+ - Separate general spine findings (alignment, lordosis, vertebral heights) from level-specific findings
90
+ - Use dedicated sections for: "Spinal Cord", "Bones" (for marrow/vertebral body lesions), "Paraspinal Soft Tissues" (for muscle findings)
91
+ - Each spinal level should get its own section when findings are described level-by-level
92
+ - This level-by-level organization is preferred over generic "Spine" labeling for clinical utility
93
+
94
+ ### Non-spine skeletal findings:
95
+ For non-spine skeletal findings, unify them under a single section like "Bones." Only keep laterality (Right/Left) if there is symmetry in the findings.
96
+
97
+ ## Required JSON Format
98
+
99
+ Each final answer must be valid JSON with an array key "extractions". Each "extraction" is an object with:
100
+
101
+ ```json
102
+ {{
103
+ "text": "...",
104
+ "category": "findings_prefix" | "findings_body" | "findings_suffix",
105
+ "attributes": {{}}
106
+ }}
107
+ ```
108
+
109
+ Within "attributes" each attribute should be a key-value pair as shown in the examples below. The attribute **"clinical_significance"** MUST be included for findings_body extractions and should be one of: **"normal"**, **"minor"**, **"significant"**, or **"not_applicable"** to indicate the importance of the finding.
110
+
111
+ ---
112
+
113
+ # Few-Shot Examples
114
+
115
+ The following examples demonstrate how to properly structure different types of radiology reports:
116
+
117
+ {examples}
118
+
119
+ {inference_section}
120
+ """
121
+ ).strip()
prompt_lib.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Processing utilities for radiology report structuring prompts.
2
+
3
+ This module provides helper functions for processing and formatting prompts
4
+ used in the LangExtract system for radiology report structuring.
5
+ """
6
+
7
+ import dataclasses
8
+ import json
9
+ from typing import Optional
10
+
11
+ from langextract.data import ExampleData
12
+ from langextract.data_lib import enum_asdict_factory
13
+
14
+ from prompt_instruction import PROMPT_INSTRUCTION
15
+
16
+
17
+ def clean_dict(obj):
18
+ """Removes null values and empty objects/lists from dictionary recursively.
19
+
20
+ This function recursively traverses a dictionary or list structure
21
+ and removes any keys with null values, empty dictionaries, or empty
22
+ lists to create cleaner JSON output for the prompt examples.
23
+
24
+ Args:
25
+ obj: The object to clean (dict, list, or primitive value).
26
+
27
+ Returns:
28
+ The cleaned object with null/empty values removed.
29
+ """
30
+ if isinstance(obj, dict):
31
+ cleaned = {}
32
+ for key, value in obj.items():
33
+ cleaned_value = clean_dict(value)
34
+ # Only include non-null, non-empty values
35
+ if (
36
+ cleaned_value is not None
37
+ and cleaned_value != {}
38
+ and cleaned_value != []
39
+ ):
40
+ cleaned[key] = cleaned_value
41
+ return cleaned
42
+ elif isinstance(obj, list):
43
+ return [clean_dict(item) for item in obj if clean_dict(item) is not None]
44
+ else:
45
+ return obj
46
+
47
+
48
+ def generate_markdown_prompt(
49
+ examples: list[ExampleData], input_text: Optional[str] = None
50
+ ) -> str:
51
+ """Generate markdown prompt with examples using LangExtract's enum_asdict_factory.
52
+
53
+ Args:
54
+ examples: List of ExampleData objects for few-shot learning
55
+ input_text: Optional input text to include in inference example
56
+
57
+ Returns:
58
+ Formatted markdown string containing the complete prompt
59
+ """
60
+ examples_list = []
61
+
62
+ for i, example in enumerate(examples, 1):
63
+ example_dict = dataclasses.asdict(example, dict_factory=enum_asdict_factory)
64
+
65
+ # Clean up null values and empty objects
66
+ cleaned_extractions = clean_dict({"extractions": example_dict["extractions"]})
67
+ json_output = json.dumps(cleaned_extractions, indent=2)
68
+
69
+ example_section = f"""## Example {i}
70
+
71
+ **Input Text:**
72
+ ```
73
+ {example.text}
74
+ ```
75
+
76
+ **Expected Output:**
77
+ ```json
78
+ {json_output}
79
+ ```"""
80
+ examples_list.append(example_section)
81
+
82
+ examples_formatted = "\n\n---\n\n".join(examples_list)
83
+
84
+ # Format inference section if input text provided
85
+ inference_section = ""
86
+ if input_text:
87
+ inference_section = f"""
88
+
89
+ ## Inference Example:
90
+
91
+ **Input Text:**
92
+ ```
93
+ {input_text}
94
+ ```
95
+
96
+ **Expected Output:**
97
+ """
98
+
99
+ return PROMPT_INSTRUCTION.format(
100
+ examples=examples_formatted, inference_section=inference_section
101
+ )
pyproject.toml ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "radextract"
7
+ version = "0.1.0"
8
+ description = "Radiology Report Structuring Demo using LangExtract"
9
+ readme = "README.md"
10
+ license = {text = "Apache-2.0"}
11
+ authors = [
12
+ {name = "Akshay Goel", email = "[email protected]"},
13
+ ]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Healthcare Industry",
17
+ "Intended Audience :: Science/Research",
18
+ "License :: OSI Approved :: Apache Software License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
26
+ "Topic :: Text Processing :: Linguistic",
27
+ ]
28
+ requires-python = ">=3.10"
29
+ dependencies = [
30
+ "Flask>=3.1.0",
31
+ "Flask-Limiter>=3.5.0",
32
+ "gunicorn>=23.0.0",
33
+ "langextract>=0.1.3",
34
+ "pandas>=1.3.0",
35
+ "numpy>=1.20.0",
36
+ "ml-collections>=0.1.0",
37
+ "pydantic>=1.8.0",
38
+ "requests>=2.25.0",
39
+ "typing-extensions>=4.0.0",
40
+ "more-itertools>=8.0.0",
41
+ "langfun>=0.1.0",
42
+ "google-genai>=0.1.0",
43
+ "python-dotenv>=1.0.0",
44
+ "ftfy>=6.0.0",
45
+ ]
46
+
47
+ [project.optional-dependencies]
48
+ dev = [
49
+ "pytest==7.4.0",
50
+ "pylint==2.17.5",
51
+ "pyink==24.10.1",
52
+ "autoflake==2.3.1",
53
+ ]
54
+
55
+ [project.urls]
56
+ Homepage = "https://huggingface.co/spaces/google/radextract"
57
+ Repository = "https://huggingface.co/spaces/google/radextract"
58
+ "Source Code" = "https://github.com/google/langextract"
59
+ Documentation = "https://github.com/google/langextract"
60
+
61
+ [tool.setuptools]
62
+ packages = ["radextract"]
63
+
64
+ [tool.setuptools.package-dir]
65
+ radextract = "."
66
+
67
+ [tool.pyink]
68
+ line-length = 88
69
+ target-version = ['py39']
70
+ pyink-indentation = 4
71
+ pyink-use-majority-quotes = true
72
+
73
+ [tool.pylint.messages_control]
74
+ disable = [
75
+ "missing-docstring",
76
+ "too-few-public-methods",
77
+ "too-many-arguments",
78
+ "too-many-locals",
79
+ "too-many-branches",
80
+ "too-many-statements",
81
+ ]
82
+
83
+ [tool.pytest.ini_options]
84
+ testpaths = ["tests"]
85
+ python_files = ["test_*.py", "*_test.py"]
report_examples.py ADDED
@@ -0,0 +1,645 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Example radiology reports for training the structuring model.
2
+
3
+ This module contains curated examples of radiology reports with their
4
+ corresponding structured extractions. These examples are used for few-shot
5
+ learning with LangExtract to train the model on proper categorization of
6
+ report sections into prefix, body, and suffix components with appropriate
7
+ clinical significance labels.
8
+
9
+ The examples cover various imaging modalities including CT, MRI, and different
10
+ anatomical regions (spine, abdomen, brain, knee) to provide comprehensive
11
+ training coverage for the radiology report structuring task.
12
+ """
13
+
14
+ import textwrap
15
+ from enum import Enum
16
+
17
+ import langextract as lx
18
+
19
+
20
+ class ReportSectionType(Enum):
21
+ PREFIX = "findings_prefix"
22
+ BODY = "findings_body"
23
+ SUFFIX = "findings_suffix"
24
+
25
+
26
+ def get_examples_for_model() -> list[lx.data.ExampleData]:
27
+ """Examples that structure radiology reports into semantic sections.
28
+
29
+ Returns:
30
+ List of ExampleData objects containing radiology report examples
31
+ with their corresponding structured extractions for training
32
+ the language model.
33
+ """
34
+ return [
35
+ lx.data.ExampleData(
36
+ text=textwrap.dedent(
37
+ """\
38
+ EXAMINATION: CT ABDOMEN AND PELVIS WITH IV CONTRAST
39
+ CLINICAL INDICATION: Abdominal pain.
40
+ COMPARISON: None.
41
+ TECHNIQUE: Axial images of the abdomen and pelvis were obtained following the administration of intravenous contrast material. Coronal and sagittal reformations were reviewed.
42
+
43
+ FINDINGS:
44
+ No acute abnormality is seen in the visualized lung bases. The liver is normal in size and contour. There is a 1.2 cm simple-appearing low-attenuation lesion in hepatic segment VII, consistent with a cyst. The gallbladder contains numerous calcified gallstones, compatible with cholelithiasis.
45
+
46
+ IMPRESSION:
47
+ 1. Cholelithiasis without evidence of acute cholecystitis.
48
+ 2. Hepatic cyst.
49
+ """
50
+ ).rstrip(),
51
+ extractions=[
52
+ lx.data.Extraction(
53
+ extraction_text="EXAMINATION: CT ABDOMEN AND PELVIS WITH IV CONTRAST",
54
+ extraction_class="findings_prefix",
55
+ attributes={
56
+ "section": "Examination",
57
+ },
58
+ ),
59
+ lx.data.Extraction(
60
+ extraction_text="CLINICAL INDICATION: Abdominal pain.",
61
+ extraction_class="findings_prefix",
62
+ attributes={
63
+ "section": "Clinical Indication",
64
+ },
65
+ ),
66
+ lx.data.Extraction(
67
+ extraction_text="COMPARISON: None.",
68
+ extraction_class="findings_prefix",
69
+ attributes={
70
+ "section": "Comparison",
71
+ },
72
+ ),
73
+ lx.data.Extraction(
74
+ extraction_text="TECHNIQUE: Axial images of the abdomen and pelvis were obtained following the administration of intravenous contrast material. Coronal and sagittal reformations were reviewed.",
75
+ extraction_class="findings_prefix",
76
+ attributes={
77
+ "section": "Technique",
78
+ },
79
+ ),
80
+ lx.data.Extraction(
81
+ extraction_text="No acute abnormality is seen in the visualized lung bases.",
82
+ extraction_class="findings_body",
83
+ attributes={
84
+ "section": "Lungs",
85
+ "clinical_significance": "normal",
86
+ },
87
+ ),
88
+ lx.data.Extraction(
89
+ extraction_text="The liver is normal in size and contour.",
90
+ extraction_class="findings_body",
91
+ attributes={
92
+ "section": "Liver",
93
+ "clinical_significance": "normal",
94
+ },
95
+ ),
96
+ lx.data.Extraction(
97
+ extraction_text="There is a 1.2 cm simple-appearing low-attenuation lesion in hepatic segment VII, consistent with a cyst.",
98
+ extraction_class="findings_body",
99
+ attributes={
100
+ "section": "Liver",
101
+ "clinical_significance": "minor",
102
+ },
103
+ ),
104
+ lx.data.Extraction(
105
+ extraction_text="The gallbladder contains numerous calcified gallstones, compatible with cholelithiasis.",
106
+ extraction_class="findings_body",
107
+ attributes={
108
+ "section": "Gallbladder",
109
+ "clinical_significance": "minor",
110
+ },
111
+ ),
112
+ lx.data.Extraction(
113
+ extraction_text="1. Cholelithiasis without evidence of acute cholecystitis.\n2. Hepatic cyst.",
114
+ extraction_class="findings_suffix",
115
+ attributes={},
116
+ ),
117
+ ],
118
+ ),
119
+ lx.data.ExampleData(
120
+ text=textwrap.dedent(
121
+ """\
122
+ CLINICAL HISTORY:
123
+ Low back pain, rule out disc herniation
124
+
125
+ MRI LUMBAR SPINE WITHOUT CONTRAST:
126
+
127
+ FINDINGS:
128
+ The lumbar lordosis is maintained. Vertebral body heights are preserved.
129
+
130
+ There is a small hemangioma in the L3 vertebral body.
131
+
132
+ The conus medullaris terminates at L1 and appears normal.
133
+
134
+ At L2-L3, there is mild disc desiccation without significant stenosis.
135
+
136
+ At L3-L4, a small posterior disc bulge causes mild central canal narrowing.
137
+
138
+ At L4-L5, there is a large posterior disc herniation with severe central canal stenosis and nerve root impingement.
139
+
140
+ At L5-S1, mild disc bulge without significant stenosis.
141
+
142
+ The paraspinal musculature appears unremarkable.
143
+
144
+ IMPRESSION:
145
+ Large L4-L5 disc herniation with severe stenosis.
146
+ """
147
+ ).rstrip(),
148
+ extractions=[
149
+ lx.data.Extraction(
150
+ extraction_text="CLINICAL HISTORY:\nLow back pain, rule out disc herniation\n\nMRI LUMBAR SPINE WITHOUT CONTRAST:",
151
+ extraction_class="findings_prefix",
152
+ attributes={},
153
+ ),
154
+ lx.data.Extraction(
155
+ extraction_text="The lumbar lordosis is maintained. Vertebral body heights are preserved.",
156
+ extraction_class="findings_body",
157
+ attributes={
158
+ "section": "Lumbar Spine",
159
+ "clinical_significance": "normal",
160
+ },
161
+ ),
162
+ lx.data.Extraction(
163
+ extraction_text="There is a small hemangioma in the L3 vertebral body.",
164
+ extraction_class="findings_body",
165
+ attributes={
166
+ "section": "Bones",
167
+ "clinical_significance": "minor",
168
+ },
169
+ ),
170
+ lx.data.Extraction(
171
+ extraction_text="The conus medullaris terminates at L1 and appears normal.",
172
+ extraction_class="findings_body",
173
+ attributes={
174
+ "section": "Spinal Cord",
175
+ "clinical_significance": "normal",
176
+ },
177
+ ),
178
+ lx.data.Extraction(
179
+ extraction_text="At L2-L3, there is mild disc desiccation without significant stenosis.",
180
+ extraction_class="findings_body",
181
+ attributes={
182
+ "section": "Lumbar Spine Levels: L2-L3",
183
+ "clinical_significance": "minor",
184
+ },
185
+ ),
186
+ lx.data.Extraction(
187
+ extraction_text="At L3-L4, a small posterior disc bulge causes mild central canal narrowing.",
188
+ extraction_class="findings_body",
189
+ attributes={
190
+ "section": "Lumbar Spine Levels: L3-L4",
191
+ "clinical_significance": "minor",
192
+ },
193
+ ),
194
+ lx.data.Extraction(
195
+ extraction_text="At L4-L5, there is a large posterior disc herniation with severe central canal stenosis and nerve root impingement.",
196
+ extraction_class="findings_body",
197
+ attributes={
198
+ "section": "Lumbar Spine Levels: L4-L5",
199
+ "clinical_significance": "significant",
200
+ },
201
+ ),
202
+ lx.data.Extraction(
203
+ extraction_text="At L5-S1, mild disc bulge without significant stenosis.",
204
+ extraction_class="findings_body",
205
+ attributes={
206
+ "section": "Lumbar Spine Levels: L5-S1",
207
+ "clinical_significance": "minor",
208
+ },
209
+ ),
210
+ lx.data.Extraction(
211
+ extraction_text="The paraspinal musculature appears unremarkable.",
212
+ extraction_class="findings_body",
213
+ attributes={
214
+ "section": "Paraspinal Soft Tissues",
215
+ "clinical_significance": "normal",
216
+ },
217
+ ),
218
+ lx.data.Extraction(
219
+ extraction_text="Large L4-L5 disc herniation with severe stenosis.",
220
+ extraction_class="findings_suffix",
221
+ attributes={},
222
+ ),
223
+ ],
224
+ ),
225
+ lx.data.ExampleData(
226
+ text=textwrap.dedent(
227
+ """\
228
+ INDICATION:
229
+ Neck pain, radiculopathy
230
+
231
+ MRI CERVICAL SPINE:
232
+
233
+ FINDINGS:
234
+ Normal cervical lordosis is maintained. No vertebral body compression fractures.
235
+
236
+ The cervical spinal cord demonstrates normal signal intensity.
237
+
238
+ At C3-C4, no significant disc disease or stenosis.
239
+
240
+ At C4-C5, mild disc osteophyte complex with mild foraminal narrowing.
241
+
242
+ At C5-C6, moderate disc herniation with moderate central canal stenosis.
243
+
244
+ At C6-C7, small disc bulge without significant stenosis.
245
+
246
+ IMPRESSION:
247
+ Moderate C5-C6 disc herniation and stenosis.
248
+ """
249
+ ).rstrip(),
250
+ extractions=[
251
+ lx.data.Extraction(
252
+ extraction_text="INDICATION: \nNeck pain, radiculopathy\n\nMRI CERVICAL SPINE:",
253
+ extraction_class="findings_prefix",
254
+ attributes={},
255
+ ),
256
+ lx.data.Extraction(
257
+ extraction_text="Normal cervical lordosis is maintained. No vertebral body compression fractures.",
258
+ extraction_class="findings_body",
259
+ attributes={
260
+ "section": "Cervical Spine",
261
+ "clinical_significance": "normal",
262
+ },
263
+ ),
264
+ lx.data.Extraction(
265
+ extraction_text="The cervical spinal cord demonstrates normal signal intensity.",
266
+ extraction_class="findings_body",
267
+ attributes={
268
+ "section": "Spinal Cord",
269
+ "clinical_significance": "normal",
270
+ },
271
+ ),
272
+ lx.data.Extraction(
273
+ extraction_text="At C3-C4, no significant disc disease or stenosis.",
274
+ extraction_class="findings_body",
275
+ attributes={
276
+ "section": "Cervical Spine Levels: C3-C4",
277
+ "clinical_significance": "normal",
278
+ },
279
+ ),
280
+ lx.data.Extraction(
281
+ extraction_text="At C4-C5, mild disc osteophyte complex with mild foraminal narrowing.",
282
+ extraction_class="findings_body",
283
+ attributes={
284
+ "section": "Cervical Spine Levels: C4-C5",
285
+ "clinical_significance": "minor",
286
+ },
287
+ ),
288
+ lx.data.Extraction(
289
+ extraction_text="At C5-C6, moderate disc herniation with moderate central canal stenosis.",
290
+ extraction_class="findings_body",
291
+ attributes={
292
+ "section": "Cervical Spine Levels: C5-C6",
293
+ "clinical_significance": "significant",
294
+ },
295
+ ),
296
+ lx.data.Extraction(
297
+ extraction_text="At C6-C7, small disc bulge without significant stenosis.",
298
+ extraction_class="findings_body",
299
+ attributes={
300
+ "section": "Cervical Spine Levels: C6-C7",
301
+ "clinical_significance": "minor",
302
+ },
303
+ ),
304
+ lx.data.Extraction(
305
+ extraction_text="Moderate C5-C6 disc herniation and stenosis.",
306
+ extraction_class="findings_suffix",
307
+ attributes={},
308
+ ),
309
+ ],
310
+ ),
311
+ lx.data.ExampleData(
312
+ text=textwrap.dedent(
313
+ """\
314
+ TECHNIQUE:
315
+ Multidetector helical CT from lung bases to adrenals with and without intravenous contrast.
316
+
317
+ FINDINGS:
318
+ LIVER/GALLBLADDER/SPLEEN: The liver has a normal appearance. Gallbladder wall appears normal. The spleen is normal in size.
319
+
320
+ PANCREAS/ADRENALS: The pancreas and bilateral adrenal glands appear unremarkable.
321
+
322
+ RETROPERITONEUM: No lymphadenopathy. No fluid collection.
323
+
324
+ IMPRESSION:
325
+ Normal abdominal CT.
326
+ """
327
+ ).rstrip(),
328
+ extractions=[
329
+ lx.data.Extraction(
330
+ extraction_text="TECHNIQUE: \nMultidetector helical CT from lung bases to adrenals with and without intravenous contrast.",
331
+ extraction_class="findings_prefix",
332
+ attributes={},
333
+ ),
334
+ lx.data.Extraction(
335
+ extraction_text="The liver has a normal appearance.",
336
+ extraction_class="findings_body",
337
+ attributes={
338
+ "section": "Liver",
339
+ "clinical_significance": "normal",
340
+ },
341
+ ),
342
+ lx.data.Extraction(
343
+ extraction_text="Gallbladder wall appears normal.",
344
+ extraction_class="findings_body",
345
+ attributes={
346
+ "section": "Gallbladder",
347
+ "clinical_significance": "normal",
348
+ },
349
+ ),
350
+ lx.data.Extraction(
351
+ extraction_text="The spleen is normal in size.",
352
+ extraction_class="findings_body",
353
+ attributes={
354
+ "section": "Spleen",
355
+ "clinical_significance": "normal",
356
+ },
357
+ ),
358
+ lx.data.Extraction(
359
+ extraction_text="The pancreas and bilateral adrenal glands appear unremarkable.",
360
+ extraction_class="findings_body",
361
+ attributes={
362
+ "section": "Pancreas/Adrenals",
363
+ "clinical_significance": "normal",
364
+ },
365
+ ),
366
+ lx.data.Extraction(
367
+ extraction_text="No lymphadenopathy.",
368
+ extraction_class="findings_body",
369
+ attributes={
370
+ "section": "Retroperitoneum",
371
+ "clinical_significance": "normal",
372
+ },
373
+ ),
374
+ lx.data.Extraction(
375
+ extraction_text="No fluid collection.",
376
+ extraction_class="findings_body",
377
+ attributes={
378
+ "section": "Retroperitoneum",
379
+ "clinical_significance": "normal",
380
+ },
381
+ ),
382
+ lx.data.Extraction(
383
+ extraction_text="Normal abdominal CT.",
384
+ extraction_class="findings_suffix",
385
+ attributes={},
386
+ ),
387
+ ],
388
+ ),
389
+ lx.data.ExampleData(
390
+ text=textwrap.dedent(
391
+ """\
392
+ HISTORY:
393
+ Lower abdominal pain
394
+
395
+ CT ABDOMEN/PELVIS WITH CONTRAST:
396
+
397
+ FINDINGS:
398
+ LIVER: Multiple hepatic metastases are present, measuring up to 3.2 cm.
399
+
400
+ KIDNEYS: The left kidney shows moderate hydronephrosis. The right kidney appears normal.
401
+
402
+ LYMPH NODES: Enlarged retroperitoneal lymph nodes, largest measuring 2.1 cm.
403
+
404
+ IMPRESSION:
405
+ 1. Multiple hepatic metastases
406
+ 2. Left hydronephrosis
407
+ 3. Retroperitoneal lymphadenopathy
408
+ """
409
+ ).rstrip(),
410
+ extractions=[
411
+ lx.data.Extraction(
412
+ extraction_text="HISTORY: \nLower abdominal pain\n\nCT ABDOMEN/PELVIS WITH CONTRAST:",
413
+ extraction_class="findings_prefix",
414
+ attributes={},
415
+ ),
416
+ lx.data.Extraction(
417
+ extraction_text="Multiple hepatic metastases are present, measuring up to 3.2 cm.",
418
+ extraction_class="findings_body",
419
+ attributes={
420
+ "section": "Liver",
421
+ "clinical_significance": "significant",
422
+ },
423
+ ),
424
+ lx.data.Extraction(
425
+ extraction_text="The left kidney shows moderate hydronephrosis.",
426
+ extraction_class="findings_body",
427
+ attributes={
428
+ "section": "Kidneys",
429
+ "clinical_significance": "significant",
430
+ },
431
+ ),
432
+ lx.data.Extraction(
433
+ extraction_text="The right kidney appears normal.",
434
+ extraction_class="findings_body",
435
+ attributes={
436
+ "section": "Kidneys",
437
+ "clinical_significance": "normal",
438
+ },
439
+ ),
440
+ lx.data.Extraction(
441
+ extraction_text="Enlarged retroperitoneal lymph nodes, largest measuring 2.1 cm.",
442
+ extraction_class="findings_body",
443
+ attributes={
444
+ "section": "Lymph Nodes",
445
+ "clinical_significance": "significant",
446
+ },
447
+ ),
448
+ lx.data.Extraction(
449
+ extraction_text="1. Multiple hepatic metastases\n2. Left hydronephrosis \n3. Retroperitoneal lymphadenopathy",
450
+ extraction_class="findings_suffix",
451
+ attributes={},
452
+ ),
453
+ ],
454
+ ),
455
+ lx.data.ExampleData(
456
+ text=textwrap.dedent(
457
+ """\
458
+ EXAMINATION:
459
+ MRI brain without contrast
460
+
461
+ CLINICAL HISTORY:
462
+ Headaches
463
+
464
+ FINDINGS:
465
+ The brain parenchyma demonstrates normal signal intensity. No mass lesions are identified.
466
+
467
+ The ventricular system is normal in size and configuration.
468
+
469
+ No abnormal enhancement is seen.
470
+
471
+ IMPRESSION:
472
+ Normal brain MRI.
473
+ """
474
+ ).rstrip(),
475
+ extractions=[
476
+ lx.data.Extraction(
477
+ extraction_text="EXAMINATION:\nMRI brain without contrast\n\nCLINICAL HISTORY:\nHeadaches",
478
+ extraction_class="findings_prefix",
479
+ attributes={},
480
+ ),
481
+ lx.data.Extraction(
482
+ extraction_text="The brain parenchyma demonstrates normal signal intensity.",
483
+ extraction_class="findings_body",
484
+ attributes={
485
+ "section": "Brain Parenchyma",
486
+ "clinical_significance": "normal",
487
+ },
488
+ ),
489
+ lx.data.Extraction(
490
+ extraction_text="No mass lesions are identified.",
491
+ extraction_class="findings_body",
492
+ attributes={
493
+ "section": "Brain Parenchyma",
494
+ "clinical_significance": "normal",
495
+ },
496
+ ),
497
+ lx.data.Extraction(
498
+ extraction_text="The ventricular system is normal in size and configuration.",
499
+ extraction_class="findings_body",
500
+ attributes={
501
+ "section": "Ventricular System",
502
+ "clinical_significance": "normal",
503
+ },
504
+ ),
505
+ lx.data.Extraction(
506
+ extraction_text="No abnormal enhancement is seen.",
507
+ extraction_class="findings_body",
508
+ attributes={
509
+ "section": "Enhancement",
510
+ "clinical_significance": "normal",
511
+ },
512
+ ),
513
+ lx.data.Extraction(
514
+ extraction_text="Normal brain MRI.",
515
+ extraction_class="findings_suffix",
516
+ attributes={},
517
+ ),
518
+ ],
519
+ ),
520
+ lx.data.ExampleData(
521
+ text=textwrap.dedent(
522
+ """\
523
+ INDICATION:
524
+ Right knee pain
525
+
526
+ MRI RIGHT KNEE:
527
+
528
+ FINDINGS:
529
+ MENISCI: There is a complex tear of the medial meniscus. The lateral meniscus appears intact.
530
+
531
+ LIGAMENTS: The ACL shows complete rupture. The PCL, MCL, and LCL are intact.
532
+
533
+ BONES: Mild bone marrow edema is present in the medial femoral condyle.
534
+
535
+ IMPRESSION:
536
+ 1. Complex medial meniscal tear
537
+ 2. Complete ACL rupture
538
+ 3. Bone marrow edema in medial femoral condyle
539
+ """
540
+ ).rstrip(),
541
+ extractions=[
542
+ lx.data.Extraction(
543
+ extraction_text="INDICATION:\nRight knee pain\n\nMRI RIGHT KNEE:",
544
+ extraction_class="findings_prefix",
545
+ attributes={},
546
+ ),
547
+ lx.data.Extraction(
548
+ extraction_text="There is a complex tear of the medial meniscus.",
549
+ extraction_class="findings_body",
550
+ attributes={
551
+ "section": "Menisci",
552
+ "clinical_significance": "significant",
553
+ },
554
+ ),
555
+ lx.data.Extraction(
556
+ extraction_text="The lateral meniscus appears intact.",
557
+ extraction_class="findings_body",
558
+ attributes={
559
+ "section": "Menisci",
560
+ "clinical_significance": "normal",
561
+ },
562
+ ),
563
+ lx.data.Extraction(
564
+ extraction_text="The ACL shows complete rupture.",
565
+ extraction_class="findings_body",
566
+ attributes={
567
+ "section": "Ligaments",
568
+ "clinical_significance": "significant",
569
+ },
570
+ ),
571
+ lx.data.Extraction(
572
+ extraction_text="The PCL, MCL, and LCL are intact.",
573
+ extraction_class="findings_body",
574
+ attributes={
575
+ "section": "Ligaments",
576
+ "clinical_significance": "normal",
577
+ },
578
+ ),
579
+ lx.data.Extraction(
580
+ extraction_text="Mild bone marrow edema is present in the medial femoral condyle.",
581
+ extraction_class="findings_body",
582
+ attributes={
583
+ "section": "Bones",
584
+ "clinical_significance": "minor",
585
+ },
586
+ ),
587
+ lx.data.Extraction(
588
+ extraction_text="1. Complex medial meniscal tear\n2. Complete ACL rupture\n3. Bone marrow edema in medial femoral condyle",
589
+ extraction_class="findings_suffix",
590
+ attributes={},
591
+ ),
592
+ ],
593
+ ),
594
+ lx.data.ExampleData(
595
+ text=textwrap.dedent(
596
+ """\
597
+ EXAMINATION: CT CHEST
598
+
599
+ FINDINGS:
600
+ The longs are clear bilaterally. The hart size is normal. No pleural effushion.
601
+
602
+ IMPRESSION:
603
+ Normal chest CT.
604
+ """
605
+ ).rstrip(),
606
+ extractions=[
607
+ lx.data.Extraction(
608
+ extraction_text="EXAMINATION: CT CHEST",
609
+ extraction_class="findings_prefix",
610
+ attributes={
611
+ "section": "Examination",
612
+ },
613
+ ),
614
+ lx.data.Extraction(
615
+ extraction_text="The lungs are clear bilaterally.",
616
+ extraction_class="findings_body",
617
+ attributes={
618
+ "section": "Lungs",
619
+ "clinical_significance": "normal",
620
+ },
621
+ ),
622
+ lx.data.Extraction(
623
+ extraction_text="The heart size is normal.",
624
+ extraction_class="findings_body",
625
+ attributes={
626
+ "section": "Heart",
627
+ "clinical_significance": "normal",
628
+ },
629
+ ),
630
+ lx.data.Extraction(
631
+ extraction_text="No pleural effusion.",
632
+ extraction_class="findings_body",
633
+ attributes={
634
+ "section": "Pleura",
635
+ "clinical_significance": "normal",
636
+ },
637
+ ),
638
+ lx.data.Extraction(
639
+ extraction_text="Normal chest CT.",
640
+ extraction_class="findings_suffix",
641
+ attributes={},
642
+ ),
643
+ ],
644
+ ),
645
+ ]
run_docker.sh ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ set -e
4
+
5
+ # Colors for output
6
+ RED='\033[0;31m'
7
+ GREEN='\033[0;32m'
8
+ YELLOW='\033[1;33m'
9
+ NC='\033[0m'
10
+
11
+ echo -e "${GREEN}Setting up radextract with Docker${NC}"
12
+
13
+ # Check if Docker is running
14
+ if ! docker info >/dev/null 2>&1; then
15
+ echo -e "${RED}Error: Docker is not running. Please start Docker Desktop.${NC}"
16
+ exit 1
17
+ fi
18
+
19
+ # Check if env.list exists
20
+ if [ ! -f "env.list" ]; then
21
+ echo -e "${RED}Error: env.list file not found!${NC}"
22
+ echo "Please create env.list with your API keys and configuration."
23
+ exit 1
24
+ fi
25
+
26
+ # Stop and remove existing container if it exists
27
+ echo -e "${YELLOW}Cleaning up existing containers...${NC}"
28
+ docker stop radiology-report-app 2>/dev/null || true
29
+ docker rm radiology-report-app 2>/dev/null || true
30
+
31
+ # Build the Docker image
32
+ echo -e "${YELLOW}Building Docker image...${NC}"
33
+ docker build -t radiology-report-app .
34
+
35
+ # Run the container
36
+ echo -e "${YELLOW}Starting application in Docker container...${NC}"
37
+ docker run -d \
38
+ --name radiology-report-app \
39
+ --env-file env.list \
40
+ -p 7870:7870 \
41
+ -v "$(pwd)/cache:/app/cache" \
42
+ radiology-report-app
43
+
44
+ # Wait for the application to start
45
+ echo -e "${YELLOW}Waiting for application to start...${NC}"
46
+ sleep 5
47
+
48
+ # Check if the application is running
49
+ if curl -s http://localhost:7870/ >/dev/null; then
50
+ echo -e "${GREEN}Application is running at http://localhost:7870/${NC}"
51
+ echo ""
52
+ echo "To view logs: docker logs -f radiology-report-app"
53
+ echo "To stop: docker stop radiology-report-app"
54
+ echo "To restart: docker restart radiology-report-app"
55
+ else
56
+ echo -e "${RED}Application failed to start. Check logs with: docker logs radiology-report-app${NC}"
57
+ exit 1
58
+ fi
run_local.sh ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ set -e
4
+
5
+ # Colors for output
6
+ RED='\033[0;31m'
7
+ GREEN='\033[0;32m'
8
+ YELLOW='\033[1;33m'
9
+ NC='\033[0m'
10
+
11
+ echo -e "${GREEN}Setting up radextract development environment${NC}"
12
+
13
+ # Check if virtual environment exists
14
+ if [ ! -d "venv" ]; then
15
+ echo -e "${YELLOW}Creating virtual environment...${NC}"
16
+ python3 -m venv venv
17
+ fi
18
+
19
+ # Activate virtual environment
20
+ echo -e "${YELLOW}Activating virtual environment...${NC}"
21
+ source venv/bin/activate
22
+
23
+ # Install dependencies
24
+ echo -e "${YELLOW}Installing dependencies...${NC}"
25
+ if [ "$1" = "dev" ]; then
26
+ echo -e "${YELLOW}Installing with development dependencies...${NC}"
27
+ pip install -e ".[dev]"
28
+ else
29
+ pip install -e .
30
+ fi
31
+
32
+ # Check if env.list exists
33
+ if [ ! -f "env.list" ]; then
34
+ echo -e "${RED}Error: env.list file not found!${NC}"
35
+ echo -e "${YELLOW}Please create env.list with required environment variables${NC}"
36
+ exit 1
37
+ fi
38
+
39
+ # Load environment variables
40
+ echo -e "${YELLOW}Loading environment variables...${NC}"
41
+ export $(cat env.list | xargs)
42
+
43
+ # Start the application
44
+ echo -e "${GREEN}Starting radextract application on http://localhost:7870${NC}"
45
+ python app.py
sanitize.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text preprocessing for radiology reports with complex Unicode and formatting.
2
+
3
+ Handles reports containing complex Unicode symbolic characters and non-standard
4
+ structural formatting that are not currently supported by the prompt and LangExtract
5
+ library. Prevents timeout issues by normalizing problematic characters and structures
6
+ to formats compatible with downstream processing.
7
+
8
+ Typical usage example:
9
+
10
+ from sanitize import preprocess_report
11
+
12
+ clean_text = preprocess_report(raw_report)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+
19
+ import ftfy
20
+
21
+ _TRANSLATE = str.maketrans(
22
+ {
23
+ 0x2022: "*",
24
+ 0x25CF: "*",
25
+ 0x27A1: "->",
26
+ 0xF0E0: "->",
27
+ 0x2192: "->",
28
+ 0x2190: "<-",
29
+ 0x00D7: "x",
30
+ 0x2191: "up",
31
+ 0x2642: "male",
32
+ 0x2640: "female",
33
+ 0x2010: "-",
34
+ 0x2013: "-",
35
+ 0x2014: "-",
36
+ 0x00A0: " ",
37
+ }
38
+ )
39
+
40
+ _WS = re.compile(r"[ \t]+")
41
+ _BLANKS = re.compile(r"\n\s*\n\s*\n+")
42
+
43
+ # Structure normalization patterns
44
+ _BEGIN = re.compile(r"---\s*BEGIN [^-]+---\n*", re.I)
45
+ _END = re.compile(r"\n*---\s*END [^-]+---\s*", re.I)
46
+ _HEADER = re.compile(r"\*{3}\s*([^*]+?)\s*\*{3}", re.I)
47
+ _BULLET_HDR = re.compile(r"^[ \t]*[\*\u2022\u25CF-]+\s*", re.M)
48
+ _ENUM = re.compile(r"^[ \t]*(\d+)[\)\.][ \t]+", re.M)
49
+
50
+
51
+ def sanitize_text(text: str) -> str:
52
+ """Sanitizes Unicode characters and normalizes whitespace.
53
+
54
+ Applies ftfy text repair, translates problematic Unicode symbols to ASCII
55
+ equivalents, normalizes whitespace, and removes excessive blank lines.
56
+
57
+ Args:
58
+ text: The input text to sanitize.
59
+
60
+ Returns:
61
+ Sanitized text with Unicode issues resolved and whitespace normalized.
62
+ """
63
+ out = ftfy.fix_text(text, remove_control_chars=True, normalization="NFC")
64
+ out = out.translate(_TRANSLATE)
65
+ out = _WS.sub(" ", out)
66
+ out = out.replace("\r\n", "\n").replace("\r", "\n")
67
+ out = _BLANKS.sub("\n\n", out)
68
+ return out.strip()
69
+
70
+
71
+ def normalize_structure(text: str) -> str:
72
+ """Normalizes structural elements in radiology reports.
73
+
74
+ Removes report wrappers, converts asterisk headers to colon format,
75
+ removes bullet prefixes, and standardizes enumerations.
76
+
77
+ Args:
78
+ text: The input text to normalize.
79
+
80
+ Returns:
81
+ Text with structural elements normalized for consistent formatting.
82
+ """
83
+ text = _BEGIN.sub("", text)
84
+ text = _END.sub("", text)
85
+ text = _HEADER.sub(lambda m: f"{m.group(1).strip()}:", text)
86
+ text = _BULLET_HDR.sub("", text)
87
+ text = _ENUM.sub(lambda m: f"{m.group(1)}. ", text)
88
+ return text.strip()
89
+
90
+
91
+ def preprocess_report(raw: str) -> str:
92
+ """Preprocesses radiology reports with sanitization and normalization.
93
+
94
+ Combines Unicode sanitization and structural normalization to prepare
95
+ radiology reports for downstream processing. This is the main entry point
96
+ for text preprocessing.
97
+
98
+ Args:
99
+ raw: The raw radiology report text.
100
+
101
+ Returns:
102
+ Preprocessed text ready for structured extraction.
103
+ """
104
+ return normalize_structure(sanitize_text(raw))
social_sharing.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Social sharing configuration and utilities for RadExtract.
3
+
4
+ This module handles all social media sharing functionality including
5
+ URL generation, message formatting, and platform-specific configurations.
6
+ """
7
+
8
+ from urllib.parse import quote_plus
9
+
10
+
11
+ class SocialSharingConfig:
12
+ """Configuration and utilities for social media sharing."""
13
+
14
+ # Production URL for consistent sharing
15
+ PRODUCTION_URL = "https://google-radextract.hf.space"
16
+
17
+ # Twitter/X share message
18
+ TWITTER_MESSAGE = (
19
+ "Check out this new demo from @AkshayGoelMD and the team @GoogleResearch: Gemini + LangExtract structure & optimize radiology reports.\n\n"
20
+ "Try it here! → https://google-radextract.hf.space \n\n"
21
+ "#Gemini #LangExtract #RadExtract #OpenSource #Google #Radiology"
22
+ )
23
+
24
+ # LinkedIn sharing content
25
+ LINKEDIN_TITLE = "RadExtract – Radiology Report Structuring Demo"
26
+ LINKEDIN_SUMMARY = "Gemini-powered radiology report structuring demo"
27
+
28
+ @classmethod
29
+ def get_sharing_context(cls, request_url_root):
30
+ """
31
+ Generate all social sharing variables for template rendering.
32
+
33
+ Args:
34
+ request_url_root: The root URL from Flask request
35
+
36
+ Returns:
37
+ dict: All variables needed for social sharing in templates
38
+ """
39
+ page_url = request_url_root.rstrip("/")
40
+
41
+ # Use production URL for sharing (consistent experience, localhost won't work for previews)
42
+ share_url_for_sharing = (
43
+ cls.PRODUCTION_URL if "localhost" in page_url else page_url
44
+ )
45
+
46
+ return {
47
+ "share_url": page_url,
48
+ "share_url_for_sharing": share_url_for_sharing,
49
+ "share_url_encoded": quote_plus(share_url_for_sharing),
50
+ "share_text": quote_plus(cls.TWITTER_MESSAGE),
51
+ "linkedin_title": quote_plus(cls.LINKEDIN_TITLE),
52
+ "linkedin_summary": quote_plus(cls.LINKEDIN_SUMMARY),
53
+ }
start.sh ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # Check if persistent storage is available and set up logging accordingly
4
+ if [ -d "/data" ]; then
5
+ mkdir -p /data/logs
6
+ LOG_FILE="/data/logs/radextract-$(date +%Y-%m-%d).log"
7
+ exec gunicorn \
8
+ --workers 6 \
9
+ --worker-class sync \
10
+ --timeout 60 \
11
+ --keep-alive 5 \
12
+ --error-logfile - \
13
+ --log-level warning \
14
+ -b 0.0.0.0:7870 \
15
+ app:app 2>&1 | tee -a "$LOG_FILE"
16
+ else
17
+ # No persistent storage, just run normally
18
+ exec gunicorn \
19
+ --workers 6 \
20
+ --worker-class sync \
21
+ --timeout 60 \
22
+ --keep-alive 5 \
23
+ --error-logfile - \
24
+ --log-level warning \
25
+ -b 0.0.0.0:7870 \
26
+ app:app
27
+ fi
static/copy.js ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Copy functionality for RadExtract output
3
+ * Modular, testable, and maintainable
4
+ */
5
+
6
+ /**
7
+ * Initialize the copy button with event listener
8
+ */
9
+ export function initCopyButton() {
10
+ const btn = document.getElementById('copy-output');
11
+ if (!btn) return;
12
+
13
+ // Add accessibility attributes
14
+ btn.setAttribute('aria-label', 'Copy findings to clipboard');
15
+
16
+ btn.addEventListener('click', async () => {
17
+ const text = buildTextToCopy();
18
+ if (!text) return;
19
+
20
+ const succeeded = await copyToClipboard(text);
21
+ if (succeeded) flashSuccess(btn);
22
+ });
23
+
24
+ // Initialize button state based on output availability
25
+ updateCopyButtonState();
26
+ }
27
+
28
+ /**
29
+ * Build the text to copy based on current mode and output
30
+ * @returns {string} Text to copy, or empty string if nothing to copy
31
+ */
32
+ function buildTextToCopy() {
33
+ // ① Raw-JSON mode
34
+ if (document.getElementById('raw-toggle')?.checked) {
35
+ const rawOutput = document.getElementById('raw-output');
36
+ const json = rawOutput?._jsonData;
37
+ return json ? JSON.stringify(json, null, 2) : '';
38
+ }
39
+
40
+ // ② Pre-computed plain text (preferred path)
41
+ const outputEl = document.getElementById('output-text');
42
+ if (outputEl?.dataset.copy) {
43
+ return outputEl.dataset.copy;
44
+ }
45
+
46
+ // ③ Fallback: parse DOM structure (legacy support)
47
+ return parseDOMStructure(outputEl) || outputEl?.textContent || '';
48
+ }
49
+
50
+ /**
51
+ * Parse DOM structure to extract formatted text (fallback method)
52
+ * @param {HTMLElement} container - Output container element
53
+ * @returns {string} Formatted text
54
+ */
55
+ function parseDOMStructure(container) {
56
+ if (!container || !container.children.length) return '';
57
+
58
+ const sections = [];
59
+
60
+ // Get all section headers and content
61
+ const sectionHeaders = container.querySelectorAll('.section-header');
62
+ sectionHeaders.forEach((header) => {
63
+ sections.push(header.textContent);
64
+
65
+ let nextElement = header.nextElementSibling;
66
+ while (nextElement && !nextElement.classList.contains('section-header')) {
67
+ if (nextElement.classList.contains('primary-label')) {
68
+ sections.push('\n' + nextElement.textContent);
69
+ } else if (nextElement.classList.contains('finding-list')) {
70
+ nextElement.querySelectorAll('li').forEach((li) => {
71
+ sections.push('• ' + li.textContent.trim());
72
+ });
73
+ } else if (nextElement.classList.contains('single-finding')) {
74
+ sections.push('- ' + nextElement.textContent.trim());
75
+ } else if (nextElement.textContent.trim()) {
76
+ sections.push(nextElement.textContent.trim());
77
+ }
78
+ nextElement = nextElement.nextElementSibling;
79
+ }
80
+ sections.push(''); // Add blank line after each section
81
+ });
82
+
83
+ // Handle prefix content (like examination type)
84
+ const allContent = container.children;
85
+ if (
86
+ allContent.length > 0 &&
87
+ !allContent[0].classList.contains('section-header')
88
+ ) {
89
+ const prefixContent = [];
90
+ for (let i = 0; i < allContent.length; i++) {
91
+ if (allContent[i].classList.contains('section-header')) break;
92
+ if (allContent[i].textContent.trim()) {
93
+ prefixContent.push(allContent[i].textContent.trim());
94
+ }
95
+ }
96
+ if (prefixContent.length > 0) {
97
+ return prefixContent.join('\n') + '\n\n' + sections.join('\n');
98
+ }
99
+ }
100
+
101
+ return sections
102
+ .join('\n')
103
+ .replace(/\n{3,}/g, '\n\n')
104
+ .trim();
105
+ }
106
+
107
+ /**
108
+ * Copy text to clipboard with fallback for older browsers
109
+ * @param {string} text - Text to copy
110
+ * @returns {Promise<boolean>} Success status
111
+ */
112
+ async function copyToClipboard(text) {
113
+ // Check if clipboard API is available and secure context
114
+ if (navigator.clipboard && window.isSecureContext) {
115
+ try {
116
+ await navigator.clipboard.writeText(text);
117
+ return true;
118
+ } catch (err) {
119
+ console.warn('Clipboard API failed, trying fallback:', err);
120
+ return legacyCopy(text);
121
+ }
122
+ } else {
123
+ // Use fallback for older browsers or insecure contexts
124
+ return legacyCopy(text);
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Legacy clipboard copy using execCommand
130
+ * @param {string} text - Text to copy
131
+ * @returns {boolean} Success status
132
+ */
133
+ function legacyCopy(text) {
134
+ const ta = Object.assign(document.createElement('textarea'), {
135
+ value: text,
136
+ style: 'position:fixed;left:-9999px',
137
+ });
138
+ document.body.appendChild(ta);
139
+ ta.select();
140
+
141
+ let ok = false;
142
+ try {
143
+ ok = document.execCommand('copy');
144
+ } catch (err) {
145
+ console.error('Legacy copy failed:', err);
146
+ }
147
+
148
+ document.body.removeChild(ta);
149
+ return ok;
150
+ }
151
+
152
+ /**
153
+ * Show success feedback on button
154
+ * @param {HTMLElement} button - Copy button element
155
+ */
156
+ function flashSuccess(button) {
157
+ button.classList.add('copied');
158
+ button.setAttribute('title', 'Copied!');
159
+
160
+ setTimeout(() => {
161
+ button.classList.remove('copied');
162
+ button.setAttribute('title', 'Copy output to clipboard');
163
+ }, 2000);
164
+ }
165
+
166
+ /**
167
+ * Update copy button enabled/disabled state based on output availability
168
+ */
169
+ export function updateCopyButtonState() {
170
+ const btn = document.getElementById('copy-output');
171
+ if (!btn) return;
172
+
173
+ const outputText = document.getElementById('output-text');
174
+ const hasOutput = outputText && outputText.textContent.trim().length > 0;
175
+
176
+ btn.disabled = !hasOutput;
177
+ }
static/favicon.svg ADDED
static/google-research-logo.svg ADDED
static/reset.js ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // reset.js
2
+ export function initClearButton() {
3
+ const clearBtn = document.getElementById('clear-input');
4
+ const inputArea = document.getElementById('input-text');
5
+ const outputBox = document.getElementById('output-text');
6
+ const rawBox = document.getElementById('raw-output');
7
+ const outputContainer = document.getElementById('output-text-container');
8
+ const instructionsEl = document.getElementById('instructions');
9
+ const promptOutput = document.getElementById('prompt-output');
10
+
11
+ if (!clearBtn || !inputArea) return;
12
+
13
+ // Add accessibility attributes
14
+ clearBtn.setAttribute('aria-label', 'Clear input text');
15
+
16
+ // Update button state based on input content
17
+ function updateClearButtonState() {
18
+ if (inputArea.value.trim()) {
19
+ clearBtn.disabled = false;
20
+ } else {
21
+ clearBtn.disabled = true;
22
+ }
23
+ }
24
+
25
+ // Initialize button state
26
+ updateClearButtonState();
27
+
28
+ // Monitor input changes
29
+ inputArea.addEventListener('input', updateClearButtonState);
30
+
31
+ clearBtn.addEventListener('click', async () => {
32
+ // 1. Clear user input
33
+ inputArea.value = '';
34
+
35
+ // 2. Hide or empty outputs
36
+ if (outputBox) {
37
+ outputBox.textContent = '';
38
+ outputBox.dataset.copy = ''; // Clear the copy data
39
+ }
40
+
41
+ if (rawBox) {
42
+ rawBox.style.display = 'none';
43
+ rawBox.textContent = '';
44
+ rawBox._jsonData = null;
45
+ }
46
+
47
+ // 3. Clear any error messages (look for error message simple)
48
+ const errorBox = outputContainer?.querySelector('.error-message-simple');
49
+ if (errorBox) {
50
+ errorBox.remove();
51
+ }
52
+
53
+ // 4. Reset ancillary UI
54
+ const copyBtn = document.getElementById('copy-output');
55
+ if (copyBtn) {
56
+ copyBtn.disabled = true;
57
+ }
58
+
59
+ // 5. Hide prompt output if visible
60
+ if (promptOutput) {
61
+ promptOutput.style.display = 'none';
62
+ promptOutput.textContent = '';
63
+ }
64
+
65
+ // 6. Show instructions again
66
+ if (instructionsEl) {
67
+ instructionsEl.style.display = 'block';
68
+ }
69
+
70
+ // 7. Flash success animation
71
+ await flashSuccess(clearBtn);
72
+
73
+ // 8. Update button state
74
+ updateClearButtonState();
75
+
76
+ // 9. Return focus to input for quick re-entry
77
+ inputArea.focus();
78
+ });
79
+
80
+ // Export the update function so it can be called externally
81
+ return { updateClearButtonState };
82
+ }
83
+
84
+ // Export a standalone update function that can be called from other modules
85
+ export function updateClearButtonState() {
86
+ const clearBtn = document.getElementById('clear-input');
87
+ const inputArea = document.getElementById('input-text');
88
+
89
+ if (!clearBtn || !inputArea) return;
90
+
91
+ if (inputArea.value.trim()) {
92
+ clearBtn.disabled = false;
93
+ } else {
94
+ clearBtn.disabled = true;
95
+ }
96
+ }
97
+
98
+ // Flash success animation (similar to copy button)
99
+ async function flashSuccess(button) {
100
+ button.classList.add('cleared');
101
+ await new Promise((resolve) => setTimeout(resolve, 1500));
102
+ button.classList.remove('cleared');
103
+ }
static/sample_reports.json ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "samples": [
3
+ {
4
+ "id": "abdominal_ct",
5
+ "title": "Abdominal CT",
6
+ "modality": "CT",
7
+ "text": "EXAMINATION: CT abdomen and pelvis with IV contrast\nCLINICAL INDICATION: Abdominal pain, rule out acute pathology\nCOMPARISON: None available\nTECHNIQUE: Axial images of the abdomen and pelvis were obtained following administration of intravenous contrast material. Coronal and sagittal reformations were performed.\n\nFINDINGS:\nNo acute abnormality is seen in the visualized lung bases. The liver is normal in size and contour. There is a 1.2 cm simple-appearing low-attenuation lesion in hepatic segment VII, consistent with a cyst. The gallbladder contains numerous calcified gallstones, compatible with cholelithiasis, without gallbladder wall thickening, pericholecystic fluid, or other sonographic signs of acute cholecystitis. The common bile duct is non-dilated, measuring approximately 4 mm. The pancreas is unremarkable without focal mass or peripancreatic inflammatory stranding. The spleen and adrenal glands appear unremarkable. A 9 mm simple left renal cyst is noted. The kidneys are otherwise unremarkable without hydronephrosis or nephrolithiasis. There is sigmoid diverticulosis without evidence of acute diverticulitis.\n\nIMPRESSION:\n1. Cholelithiasis without evidence of acute cholecystitis.\n2. Hepatic and renal cysts.\n3. Sigmoid diverticulosis without acute diverticulitis."
8
+ },
9
+ {
10
+ "id": "lumbar_spine_mri",
11
+ "title": "Lumbar Spine MRI",
12
+ "modality": "MRI",
13
+ "text": "Exam: MRI Lumbar Spine\nClinical Indication: Low back pain, radiculopathy\n\nThere is mild degenerative anterolisthesis of L4 on L5. The normal lumbar lordosis is otherwise maintained. Vertebral body heights are preserved. There is a T1 and T2 hyperintense lesion in the L2 vertebral body consistent with a benign hemangioma. Marrow signal is otherwise unremarkable. The conus medullaris terminates at a normal level and is unremarkable in signal intensity.\n\nAt L1-L2 and L2-L3, there is mild disc desiccation without significant canal or foraminal stenosis. At L3-L4, a shallow posterior disc bulge and mild facet arthropathy result in mild central canal narrowing. The neural foramina are patent. At L4-L5, there is advanced disc space narrowing and desiccation. A broad-based posterior disc protrusion with a superimposed left paracentral extrusion severely narrows the central canal and contacts the traversing left S1 nerve root. There is moderate left neural foraminal stenosis. At L5-S1, mild disc desiccation is present without significant canal or foraminal stenosis. The paraspinal soft tissues are unremarkable.\n\nIMPRESSION:\n1. Severe L4-L5 disc protrusion with superimposed left paracentral extrusion, resulting in severe central canal narrowing and contact with the left S1 nerve root.\n2. Multilevel degenerative disc disease, most advanced at L4-L5.\n3. Benign hemangioma in L2 vertebral body."
14
+ },
15
+ {
16
+ "id": "shoulder_mri",
17
+ "title": "Shoulder MRI",
18
+ "modality": "MRI",
19
+ "text": "A full-thickness, full-width tear of the supraspinatus tendon is present, with the torn tendon end retracted approximately 2 cm medially to the level of the glenoid rim. There is moderate fatty infiltration and atrophy of the supraspinatus muscle. The infraspinatus, teres minor, and subscapularis tendons and muscles appear intact. There is a moderate joint effusion with synovial thickening. The glenoid labrum shows a small superior labral tear. The biceps tendon is intact and properly positioned within the bicipital groove. The acromioclavicular joint shows mild degenerative changes with small osteophytes but no significant narrowing."
20
+ },
21
+ {
22
+ "id": "abdominal_mri_pkd",
23
+ "title": "Abdominal MRI",
24
+ "modality": "MRI",
25
+ "text": "EXAMINATION: MRI abdomen without and with gadolinium contrast\nCLINICAL INDICATION: Polycystic kidney disease with suspected cyst infection, flank pain, fever\nCOMPARISON: CT abdomen from 3 months ago\nTECHNIQUE: Axial and coronal T1-weighted, T2-weighted, and post-gadolinium images were obtained.\n\nFINDINGS:\nBoth kidneys are markedly enlarged. The right kidney measures 18.2 cm and the left kidney measures 17.8 cm in length. Innumerable thin-walled cysts of varying sizes are present throughout both kidneys, consistent with autosomal dominant polycystic kidney disease. Several cysts demonstrate T1 hyperintensity consistent with hemorrhagic or proteinaceous content, particularly a 4.2 cm cyst in the right upper pole and a 3.1 cm cyst in the left mid-pole. \n\nA complex 5.8 cm cyst in the left lower pole demonstrates thick irregular walls, internal septations, and rim enhancement following contrast administration, highly suspicious for infected cyst. Surrounding perinephric inflammatory stranding is present. An additional 2.8 cm cyst in the right lower pole shows similar findings concerning for secondary infection.\n\nMultiple hepatic cysts are noted, the largest measuring 3.4 cm in segment IV. The liver is otherwise normal in signal intensity and enhancement pattern. The spleen, pancreas, and adrenal glands appear unremarkable. There is mild ascites in the pelvis. No hydronephrosis is identified despite the numerous cysts.\n\nIMPRESSION:\n1. Autosomal dominant polycystic kidney disease with bilateral renal enlargement and innumerable cysts.\n2. Probable infected cysts in the left lower pole (5.8 cm) and right lower pole (2.8 cm) with surrounding inflammatory changes.\n3. Multiple hemorrhagic cysts bilaterally.\n4. Multiple hepatic cysts.\n5. Mild ascites."
26
+ },
27
+ {
28
+ "id": "hip_mri",
29
+ "title": "Hip MRI",
30
+ "modality": "MRI",
31
+ "text": "There is a small joint effusion. Diffuse thinning of the articular cartilage is noted at the weight-bearing superior acetabulum and femoral head, with near full-thickness loss anterosuperiorly. A degenerative labral tear is present at the anterosuperior acetabulum. The joint capsule shows mild thickening. Moderate subchondral bone marrow edema is seen in the femoral head and acetabulum. Small subchondral cysts are noted in the superior acetabulum. The hip abductor tendons show signal alteration consistent with tendinosis, and there is a partial-thickness tear of the gluteus medius tendon at its greater trochanteric insertion.\n\nIMPRESSION:\n1. Moderate to severe osteoarthritis with cartilage loss and subchondral changes.\n2. Anterosuperior labral tear.\n3. Partial-thickness gluteus medius tendon tear with tendinosis."
32
+ },
33
+ {
34
+ "id": "chest_xray",
35
+ "title": "Chest X-Ray",
36
+ "modality": "XR",
37
+ "text": "Study: Chest Radiograph\n\nThe cardiac silhouette is normal in size and contour. The mediastinal contours are within normal limits. There is a 8 mm well-circumscribed nodule in the right upper lobe. The remainder of the lungs are clear without consolidation, pneumothorax, or pleural effusion. The pulmonary vasculature appears normal. No acute bony abnormalities are identified. The visualized upper abdomen is unremarkable."
38
+ },
39
+ {
40
+ "id": "cta_pulmonary_embolus",
41
+ "title": "CTA Pulmonary Embolus",
42
+ "modality": "CT",
43
+ "text": "EXAMINATION: CT angiography of the chest for pulmonary embolism\nCLINICAL INDICATION: Shortness of breath, chest pain, elevated D-dimer, rule out pulmonary embolism\nCOMPARISON: Chest X-ray from 2 days ago\nTECHNIQUE: Axial CT images of the chest were obtained following rapid intravenous administration of iodinated contrast material. Images were reconstructed in axial, coronal, and sagittal planes with MIP and VRT reformations.\n\nFINDINGS:\nThere are multiple filling defects consistent with acute pulmonary emboli involving the right main pulmonary artery extending into the right upper and middle lobe segmental branches. Additional smaller emboli are present in the left lower lobe subsegmental arteries. The main pulmonary artery is mildly dilated, measuring 3.2 cm in diameter. There is mild right heart strain with flattening of the interventricular septum and enlargement of the right ventricle. No evidence of right heart failure or pericardial effusion.\n\nThe lungs show mild bilateral lower lobe atelectasis and small bilateral pleural effusions. No consolidation or pneumothorax is identified. The mediastinal and hilar lymph nodes are not enlarged. The aorta and great vessels appear normal. The visualized portions of the upper abdomen are unremarkable. No acute bony abnormalities are identified.\n\nIMPRESSION:\n1. Acute pulmonary emboli involving the right main, upper and middle lobe segmental arteries, and left lower lobe subsegmental arteries.\n2. Mild pulmonary hypertension with right heart strain.\n3. Small bilateral pleural effusions and bilateral lower lobe atelectasis."
44
+ },
45
+ {
46
+ "id": "abdominal_ultrasound",
47
+ "title": "Abdominal Ultrasound",
48
+ "modality": "US",
49
+ "text": "EXAMINATION: Ultrasound of the abdomen\nCLINICAL INDICATION: Right upper quadrant pain, abnormal liver function tests\nCOMPARISON: None available\nTECHNIQUE: Real-time ultrasound examination of the abdomen was performed using a curved array transducer. Multiple images were obtained in sagittal, transverse, and oblique planes.\n\nFINDINGS:\nThe liver is normal in size measuring 15.2 cm in the midclavicular line. The hepatic parenchyma demonstrates increased echogenicity consistent with fatty infiltration. There is a well-defined hyperechoic lesion in the right hepatic lobe measuring 2.1 x 1.8 cm, consistent with a hemangioma. No focal hepatic masses or intrahepatic biliary dilatation is identified. Portal vein flow is normal on Doppler evaluation. The gallbladder is distended and contains multiple echogenic foci with posterior acoustic shadowing, consistent with cholelithiasis. The largest stone measures approximately 1.5 cm. The gallbladder wall measures 2 mm in thickness, which is within normal limits. No pericholecystic fluid is identified. Common bile duct measures 4 mm, which is normal. The visualized portions of the pancreatic head and body appear normal in echogenicity and size. The pancreatic duct is not dilated. The right kidney measures 10.8 cm and the left kidney measures 11.1 cm. Both kidneys demonstrate normal cortical echogenicity and corticomedullary differentiation. No hydronephrosis, stones, or masses are identified. The spleen is normal in size and echogenicity, measuring 10.2 cm in length.\n\nIMPRESSION:\n1. Cholelithiasis without evidence of acute cholecystitis.\n2. Hepatic steatosis (fatty liver).\n3. 2.1 cm hepatic hemangioma in the right lobe.\n4. Normal kidneys, spleen, and visualized pancreas."
50
+ },
51
+ {
52
+ "id": "cervical_spine_mri",
53
+ "title": "Cervical Spine MRI",
54
+ "modality": "MRI",
55
+ "text": "MRI Cervical Spine:\nComparison: MRI cervical spine dated 6 months ago\n\nThe cervical lordosis is maintained. Vertebral body heights and alignment are preserved. The spinal cord demonstrates normal signal intensity throughout its visualized extent. At C3-C4, there is mild disc desiccation without significant canal narrowing. At C4-C5, a small posterior disc osteophyte complex results in mild central canal narrowing. The neural foramina remain patent. At C5-C6, there is moderate disc space narrowing with a broad-based posterior disc bulge and bilateral uncinate spurring, causing mild to moderate bilateral neural foraminal narrowing. At C6-C7, mild disc desiccation is present without significant stenosis. The prevertebral soft tissues are unremarkable.\n\nIMPRESSION:\n1. Multilevel cervical spondylosis, most pronounced at C5-C6.\n2. Mild to moderate bilateral C5-C6 neural foraminal narrowing.\n3. No spinal cord compression or significant central canal stenosis."
56
+ },
57
+ {
58
+ "id": "whole_body_petct",
59
+ "title": "Whole-Body FDG PET/CT",
60
+ "modality": "PET",
61
+ "text": "EXAMINATION: Whole-body fluorodeoxyglucose (FDG) PET/CT\nCLINICAL INDICATION: Staging of newly diagnosed non-small-cell lung carcinoma (NSCLC)\nCOMPARISON: None available\nTECHNIQUE: Following a 60-minute uptake period after intravenous administration of 12 mCi of FDG, low-dose non-contrast CT images were obtained for attenuation correction and anatomic localization, followed by emission PET images from the skull base to mid-thigh.\n\nFINDINGS:\nA 3.1 cm spiculated mass in the right upper lobe demonstrates intense FDG uptake (SUVmax 12.4). Ipsilateral mediastinal (station 4R) lymph node measuring 1.2 cm shows increased activity (SUVmax 6.8). No contralateral mediastinal or hilar hypermetabolic nodes.\n\nMultiple focal areas of increased FDG uptake are seen in the axial and appendicular skeleton corresponding to sclerotic lesions on CT, compatible with osseous metastases (largest in right iliac bone, SUVmax 9.1). No abnormal activity in the liver, adrenal glands, or brain. Physiologic tracer distribution in myocardium, kidneys, and urinary bladder.\n\nIMPRESSION:\n1. FDG-avid right upper-lobe primary lung malignancy with hypermetabolic right paratracheal nodal metastasis (consistent with at least N2 disease).\n2. Numerous FDG-avid osseous metastases consistent with Stage IV disease."
62
+ }
63
+ ]
64
+ }
static/script.js ADDED
@@ -0,0 +1,1320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @fileoverview Interactive radiology report structuring demo interface.
3
+ *
4
+ * This script provides the frontend functionality for the radiology report
5
+ * structuring application, including sample report loading, API communication,
6
+ * and interactive hover-to-highlight functionality between structured output
7
+ * and original input text.
8
+ */
9
+
10
+ // Import copy functionality
11
+ import { initCopyButton, updateCopyButtonState } from './copy.js';
12
+ // Import clear functionality
13
+ import { initClearButton, updateClearButtonState } from './reset.js';
14
+
15
+ document.addEventListener('DOMContentLoaded', function () {
16
+ // === CONFIGURATION CONSTANTS ===
17
+ const GRID_CONFIG = {
18
+ MOBILE_MIN_WIDTH: 120,
19
+ DESKTOP_MIN_WIDTH: 160,
20
+ MOBILE_BREAKPOINT: 768,
21
+ NARROW_BREAKPOINT: 360,
22
+ MAX_LABEL_LENGTH: 60,
23
+ BALANCE_DELAY: 100,
24
+ RESIZE_DEBOUNCE: 250,
25
+ };
26
+
27
+ const UI_CONFIG = {
28
+ SCROLL_SMOOTH_BEHAVIOR: 'smooth',
29
+ SCROLL_OFFSET_BUFFER: 100,
30
+ };
31
+
32
+ // === GLOBAL STATE ===
33
+ // Variables are declared where they're first used to avoid redeclaration errors
34
+
35
+ // === UTILITY FUNCTIONS ===
36
+
37
+ /**
38
+ * Checks if the device is a touch-only device (no hover capability).
39
+ * Uses CSS media queries to accurately detect hover capability rather than just touch presence.
40
+ * @returns {boolean} True if it's a touch-only device, false if it can hover
41
+ */
42
+ const isTouchDevice = () =>
43
+ !window.matchMedia('(hover: hover) and (pointer: fine)').matches;
44
+
45
+ /**
46
+ * Clears all highlights from text spans.
47
+ */
48
+ function clearAllHighlights() {
49
+ const spans = document.querySelectorAll('.text-span.highlight');
50
+ spans.forEach((span) => {
51
+ span.classList.remove('highlight');
52
+ span.dataset.highlighted = 'false';
53
+ });
54
+ clearInputHighlight();
55
+ }
56
+
57
+ // Add global click handler to clear highlights when clicking outside on mobile
58
+ document.addEventListener('click', function (e) {
59
+ if (isTouchDevice() && !e.target.classList.contains('text-span')) {
60
+ clearAllHighlights();
61
+ }
62
+ });
63
+
64
+ const predictButton = document.getElementById('predict-button');
65
+ const inputText = document.getElementById('input-text');
66
+ const outputTextContainer = document.getElementById('output-text');
67
+ const instructionsEl = document.querySelector('.instructions');
68
+ const loadingOverlay = document.getElementById('loading-overlay');
69
+ let processingLoadingTimer = null;
70
+ let originalInputText = '';
71
+
72
+ // Disable virtual keyboard on mobile devices
73
+ let allowInputFocus = false;
74
+
75
+ if (isTouchDevice()) {
76
+ // Prevent focus to avoid virtual keyboard, except during programmatic highlighting
77
+ inputText.addEventListener('focus', function (e) {
78
+ if (!allowInputFocus) {
79
+ e.target.blur();
80
+ }
81
+ });
82
+ }
83
+
84
+ let sampleReportsData = null;
85
+ let currentSampleId = null;
86
+
87
+ // Model dropdown elements
88
+ const modelSelect = document.getElementById('model-select');
89
+ const modelNameSpan = document.getElementById('model-name');
90
+ const modelLink = document.getElementById('model-link');
91
+
92
+ /**
93
+ * Mapping of model IDs to their display information.
94
+ * @const {Object<string, {text: string, link: string}>}
95
+ */
96
+ const modelInfo = {
97
+ 'gemini-2.5-flash': {
98
+ text: 'Gemini 2.5 Flash',
99
+ link: 'https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-5-flash',
100
+ },
101
+ 'gemini-2.5-pro': {
102
+ text: 'Gemini 2.5 Pro',
103
+ link: 'https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-5-pro',
104
+ },
105
+ };
106
+
107
+ /**
108
+ * Updates the model information display based on the selected model.
109
+ */
110
+ function updateModelInfo() {
111
+ const selectedModel = modelSelect.value;
112
+ if (modelNameSpan)
113
+ modelNameSpan.textContent = modelInfo[selectedModel].text;
114
+ if (modelLink) modelLink.href = modelInfo[selectedModel].link;
115
+ }
116
+
117
+ if (modelSelect) {
118
+ modelSelect.addEventListener('change', updateModelInfo);
119
+ updateModelInfo();
120
+ }
121
+
122
+ // Cache optimization elements
123
+ const cacheToggle = document.getElementById('cache-toggle');
124
+
125
+ // LX Toggle elements
126
+ const promptToggle = document.getElementById('prompt-toggle');
127
+ const rawToggle = document.getElementById('raw-toggle');
128
+
129
+ // Initialize copy functionality
130
+ initCopyButton();
131
+
132
+ // Initialize clear functionality
133
+ initClearButton();
134
+
135
+ /**
136
+ * Detect mobile devices and update placeholder text
137
+ * Mobile UX does not have text entry to avoid disrupting the user interaction
138
+ * with extractions in the output - users can only select from samples
139
+ */
140
+ function updatePlaceholderForMobile() {
141
+ const isMobile =
142
+ /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) ||
143
+ (navigator.maxTouchPoints && navigator.maxTouchPoints > 0);
144
+
145
+ if (isMobile) {
146
+ inputText.placeholder = 'Please select a sample from above...';
147
+ }
148
+ }
149
+
150
+ updatePlaceholderForMobile();
151
+
152
+ /**
153
+ * Updates model dropdown state based on cache toggle.
154
+ * When cache is enabled, model dropdown is disabled since cache is model-specific.
155
+ */
156
+ function updateModelDropdownState() {
157
+ if (modelSelect && cacheToggle) {
158
+ modelSelect.disabled = cacheToggle.checked;
159
+ // Add visual indication
160
+ if (cacheToggle.checked) {
161
+ modelSelect.style.opacity = '0.6';
162
+ modelSelect.style.cursor = 'not-allowed';
163
+ } else {
164
+ modelSelect.style.opacity = '1';
165
+ modelSelect.style.cursor = 'pointer';
166
+ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Handles cache toggle changes.
172
+ */
173
+ if (cacheToggle) {
174
+ cacheToggle.addEventListener('change', updateModelDropdownState);
175
+ updateModelDropdownState();
176
+ }
177
+
178
+ /**
179
+ * Updates LX toggles state based on content availability.
180
+ * Disables toggles when input is empty or no output is generated.
181
+ */
182
+ function updateLXToggleStates() {
183
+ const hasInput = inputText && inputText.value.trim().length > 0;
184
+ const hasOutput =
185
+ outputTextContainer && outputTextContainer.textContent.trim().length > 0;
186
+
187
+ if (promptToggle) {
188
+ promptToggle.disabled = !hasInput;
189
+ if (!hasInput) {
190
+ promptToggle.checked = false;
191
+ promptToggle.style.opacity = '0.5';
192
+ promptToggle.style.cursor = 'not-allowed';
193
+ } else {
194
+ promptToggle.style.opacity = '1';
195
+ promptToggle.style.cursor = 'pointer';
196
+ }
197
+
198
+ // Synchronize mobile toggle state
199
+ const mobilePromptToggle = document.getElementById(
200
+ 'prompt-toggle-mobile',
201
+ );
202
+ if (mobilePromptToggle) {
203
+ mobilePromptToggle.disabled = !hasInput;
204
+ mobilePromptToggle.checked = promptToggle.checked;
205
+ mobilePromptToggle.style.opacity = promptToggle.style.opacity;
206
+ mobilePromptToggle.style.cursor = promptToggle.style.cursor;
207
+ }
208
+ }
209
+
210
+ if (rawToggle) {
211
+ rawToggle.disabled = !hasOutput;
212
+ if (!hasOutput) {
213
+ rawToggle.checked = false;
214
+ rawToggle.style.opacity = '0.5';
215
+ rawToggle.style.cursor = 'not-allowed';
216
+ } else {
217
+ rawToggle.style.opacity = '1';
218
+ rawToggle.style.cursor = 'pointer';
219
+ }
220
+
221
+ // Synchronize mobile toggle state
222
+ const mobileRawToggle = document.getElementById('raw-toggle-mobile');
223
+ if (mobileRawToggle) {
224
+ mobileRawToggle.disabled = !hasOutput;
225
+ mobileRawToggle.checked = rawToggle.checked;
226
+ mobileRawToggle.style.opacity = rawToggle.style.opacity;
227
+ mobileRawToggle.style.cursor = rawToggle.style.cursor;
228
+ }
229
+ }
230
+ }
231
+
232
+ updateLXToggleStates();
233
+ updateCopyButtonState();
234
+
235
+ /**
236
+ * Loads sample reports from the static JSON file.
237
+ * @returns {Promise<void>}
238
+ */
239
+ async function loadSampleReports() {
240
+ try {
241
+ const response = await fetch('/static/sample_reports.json');
242
+ const data = await response.json();
243
+ sampleReportsData = data;
244
+ initializeSampleButtons();
245
+ } catch (error) {
246
+ console.error('Failed to load sample reports:', error);
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Initializes the sample report buttons in the UI.
252
+ */
253
+ function initializeSampleButtons() {
254
+ if (!sampleReportsData || !sampleReportsData.samples) return;
255
+
256
+ const sampleButtonsContainer = document.querySelector('.sample-buttons');
257
+ if (!sampleButtonsContainer) return;
258
+
259
+ sampleButtonsContainer.innerHTML = '';
260
+
261
+ const sortedSamples = [...sampleReportsData.samples].sort((a, b) =>
262
+ a.title.localeCompare(b.title),
263
+ );
264
+
265
+ sortedSamples.forEach((sample) => {
266
+ const button = document.createElement('button');
267
+ button.className = 'sample-button';
268
+ button.setAttribute('data-sample-id', sample.id);
269
+
270
+ button.innerHTML = `
271
+ <div class="sample-button-content">
272
+ <div class="sample-title">${sample.title}</div>
273
+ <div class="sample-meta">
274
+ <span class="sample-modality">${sample.modality}</span>
275
+ </div>
276
+ </div>
277
+ `;
278
+
279
+ const modalitySpan = button.querySelector('.sample-modality');
280
+ if (modalitySpan) {
281
+ modalitySpan.classList.add(`mod-${sample.modality.toLowerCase()}`);
282
+ }
283
+
284
+ button.addEventListener('click', function () {
285
+ loadSampleReport(sample);
286
+ document
287
+ .querySelectorAll('.sample-button.active')
288
+ .forEach((btn) => btn.classList.remove('active'));
289
+ this.classList.add('active');
290
+ });
291
+
292
+ sampleButtonsContainer.appendChild(button);
293
+ });
294
+
295
+ setTimeout(() => {
296
+ balanceByColumnCount();
297
+ }, GRID_CONFIG.BALANCE_DELAY);
298
+ }
299
+
300
+ /**
301
+ * Balances sample button rows by calculating optimal column count for even distribution.
302
+ * Keeps row-wise reading order while achieving visual balance (e.g., 5+5 instead of 6+4).
303
+ * Uses responsive sizing for better mobile experience.
304
+ */
305
+ function balanceByColumnCount() {
306
+ const container = document.querySelector('.sample-buttons');
307
+ if (!container) {
308
+ console.warn('Sample buttons container not found');
309
+ return;
310
+ }
311
+
312
+ const cards = container.querySelectorAll('.sample-button').length;
313
+ const styles = getComputedStyle(container);
314
+ const gap = parseFloat(styles.columnGap) || 12;
315
+
316
+ const viewport = window.innerWidth;
317
+ const minWidth =
318
+ viewport <= GRID_CONFIG.MOBILE_BREAKPOINT
319
+ ? GRID_CONFIG.MOBILE_MIN_WIDTH
320
+ : GRID_CONFIG.DESKTOP_MIN_WIDTH;
321
+
322
+ const containerWidth = container.clientWidth;
323
+ const columnsFit = Math.max(
324
+ 1,
325
+ Math.floor((containerWidth + gap) / (minWidth + gap)),
326
+ );
327
+
328
+ if (viewport <= GRID_CONFIG.NARROW_BREAKPOINT) {
329
+ return;
330
+ }
331
+
332
+ // Find the column count that provides the most even distribution
333
+ let bestCols = columnsFit;
334
+ let bestRem = cards % columnsFit;
335
+
336
+ for (let cols = columnsFit - 1; cols >= 1; cols--) {
337
+ const rem = cards % cols;
338
+ if (rem === 0) {
339
+ bestCols = cols;
340
+ break; // Perfect distribution found
341
+ }
342
+ if (rem > bestRem) continue; // Worse distribution, skip
343
+ bestCols = cols;
344
+ bestRem = rem;
345
+ }
346
+
347
+ // Mobile-specific logic: prefer 2-3 columns for better touch targets
348
+ if (viewport <= GRID_CONFIG.MOBILE_BREAKPOINT) {
349
+ if (bestCols === 1 && columnsFit >= 2) {
350
+ bestCols = 2; // Force at least 2 columns on mobile
351
+ } else if (bestCols > 3 && cards >= 6) {
352
+ // If we have many columns, prefer 2-3 for mobile UX
353
+ const cols2Rem = cards % 2;
354
+ const cols3Rem = cards % 3;
355
+ if (cols2Rem <= cols3Rem) {
356
+ bestCols = 2;
357
+ } else {
358
+ bestCols = 3;
359
+ }
360
+ }
361
+ }
362
+
363
+ // Always apply the balanced column count for optimal visual distribution
364
+ container.style.gridTemplateColumns = `repeat(${bestCols}, minmax(${minWidth}px, 1fr))`;
365
+ }
366
+
367
+ /**
368
+ * Loads a sample report into the input area and automatically processes it.
369
+ * @param {Object} sample - The sample report data object
370
+ */
371
+ function loadSampleReport(sample) {
372
+ scrollToOutput();
373
+
374
+ // Normalize line endings for sample text
375
+ inputText.value = sample.text.replace(/\r\n?/g, '\n');
376
+
377
+ // Update clear button state after loading sample
378
+ updateClearButtonState();
379
+
380
+ outputTextContainer.innerHTML = '';
381
+ instructionsEl.style.display = 'block';
382
+ currentSampleId = sample.id;
383
+
384
+ // Automatically enable cache for sample reports
385
+ if (cacheToggle) {
386
+ cacheToggle.checked = true;
387
+ // Trigger the change event to update model dropdown state
388
+ updateModelDropdownState();
389
+ }
390
+
391
+ setTimeout(() => {
392
+ predictButton.click();
393
+ }, 100);
394
+ }
395
+
396
+ loadSampleReports();
397
+
398
+ let resizeTimeout;
399
+ window.addEventListener('resize', () => {
400
+ clearTimeout(resizeTimeout);
401
+ resizeTimeout = setTimeout(() => {
402
+ balanceByColumnCount();
403
+ }, GRID_CONFIG.RESIZE_DEBOUNCE);
404
+ });
405
+
406
+ /**
407
+ * Updates the cache status display in the UI.
408
+ * @returns {Promise<void>}
409
+ */
410
+ async function updateCacheStatus() {
411
+ try {
412
+ const response = await fetch('/cache/stats');
413
+ const stats = await response.json();
414
+ const statusEl = document.getElementById('cache-status');
415
+ if (statusEl && stats.total_entries > 0) {
416
+ statusEl.textContent = `(${stats.sample_entries} samples cached)`;
417
+ } else if (statusEl) {
418
+ statusEl.textContent = '';
419
+ }
420
+ } catch (e) {
421
+ console.log('Cache stats not available');
422
+ }
423
+ }
424
+
425
+ updateCacheStatus();
426
+
427
+ inputText.addEventListener('input', function () {
428
+ if (
429
+ currentSampleId &&
430
+ inputText.value !==
431
+ sampleReportsData?.samples?.find((s) => s.id === currentSampleId)?.text
432
+ ) {
433
+ currentSampleId = null;
434
+ document
435
+ .querySelectorAll('.sample-button.active')
436
+ .forEach((btn) => btn.classList.remove('active'));
437
+ }
438
+
439
+ // Uncheck cache when input text is modified (cache no longer applies)
440
+ if (cacheToggle && cacheToggle.checked) {
441
+ cacheToggle.checked = false;
442
+ updateModelDropdownState(); // Re-enable model dropdown
443
+ updateCacheStatus(); // Update cache status display
444
+ }
445
+
446
+ // Update LX toggle states based on input content
447
+ updateLXToggleStates();
448
+ updateCopyButtonState();
449
+ });
450
+
451
+ predictButton.addEventListener('click', async function () {
452
+ predictButton.disabled = true;
453
+ predictButton.textContent = 'Processing...';
454
+ const cacheEnabled = cacheToggle ? cacheToggle.checked : true;
455
+ if (processingLoadingTimer) clearTimeout(processingLoadingTimer);
456
+
457
+ // Show loading overlay after 200ms
458
+ processingLoadingTimer = setTimeout(() => {
459
+ if (loadingOverlay) {
460
+ loadingOverlay.style.display = 'flex';
461
+ const loaderMessage = document.querySelector('.loader-message');
462
+ if (loaderMessage) {
463
+ const modelText =
464
+ (modelSelect && modelInfo[modelSelect.value]?.text) ||
465
+ 'Gemini 2.5 Flash';
466
+ loaderMessage.textContent = `Running LangExtract with ${modelText}...`;
467
+ }
468
+
469
+ if (typeof gsap !== 'undefined') {
470
+ startLoaderAnimation();
471
+ }
472
+ }
473
+ }, 200);
474
+ inputText.value = inputText.value.replace(/\r\n?/g, '\n');
475
+
476
+ originalInputText = inputText.value;
477
+ outputTextContainer.innerHTML = '';
478
+ updateLXToggleStates(); // Disable toggles when output is cleared
479
+ updateCopyButtonState();
480
+
481
+ try {
482
+ const useCache = cacheEnabled;
483
+
484
+ const headers = { 'Content-Type': 'text/plain' };
485
+ if (modelSelect) {
486
+ headers['X-Model-ID'] = modelSelect.value;
487
+ }
488
+ if (useCache) {
489
+ headers['X-Use-Cache'] = 'true';
490
+ if (currentSampleId) {
491
+ headers['X-Sample-ID'] = currentSampleId;
492
+ }
493
+ } else {
494
+ headers['X-Use-Cache'] = 'false';
495
+ }
496
+
497
+ const response = await fetch('/predict', {
498
+ method: 'POST',
499
+ headers: headers,
500
+ body: originalInputText,
501
+ });
502
+
503
+ if (!response.ok) {
504
+ const errorText = await response.text();
505
+ let errorJson;
506
+ try {
507
+ errorJson = JSON.parse(errorText);
508
+ } catch (parseError) {
509
+ throw new Error(errorText || 'unknown error');
510
+ }
511
+
512
+ const error = new Error(errorJson.error || 'unknown error');
513
+ error.details = errorJson;
514
+ throw error;
515
+ }
516
+
517
+ // Stop the initial overlay timer so it doesn't overwrite cache message
518
+ if (processingLoadingTimer) {
519
+ clearTimeout(processingLoadingTimer);
520
+ processingLoadingTimer = null;
521
+ }
522
+
523
+ const data = await response.json();
524
+
525
+ // Handle cached results with simulated loading
526
+ if (data.from_cache) {
527
+ // Ensure overlay is visible (may not be if response was quick)
528
+ if (loadingOverlay && loadingOverlay.style.display === 'none') {
529
+ loadingOverlay.style.display = 'flex';
530
+ if (typeof gsap !== 'undefined') {
531
+ startLoaderAnimation();
532
+ }
533
+ }
534
+
535
+ // Update loading message for cached results
536
+ const loaderMessage = document.querySelector('.loader-message');
537
+ if (loaderMessage) {
538
+ loaderMessage.textContent =
539
+ 'Loading LangExtract Result from Cache...';
540
+ }
541
+
542
+ // Add 1-2 second delay for cached results to simulate loading
543
+ const delay = Math.random() * 1000 + 2000; // 2-3 seconds
544
+ await new Promise((resolve) => setTimeout(resolve, delay));
545
+ }
546
+
547
+ if (data.sanitized_input && data.sanitized_input !== originalInputText) {
548
+ const inputText = document.getElementById('input-text');
549
+ if (inputText) {
550
+ inputText.value = data.sanitized_input;
551
+ updateClearButtonState();
552
+ }
553
+ }
554
+
555
+ if (data.text) {
556
+ if (
557
+ data.segments &&
558
+ Array.isArray(data.segments) &&
559
+ data.segments.length > 0
560
+ ) {
561
+ renderSegments(data.segments);
562
+ updateLXToggleStates(); // Enable/update toggles when output is generated
563
+ updateCopyButtonState();
564
+
565
+ // Update raw / prompt panes
566
+ const rawOutput = document.getElementById('raw-output');
567
+ const promptOutput = document.getElementById('prompt-output');
568
+
569
+ if (rawToggle && rawOutput) {
570
+ const rawData = data.annotated_document_json || {
571
+ error: 'No annotated document data available',
572
+ available_data: data,
573
+ };
574
+
575
+ rawOutput.innerHTML = '';
576
+
577
+ const formatter = new JSONFormatter(rawData, {
578
+ hoverPreviewEnabled: true,
579
+ animateOpen: false,
580
+ animateClose: false,
581
+ theme: 'light',
582
+ open: true,
583
+ });
584
+
585
+ const renderedElement = formatter.render();
586
+ rawOutput.appendChild(renderedElement);
587
+ rawOutput._jsonFormatter = formatter;
588
+ rawOutput._jsonData = rawData;
589
+
590
+ setTimeout(() => {
591
+ try {
592
+ if (formatter.openAtDepth) {
593
+ formatter.openAtDepth(3);
594
+ }
595
+ } catch (e) {
596
+ // Ignore errors if formatter doesn't support openAtDepth
597
+ }
598
+
599
+ const togglers = rawOutput.querySelectorAll(
600
+ '.json-formatter-toggler',
601
+ );
602
+ togglers.forEach((toggler) => {
603
+ try {
604
+ toggler.click();
605
+ } catch (e) {
606
+ // Ignore click errors on JSON formatter togglers
607
+ }
608
+ });
609
+ }, 10);
610
+
611
+ rawToggle.checked = false;
612
+ rawOutput.style.display = 'none';
613
+ outputTextContainer.style.display = 'block';
614
+ }
615
+
616
+ if (promptOutput) {
617
+ const promptText = data.raw_prompt || 'Prompt data not available.';
618
+ if (typeof marked !== 'undefined' && data.raw_prompt) {
619
+ // Render markdown with syntax highlighting support
620
+ promptOutput.innerHTML = marked.parse(promptText);
621
+ } else {
622
+ // Fallback to plain text
623
+ promptOutput.textContent = promptText;
624
+ }
625
+ promptToggle.checked = false;
626
+ showPromptView(false);
627
+ }
628
+
629
+ const hasIntervals = data.segments.some(
630
+ (segment) => segment.intervals && segment.intervals.length > 0,
631
+ );
632
+
633
+ instructionsEl.style.display = 'block';
634
+ if (!hasIntervals) {
635
+ instructionsEl.innerHTML =
636
+ '<p><strong>Note:</strong> Hover functionality is not available for this result.</p>';
637
+ }
638
+ } else {
639
+ outputTextContainer.textContent = data.text;
640
+ instructionsEl.style.display = 'none';
641
+ }
642
+ } else {
643
+ outputTextContainer.textContent = 'No content returned from server.';
644
+ instructionsEl.style.display = 'none';
645
+ }
646
+ } catch (error) {
647
+ if (error.details && typeof error.details === 'object') {
648
+ if (error.details.error === 'Empty input') {
649
+ const friendlyMessage = [
650
+ '<div class="error-message-simple" role="alert">',
651
+ ' <h3>📝 Input Required</h3>',
652
+ ' <p>Please paste or type a radiology report in the input area.</p>',
653
+ ' <p class="suggestion">You can try one of the sample reports below to see how the structuring works.</p>',
654
+ '</div>',
655
+ ].join('\n');
656
+ outputTextContainer.innerHTML = friendlyMessage;
657
+ } else if (
658
+ error.details.error === 'Input too long' &&
659
+ error.details.max_length
660
+ ) {
661
+ const friendlyMessage = [
662
+ '<div class="error-message-simple" role="alert">',
663
+ ' <h3>⚠️ Input Too Long</h3>',
664
+ ` <p>Your input contains <strong>${originalInputText.length.toLocaleString()}</strong> characters, `,
665
+ ` but this demo is limited to <strong>${error.details.max_length.toLocaleString()}</strong> characters `,
666
+ " to reduce the load on this demo's Gemini API key.</p>",
667
+ ' <p class="suggestion">Try using a shorter excerpt from your report, or focus on the most relevant sections.</p>',
668
+ ' <div class="deploy-note">',
669
+ ' <strong>💡 Tip:</strong> If you deploy the source code with your own Gemini API key, you can modify this limit.',
670
+ ' </div>',
671
+ '</div>',
672
+ ].join('\n');
673
+ outputTextContainer.innerHTML = friendlyMessage;
674
+ } else {
675
+ let errorMessage = `Error: ${error.details.error}\n\n`;
676
+ errorMessage += `${error.details.message}`;
677
+
678
+ if (error.details.max_length) {
679
+ errorMessage += `\n\nMaximum allowed length: ${error.details.max_length} characters`;
680
+ }
681
+
682
+ outputTextContainer.textContent = errorMessage;
683
+ }
684
+ } else {
685
+ outputTextContainer.textContent = `Error: ${error.message}`;
686
+ }
687
+ instructionsEl.style.display = 'none';
688
+ } finally {
689
+ if (processingLoadingTimer) {
690
+ clearTimeout(processingLoadingTimer);
691
+ processingLoadingTimer = null;
692
+ }
693
+ if (loadingOverlay) loadingOverlay.style.display = 'none';
694
+
695
+ const message = document.querySelector('.loader-message');
696
+ const spinner = document.querySelector('.spinner');
697
+ if (message && spinner) {
698
+ gsap.killTweensOf([message, spinner]);
699
+ gsap.set([message, spinner], { clearProps: 'all' });
700
+ }
701
+ predictButton.disabled = false;
702
+ predictButton.textContent = 'Process';
703
+
704
+ updateCacheStatus();
705
+ }
706
+ });
707
+
708
+ /**
709
+ * Renders segments as interactive elements in the output container.
710
+ * @param {Array<Object>} segments - Array of segment objects from the API response
711
+ */
712
+ function renderSegments(segments) {
713
+ outputTextContainer.innerHTML = '';
714
+ const plainTextParts = []; // Collect plain text for data-copy
715
+
716
+ const segmentsByType = {
717
+ prefix: segments.filter((seg) => seg.type === 'prefix'),
718
+ body: segments.filter((seg) => seg.type === 'body'),
719
+ suffix: segments.filter((seg) => seg.type === 'suffix'),
720
+ };
721
+
722
+ if (segmentsByType.prefix.length > 0) {
723
+ // Check if there's an Examination segment that should get a header
724
+ const examinationSegments = segmentsByType.prefix.filter(
725
+ (seg) => seg.label && seg.label.toLowerCase() === 'examination',
726
+ );
727
+ const otherPrefixSegments = segmentsByType.prefix.filter(
728
+ (seg) => !seg.label || seg.label.toLowerCase() !== 'examination',
729
+ );
730
+
731
+ // Render Examination segments with content as header (no "EXAMINATION:" prefix)
732
+ if (examinationSegments.length > 0) {
733
+ examinationSegments.forEach((segment) => {
734
+ let content = segment.content;
735
+ // Remove various examination prefixes
736
+ const examPrefixes = ['EXAMINATION:', 'EXAM:', 'STUDY:'];
737
+ const upperContent = content.toUpperCase();
738
+
739
+ for (const prefix of examPrefixes) {
740
+ if (upperContent.startsWith(prefix)) {
741
+ content = content.substring(prefix.length).trim();
742
+ break;
743
+ }
744
+ }
745
+
746
+ // Use the clean content as the header text (capitalized)
747
+ if (content) {
748
+ appendSectionHeader(content.toUpperCase());
749
+ plainTextParts.push(content.toUpperCase());
750
+ }
751
+ });
752
+ outputTextContainer.appendChild(document.createElement('br'));
753
+ }
754
+
755
+ // Render other prefix segments normally
756
+ if (otherPrefixSegments.length > 0) {
757
+ otherPrefixSegments.forEach((segment) => {
758
+ outputTextContainer.appendChild(createSegmentElement(segment));
759
+ plainTextParts.push(segment.content);
760
+ });
761
+ outputTextContainer.appendChild(document.createElement('br'));
762
+ }
763
+ }
764
+
765
+ if (segmentsByType.body.length > 0) {
766
+ appendSectionHeader('FINDINGS:');
767
+ plainTextParts.push('\nFINDINGS:');
768
+
769
+ const groupMap = new Map();
770
+ segmentsByType.body.forEach((seg) => {
771
+ const rawLabel = seg.label || 'Other';
772
+ const parts = rawLabel.split(':');
773
+ const primary = parts[0].trim();
774
+ const sub = parts.slice(1).join(':').trim();
775
+ if (!groupMap.has(primary)) groupMap.set(primary, []);
776
+ groupMap.get(primary).push({ segment: seg, sublabel: sub });
777
+ });
778
+
779
+ groupMap.forEach((items, primary) => {
780
+ const primaryHeader = document.createElement('div');
781
+ primaryHeader.className = 'primary-label';
782
+ primaryHeader.textContent = primary;
783
+ outputTextContainer.appendChild(primaryHeader);
784
+ plainTextParts.push('\n' + primary);
785
+
786
+ if (items.length === 1) {
787
+ const p = document.createElement('p');
788
+ p.className = 'single-finding';
789
+ const labelSpan = document.createElement('span');
790
+ labelSpan.classList.add('segment-sublabel');
791
+ if (items[0].sublabel) {
792
+ labelSpan.textContent = `${items[0].sublabel}: `;
793
+ p.appendChild(labelSpan);
794
+ }
795
+ p.appendChild(createContentWithIntervalSpans(items[0].segment));
796
+ outputTextContainer.appendChild(p);
797
+ plainTextParts.push('- ' + p.textContent.trim());
798
+ } else {
799
+ const ul = document.createElement('ul');
800
+ ul.className = 'finding-list';
801
+ outputTextContainer.appendChild(ul);
802
+
803
+ items.forEach((item) => {
804
+ const li = document.createElement('li');
805
+ const labelSpan = document.createElement('span');
806
+ labelSpan.classList.add('segment-sublabel');
807
+ if (item.sublabel) {
808
+ labelSpan.textContent = `${item.sublabel}: `;
809
+ li.appendChild(labelSpan);
810
+ }
811
+ li.appendChild(createContentWithIntervalSpans(item.segment));
812
+ ul.appendChild(li);
813
+ plainTextParts.push('• ' + li.textContent.trim());
814
+ });
815
+ }
816
+ });
817
+ }
818
+
819
+ if (segmentsByType.suffix.length > 0) {
820
+ appendSectionHeader('IMPRESSION:');
821
+ plainTextParts.push('\nIMPRESSION:');
822
+
823
+ segmentsByType.suffix.forEach((segment) => {
824
+ outputTextContainer.appendChild(createSegmentElement(segment));
825
+ plainTextParts.push(segment.content);
826
+ });
827
+ }
828
+
829
+ // Store pre-computed plain text for efficient copying
830
+ const plainText = plainTextParts
831
+ .join('\n')
832
+ .replace(/\n{3,}/g, '\n\n')
833
+ .trim();
834
+ const outputEl = document.getElementById('output-text');
835
+ if (outputEl) {
836
+ outputEl.dataset.copy = plainText;
837
+ }
838
+ }
839
+
840
+ /**
841
+ * Helper function to create section headers.
842
+ * @param {string} text - The header text to display
843
+ */
844
+ function appendSectionHeader(text) {
845
+ const header = document.createElement('div');
846
+ header.className = 'section-header';
847
+ header.textContent = text;
848
+ outputTextContainer.appendChild(header);
849
+ }
850
+
851
+ /**
852
+ * Creates a DOM element for a segment.
853
+ * @param {Object} segment - The segment data object
854
+ * @returns {HTMLElement} The created segment element
855
+ */
856
+ function createSegmentElement(segment) {
857
+ const segmentDiv = document.createElement('div');
858
+ segmentDiv.classList.add('segment', `segment-${segment.type}`);
859
+
860
+ if (segment.type === 'body' && segment.label) {
861
+ const labelSpan = document.createElement('span');
862
+ labelSpan.classList.add('segment-label');
863
+ labelSpan.textContent = `${segment.label}: `;
864
+ segmentDiv.appendChild(labelSpan);
865
+ }
866
+
867
+ segmentDiv.appendChild(createContentWithIntervalSpans(segment));
868
+ return segmentDiv;
869
+ }
870
+
871
+ /**
872
+ * Creates content with interval spans for highlighting functionality.
873
+ * @param {Object} segment - The content segment with intervals and metadata
874
+ * @returns {DocumentFragment} Fragment containing the processed content
875
+ */
876
+ function createContentWithIntervalSpans(segment) {
877
+ const fragment = document.createDocumentFragment();
878
+
879
+ if (segment.intervals && segment.intervals.length > 0) {
880
+ const contentSpan = createIntervalSpan(segment);
881
+ addIntervalEventListeners(contentSpan);
882
+ fragment.appendChild(contentSpan);
883
+ } else {
884
+ fragment.appendChild(createRegularSpan(segment));
885
+ }
886
+
887
+ return fragment;
888
+ }
889
+
890
+ /**
891
+ * Creates a span element for content with intervals (highlighting capability).
892
+ * @param {Object} segment - The content segment
893
+ * @returns {HTMLSpanElement} The created span element
894
+ */
895
+ function createIntervalSpan(segment) {
896
+ const interval = segment.intervals[0];
897
+ const contentSpan = document.createElement('span');
898
+ contentSpan.classList.add('text-span');
899
+
900
+ // Set data attributes for position tracking
901
+ contentSpan.dataset.startPos = interval.startPos;
902
+ contentSpan.dataset.endPos = interval.endPos;
903
+ contentSpan.dataset.type = segment.type;
904
+ contentSpan.dataset.label = segment.label || '';
905
+
906
+ // Handle label styling if present
907
+ const labelInfo = extractLabelInfo(segment.content);
908
+ if (labelInfo.hasLabel) {
909
+ setupLabelSpan(contentSpan, labelInfo);
910
+ } else {
911
+ contentSpan.textContent = segment.content;
912
+ }
913
+
914
+ // Apply significance-based styling
915
+ applySignificanceStyles(contentSpan, segment.significance);
916
+
917
+ return contentSpan;
918
+ }
919
+
920
+ /**
921
+ * Extracts label information from content.
922
+ * @param {string} content - The content to analyze
923
+ * @returns {Object} Label information object
924
+ */
925
+ function extractLabelInfo(content) {
926
+ const colonIndex = content.indexOf(':');
927
+ const hasLabel =
928
+ colonIndex > 0 && colonIndex < GRID_CONFIG.MAX_LABEL_LENGTH;
929
+
930
+ return {
931
+ hasLabel,
932
+ labelText: hasLabel ? content.slice(0, colonIndex) : '',
933
+ restText: hasLabel ? content.slice(colonIndex) : content,
934
+ };
935
+ }
936
+
937
+ /**
938
+ * Sets up span with label and content parts for CSS styling.
939
+ * @param {HTMLSpanElement} contentSpan - The span to configure
940
+ * @param {Object} labelInfo - Label information object
941
+ */
942
+ function setupLabelSpan(contentSpan, labelInfo) {
943
+ contentSpan.classList.add('has-label');
944
+
945
+ const labelSpan = document.createElement('span');
946
+ labelSpan.className = 'label-part';
947
+ labelSpan.textContent = labelInfo.labelText;
948
+
949
+ const contentPartSpan = document.createElement('span');
950
+ contentPartSpan.className = 'content-part';
951
+ contentPartSpan.textContent = labelInfo.restText;
952
+
953
+ contentSpan.appendChild(labelSpan);
954
+ contentSpan.appendChild(contentPartSpan);
955
+ }
956
+
957
+ /**
958
+ * Applies significance-based CSS classes to content spans.
959
+ * @param {HTMLSpanElement} span - The span to style
960
+ * @param {string} significance - The significance level
961
+ */
962
+ function applySignificanceStyles(span, significance) {
963
+ if (significance) {
964
+ const significanceLevel = (significance || '').toLowerCase();
965
+ if (
966
+ significanceLevel === 'minor' ||
967
+ significanceLevel === 'significant'
968
+ ) {
969
+ span.classList.add(`significance-${significanceLevel}`);
970
+ }
971
+ }
972
+ }
973
+
974
+ /**
975
+ * Creates a regular span for content without intervals.
976
+ * @param {Object} segment - The content segment
977
+ * @returns {HTMLSpanElement} The created span element
978
+ */
979
+ function createRegularSpan(segment) {
980
+ const regularSpan = document.createElement('span');
981
+ regularSpan.textContent = segment.content;
982
+
983
+ // Apply significance styling even for non-interval content
984
+ applySignificanceStyles(regularSpan, segment.significance);
985
+
986
+ return regularSpan;
987
+ }
988
+
989
+ /**
990
+ * Adds event listeners for interval spans with distinct desktop/mobile interaction patterns.
991
+ * Desktop: Hover to highlight/unhighlight instantly
992
+ * Mobile: Tap to toggle highlight on/off
993
+ * @param {HTMLSpanElement} contentSpan - The span to add listeners to
994
+ */
995
+ function addIntervalEventListeners(contentSpan) {
996
+ const isDesktop = !isTouchDevice();
997
+
998
+ if (isDesktop) {
999
+ // Desktop: Hover-based highlighting
1000
+ contentSpan.addEventListener('mouseenter', function () {
1001
+ contentSpan.classList.add('highlight');
1002
+ const startPos = parseInt(contentSpan.dataset.startPos);
1003
+ const endPos = parseInt(contentSpan.dataset.endPos);
1004
+ if (!isNaN(startPos) && !isNaN(endPos)) {
1005
+ highlightInputText(startPos, endPos);
1006
+ }
1007
+ });
1008
+
1009
+ contentSpan.addEventListener('mouseleave', function () {
1010
+ contentSpan.classList.remove('highlight');
1011
+ clearInputHighlight();
1012
+ });
1013
+ } else {
1014
+ // Mobile: Tap-based highlighting (toggle)
1015
+ contentSpan.addEventListener('touchstart', function (e) {
1016
+ e.preventDefault();
1017
+ handleMobileHighlight(contentSpan);
1018
+ });
1019
+
1020
+ contentSpan.addEventListener('click', function (e) {
1021
+ e.preventDefault();
1022
+ handleMobileHighlight(contentSpan);
1023
+ });
1024
+ }
1025
+ }
1026
+
1027
+ /**
1028
+ * Handles mobile highlighting toggle for touch devices.
1029
+ * Toggles highlight on/off when tapping the same span, or switches to new span.
1030
+ * @param {HTMLSpanElement} span - The span to highlight
1031
+ */
1032
+ function handleMobileHighlight(span) {
1033
+ const isCurrentlyHighlighted = span.classList.contains('highlight');
1034
+
1035
+ // Clear all highlights first
1036
+ clearAllHighlights();
1037
+
1038
+ // If this span wasn't highlighted before, highlight it now
1039
+ if (!isCurrentlyHighlighted) {
1040
+ span.classList.add('highlight');
1041
+ span.dataset.highlighted = 'true';
1042
+
1043
+ const startPos = parseInt(span.dataset.startPos);
1044
+ const endPos = parseInt(span.dataset.endPos);
1045
+ if (!isNaN(startPos) && !isNaN(endPos)) {
1046
+ highlightInputText(startPos, endPos);
1047
+ }
1048
+ } else {
1049
+ // If it was highlighted, just clear (already done above)
1050
+ clearInputHighlight();
1051
+ }
1052
+ }
1053
+
1054
+ /**
1055
+ * Highlights text in the input textarea based on character positions.
1056
+ * @param {number} startPos - Starting character position
1057
+ * @param {number} endPos - Ending character position
1058
+ */
1059
+ function highlightInputText(startPos, endPos) {
1060
+ // Enable focus for programmatic text selection
1061
+ if (isTouchDevice()) {
1062
+ allowInputFocus = true;
1063
+ }
1064
+
1065
+ inputText.focus();
1066
+ if (typeof inputText.setSelectionRange === 'function') {
1067
+ inputText.setSelectionRange(startPos, endPos);
1068
+ scrollInputToRange(startPos, endPos); // Centre the selection in viewport
1069
+ }
1070
+
1071
+ // Restore focus prevention
1072
+ if (isTouchDevice()) {
1073
+ allowInputFocus = false;
1074
+ }
1075
+ }
1076
+
1077
+ /**
1078
+ * Scrolls the textarea so the selected range is vertically centered in the viewport.
1079
+ * Uses a temporary clone to calculate precise text measurements for accurate positioning.
1080
+ * @param {number} startPos - Start position of the selection
1081
+ * @param {number} endPos - End position of the selection
1082
+ */
1083
+ function scrollInputToRange(startPos, endPos) {
1084
+ const style = window.getComputedStyle(inputText);
1085
+ const clone = document.createElement('textarea');
1086
+
1087
+ // Clone essential styles so scrollHeight matches the real textarea
1088
+ const ESSENTIAL_STYLES = [
1089
+ 'width',
1090
+ 'fontFamily',
1091
+ 'fontSize',
1092
+ 'fontWeight',
1093
+ 'lineHeight',
1094
+ 'letterSpacing',
1095
+ 'padding',
1096
+ 'border',
1097
+ 'boxSizing',
1098
+ ];
1099
+ ESSENTIAL_STYLES.forEach((prop) => (clone.style[prop] = style[prop]));
1100
+
1101
+ // Position clone off-screen for measurement
1102
+ Object.assign(clone.style, {
1103
+ position: 'absolute',
1104
+ top: '-9999px',
1105
+ height: 'auto',
1106
+ });
1107
+
1108
+ document.body.appendChild(clone);
1109
+
1110
+ try {
1111
+ // Calculate height before the selection
1112
+ clone.value = originalInputText.slice(0, startPos);
1113
+ const heightBefore = clone.scrollHeight;
1114
+
1115
+ // Calculate height of the selection itself
1116
+ clone.value = originalInputText.slice(startPos, endPos);
1117
+ const heightSelection = clone.scrollHeight;
1118
+
1119
+ // Calculate optimal scroll position to center the selection
1120
+ const viewportHeight = inputText.clientHeight;
1121
+ const targetScrollTop = Math.max(
1122
+ 0,
1123
+ heightBefore - viewportHeight / 2 + heightSelection / 2,
1124
+ );
1125
+
1126
+ inputText.scrollTo({
1127
+ top: targetScrollTop,
1128
+ behavior: UI_CONFIG.SCROLL_SMOOTH_BEHAVIOR,
1129
+ });
1130
+ } finally {
1131
+ // Always cleanup the clone element
1132
+ document.body.removeChild(clone);
1133
+ }
1134
+ }
1135
+
1136
+ /**
1137
+ * Starts the GSAP loader pulse animation.
1138
+ */
1139
+ function startLoaderAnimation() {
1140
+ const message = document.querySelector('.loader-message');
1141
+ const spinner = document.querySelector('.spinner');
1142
+
1143
+ if (!message || !spinner) return;
1144
+
1145
+ gsap.killTweensOf([message, spinner]);
1146
+ gsap.set([message, spinner], { clearProps: 'all' });
1147
+
1148
+ gsap.to(spinner, {
1149
+ rotation: 360,
1150
+ duration: 1.8,
1151
+ ease: 'none',
1152
+ repeat: -1,
1153
+ });
1154
+
1155
+ gsap.fromTo(
1156
+ message,
1157
+ {
1158
+ opacity: 0.4,
1159
+ scale: 0.98,
1160
+ },
1161
+ {
1162
+ opacity: 1,
1163
+ scale: 1,
1164
+ duration: 1.2,
1165
+ ease: 'power2.inOut',
1166
+ yoyo: true,
1167
+ repeat: -1,
1168
+ },
1169
+ );
1170
+
1171
+ gsap.to(message, {
1172
+ color: '#4285F4',
1173
+ duration: 2,
1174
+ ease: 'sine.inOut',
1175
+ yoyo: true,
1176
+ repeat: -1,
1177
+ });
1178
+ }
1179
+
1180
+ /**
1181
+ * Clears any highlighting in the input textarea.
1182
+ */
1183
+ function clearInputHighlight() {
1184
+ if (document.activeElement === inputText) {
1185
+ inputText.blur();
1186
+ }
1187
+ }
1188
+
1189
+ const rawOutput = document.getElementById('raw-output');
1190
+ const promptOutput = document.getElementById('prompt-output');
1191
+
1192
+ /**
1193
+ * Shows or hides the prompt view panel.
1194
+ * @param {boolean} show - Whether to show the prompt view
1195
+ */
1196
+ function showPromptView(show) {
1197
+ if (!promptOutput) return;
1198
+ promptOutput.style.display = show ? 'block' : 'none';
1199
+ inputText.style.display = show ? 'none' : 'block';
1200
+ }
1201
+
1202
+ if (rawToggle) {
1203
+ rawToggle.addEventListener('change', () => {
1204
+ const showRaw = rawToggle.checked;
1205
+ rawOutput.style.display = showRaw ? 'block' : 'none';
1206
+ outputTextContainer.style.display = showRaw ? 'none' : 'block';
1207
+
1208
+ const mobileRawToggle = document.getElementById('raw-toggle-mobile');
1209
+ if (mobileRawToggle) {
1210
+ mobileRawToggle.checked = showRaw;
1211
+ }
1212
+
1213
+ if (showRaw) {
1214
+ setTimeout(() => {
1215
+ const formatter = rawOutput._jsonFormatter;
1216
+ if (formatter && formatter.openAtDepth) {
1217
+ try {
1218
+ formatter.openAtDepth(3);
1219
+ return;
1220
+ } catch (e) {
1221
+ // Fall back to manual clicking
1222
+ }
1223
+ }
1224
+
1225
+ // Fallback: manually click the root toggler if it's collapsed
1226
+ const rootToggler = rawOutput.querySelector(
1227
+ '.json-formatter-toggler',
1228
+ );
1229
+ if (rootToggler) {
1230
+ const arrow =
1231
+ rootToggler.querySelector('.json-formatter-toggler-link') ||
1232
+ rootToggler;
1233
+ const arrowText = arrow.textContent || arrow.innerText || '';
1234
+
1235
+ if (arrowText.includes('►') || !arrowText.includes('▼')) {
1236
+ try {
1237
+ rootToggler.click();
1238
+ } catch (e) {
1239
+ console.error('Failed to expand JSON:', e);
1240
+ }
1241
+ }
1242
+ }
1243
+ }, 100);
1244
+ }
1245
+ });
1246
+ }
1247
+
1248
+ if (promptToggle) {
1249
+ promptToggle.addEventListener('change', () => {
1250
+ const showPrompt = promptToggle.checked;
1251
+ showPromptView(showPrompt);
1252
+ // Synchronize with mobile toggle
1253
+ const mobilePromptToggle = document.getElementById(
1254
+ 'prompt-toggle-mobile',
1255
+ );
1256
+ if (mobilePromptToggle) {
1257
+ mobilePromptToggle.checked = showPrompt;
1258
+ }
1259
+ });
1260
+ }
1261
+
1262
+ // Mobile prompt toggle event handling
1263
+ const mobilePromptToggle = document.getElementById('prompt-toggle-mobile');
1264
+ if (mobilePromptToggle && promptToggle) {
1265
+ mobilePromptToggle.addEventListener('change', () => {
1266
+ const showPrompt = mobilePromptToggle.checked;
1267
+ promptToggle.checked = showPrompt;
1268
+ showPromptView(showPrompt);
1269
+ });
1270
+ }
1271
+
1272
+ // Mobile raw toggle event handling
1273
+ const mobileRawToggle = document.getElementById('raw-toggle-mobile');
1274
+ if (mobileRawToggle && rawToggle) {
1275
+ mobileRawToggle.addEventListener('change', () => {
1276
+ const showRaw = mobileRawToggle.checked;
1277
+ rawToggle.checked = showRaw;
1278
+ rawToggle.dispatchEvent(new Event('change'));
1279
+ });
1280
+ }
1281
+ });
1282
+
1283
+ /**
1284
+ * Scrolls to the output panel to direct user focus to the results area.
1285
+ * Provides improved navigation experience for sample report selection workflow.
1286
+ */
1287
+ function scrollToOutput() {
1288
+ const outputContainer = document.getElementById('output-container');
1289
+
1290
+ if (outputContainer) {
1291
+ // Smooth scroll to the output area
1292
+ outputContainer.scrollIntoView({
1293
+ behavior: 'smooth',
1294
+ block: 'center',
1295
+ });
1296
+ }
1297
+ }
1298
+
1299
+ /**
1300
+ * Toggles the interface options panel between expanded and collapsed states.
1301
+ */
1302
+ function toggleInterfaceOptions() {
1303
+ const content = document.getElementById('interface-options-content');
1304
+ const icon = document.getElementById('interface-expand-icon');
1305
+
1306
+ if (content.style.display === 'none' || content.style.display === '') {
1307
+ content.style.display = 'block';
1308
+ icon.classList.add('expanded');
1309
+ } else {
1310
+ content.style.display = 'none';
1311
+ icon.classList.remove('expanded');
1312
+ }
1313
+ }
1314
+
1315
+ // Set up event delegation for interface toggle
1316
+ document.addEventListener('click', (e) => {
1317
+ if (e.target.closest('[data-action="toggle-interface"]')) {
1318
+ toggleInterfaceOptions();
1319
+ }
1320
+ });
static/style.css ADDED
@@ -0,0 +1,2239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+
3
+ /* === Google Material Palette === */
4
+ :root {
5
+ --google-blue: #1a73e8;
6
+ --google-blue-dark: #174ea6;
7
+ --google-blue-light: #4285f4;
8
+ --google-purple: #9c27b0;
9
+ --google-purple-dark: #7b1fa2;
10
+ --google-purple-light: #e1bee7;
11
+ --google-grey-900: #202124;
12
+ --google-grey-700: #5f6368;
13
+ --google-grey-200: #e8eaed;
14
+ --google-grey-100: #f1f3f4;
15
+ --google-yellow: #f9ab00;
16
+ }
17
+
18
+ body {
19
+ background-color: var(--google-grey-100);
20
+ font-family: 'Google Sans Text', sans-serif;
21
+ }
22
+
23
+ .elev-1 {
24
+ box-shadow: 0 1px 3px rgba(60, 64, 67, 0.15);
25
+ }
26
+
27
+ /* Header and Banner */
28
+ .header-container {
29
+ margin-top: 40px;
30
+ margin-bottom: 32px;
31
+ text-align: center;
32
+ }
33
+
34
+ .header-container h1 {
35
+ font-family: 'Google Sans', sans-serif;
36
+ font-weight: 500;
37
+ letter-spacing: -0.03em;
38
+ margin: 0 0 12px;
39
+ font-size: clamp(
40
+ 1.6rem,
41
+ 4vw + 0.5rem,
42
+ 2.75rem
43
+ ); /* ~26px → 44px fluid scaling */
44
+ line-height: 1.2; /* tighter on large screens */
45
+ text-wrap: balance; /* modern browsers balance line lengths */
46
+ color: var(--google-blue-dark);
47
+ }
48
+
49
+ /* Ensure brand-split stays inline by default */
50
+ .brand-split {
51
+ display: inline;
52
+ }
53
+
54
+ .banner {
55
+ background: #fff;
56
+ color: var(--google-grey-900);
57
+ border: 1px solid var(--google-grey-200);
58
+ box-shadow: 0 1px 3px rgba(60, 64, 67, 0.15);
59
+ padding: 20px;
60
+ }
61
+
62
+ .banner-content h2 {
63
+ font-family: 'Google Sans', sans-serif;
64
+ font-weight: 600;
65
+ font-size: 28px;
66
+ margin: 0 0 16px;
67
+ color: var(--google-grey-900);
68
+ }
69
+
70
+ .banner-section-title {
71
+ font-family: 'Google Sans', sans-serif;
72
+ font-weight: 600;
73
+ font-size: 18px;
74
+ color: var(--google-blue-dark);
75
+ margin: 0 0 12px 0;
76
+ }
77
+
78
+ .banner-description {
79
+ font-family: 'Google Sans Text', sans-serif;
80
+ font-size: 16px;
81
+ line-height: 1.6;
82
+ color: var(--google-grey-700);
83
+ margin-bottom: 12px;
84
+ }
85
+
86
+ .banner-description:last-child {
87
+ margin-bottom: 0;
88
+ }
89
+
90
+ .banner-link {
91
+ color: var(--google-blue-dark);
92
+ text-decoration: none;
93
+ }
94
+
95
+ .banner-link:hover {
96
+ text-decoration: underline;
97
+ }
98
+
99
+ .banner-note {
100
+ font-size: 13px;
101
+ color: var(--google-grey-600);
102
+ text-align: right;
103
+ margin-top: 6px;
104
+ }
105
+
106
+ .citation-note {
107
+ font-size: 13px;
108
+ color: var(--google-grey-600);
109
+ background: #f8f9fa;
110
+ border: 1px solid var(--google-grey-200);
111
+ border-radius: 8px;
112
+ padding: 10px 16px;
113
+ margin: 12px 0 0 0;
114
+ line-height: 1.5;
115
+ text-align: center;
116
+ }
117
+
118
+ .how-to-use {
119
+ background: rgba(255, 255, 255, 0.1);
120
+ border-radius: 8px;
121
+ padding: 20px;
122
+ border-left: 4px solid rgba(255, 255, 255, 0.3);
123
+ }
124
+
125
+ .how-to-use h3 {
126
+ margin: 0 0 15px 0;
127
+ font-size: 1.2em;
128
+ font-weight: 500;
129
+ }
130
+
131
+ .how-to-use ol {
132
+ margin: 0;
133
+ padding-left: 20px;
134
+ }
135
+
136
+ .how-to-use li {
137
+ margin-bottom: 8px;
138
+ line-height: 1.5;
139
+ }
140
+
141
+ /* Attribution block - semantic grouping */
142
+ .attribution {
143
+ display: flex;
144
+ flex-direction: column;
145
+ align-items: center;
146
+ text-align: center;
147
+ }
148
+
149
+ /* Google Research Logo */
150
+ .google-research-logo {
151
+ margin-top: 2px;
152
+ margin-bottom: 12px;
153
+ }
154
+
155
+ .attribution .google-research-logo img {
156
+ height: 1.5em; /* ~24-26px when subtitle is 16-18px */
157
+ width: auto;
158
+ opacity: 1;
159
+ }
160
+
161
+ /* Disclaimer */
162
+ .disclaimer-container {
163
+ margin-top: 8px;
164
+ }
165
+
166
+ .disclaimer-box {
167
+ display: flex;
168
+ align-items: flex-start;
169
+ gap: 12px;
170
+ background: #fffbeb;
171
+ border: none;
172
+ border-left: 4px solid var(--google-yellow);
173
+ border-radius: 12px;
174
+ padding: 12px 16px;
175
+ font-size: 14px;
176
+ line-height: 1.5;
177
+ box-shadow: 0 1px 3px rgba(60, 64, 67, 0.1);
178
+ }
179
+
180
+ .disclaimer-box p {
181
+ margin: 0;
182
+ }
183
+
184
+ .disclaimer-box strong {
185
+ color: #495057;
186
+ font-weight: 600;
187
+ }
188
+
189
+ .disclaimer-icon {
190
+ color: var(--google-yellow);
191
+ font-size: 18px;
192
+ }
193
+
194
+ .disclaimer-text {
195
+ color: var(--google-grey-700);
196
+ }
197
+
198
+ /* Sample Reports */
199
+ .samples-container {
200
+ background-color: #fff;
201
+ border-radius: 16px;
202
+ padding: 24px;
203
+ margin-bottom: 25px;
204
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
205
+ border: 1px solid #e9ecef;
206
+ }
207
+
208
+ .samples-container h3 {
209
+ font-family: 'Google Sans', sans-serif;
210
+ font-size: 20px;
211
+ font-weight: normal;
212
+ margin-bottom: 6px;
213
+ }
214
+
215
+ .samples-description {
216
+ font-size: 15px;
217
+ margin-bottom: 24px;
218
+ line-height: 1.6;
219
+ color: var(--google-grey-700);
220
+ }
221
+
222
+ .samples-description strong {
223
+ color: var(--google-blue-dark);
224
+ font-weight: 600;
225
+ }
226
+
227
+ .instruction-step {
228
+ margin-bottom: 6px;
229
+ padding-left: 0;
230
+ display: flex;
231
+ align-items: center;
232
+ gap: 8px;
233
+ }
234
+
235
+ .step-number {
236
+ display: inline-flex;
237
+ align-items: center;
238
+ justify-content: center;
239
+ width: 20px;
240
+ height: 20px;
241
+ border-radius: 50%;
242
+ background-color: #e8f0fe;
243
+ color: var(--google-blue);
244
+ font-size: 12px;
245
+ font-weight: 600;
246
+ flex-shrink: 0;
247
+ }
248
+
249
+ /* === SAMPLE REPORT BUTTON GRID ================================= */
250
+ .sample-buttons {
251
+ display: grid;
252
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
253
+ gap: clamp(8px, 1vw, 16px);
254
+ margin-top: 8px;
255
+ grid-auto-flow: row;
256
+ justify-content: center;
257
+ }
258
+
259
+ /* Tip under sample buttons */
260
+ .samples-tip {
261
+ font-family:
262
+ 'Google Sans',
263
+ -apple-system,
264
+ BlinkMacSystemFont,
265
+ 'Segoe UI',
266
+ Roboto,
267
+ sans-serif;
268
+ font-size: 14px;
269
+ color: var(--google-grey-700);
270
+ background-color: #f8f9fa;
271
+ padding: 12px 16px;
272
+ border-radius: 8px;
273
+ margin-top: 8px;
274
+ font-weight: 400;
275
+ line-height: 1.6;
276
+ }
277
+
278
+ /* Desktop tip visibility */
279
+ .tip-desktop {
280
+ display: block;
281
+ }
282
+
283
+ /* Mobile tip - show only on touch devices */
284
+ @media (hover: none) {
285
+ .mobile-tip {
286
+ margin: 0.75rem 1rem 1rem;
287
+ font-size: 0.875rem;
288
+ color: #5f6368;
289
+ background: #f8f9fa;
290
+ border-radius: 8px;
291
+ padding: 0.75rem 1rem;
292
+ line-height: 1.5;
293
+ display: block;
294
+ text-align: left;
295
+ }
296
+ .mobile-tip .icon {
297
+ display: inline;
298
+ font-size: 1rem;
299
+ margin-right: 0.25rem;
300
+ }
301
+ .mobile-tip strong {
302
+ color: #1a73e8;
303
+ font-weight: 600;
304
+ }
305
+ .mobile-tip em {
306
+ font-style: italic;
307
+ color: #5f6368;
308
+ font-size: 0.825rem;
309
+ }
310
+ }
311
+
312
+ /* Hide mobile tip on mouse/desktop */
313
+ @media (hover: hover) {
314
+ .mobile-tip {
315
+ display: none;
316
+ }
317
+ }
318
+
319
+ /* === CARD ====================================================== */
320
+ .sample-button {
321
+ display: block;
322
+ padding: clamp(12px, 1.2vw, 18px);
323
+ border-radius: 8px;
324
+ background: var(--google-grey-100);
325
+ border: 1px solid var(--google-grey-200);
326
+ color: var(--google-grey-900);
327
+ cursor: pointer;
328
+ position: relative;
329
+ overflow: hidden;
330
+ transition:
331
+ transform 0.15s ease,
332
+ box-shadow 0.15s ease;
333
+ box-shadow:
334
+ 0 2px 6px rgba(60, 64, 67, 0.15),
335
+ 0 1px 2px rgba(60, 64, 67, 0.1);
336
+ }
337
+
338
+ .sample-button-content {
339
+ text-align: left;
340
+ }
341
+
342
+ /* === TYPOGRAPHY =============================================== */
343
+ .sample-title {
344
+ font-weight: 500;
345
+ letter-spacing: -0.15px;
346
+ font-size: clamp(0.95rem, 1.55vw, 1.1rem);
347
+ line-height: 1.3;
348
+ margin: 0 0 6px 0;
349
+ }
350
+ .sample-meta {
351
+ font-size: clamp(0.72rem, 1.2vw, 0.85rem);
352
+ opacity: 0.8;
353
+ display: flex;
354
+ justify-content: flex-start;
355
+ align-items: center;
356
+ }
357
+
358
+ .sample-modality {
359
+ padding: 0.12em 0.7em;
360
+ border-radius: 14px;
361
+ font-weight: 600;
362
+ background: rgba(255, 255, 255, 0.2);
363
+ font-size: 12px;
364
+ text-transform: uppercase;
365
+ letter-spacing: 0.5px;
366
+ }
367
+
368
+ .sample-modality.mod-ct {
369
+ background: rgba(66, 133, 244, 0.15);
370
+ color: var(--google-blue-dark);
371
+ }
372
+
373
+ .sample-modality.mod-mri {
374
+ background: rgba(156, 39, 176, 0.15);
375
+ color: var(--google-purple-dark);
376
+ }
377
+
378
+ .sample-modality.mod-xr {
379
+ background: rgba(255, 152, 0, 0.15);
380
+ color: #e65100;
381
+ }
382
+
383
+ .sample-modality.mod-us {
384
+ background: rgba(0, 188, 212, 0.15);
385
+ color: #00695c;
386
+ }
387
+
388
+ .sample-modality.mod-pet {
389
+ background: rgba(255, 64, 129, 0.15);
390
+ color: #ad1457;
391
+ }
392
+
393
+ .sample-button:hover {
394
+ background: var(--google-grey-200);
395
+ border-color: var(--google-blue-light);
396
+ transform: translateY(-2px);
397
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
398
+ }
399
+ .sample-button:active {
400
+ transform: none;
401
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
402
+ }
403
+
404
+ /* === MINIMUM TOUCH SIZE ======================================= */
405
+ @supports (height: 100lvh) {
406
+ .sample-button {
407
+ min-height: 44px;
408
+ }
409
+ }
410
+
411
+ /* Mobile optimization for sample buttons */
412
+ @media (max-width: 768px) {
413
+ .sample-button {
414
+ padding: 10px 12px;
415
+ }
416
+
417
+ .sample-title {
418
+ font-size: clamp(0.9rem, 2.5vw, 1rem);
419
+ line-height: 1.3;
420
+ }
421
+
422
+ .sample-meta {
423
+ margin-top: 4px;
424
+ }
425
+
426
+ /* Hide desktop tip on mobile */
427
+ .tip-desktop {
428
+ display: none;
429
+ }
430
+ }
431
+
432
+ /* Very narrow phones - horizontal scroll */
433
+ @media (max-width: 360px) {
434
+ .sample-buttons {
435
+ display: flex;
436
+ overflow-x: auto;
437
+ scroll-snap-type: x mandatory;
438
+ padding-inline: 4px;
439
+ }
440
+ .sample-button {
441
+ flex: 0 0 80%;
442
+ margin-inline: 4px;
443
+ scroll-snap-align: start;
444
+ }
445
+ }
446
+
447
+ /* Loading state */
448
+ .sample-button.loading {
449
+ pointer-events: none;
450
+ opacity: 0.6;
451
+ }
452
+ .sample-button.loading::after {
453
+ content: '';
454
+ position: absolute;
455
+ inset: 0;
456
+ background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="10" stroke="%234285F4" stroke-width="2" stroke-dasharray="31.416" stroke-dashoffset="31.416"><animateTransform attributeName="transform" type="rotate" dur="1s" values="0 12 12;360 12 12" repeatCount="indefinite"/></circle></svg>')
457
+ center / 24px 24px no-repeat;
458
+ }
459
+
460
+ /* Mobile touch device optimizations */
461
+ @media (hover: none) and (pointer: coarse) {
462
+ .large-text-area {
463
+ -webkit-user-select: none;
464
+ user-select: none;
465
+ -webkit-touch-callout: none;
466
+ -webkit-tap-highlight-color: transparent;
467
+ }
468
+
469
+ .output-container,
470
+ #prompt-output {
471
+ -webkit-overflow-scrolling: touch;
472
+ will-change: scroll-position;
473
+ overscroll-behavior: contain;
474
+ }
475
+
476
+ .text-span {
477
+ touch-action: manipulation;
478
+ -webkit-user-select: none;
479
+ user-select: none;
480
+ -webkit-touch-callout: none;
481
+ }
482
+ }
483
+
484
+ /* Text Areas */
485
+ .text-area-container {
486
+ display: grid;
487
+ grid-template-columns: 1fr 1fr;
488
+ gap: 24px;
489
+ }
490
+
491
+ /* Ensure both panels handle long content consistently */
492
+ .large-text-area,
493
+ .output-container {
494
+ min-width: 0; /* Prevent grid overflow */
495
+ max-width: 100%; /* Stay within grid bounds */
496
+ overflow-wrap: break-word; /* Break long words */
497
+ word-break: break-word; /* Handle long headers */
498
+ }
499
+
500
+ .text-area-wrapper {
501
+ flex: 1;
502
+ }
503
+
504
+ .text-area-wrapper h2 {
505
+ margin: 0 0 15px 0;
506
+ color: #2c3e50;
507
+ font-size: 1.3em;
508
+ font-weight: 500;
509
+ border-bottom: 2px solid #e9ecef;
510
+ padding-bottom: 8px;
511
+ }
512
+
513
+ .large-text-area {
514
+ width: 100%;
515
+ height: clamp(300px, 50vh, 500px);
516
+ min-height: 300px;
517
+ padding: 15px;
518
+ box-sizing: border-box;
519
+ resize: vertical;
520
+ border: 1px solid var(--google-grey-200);
521
+ border-radius: 8px;
522
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
523
+ font-size: 14px;
524
+ line-height: 1.5;
525
+ transition: border-color 0.3s ease;
526
+ -webkit-overflow-scrolling: touch;
527
+ scroll-behavior: smooth;
528
+ }
529
+
530
+ .large-text-area:focus {
531
+ outline: none;
532
+ border-color: var(--google-blue);
533
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.2);
534
+ }
535
+
536
+ .output-container {
537
+ width: 100%;
538
+ height: clamp(300px, 50vh, 500px);
539
+ min-height: 300px;
540
+ overflow: auto;
541
+ border: 2px solid #e9ecef;
542
+ border-radius: 8px;
543
+ background-color: #f8f9fa;
544
+ position: relative;
545
+ /* Ensure container doesn't expand beyond grid bounds */
546
+ box-sizing: border-box;
547
+ -webkit-overflow-scrolling: touch;
548
+ scroll-behavior: smooth;
549
+ }
550
+
551
+ /* Loading Overlay */
552
+ .loading-overlay {
553
+ position: absolute;
554
+ top: 0;
555
+ left: 0;
556
+ width: 100%;
557
+ height: 100%;
558
+ background: rgba(240, 248, 255, 0.85);
559
+ backdrop-filter: blur(2px);
560
+ display: flex;
561
+ align-items: center;
562
+ justify-content: center;
563
+ flex-direction: column;
564
+ border-radius: 8px;
565
+ z-index: 10;
566
+ }
567
+
568
+ .loader-message {
569
+ font-family: 'Google Sans', sans-serif;
570
+ font-size: 15px;
571
+ font-weight: 500;
572
+ color: var(--google-blue-dark);
573
+ display: inline-block;
574
+ }
575
+
576
+ .loader-text {
577
+ margin-top: 24px;
578
+ text-align: center;
579
+ }
580
+
581
+ .spinner {
582
+ width: 80px;
583
+ height: 80px;
584
+ border: 8px solid rgba(26, 115, 232, 0.15);
585
+ border-top-color: var(--google-blue-dark);
586
+ border-right-color: var(--google-blue);
587
+ border-radius: 50%;
588
+ filter: drop-shadow(0 3px 6px rgba(26, 115, 232, 0.15));
589
+ }
590
+
591
+ .sample-button.active {
592
+ background: var(--google-blue);
593
+ color: #fff;
594
+ border-color: var(--google-blue);
595
+ box-shadow: 0 2px 8px rgba(26, 115, 232, 0.3);
596
+ transform: translateY(-1px);
597
+ }
598
+
599
+ .sample-button.active .sample-modality {
600
+ background: rgba(255, 255, 255, 0.2);
601
+ color: #fff;
602
+ }
603
+
604
+ .sample-button.active:hover {
605
+ background: var(--google-blue-light);
606
+ border-color: var(--google-blue-light);
607
+ box-shadow: 0 3px 12px rgba(26, 115, 232, 0.4);
608
+ }
609
+
610
+ .output-text {
611
+ width: 100%;
612
+ height: 100%;
613
+ padding: 15px;
614
+ margin: 0;
615
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
616
+ border: none;
617
+ overflow: auto;
618
+ white-space: normal;
619
+ word-wrap: break-word;
620
+ box-sizing: border-box;
621
+ background-color: transparent;
622
+ font-size: 14px;
623
+ line-height: 1.5;
624
+ }
625
+
626
+ #output-text {
627
+ white-space: pre-wrap;
628
+ }
629
+
630
+ #predict-button {
631
+ padding: 12px 24px;
632
+ background: var(--google-blue);
633
+ color: white;
634
+ border: none;
635
+ border-radius: 6px;
636
+ cursor: pointer;
637
+ font-size: 14px;
638
+ font-weight: 500;
639
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
640
+ box-shadow: 0 1px 3px rgba(60, 64, 67, 0.15);
641
+ margin: 12px auto 8px auto;
642
+ display: block;
643
+ position: relative;
644
+ overflow: hidden;
645
+ }
646
+
647
+ #predict-button:hover {
648
+ background: var(--google-blue-light);
649
+ transform: translateY(-1px);
650
+ box-shadow: 0 2px 8px rgba(26, 115, 232, 0.3);
651
+ }
652
+
653
+ #predict-button:active {
654
+ transform: translateY(0);
655
+ box-shadow: 0 1px 4px rgba(26, 115, 232, 0.3);
656
+ }
657
+
658
+ #predict-button:disabled {
659
+ background: #bdc1c6;
660
+ cursor: not-allowed;
661
+ transform: none;
662
+ box-shadow: 0 1px 2px rgba(60, 64, 67, 0.1);
663
+ }
664
+
665
+ .text-span {
666
+ cursor: pointer;
667
+ border-radius: 2px;
668
+ transition: background-color 0.2s;
669
+ }
670
+
671
+ /* Style labels within text spans */
672
+ .text-span.has-label .label-part {
673
+ font-weight: 600;
674
+ color: var(--google-blue);
675
+ font-family: 'Google Sans', sans-serif;
676
+ pointer-events: none; /* Let hover events bubble to parent .text-span */
677
+ }
678
+
679
+ .text-span.has-label .content-part {
680
+ font-weight: normal;
681
+ }
682
+
683
+ .text-span:hover {
684
+ background-color: rgba(66, 165, 245, 0.2);
685
+ outline: 1px solid rgba(66, 165, 245, 0.3);
686
+ }
687
+
688
+ .highlight {
689
+ background-color: rgba(66, 165, 245, 0.35) !important;
690
+ outline: 1px solid rgba(66, 165, 245, 0.5) !important;
691
+ }
692
+
693
+ /* Input textarea selection - exactly match output highlighting */
694
+ .large-text-area::selection {
695
+ background-color: rgba(66, 165, 245, 0.35) !important;
696
+ color: inherit !important;
697
+ }
698
+
699
+ .large-text-area::-moz-selection {
700
+ background-color: rgba(66, 165, 245, 0.35) !important;
701
+ color: inherit !important;
702
+ }
703
+
704
+ #output-text-container {
705
+ position: relative;
706
+ }
707
+
708
+ #input-text-container {
709
+ position: relative;
710
+ }
711
+
712
+ #output-text-container .significance-minor {
713
+ text-decoration: underline;
714
+ text-decoration-style: solid;
715
+ text-decoration-color: #fbc02d; /* subtle yellow */
716
+ text-decoration-thickness: 2px;
717
+ }
718
+
719
+ #output-text-container .significance-significant {
720
+ text-decoration: underline;
721
+ text-decoration-style: solid;
722
+ text-decoration-color: #f48fb1; /* subtle light pink */
723
+ text-decoration-thickness: 2px;
724
+ }
725
+
726
+ /* Report Structure */
727
+ .segment-label {
728
+ font-weight: bold;
729
+ }
730
+
731
+ /* Sub-label styling */
732
+ .segment-sublabel {
733
+ font-family: 'Google Sans', sans-serif; /* align with headers */
734
+ font-weight: 600; /* stronger bold for clarity */
735
+ color: var(--google-grey-900);
736
+ margin-right: 2px;
737
+ white-space: nowrap;
738
+ }
739
+
740
+ .segment-body {
741
+ margin-bottom: 12px;
742
+ }
743
+
744
+ .section-header {
745
+ font-family: 'Google Sans', sans-serif;
746
+ font-weight: 600;
747
+ font-size: 1.1em;
748
+ margin-top: 12px;
749
+ margin-bottom: 2px;
750
+ color: var(--google-blue-dark);
751
+ }
752
+
753
+ textarea::selection {
754
+ background-color: #ffeb3b;
755
+ color: #000;
756
+ }
757
+
758
+ .instructions {
759
+ background: var(--google-grey-100);
760
+ border-radius: 8px;
761
+ border: 1px solid var(--google-grey-200);
762
+ padding: 10px 16px;
763
+ font-size: 14px;
764
+ color: var(--google-grey-700);
765
+ }
766
+
767
+ .instructions p {
768
+ margin: 0;
769
+ line-height: 1.6;
770
+ color: #2c3e50;
771
+ font-size: 1em;
772
+ }
773
+
774
+ .instructions strong {
775
+ color: #1565c0;
776
+ font-weight: 600;
777
+ }
778
+
779
+ .instructions ul {
780
+ margin: 15px 0;
781
+ padding-left: 20px;
782
+ }
783
+
784
+ .instructions li {
785
+ margin-bottom: 8px;
786
+ line-height: 1.6;
787
+ }
788
+
789
+ .instructions li strong {
790
+ color: #2c3e50;
791
+ }
792
+
793
+ .samples-container h3,
794
+ .header-container h1 {
795
+ font-weight: 400;
796
+ }
797
+
798
+ .page-wrapper {
799
+ max-width: 1200px;
800
+ margin: 0 auto;
801
+ padding: 24px;
802
+ }
803
+
804
+ .card {
805
+ background: #fff;
806
+ border: 1px solid var(--google-grey-200);
807
+ border-radius: 12px;
808
+ box-shadow: 0 1px 3px rgba(60, 64, 67, 0.15);
809
+ padding: 24px;
810
+ }
811
+
812
+ .text-area-container {
813
+ display: grid;
814
+ grid-template-columns: 1fr 1fr;
815
+ gap: 24px;
816
+ }
817
+
818
+ .banner.card {
819
+ padding: 24px;
820
+ border-radius: 16px;
821
+ margin-top: 24px;
822
+ border-color: var(--google-grey-200);
823
+ }
824
+
825
+ .banner-content {
826
+ max-width: 820px;
827
+ margin: 0 auto;
828
+ text-align: left;
829
+ }
830
+
831
+ .sub-header {
832
+ margin: 0;
833
+ font-family: 'Google Sans Text', sans-serif;
834
+ font-size: 18px;
835
+ color: var(--google-grey-700);
836
+ }
837
+
838
+ /* Modality color tags */
839
+ .sample-modality.ct {
840
+ background: #e3f2fd;
841
+ color: #1565c0;
842
+ }
843
+ .sample-modality.mri {
844
+ background: #e8f5e9;
845
+ color: #2e7d32;
846
+ }
847
+ .sample-modality.xr {
848
+ background: #fff3e0;
849
+ color: #e65100;
850
+ }
851
+ .sample-modality.us {
852
+ background: #e0f2f1;
853
+ color: #00695c;
854
+ }
855
+ .sample-modality.pet {
856
+ background: #fce4ec;
857
+ color: #ad1457;
858
+ }
859
+ /* keep active chip modality white when selected */
860
+ .sample-button.active .sample-modality.ct,
861
+ .sample-button.active .sample-modality.mri,
862
+ .sample-button.active .sample-modality.xr,
863
+ .sample-button.active .sample-modality.us,
864
+ .sample-button.active .sample-modality.pet {
865
+ background: rgba(255, 255, 255, 0.2);
866
+ color: #fff;
867
+ }
868
+
869
+ .primary-label {
870
+ font-family: 'Google Sans Text', sans-serif;
871
+ font-weight: 500;
872
+ margin-top: 10px;
873
+ margin-bottom: 2px;
874
+ color: var(--google-blue-dark);
875
+ }
876
+
877
+ .finding-list {
878
+ margin: 0 0 4px 0;
879
+ padding-left: 0;
880
+ list-style-position: inside;
881
+ }
882
+ .finding-list li {
883
+ margin-bottom: 6px;
884
+ line-height: 1.5;
885
+ padding-left: 0.4em;
886
+ text-indent: -0.4em;
887
+ }
888
+ .single-finding {
889
+ margin: 0 0 6px 0;
890
+ }
891
+
892
+ @keyframes loaderPulse {
893
+ 0%,
894
+ 100% {
895
+ opacity: 0.35;
896
+ }
897
+ 50% {
898
+ opacity: 0.8;
899
+ }
900
+ }
901
+
902
+ .source-link {
903
+ display: none;
904
+ }
905
+
906
+ .footer-note {
907
+ font-size: 13px;
908
+ color: var(--google-grey-600);
909
+ text-align: center;
910
+ margin-top: 24px;
911
+ }
912
+ .footer-note .banner-link {
913
+ font-weight: 500;
914
+ color: var(--google-blue-dark);
915
+ }
916
+ .footer-note .hug-emoji {
917
+ font-size: 16px;
918
+ }
919
+
920
+ .banner-description + .banner-description {
921
+ border-top: 1px solid var(--google-grey-200);
922
+ padding-top: 12px;
923
+ }
924
+
925
+ .output-header {
926
+ display: flex;
927
+ justify-content: space-between;
928
+ align-items: center;
929
+ margin-bottom: 2px;
930
+ }
931
+
932
+ .copy-button-overlay {
933
+ position: absolute;
934
+ bottom: 12px;
935
+ right: 20px;
936
+ width: 36px;
937
+ height: 36px;
938
+ border-radius: 50%;
939
+ background-color: rgba(255, 255, 255, 0.6);
940
+ border: 1px solid rgba(0, 0, 0, 0.06);
941
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
942
+ color: #9aa0a6;
943
+ cursor: pointer;
944
+ transition: all 0.3s ease;
945
+ display: flex;
946
+ align-items: center;
947
+ justify-content: center;
948
+ backdrop-filter: blur(8px);
949
+ z-index: 5;
950
+ opacity: 0.7;
951
+ }
952
+
953
+ .copy-button-overlay:hover {
954
+ background-color: rgba(255, 255, 255, 0.85);
955
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
956
+ color: #5f6368;
957
+ opacity: 1;
958
+ }
959
+
960
+ .copy-button-overlay:active {
961
+ transform: scale(0.98);
962
+ }
963
+
964
+ .copy-button-overlay.copied {
965
+ background-color: rgba(230, 247, 255, 0.8);
966
+ border-color: rgba(24, 144, 255, 0.3);
967
+ color: #1890ff;
968
+ opacity: 0.9;
969
+ }
970
+
971
+ .copy-button-overlay svg {
972
+ width: 18px;
973
+ height: 18px;
974
+ stroke-width: 1.5;
975
+ }
976
+
977
+ .copy-button-overlay.copied svg {
978
+ display: none;
979
+ }
980
+
981
+ .copy-button-overlay.copied::after {
982
+ content: '✓';
983
+ font-size: 16px;
984
+ font-weight: 500;
985
+ }
986
+
987
+ .copy-button-overlay:disabled {
988
+ opacity: 0;
989
+ pointer-events: none;
990
+ }
991
+
992
+ .clear-button-overlay {
993
+ position: absolute;
994
+ bottom: 12px;
995
+ right: 20px;
996
+ width: 36px;
997
+ height: 36px;
998
+ border-radius: 50%;
999
+ background-color: rgba(255, 255, 255, 0.6);
1000
+ border: 1px solid rgba(0, 0, 0, 0.06);
1001
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
1002
+ color: #9aa0a6;
1003
+ cursor: pointer;
1004
+ transition: all 0.3s ease;
1005
+ display: flex;
1006
+ align-items: center;
1007
+ justify-content: center;
1008
+ backdrop-filter: blur(8px);
1009
+ z-index: 5;
1010
+ opacity: 0.7;
1011
+ }
1012
+
1013
+ .clear-button-overlay:hover {
1014
+ background-color: rgba(255, 255, 255, 0.85);
1015
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
1016
+ color: #5f6368;
1017
+ opacity: 1;
1018
+ }
1019
+
1020
+ .clear-button-overlay:active {
1021
+ transform: scale(0.98);
1022
+ }
1023
+
1024
+ .clear-button-overlay.cleared {
1025
+ background-color: rgba(230, 247, 255, 0.8);
1026
+ border-color: rgba(24, 144, 255, 0.3);
1027
+ color: #1890ff;
1028
+ opacity: 0.9;
1029
+ }
1030
+
1031
+ .clear-button-overlay svg {
1032
+ width: 18px;
1033
+ height: 18px;
1034
+ stroke-width: 1.5;
1035
+ }
1036
+
1037
+ .clear-button-overlay.cleared svg {
1038
+ display: none;
1039
+ }
1040
+
1041
+ .clear-button-overlay.cleared::after {
1042
+ content: '✓';
1043
+ font-size: 16px;
1044
+ font-weight: 500;
1045
+ }
1046
+
1047
+ .clear-button-overlay:disabled {
1048
+ opacity: 0;
1049
+ pointer-events: none;
1050
+ }
1051
+ .toggle-group {
1052
+ display: flex;
1053
+ gap: 16px;
1054
+ align-items: center;
1055
+ }
1056
+
1057
+ .cache-toggle {
1058
+ font-size: 13px;
1059
+ color: var(--google-grey-700);
1060
+ user-select: none;
1061
+ }
1062
+
1063
+ .raw-toggle,
1064
+ .prompt-toggle {
1065
+ font-size: 13px;
1066
+ color: #546e7a;
1067
+ font-weight: 500;
1068
+ user-select: none;
1069
+ transition: color 0.2s ease;
1070
+ }
1071
+
1072
+ .raw-toggle:hover,
1073
+ .prompt-toggle:hover {
1074
+ color: #37474f;
1075
+ }
1076
+
1077
+ .cache-toggle input,
1078
+ .raw-toggle input {
1079
+ margin-right: 4px;
1080
+ }
1081
+
1082
+ .cache-status {
1083
+ font-size: 11px;
1084
+ color: var(--google-grey-700);
1085
+ margin-left: 4px;
1086
+ opacity: 0.8;
1087
+ }
1088
+
1089
+ .raw-json {
1090
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
1091
+ font-size: 12px;
1092
+ line-height: 1.4;
1093
+ background: #fafafa;
1094
+ border: 1px solid var(--google-grey-200);
1095
+ border-radius: 8px;
1096
+ padding: 16px;
1097
+ max-height: 500px;
1098
+ overflow-y: auto;
1099
+ overflow-x: hidden !important;
1100
+ word-wrap: break-word;
1101
+ word-break: break-word;
1102
+ width: 100%;
1103
+ box-sizing: border-box;
1104
+ }
1105
+
1106
+ /* Disable animations to prevent flicker */
1107
+ .raw-json * {
1108
+ animation: none !important;
1109
+ transition: none !important;
1110
+ }
1111
+
1112
+ /* Override JSON formatter table layout for text wrapping */
1113
+ .raw-json .json-formatter-row {
1114
+ display: block !important;
1115
+ width: 100% !important;
1116
+ table-layout: auto !important;
1117
+ }
1118
+
1119
+ .raw-json .json-formatter-row > * {
1120
+ display: inline !important;
1121
+ max-width: 100% !important;
1122
+ }
1123
+
1124
+ /* Force the entire JSON formatter to use block layout */
1125
+ .raw-json .json-formatter-table {
1126
+ display: block !important;
1127
+ width: 100% !important;
1128
+ table-layout: fixed !important;
1129
+ }
1130
+
1131
+ .raw-json .json-formatter-table,
1132
+ .raw-json .json-formatter-table * {
1133
+ table-layout: fixed !important;
1134
+ word-wrap: break-word !important;
1135
+ overflow-wrap: break-word !important;
1136
+ }
1137
+
1138
+ /* Basic JSON formatter styling */
1139
+ .raw-json .json-formatter-key {
1140
+ color: #0066cc;
1141
+ }
1142
+ .raw-json .json-formatter-string {
1143
+ color: #22863a;
1144
+ word-wrap: break-word;
1145
+ word-break: break-word;
1146
+ white-space: pre-wrap;
1147
+ display: inline !important;
1148
+ max-width: 100% !important;
1149
+ vertical-align: top !important;
1150
+ overflow-wrap: break-word !important;
1151
+ }
1152
+ .raw-json .json-formatter-number {
1153
+ color: #005cc5;
1154
+ }
1155
+ .raw-json .json-formatter-boolean {
1156
+ color: #d73a49;
1157
+ }
1158
+ .raw-json .json-formatter-null {
1159
+ color: #6f42c1;
1160
+ }
1161
+ .raw-json .json-formatter-toggler {
1162
+ color: #586069;
1163
+ cursor: pointer;
1164
+ }
1165
+ .raw-json .json-formatter-toggler:hover {
1166
+ color: #0366d6;
1167
+ }
1168
+
1169
+ /* Force text wrapping for all elements */
1170
+ .raw-json .json-formatter-string,
1171
+ .raw-json .json-formatter-key,
1172
+ .raw-json .json-formatter-row,
1173
+ .raw-json .json-formatter-row *,
1174
+ .raw-json .json-formatter-preview {
1175
+ max-width: 100% !important;
1176
+ overflow-wrap: break-word !important;
1177
+ word-break: break-word !important;
1178
+ white-space: pre-wrap !important;
1179
+ box-sizing: border-box !important;
1180
+ min-width: 0 !important;
1181
+ width: auto !important;
1182
+ }
1183
+
1184
+ /* Prevent horizontal scrolling and text cutoff */
1185
+ .raw-json,
1186
+ .raw-json * {
1187
+ max-width: 100% !important;
1188
+ word-wrap: break-word !important;
1189
+ overflow-wrap: break-word !important;
1190
+ hyphens: auto !important;
1191
+ overflow-x: hidden !important;
1192
+ }
1193
+
1194
+ #raw-output {
1195
+ width: 100% !important;
1196
+ max-width: 100% !important;
1197
+ box-sizing: border-box !important;
1198
+ overflow-wrap: break-word !important;
1199
+ }
1200
+
1201
+ .input-header,
1202
+ .output-header {
1203
+ display: flex;
1204
+ justify-content: space-between;
1205
+ align-items: center;
1206
+ gap: 12px;
1207
+ flex-wrap: nowrap;
1208
+ border-bottom: 2px solid #e9ecef;
1209
+ padding-bottom: 4px;
1210
+ margin-bottom: 8px;
1211
+ }
1212
+
1213
+ .input-controls {
1214
+ display: flex;
1215
+ align-items: center;
1216
+ gap: 16px;
1217
+ flex-wrap: wrap;
1218
+ }
1219
+
1220
+ /* Panel controls at bottom of Input/Output container */
1221
+ .panel-controls {
1222
+ display: flex !important;
1223
+ justify-content: center !important;
1224
+ align-items: center !important;
1225
+ gap: 16px !important;
1226
+ padding: 4px 16px !important;
1227
+ border-top: 1px solid #e9ecef !important;
1228
+ background: #fafbfc !important;
1229
+ border-radius: 0 0 16px 16px !important;
1230
+ margin-top: 0px !important;
1231
+ margin-left: auto !important;
1232
+ margin-right: auto !important;
1233
+ flex-wrap: wrap !important;
1234
+ text-align: center !important;
1235
+ width: 100% !important;
1236
+ box-sizing: border-box !important;
1237
+ grid-column: 1 / -1 !important;
1238
+ }
1239
+
1240
+ .input-header h2,
1241
+ .output-header h2 {
1242
+ margin: 0;
1243
+ line-height: 1.2;
1244
+ }
1245
+
1246
+ .text-area-wrapper h2 {
1247
+ border: none;
1248
+ }
1249
+
1250
+ #prompt-output {
1251
+ width: 100%;
1252
+ height: clamp(300px, 50vh, 500px);
1253
+ min-height: 300px;
1254
+ overflow: auto;
1255
+ border: 2px solid #e9ecef;
1256
+ border-radius: 8px;
1257
+ background: #fafafa;
1258
+ padding: 16px;
1259
+ box-sizing: border-box;
1260
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
1261
+ font-size: 12px;
1262
+ line-height: 1.4;
1263
+ white-space: normal;
1264
+ word-wrap: break-word;
1265
+ word-break: break-word;
1266
+ overflow-wrap: break-word;
1267
+ -webkit-overflow-scrolling: touch;
1268
+ scroll-behavior: smooth;
1269
+ }
1270
+
1271
+ /* Markdown styling for prompt output */
1272
+ #prompt-output h1 {
1273
+ font-family: 'Google Sans', sans-serif;
1274
+ font-size: 18px;
1275
+ font-weight: 600;
1276
+ color: var(--google-blue-dark);
1277
+ margin: 0 0 16px 0;
1278
+ padding-bottom: 8px;
1279
+ border-bottom: 2px solid var(--google-grey-200);
1280
+ }
1281
+
1282
+ #prompt-output h2 {
1283
+ font-family: 'Google Sans', sans-serif;
1284
+ font-size: 16px;
1285
+ font-weight: 500;
1286
+ color: var(--google-blue-dark);
1287
+ margin: 24px 0 12px 0;
1288
+ }
1289
+
1290
+ #prompt-output h3 {
1291
+ font-family: 'Google Sans', sans-serif;
1292
+ font-size: 14px;
1293
+ font-weight: 500;
1294
+ color: var(--google-grey-700);
1295
+ margin: 16px 0 8px 0;
1296
+ }
1297
+
1298
+ #prompt-output strong {
1299
+ font-weight: 600;
1300
+ color: var(--google-blue-dark);
1301
+ }
1302
+
1303
+ #prompt-output blockquote {
1304
+ margin: 12px 0;
1305
+ padding: 8px 16px;
1306
+ border-left: 4px solid var(--google-blue);
1307
+ background: rgba(26, 115, 232, 0.05);
1308
+ font-style: italic;
1309
+ }
1310
+
1311
+ #prompt-output code {
1312
+ font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
1313
+ background: rgba(0, 0, 0, 0.05);
1314
+ padding: 2px 4px;
1315
+ border-radius: 3px;
1316
+ font-size: 11px;
1317
+ }
1318
+
1319
+ #prompt-output pre {
1320
+ background: #f5f5f5;
1321
+ border: 1px solid var(--google-grey-200);
1322
+ border-radius: 6px;
1323
+ padding: 12px;
1324
+ overflow-x: auto;
1325
+ margin: 12px 0;
1326
+ font-size: 11px;
1327
+ line-height: 1.3;
1328
+ word-wrap: break-word;
1329
+ white-space: pre-wrap;
1330
+ }
1331
+
1332
+ #prompt-output pre code {
1333
+ background: none;
1334
+ padding: 0;
1335
+ border-radius: 0;
1336
+ }
1337
+
1338
+ #prompt-output ul,
1339
+ #prompt-output ol {
1340
+ margin: 8px 0;
1341
+ padding-left: 20px;
1342
+ }
1343
+
1344
+ #prompt-output li {
1345
+ margin-bottom: 4px;
1346
+ line-height: 1.4;
1347
+ }
1348
+
1349
+ #prompt-output p {
1350
+ margin: 8px 0;
1351
+ line-height: 1.5;
1352
+ font-family: 'Google Sans Text', sans-serif;
1353
+ font-size: 13px;
1354
+ }
1355
+
1356
+ .no-bold {
1357
+ font-weight: 400;
1358
+ }
1359
+
1360
+ .model-select-container {
1361
+ display: flex;
1362
+ align-items: center;
1363
+ gap: 6px;
1364
+ font-size: 14px;
1365
+ margin: 0;
1366
+ }
1367
+
1368
+ .model-select-container select {
1369
+ padding: 6px 12px;
1370
+ border-radius: 6px;
1371
+ border: 1px solid var(--google-grey-200);
1372
+ font-size: 14px;
1373
+ background: #fff;
1374
+ cursor: pointer;
1375
+ }
1376
+
1377
+ .model-select-container select:focus {
1378
+ outline: none;
1379
+ border-color: var(--google-blue);
1380
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.2);
1381
+ }
1382
+
1383
+ .model-select-container label {
1384
+ font-weight: 500;
1385
+ color: var(--google-grey-700);
1386
+ }
1387
+
1388
+ .banner-divider {
1389
+ border: none;
1390
+ border-top: 1px solid var(--google-grey-200);
1391
+ margin: 24px 0 18px 0;
1392
+ }
1393
+
1394
+ /* Interface Options Panel */
1395
+ .interface-options-panel {
1396
+ margin: 16px 0;
1397
+ padding: 16px !important; /* Override default card padding for more compact layout */
1398
+ }
1399
+
1400
+ .interface-options-header {
1401
+ cursor: pointer;
1402
+ transition: background-color 0.2s ease;
1403
+ border-radius: 8px;
1404
+ padding: 4px;
1405
+ margin: -4px;
1406
+ }
1407
+
1408
+ .interface-options-header:hover {
1409
+ background-color: var(--google-grey-100);
1410
+ }
1411
+
1412
+ .interface-options-title {
1413
+ font-family: 'Google Sans', sans-serif;
1414
+ font-size: 20px;
1415
+ font-weight: normal;
1416
+ margin: 0 0 6px 0;
1417
+ color: var(--google-grey-900);
1418
+ }
1419
+
1420
+ .interface-options-summary {
1421
+ display: flex;
1422
+ justify-content: space-between;
1423
+ align-items: center;
1424
+ font-size: 13px;
1425
+ color: var(--google-grey-700);
1426
+ margin-bottom: 4px;
1427
+ }
1428
+
1429
+ .expand-icon {
1430
+ color: var(--google-blue-dark);
1431
+ transition:
1432
+ transform 0.3s ease,
1433
+ color 0.2s ease,
1434
+ background-color 0.2s ease;
1435
+ font-size: 20px;
1436
+ cursor: pointer;
1437
+ opacity: 0.9;
1438
+ padding: 3px;
1439
+ border-radius: 50%;
1440
+ background-color: transparent;
1441
+ }
1442
+
1443
+ .expand-icon:hover {
1444
+ color: var(--google-blue);
1445
+ opacity: 1;
1446
+ background-color: rgba(66, 133, 244, 0.1);
1447
+ transform: scale(1.05);
1448
+ }
1449
+
1450
+ .expand-icon.expanded {
1451
+ transform: rotate(180deg);
1452
+ }
1453
+
1454
+ .expand-icon.expanded:hover {
1455
+ transform: rotate(180deg) scale(1.05);
1456
+ }
1457
+
1458
+ .interface-options-content {
1459
+ margin-top: 10px;
1460
+ transition: all 0.3s ease;
1461
+ }
1462
+
1463
+ .interface-options-grid {
1464
+ display: grid;
1465
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
1466
+ gap: 14px;
1467
+ margin-bottom: 10px;
1468
+ }
1469
+
1470
+ .interface-option {
1471
+ padding: 12px;
1472
+ border: 1px solid var(--google-grey-200);
1473
+ border-radius: 8px;
1474
+ background: #fff;
1475
+ transition: border-color 0.2s ease;
1476
+ }
1477
+
1478
+ .interface-option:hover {
1479
+ border-color: var(--google-blue-light);
1480
+ }
1481
+
1482
+ .option-header {
1483
+ display: flex;
1484
+ align-items: center;
1485
+ margin-bottom: 6px;
1486
+ }
1487
+
1488
+ .option-icon {
1489
+ font-size: 18px;
1490
+ color: var(--google-blue);
1491
+ margin-right: 6px;
1492
+ }
1493
+
1494
+ .option-header strong {
1495
+ font-family: 'Google Sans', sans-serif;
1496
+ font-size: 14px;
1497
+ color: var(--google-grey-900);
1498
+ }
1499
+
1500
+ .option-description {
1501
+ font-size: 13px;
1502
+ color: var(--google-grey-700);
1503
+ line-height: 1.3;
1504
+ margin: 0;
1505
+ }
1506
+
1507
+ .interface-options-note {
1508
+ display: flex;
1509
+ align-items: center;
1510
+ justify-content: center;
1511
+ padding: 8px 12px;
1512
+ background: var(--google-grey-100);
1513
+ border-radius: 6px;
1514
+ font-size: 12px;
1515
+ color: var(--google-grey-700);
1516
+ }
1517
+
1518
+ .note-icon {
1519
+ font-size: 16px;
1520
+ color: var(--google-blue);
1521
+ margin-right: 6px;
1522
+ }
1523
+
1524
+ /* Clinical Significance Legend */
1525
+ .clinical-significance-legend {
1526
+ margin: 0 0 4px 0;
1527
+ padding: 6px 12px;
1528
+ background: #fff;
1529
+ border: 1px solid var(--google-grey-200);
1530
+ border-radius: 6px;
1531
+ display: flex;
1532
+ justify-content: flex-end;
1533
+ align-items: center;
1534
+ gap: 16px;
1535
+ font-size: 12px;
1536
+ }
1537
+
1538
+ .legend-title {
1539
+ font-weight: 600;
1540
+ color: var(--google-grey-700);
1541
+ font-size: 12px;
1542
+ }
1543
+
1544
+ .legend-item {
1545
+ display: inline-flex;
1546
+ align-items: center;
1547
+ color: var(--google-grey-700);
1548
+ font-weight: 500;
1549
+ }
1550
+
1551
+ .legend-line {
1552
+ display: inline-block;
1553
+ width: 18px;
1554
+ height: 2px;
1555
+ border-radius: 1px;
1556
+ margin-right: 4px;
1557
+ }
1558
+
1559
+ .legend-line.minor {
1560
+ background-color: #fbc02d;
1561
+ }
1562
+
1563
+ .legend-line.major {
1564
+ background-color: #f48fb1;
1565
+ }
1566
+
1567
+ .legend-line.grounding {
1568
+ background-color: rgba(66, 165, 245, 0.7);
1569
+ border: 1px solid rgba(66, 165, 245, 0.8);
1570
+ }
1571
+
1572
+ .action-bar {
1573
+ margin-top: 16px;
1574
+ display: flex;
1575
+ align-items: center;
1576
+ justify-content: center;
1577
+ gap: 16px;
1578
+ position: relative;
1579
+ }
1580
+
1581
+ .action-bar #predict-button {
1582
+ margin: 0;
1583
+ }
1584
+
1585
+ /* Social sharing buttons */
1586
+
1587
+ /* Top header share placement */
1588
+ .share-top {
1589
+ display: none;
1590
+ align-items: center;
1591
+ gap: 8px;
1592
+ font-size: 14px;
1593
+ margin: 8px 0 24px 0;
1594
+ justify-content: center;
1595
+ color: var(--google-grey-700);
1596
+ }
1597
+
1598
+ /* Bottom share placement */
1599
+ .share-bottom {
1600
+ display: flex;
1601
+ align-items: center;
1602
+ gap: 8px;
1603
+ font-size: 12px;
1604
+ margin: 48px 0 8px 0;
1605
+ justify-content: center;
1606
+ flex-wrap: wrap;
1607
+ color: var(--google-grey-600);
1608
+ opacity: 0.85;
1609
+ }
1610
+
1611
+ /* Bottom share button styling */
1612
+ .share-bottom .shr-btn {
1613
+ width: 24px;
1614
+ height: 24px;
1615
+ background: transparent;
1616
+ border: none;
1617
+ opacity: 0.7;
1618
+ }
1619
+
1620
+ .share-bottom .shr-btn svg {
1621
+ width: 18px;
1622
+ height: 18px;
1623
+ }
1624
+
1625
+ .share-bottom .shr-btn:hover {
1626
+ background: #f8f9fa;
1627
+ opacity: 1;
1628
+ transform: none;
1629
+ box-shadow: none;
1630
+ }
1631
+
1632
+ /* Common share button styling */
1633
+ .shr-btn {
1634
+ display: inline-flex;
1635
+ align-items: center;
1636
+ justify-content: center;
1637
+ width: 32px;
1638
+ height: 32px;
1639
+ border-radius: 50%;
1640
+ background: #f8f9fa;
1641
+ color: #5f6368;
1642
+ transition: all 0.2s ease;
1643
+ text-decoration: none;
1644
+ border: 1px solid #e8eaed;
1645
+ }
1646
+
1647
+ .shr-btn:hover {
1648
+ background: #e8f0fe;
1649
+ transform: translateY(-1px);
1650
+ box-shadow: 0 2px 8px rgba(66, 133, 244, 0.2);
1651
+ }
1652
+
1653
+ .shr-btn:focus {
1654
+ outline: 2px solid var(--google-blue);
1655
+ outline-offset: 2px;
1656
+ }
1657
+
1658
+ .shr-btn.shr-x {
1659
+ color: #1da1f2;
1660
+ }
1661
+
1662
+ .shr-btn.shr-x:hover {
1663
+ background: #e3f2fd;
1664
+ color: #1976d2;
1665
+ }
1666
+
1667
+ .shr-btn.shr-li {
1668
+ color: #0a66c2;
1669
+ }
1670
+
1671
+ .shr-btn.shr-li:hover {
1672
+ background: #e3f2fd;
1673
+ color: #1565c0;
1674
+ }
1675
+
1676
+ /* Mobile-only toggles - hidden on desktop */
1677
+ .mobile-toggle {
1678
+ display: none;
1679
+ }
1680
+
1681
+ /* Mobile optimizations */
1682
+ @media (max-width: 768px) {
1683
+ .share-bottom {
1684
+ margin-top: 20px;
1685
+ }
1686
+
1687
+ .shr-btn {
1688
+ width: 28px;
1689
+ height: 28px;
1690
+ }
1691
+
1692
+ .shr-btn svg {
1693
+ width: 16px;
1694
+ height: 16px;
1695
+ }
1696
+
1697
+ /* Center Input and Output headers on mobile */
1698
+ .input-header,
1699
+ .output-header {
1700
+ justify-content: center;
1701
+ text-align: center;
1702
+ }
1703
+
1704
+ /* Mobile panel controls - 2-column grid layout */
1705
+ .panel-controls {
1706
+ display: grid !important;
1707
+ grid-template-columns: repeat(2, 1fr);
1708
+ column-gap: 8px;
1709
+ row-gap: 8px;
1710
+ padding: 12px 16px;
1711
+ justify-items: stretch;
1712
+ align-items: center;
1713
+ border-top: 1px solid #e9ecef;
1714
+ background: #fafbfc;
1715
+ }
1716
+
1717
+ /* Grid item styling for all controls */
1718
+ .panel-controls .model-select-container,
1719
+ .panel-controls .cache-toggle,
1720
+ .panel-controls .prompt-toggle.mobile-toggle,
1721
+ .panel-controls .raw-toggle.mobile-toggle {
1722
+ font-size: 12px !important;
1723
+ color: var(--google-grey-700) !important;
1724
+ font-weight: normal !important;
1725
+ user-select: none;
1726
+ display: flex;
1727
+ align-items: center;
1728
+ gap: 4px;
1729
+ transition: none !important;
1730
+ text-decoration: none !important;
1731
+ }
1732
+
1733
+ /* Hide desktop header toggles on mobile */
1734
+ .input-header .prompt-toggle,
1735
+ .output-header .raw-toggle {
1736
+ display: none;
1737
+ }
1738
+
1739
+ /* Hide cache status text on mobile */
1740
+ .cache-status {
1741
+ display: none;
1742
+ }
1743
+
1744
+ /* Standardized checkbox sizing */
1745
+ .panel-controls label input {
1746
+ width: 16px;
1747
+ height: 16px;
1748
+ margin-right: 4px !important;
1749
+ }
1750
+
1751
+ /* Uniform checkbox appearance */
1752
+ .panel-controls .cache-toggle input,
1753
+ .panel-controls .prompt-toggle.mobile-toggle input,
1754
+ .panel-controls .raw-toggle.mobile-toggle input {
1755
+ appearance: auto;
1756
+ margin: 0 4px 0 0 !important;
1757
+ padding: 0;
1758
+ border: none;
1759
+ background: none;
1760
+ width: 16px !important;
1761
+ height: 16px !important;
1762
+ }
1763
+
1764
+ /* Remove hover effects on mobile */
1765
+ .panel-controls .cache-toggle:hover,
1766
+ .panel-controls .prompt-toggle.mobile-toggle:hover,
1767
+ .panel-controls .raw-toggle.mobile-toggle:hover {
1768
+ color: var(--google-grey-700) !important;
1769
+ transition: none !important;
1770
+ transform: none !important;
1771
+ }
1772
+
1773
+ /* Mobile disclaimer styling */
1774
+ .disclaimer-container {
1775
+ margin-top: 24px;
1776
+ }
1777
+
1778
+ .disclaimer-box {
1779
+ font-size: 12px !important;
1780
+ padding: 10px 14px !important;
1781
+ background: #fafbfc !important;
1782
+ border-left: 3px solid #e8c547 !important;
1783
+ box-shadow: 0 1px 2px rgba(60, 64, 67, 0.05) !important;
1784
+ border-radius: 8px !important;
1785
+ gap: 10px !important;
1786
+ }
1787
+
1788
+ .disclaimer-icon {
1789
+ font-size: 16px !important;
1790
+ color: #e8c547 !important;
1791
+ }
1792
+
1793
+ .disclaimer-text {
1794
+ color: #6b7280 !important;
1795
+ line-height: 1.4 !important;
1796
+ }
1797
+
1798
+ /* Mobile Google Research logo */
1799
+ .google-research-logo {
1800
+ margin-top: 2px !important;
1801
+ margin-bottom: 20px !important;
1802
+ }
1803
+
1804
+ .attribution .google-research-logo img {
1805
+ height: 1.4em !important; /* ~21-22px when subtitle is 15-16px */
1806
+ opacity: 1 !important;
1807
+ }
1808
+ }
1809
+
1810
+ /* Small mobile devices - tighter logo scaling */
1811
+ @media (max-width: 480px) {
1812
+ .attribution .google-research-logo img {
1813
+ height: 1.3em !important; /* ~20px, prevents headline competition */
1814
+ }
1815
+ }
1816
+
1817
+ /* Print styles - ensure logo visibility */
1818
+ @media print {
1819
+ .attribution .google-research-logo img {
1820
+ opacity: 1 !important;
1821
+ }
1822
+ }
1823
+
1824
+ /* Position legend on the right - desktop only */
1825
+ @media (min-width: 769px) {
1826
+ .clinical-significance-legend {
1827
+ position: absolute;
1828
+ right: 0;
1829
+ font-size: 0.8em;
1830
+ }
1831
+ }
1832
+
1833
+ /* === MOBILE RESPONSIVE OPTIMIZATIONS === */
1834
+
1835
+ /* Mobile breakpoint for tablets and phones */
1836
+ @media (max-width: 768px) {
1837
+ /* Smaller citation note on mobile */
1838
+ .citation-note {
1839
+ font-size: 12px;
1840
+ padding: 8px 14px;
1841
+ margin: 10px 0 0 0;
1842
+ }
1843
+ /* Page wrapper adjustments for mobile */
1844
+ .page-wrapper {
1845
+ padding: 16px;
1846
+ max-width: 100%;
1847
+ }
1848
+
1849
+ /* Header optimization for mobile */
1850
+ .header-container h1 {
1851
+ margin-bottom: 8px;
1852
+ margin-top: 1.2rem; /* Reduce top margin to keep content above fold */
1853
+ }
1854
+ }
1855
+
1856
+ /* Optional forced break for perfect balance on very narrow screens */
1857
+ @media (max-width: 430px) {
1858
+ .brand-split {
1859
+ display: block; /* Forces line break on very narrow screens */
1860
+ }
1861
+
1862
+ .sub-header {
1863
+ font-size: 16px;
1864
+ }
1865
+
1866
+ /* Card padding optimization for mobile */
1867
+ .card {
1868
+ padding: 16px;
1869
+ border-radius: 8px;
1870
+ }
1871
+
1872
+ .banner.card {
1873
+ padding: 16px;
1874
+ border-radius: 8px;
1875
+ margin-top: 16px;
1876
+ }
1877
+
1878
+ /* Keep side-by-side but optimize for mobile */
1879
+ .text-area-container {
1880
+ grid-template-columns: 1fr 1fr !important;
1881
+ gap: 12px !important;
1882
+ }
1883
+
1884
+ .large-text-area {
1885
+ height: clamp(300px, 55vh, 450px);
1886
+ min-height: 300px;
1887
+ font-size: 13px !important;
1888
+ }
1889
+
1890
+ .output-container {
1891
+ height: clamp(300px, 55vh, 450px);
1892
+ min-height: 300px;
1893
+ }
1894
+
1895
+ .output-text {
1896
+ font-size: 13px !important;
1897
+ }
1898
+
1899
+ #prompt-output {
1900
+ height: clamp(300px, 55vh, 450px);
1901
+ min-height: 300px;
1902
+ }
1903
+
1904
+ /* Compact tip text for mobile */
1905
+ .samples-tip {
1906
+ font-size: 12px !important;
1907
+ padding: 8px 12px !important;
1908
+ margin-top: 12px !important;
1909
+ line-height: 1.4 !important;
1910
+ }
1911
+
1912
+ .output-container,
1913
+ .large-text-area,
1914
+ #prompt-output {
1915
+ -webkit-overflow-scrolling: touch !important;
1916
+ scroll-behavior: smooth !important;
1917
+ transform: translateZ(0) !important;
1918
+ -webkit-transform: translateZ(0) !important;
1919
+ }
1920
+
1921
+ /* Mobile highlighting and touch interactions */
1922
+ .text-span {
1923
+ cursor: pointer !important;
1924
+ padding: 2px 1px !important;
1925
+ margin: 1px 0 !important;
1926
+ border-radius: 3px !important;
1927
+ transition: all 0.2s ease !important;
1928
+ /* Increase touch target size slightly */
1929
+ min-height: 18px !important;
1930
+
1931
+ display: inline !important;
1932
+ white-space: normal !important; /* ensure wrapping inside span */
1933
+ overflow-wrap: anywhere !important; /* long words like "FDG/CT" */
1934
+ word-break: break-word !important; /* safety net */
1935
+ position: relative !important;
1936
+ touch-action: manipulation !important;
1937
+ }
1938
+
1939
+ .text-span:hover,
1940
+ .text-span:active {
1941
+ background-color: rgba(66, 165, 245, 0.2) !important;
1942
+ outline: 1px solid rgba(66, 165, 245, 0.3) !important;
1943
+ transform: none !important; /* Disable transform on mobile */
1944
+ }
1945
+
1946
+ /* On mobile, add subtle visual cue that elements are tappable */
1947
+ @media (hover: none) and (pointer: coarse) {
1948
+ .text-span {
1949
+ position: relative;
1950
+ }
1951
+
1952
+ .text-span:active {
1953
+ transform: scale(0.98) !important;
1954
+ transition: transform 0.1s ease !important;
1955
+ }
1956
+ }
1957
+
1958
+ .text-span.highlight {
1959
+ background-color: rgba(66, 165, 245, 0.35) !important;
1960
+ outline: 1px solid rgba(66, 165, 245, 0.5) !important;
1961
+ font-weight: 500 !important;
1962
+ }
1963
+
1964
+ /* Input textarea selection - exactly match output highlighting */
1965
+ .large-text-area::selection {
1966
+ background-color: rgba(66, 165, 245, 0.35) !important;
1967
+ color: inherit !important;
1968
+ }
1969
+
1970
+ .large-text-area::-moz-selection {
1971
+ background-color: rgba(66, 165, 245, 0.35) !important;
1972
+ color: inherit !important;
1973
+ }
1974
+
1975
+ /* Header adjustments for mobile - make more compact */
1976
+ .input-header,
1977
+ .output-header {
1978
+ flex-direction: row !important;
1979
+ flex-wrap: wrap !important;
1980
+ align-items: center !important;
1981
+ gap: 6px !important;
1982
+ margin-bottom: 6px !important;
1983
+ }
1984
+
1985
+ .input-header h2,
1986
+ .output-header h2 {
1987
+ font-size: 1em !important;
1988
+ margin: 0 !important;
1989
+ }
1990
+
1991
+ /* Compact toggles for mobile headers */
1992
+ .prompt-toggle,
1993
+ .raw-toggle {
1994
+ font-size: 10px !important;
1995
+ }
1996
+
1997
+ .prompt-toggle label,
1998
+ .raw-toggle label {
1999
+ font-size: 10px !important;
2000
+ }
2001
+
2002
+ /* Make toggle labels specifically smaller */
2003
+ .prompt-toggle,
2004
+ .raw-toggle {
2005
+ color: var(--google-grey-700) !important;
2006
+ font-weight: 400 !important;
2007
+ }
2008
+
2009
+ /* Control panels optimization for mobile */
2010
+ .panel-controls {
2011
+ flex-direction: row !important;
2012
+ flex-wrap: wrap !important;
2013
+ justify-content: center !important;
2014
+ gap: 8px !important;
2015
+ padding: 8px !important;
2016
+ }
2017
+
2018
+ /* ========== Mobile layout for Process button + legend ========== */
2019
+ .action-bar {
2020
+ display: flex; /* already in your base CSS */
2021
+ flex-direction: column; /* vertical stack */
2022
+ align-items: center; /* centre both items */
2023
+ gap: 32px;
2024
+ margin-top: 24px;
2025
+ }
2026
+
2027
+ /* Process button - REDUCED SIZE FOR MOBILE */
2028
+ #predict-button {
2029
+ /* width 100% on small screens, but <=200px as you had */
2030
+ width: 100%;
2031
+ max-width: 180px;
2032
+ padding: 10px 16px;
2033
+ font-size: 14px;
2034
+ /* no position, no z-index, no order change needed */
2035
+ }
2036
+
2037
+ /* Legend – full-width, centred text */
2038
+ .clinical-significance-legend {
2039
+ display: flex; /* keep the flex row */
2040
+ flex-wrap: wrap;
2041
+ justify-content: center;
2042
+ gap: 8px;
2043
+
2044
+ width: 100%;
2045
+ max-width: 320px;
2046
+ text-align: center; /* labels do not hug the left */
2047
+ font-size: 0.75em; /* you already tested this */
2048
+
2049
+ /* Normal flow item – no position / top / left / z-index */
2050
+ }
2051
+
2052
+ .legend-item {
2053
+ font-size: 1em;
2054
+ }
2055
+
2056
+ /* Interface options panel for mobile */
2057
+ .interface-options-grid {
2058
+ grid-template-columns: 1fr !important;
2059
+ gap: 12px !important;
2060
+ }
2061
+
2062
+ .interface-option {
2063
+ padding: 12px !important;
2064
+ }
2065
+
2066
+ /* Mobile toggle standardization */
2067
+ .cache-toggle,
2068
+ .raw-toggle,
2069
+ .prompt-toggle,
2070
+ .mobile-toggle {
2071
+ font-size: 12px !important;
2072
+ color: var(--google-grey-700) !important;
2073
+ font-weight: normal !important;
2074
+ transition: none !important;
2075
+ }
2076
+
2077
+ .model-select-container {
2078
+ text-align: center !important;
2079
+ }
2080
+
2081
+ .model-select-container label {
2082
+ font-size: 12px !important;
2083
+ }
2084
+
2085
+ #model-select {
2086
+ padding: 6px !important;
2087
+ font-size: 12px !important;
2088
+ }
2089
+
2090
+ /* Cache status text */
2091
+ .cache-status {
2092
+ font-size: 11px !important;
2093
+ }
2094
+ }
2095
+
2096
+ /* Smaller mobile phones */
2097
+ @media (max-width: 480px) {
2098
+ /* Even smaller citation note on small phones */
2099
+ .citation-note {
2100
+ font-size: 11px;
2101
+ padding: 6px 12px;
2102
+ }
2103
+ .page-wrapper {
2104
+ padding: 8px;
2105
+ }
2106
+
2107
+ .header-container h1 {
2108
+ font-size: 22px;
2109
+ }
2110
+
2111
+ .card {
2112
+ padding: 8px;
2113
+ }
2114
+
2115
+ /* Even more compact for very small screens */
2116
+ .text-area-container {
2117
+ gap: 8px !important;
2118
+ }
2119
+
2120
+ .large-text-area,
2121
+ .output-container,
2122
+ #prompt-output {
2123
+ height: clamp(250px, 50vh, 400px);
2124
+ min-height: 250px;
2125
+ }
2126
+
2127
+ .large-text-area {
2128
+ font-size: 12px !important;
2129
+ }
2130
+
2131
+ .output-text {
2132
+ font-size: 12px !important;
2133
+ }
2134
+
2135
+ /* Even more compact tip for very small screens */
2136
+ .samples-tip {
2137
+ font-size: 11px !important;
2138
+ padding: 6px 10px !important;
2139
+ margin-top: 8px !important;
2140
+ line-height: 1.3 !important;
2141
+ }
2142
+
2143
+ /* Smaller button for very small screens */
2144
+ #predict-button {
2145
+ max-width: 160px !important;
2146
+ padding: 8px 14px !important;
2147
+ font-size: 13px !important;
2148
+ }
2149
+
2150
+ /* Tighter spacing for very small screens */
2151
+ .action-bar {
2152
+ gap: 28px; /* slightly smaller gap for tiny screens */
2153
+ margin-top: 20px;
2154
+ }
2155
+
2156
+ .clinical-significance-legend {
2157
+ font-size: 0.65em; /* smaller text for tiny screens */
2158
+ max-width: 300px; /* slightly narrower */
2159
+ }
2160
+
2161
+ .input-header h2,
2162
+ .output-header h2 {
2163
+ font-size: 0.9em !important;
2164
+ }
2165
+
2166
+ /* Extra small toggle labels for tiny screens */
2167
+ .prompt-toggle,
2168
+ .raw-toggle {
2169
+ font-size: 9px !important;
2170
+ }
2171
+
2172
+ .prompt-toggle label,
2173
+ .raw-toggle label {
2174
+ font-size: 9px !important;
2175
+ }
2176
+ }
2177
+
2178
+ /* Simple Error Message Styling */
2179
+ .error-message-simple {
2180
+ background-color: #fef2f2;
2181
+ border: 1px solid #fecaca;
2182
+ border-radius: 8px;
2183
+ padding: 24px;
2184
+ margin: 16px 0;
2185
+ text-align: center;
2186
+ animation: fadeIn 0.3s ease-in;
2187
+ }
2188
+
2189
+ .error-message-simple h3 {
2190
+ color: #991b1b;
2191
+ margin: 0 0 16px 0;
2192
+ font-size: 1.35rem;
2193
+ font-weight: 600;
2194
+ font-family: 'Google Sans', sans-serif;
2195
+ }
2196
+
2197
+ .error-message-simple p {
2198
+ margin: 0 0 12px 0;
2199
+ line-height: 1.6;
2200
+ color: #7f1d1d;
2201
+ }
2202
+
2203
+ .error-message-simple strong {
2204
+ color: #991b1b;
2205
+ font-weight: 600;
2206
+ }
2207
+
2208
+ .error-message-simple .suggestion {
2209
+ color: #92400e;
2210
+ font-style: italic;
2211
+ margin-bottom: 20px;
2212
+ }
2213
+
2214
+ .error-message-simple .deploy-note {
2215
+ background-color: #fef3c7;
2216
+ border-radius: 6px;
2217
+ padding: 12px;
2218
+ margin-top: 16px;
2219
+ font-size: 0.9rem;
2220
+ color: #78350f;
2221
+ }
2222
+
2223
+ .error-message-simple .deploy-note strong {
2224
+ color: #92400e;
2225
+ }
2226
+
2227
+ @keyframes fadeIn {
2228
+ from {
2229
+ opacity: 0;
2230
+ transform: translateY(-10px);
2231
+ }
2232
+ to {
2233
+ opacity: 1;
2234
+ transform: translateY(0);
2235
+ }
2236
+ }
2237
+
2238
+ /* Copy and clear button overlays always use light mode styling */
2239
+ /* Error messages always use light mode styling */
structure_report.py ADDED
@@ -0,0 +1,734 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core radiology report structuring functionality using LangExtract.
2
+
3
+ This module provides the RadiologyReportStructurer class that processes raw
4
+ radiology reports into structured segments categorized as prefix, body, or suffix sections with clinical significance annotations (normal, minor, significant).
5
+
6
+ The structuring uses LangExtract with example-guided prompting to extract segments with character intervals that enable interactive hover-to-highlight functionality in the web frontend.
7
+
8
+ Backend-Frontend Integration:
9
+ - Backend generates segments with character intervals (startPos/endPos)
10
+ - Frontend creates interactive spans that highlight corresponding input text on hover
11
+ - Significance levels drive CSS styling for visual differentiation
12
+ - Segment types organize content into structured sections (EXAMINATION, FINDINGS, IMPRESSION)
13
+
14
+ Example usage:
15
+
16
+ structurer = RadiologyReportStructurer(
17
+ api_key="your_api_key",
18
+ model_id="gemini-2.5-flash"
19
+ )
20
+ result = structurer.predict("FINDINGS: Normal chest CT...")
21
+ """
22
+
23
+ import collections
24
+ import dataclasses
25
+ import itertools
26
+ from enum import Enum
27
+ from functools import wraps
28
+ from typing import Any, TypedDict
29
+
30
+ import langextract as lx
31
+ import langextract.data
32
+
33
+ import prompt_instruction
34
+ import prompt_lib
35
+ import report_examples
36
+
37
+
38
+ class FrontendIntervalDict(TypedDict):
39
+ """Character interval for frontend with startPos and endPos."""
40
+
41
+ startPos: int
42
+ endPos: int
43
+
44
+
45
+ class SegmentDict(TypedDict):
46
+ """Segment dictionary for JSON response."""
47
+
48
+ type: str
49
+ label: str | None
50
+ content: str
51
+ intervals: list[FrontendIntervalDict]
52
+ significance: str | None
53
+
54
+
55
+ class SerializedExtractionDict(TypedDict):
56
+ """Serialized extraction for JSON response."""
57
+
58
+ extraction_text: str | None
59
+ extraction_class: str | None
60
+ attributes: dict[str, str] | None
61
+ char_interval: dict[str, int | None] | None
62
+ alignment_status: str | None
63
+
64
+
65
+ class ResponseDict(TypedDict):
66
+ """Complete response dictionary structure."""
67
+
68
+ segments: list[SegmentDict]
69
+ annotated_document_json: dict[str, Any]
70
+ text: str
71
+ raw_prompt: str
72
+
73
+
74
+ FINDINGS_HEADER = "FINDINGS:"
75
+ IMPRESSION_HEADER = "IMPRESSION:"
76
+ EXAMINATION_HEADER = "EXAMINATION:"
77
+ SECTION_ATTRIBUTE_KEY = "section"
78
+ START_POSITION = "startPos"
79
+ END_POSITION = "endPos"
80
+
81
+ EXAM_PREFIXES = ("EXAMINATION:", "EXAM:", "STUDY:")
82
+
83
+ EXAMINATION_LABEL = "examination"
84
+ PREFIX_LABEL = "prefix"
85
+
86
+ SIGNIFICANCE_NORMAL = "normal"
87
+ SIGNIFICANCE_MINOR = "minor"
88
+ SIGNIFICANCE_SIGNIFICANT = "significant"
89
+ SIGNIFICANCE_NOT_APPLICABLE = "not_applicable"
90
+
91
+
92
+ def _initialize_langextract_patches():
93
+ """Initialize LangExtract patches for proper alignment behavior.
94
+
95
+ This function applies necessary patches to LangExtract's Resolver.align method to force accept_match_lesser=False and set fuzzy_alignment_threshold to 0.50. This should be called before using LangExtract functionality.
96
+
97
+ Note: This is a temporary workaround until LangExtract exposes
98
+ accept_match_lesser and fuzzy_alignment_threshold parameters via its public API.
99
+ """
100
+ # Store original method
101
+ original_align = lx.resolver.Resolver.align
102
+
103
+ @wraps(original_align)
104
+ def _align_patched(self, *args, **kwargs):
105
+ # Set default if not explicitly provided
106
+ kwargs.setdefault("accept_match_lesser", False)
107
+ # Set fuzzy matching threshold to 0.50
108
+ kwargs.setdefault("fuzzy_alignment_threshold", 0.50)
109
+ return original_align(self, *args, **kwargs)
110
+
111
+ # Apply the patch
112
+ lx.resolver.Resolver.align = _align_patched
113
+
114
+
115
+ class ReportSectionType(Enum):
116
+ """Enum representing sections of a radiology report with their extraction class names."""
117
+
118
+ PREFIX = "findings_prefix"
119
+ BODY = "findings_body"
120
+ SUFFIX = "findings_suffix"
121
+
122
+ @property
123
+ def display_name(self) -> str:
124
+ """Returns the lowercase section type name for display purposes."""
125
+ return self.name.lower()
126
+
127
+
128
+ @dataclasses.dataclass
129
+ class Segment:
130
+ """Represents a single merged segment of text in the final structured report.
131
+
132
+ Attributes:
133
+ type: The section type (prefix, body, or suffix).
134
+ label: Optional section label for organization.
135
+ content: The text content of this segment.
136
+ intervals: List of character position intervals.
137
+ significance: Optional clinical significance indicator.
138
+ """
139
+
140
+ type: ReportSectionType
141
+ label: str | None
142
+ content: str
143
+ intervals: list[FrontendIntervalDict]
144
+ significance: str | None = None
145
+
146
+ def to_dict(self) -> SegmentDict:
147
+ """Converts the segment to a dictionary representation.
148
+
149
+ Returns:
150
+ A dictionary containing all segment data with type as display name.
151
+ """
152
+ return SegmentDict(
153
+ type=self.type.display_name,
154
+ label=self.label,
155
+ content=self.content,
156
+ intervals=self.intervals,
157
+ significance=self.significance,
158
+ )
159
+
160
+
161
+ class RadiologyReportStructurer:
162
+ """Structures radiology reports using LangExtract and large language models.
163
+
164
+ This class processes raw radiology report text and converts it
165
+ into structured segments categorized as prefix, body, or suffix
166
+ sections with appropriate labeling and clinical significance annotations.
167
+ """
168
+
169
+ api_key: str | None
170
+ model_id: str
171
+ temperature: float
172
+ examples: list[langextract.data.ExampleData]
173
+ _patches_initialized: bool
174
+
175
+ def __init__(
176
+ self,
177
+ api_key: str | None = None,
178
+ model_id: str = "gemini-2.5-flash",
179
+ temperature: float = 0.0,
180
+ ):
181
+ """Initializes the RadiologyReportStructurer.
182
+
183
+ Args:
184
+ api_key: API key for the language model service.
185
+ model_id: Identifier for the specific model to use.
186
+ temperature: Sampling temperature for model generation.
187
+ """
188
+ self.api_key = api_key
189
+ self.model_id = model_id
190
+ self.temperature = temperature
191
+ self.examples = report_examples.get_examples_for_model()
192
+ self._patches_initialized = False
193
+
194
+ def _ensure_patches_initialized(self):
195
+ """Ensure LangExtract patches are initialized before use."""
196
+ if not self._patches_initialized:
197
+ _initialize_langextract_patches()
198
+ self._patches_initialized = True
199
+
200
+ def _generate_formatted_prompt_with_examples(
201
+ self, input_text: str | None = None
202
+ ) -> str:
203
+ """Generates a comprehensive, markdown-formatted prompt including examples.
204
+
205
+ Args:
206
+ input_text: Optional input text to include in the prompt display.
207
+
208
+ Returns:
209
+ A markdown-formatted string containing the full prompt and examples.
210
+ """
211
+ return prompt_lib.generate_markdown_prompt(self.examples, input_text)
212
+
213
+ def predict(self, report_text: str, max_char_buffer: int = 2000) -> ResponseDict:
214
+ """Processes a radiology report text into structured format.
215
+
216
+ Takes raw radiology report text and uses LangExtract with example-guided
217
+ prompting to extract structured segments with character intervals and
218
+ clinical significance annotations.
219
+
220
+ Args:
221
+ report_text: Raw radiology report text to be processed.
222
+ max_char_buffer: Maximum character buffer size for processing.
223
+
224
+ Returns:
225
+ A dictionary containing:
226
+ - segments: List of structured report segments
227
+ - annotated_document_json: Raw extraction results
228
+ - text: Formatted text representation
229
+
230
+ Raises:
231
+ ValueError: If report_text is empty or whitespace-only.
232
+ """
233
+ if not report_text.strip():
234
+ raise ValueError("Report text cannot be empty")
235
+
236
+ try:
237
+ result = self._perform_langextract(report_text, max_char_buffer)
238
+ return self._build_response(result, report_text)
239
+ except (ValueError, TypeError, AttributeError) as e:
240
+ return ResponseDict(
241
+ text=f"Error processing report: {str(e)}",
242
+ segments=[],
243
+ annotated_document_json={},
244
+ raw_prompt="",
245
+ )
246
+
247
+ def _perform_langextract(
248
+ self, report_text: str, max_char_buffer: int
249
+ ) -> langextract.data.AnnotatedDocument:
250
+ """Performs LangExtract processing on the input text.
251
+
252
+ Args:
253
+ report_text: Raw radiology report text to be processed.
254
+ max_char_buffer: Maximum character buffer size for processing.
255
+
256
+ Returns:
257
+ LangExtract result object containing extractions.
258
+
259
+ Raises:
260
+ ValueError: If LangExtract processing fails.
261
+ TypeError: If invalid parameters are provided.
262
+ """
263
+ self._ensure_patches_initialized()
264
+ return lx.extract(
265
+ text_or_documents=report_text,
266
+ prompt_description=prompt_instruction.PROMPT_INSTRUCTION.split(
267
+ "# Few-Shot Examples"
268
+ )[0],
269
+ examples=self.examples,
270
+ model_id=self.model_id,
271
+ api_key=self.api_key,
272
+ max_char_buffer=max_char_buffer,
273
+ temperature=self.temperature,
274
+ # accept_match_lesser handled via monkey-patch
275
+ # (Resolver.align patched at import time)
276
+ )
277
+
278
+ def _build_response(
279
+ self, result: langextract.data.AnnotatedDocument, report_text: str
280
+ ) -> ResponseDict:
281
+ """Builds the final response dictionary from LangExtract results.
282
+
283
+ Args:
284
+ result: LangExtract result object containing extractions.
285
+ report_text: Original input text for prompt generation.
286
+
287
+ Returns:
288
+ Dictionary containing structured segments and metadata.
289
+ """
290
+ segments = self._build_segments_from_langextract_result(result)
291
+ organized_segments = self._organize_segments_by_label(segments)
292
+
293
+ response: ResponseDict = {
294
+ "segments": [segment.to_dict() for segment in organized_segments],
295
+ "annotated_document_json": self._serialize_extraction_results(result),
296
+ "text": self._format_segments_to_text(organized_segments),
297
+ "raw_prompt": self._generate_formatted_prompt_with_examples(report_text),
298
+ }
299
+
300
+ return response
301
+
302
+ def _serialize_extraction_results(
303
+ self, result: langextract.data.AnnotatedDocument
304
+ ) -> dict[str, Any]:
305
+ """Serializes LangExtract results for JSON response.
306
+
307
+ Args:
308
+ result: LangExtract result object containing extractions.
309
+
310
+ Returns:
311
+ Dictionary containing serialized extraction data or error information.
312
+ """
313
+ try:
314
+ if not hasattr(result, "extractions"):
315
+ return {"error": "No extractions found in result"}
316
+
317
+ return {
318
+ "extractions": [
319
+ self._serialize_single_extraction(extraction)
320
+ for extraction in result.extractions
321
+ ]
322
+ }
323
+ except (AttributeError, TypeError, KeyError) as e:
324
+ return {
325
+ "error": "Failed to serialize extraction result",
326
+ "error_message": str(e),
327
+ "fallback_string": str(result),
328
+ }
329
+
330
+ def _serialize_single_extraction(
331
+ self, extraction: langextract.data.Extraction
332
+ ) -> SerializedExtractionDict:
333
+ """Serializes a single extraction to dictionary format."""
334
+ return {
335
+ "extraction_text": extraction.extraction_text,
336
+ "extraction_class": extraction.extraction_class,
337
+ "attributes": extraction.attributes,
338
+ "char_interval": self._extract_char_interval(extraction),
339
+ "alignment_status": self._get_alignment_status_string(extraction),
340
+ }
341
+
342
+ def _get_alignment_status_string(
343
+ self, extraction: langextract.data.Extraction
344
+ ) -> str | None:
345
+ """Extracts alignment status from extraction as string."""
346
+ status = getattr(extraction, "alignment_status", None)
347
+ return str(status) if status is not None else None
348
+
349
+ def _build_segments_from_langextract_result(
350
+ self, result: langextract.data.AnnotatedDocument
351
+ ) -> list[Segment]:
352
+ """Builds segments from LangExtract result data using one-segment-per-interval strategy.
353
+
354
+ Creates exactly one segment per character interval to enable precise
355
+ frontend hover-to-highlight functionality. Processes only
356
+ langextract.data.Extraction objects for consistent typing.
357
+
358
+ Args:
359
+ result: LangExtract result object containing extractions.
360
+
361
+ Returns:
362
+ List of Segment objects optimized for frontend rendering and interaction.
363
+ """
364
+ segments_list = []
365
+
366
+ for extraction in result.extractions:
367
+ section_type = self._map_section(extraction.extraction_class)
368
+
369
+ if section_type is None:
370
+ continue
371
+
372
+ section_label = self._determine_section_label(
373
+ extraction.attributes, section_type
374
+ )
375
+ significance_val = self._extract_clinical_significance(
376
+ extraction.attributes
377
+ )
378
+ intervals = self._get_intervals_from_extraction_dict(
379
+ extraction, extraction.char_interval
380
+ )
381
+
382
+ segments_list.extend(
383
+ self._create_segments_for_intervals(
384
+ section_type,
385
+ section_label,
386
+ extraction.extraction_text,
387
+ intervals,
388
+ significance_val,
389
+ )
390
+ )
391
+
392
+ return segments_list
393
+
394
+ def _determine_section_label(
395
+ self,
396
+ attributes: dict[str, str] | None,
397
+ section_type: ReportSectionType,
398
+ ) -> str:
399
+ """Determines the appropriate section label for a segment."""
400
+ if attributes and isinstance(attributes, dict):
401
+ section_label = attributes.get(SECTION_ATTRIBUTE_KEY)
402
+ if section_label:
403
+ return section_label
404
+ return section_type.display_name
405
+
406
+ def _extract_clinical_significance(
407
+ self, attributes: dict[str, str] | None
408
+ ) -> str | None:
409
+ """Extracts clinical significance from attributes safely."""
410
+ if not attributes or not isinstance(attributes, dict):
411
+ return None
412
+
413
+ try:
414
+ sig_raw = attributes.get("clinical_significance")
415
+ if sig_raw is not None:
416
+ return getattr(sig_raw, "value", str(sig_raw)).lower()
417
+ except (AttributeError, TypeError):
418
+ pass
419
+ return None
420
+
421
+ def _create_segments_for_intervals(
422
+ self,
423
+ section_type: ReportSectionType,
424
+ section_label: str,
425
+ content: str,
426
+ intervals: list[FrontendIntervalDict],
427
+ significance: str | None,
428
+ ) -> list[Segment]:
429
+ """Creates segment objects for the given intervals."""
430
+ if not intervals:
431
+ return [
432
+ Segment(
433
+ type=section_type,
434
+ label=section_label,
435
+ content=content,
436
+ intervals=[],
437
+ significance=significance,
438
+ )
439
+ ]
440
+ return [
441
+ Segment(
442
+ type=section_type,
443
+ label=section_label,
444
+ content=content,
445
+ intervals=[interval],
446
+ significance=significance,
447
+ )
448
+ for interval in intervals
449
+ ]
450
+
451
+ def _map_section(self, extraction_class: str) -> ReportSectionType | None:
452
+ """Maps extraction class string to ReportSectionType enum."""
453
+ extraction_class = extraction_class.lower().strip()
454
+
455
+ for section_type in ReportSectionType:
456
+ if section_type.value == extraction_class:
457
+ return section_type
458
+
459
+ return None
460
+
461
+ def _get_intervals_from_extraction_dict(
462
+ self,
463
+ extraction: langextract.data.Extraction,
464
+ char_interval: langextract.data.CharInterval | dict[str, int] | None = None,
465
+ ) -> list[FrontendIntervalDict]:
466
+ """Extracts character intervals from extraction data.
467
+
468
+ Returns a list of interval dictionaries from the extraction's
469
+ char_interval in the format expected by the frontend.
470
+
471
+ Args:
472
+ extraction: langextract.data.Extraction object containing interval data.
473
+ char_interval: Optional override for character interval data.
474
+
475
+ Returns:
476
+ List of dictionaries with startPos and endPos keys.
477
+ """
478
+ interval_list = []
479
+ try:
480
+ char_interval = (
481
+ char_interval if char_interval is not None else extraction.char_interval
482
+ )
483
+
484
+ if char_interval is not None:
485
+ # Handle both dict and object formats for char_interval (langextract.data.CharInterval object or dict override)
486
+ if isinstance(char_interval, dict):
487
+ start_pos = char_interval.get("start_pos")
488
+ end_pos = char_interval.get("end_pos")
489
+ else:
490
+ start_pos = getattr(char_interval, "start_pos", None)
491
+ end_pos = getattr(char_interval, "end_pos", None)
492
+
493
+ start_position, end_position = self._extract_positions(
494
+ start_pos, end_pos
495
+ )
496
+ if start_position is not None and end_position is not None:
497
+ interval_list.append(
498
+ FrontendIntervalDict(
499
+ startPos=start_position, endPos=end_position
500
+ )
501
+ )
502
+ except Exception:
503
+ pass
504
+ return interval_list
505
+
506
+ def _extract_positions(self, start_obj, end_obj) -> tuple[int | None, int | None]:
507
+ """Extracts position integers from potentially complex objects.
508
+
509
+ Handles possible slice objects or direct integers for start and end positions.
510
+ """
511
+ if hasattr(start_obj, "start"):
512
+ start_obj = start_obj.start
513
+ if hasattr(end_obj, "stop"):
514
+ end_obj = end_obj.stop
515
+
516
+ try:
517
+ start_position = int(start_obj) if start_obj is not None else None
518
+ end_position = int(end_obj) if end_obj is not None else None
519
+ if start_position is not None and end_position is not None:
520
+ return (start_position, end_position)
521
+ except Exception:
522
+ pass
523
+ return (None, None)
524
+
525
+ def _extract_char_interval(
526
+ self, extraction: langextract.data.Extraction
527
+ ) -> dict[str, int | None] | None:
528
+ """Extracts character interval information from an extraction."""
529
+ char_interval = extraction.char_interval
530
+ if char_interval is None:
531
+ return None
532
+
533
+ return {
534
+ "start_pos": getattr(char_interval, "start_pos", None),
535
+ "end_pos": getattr(char_interval, "end_pos", None),
536
+ }
537
+
538
+ def _format_segments_to_text(self, segments: list[Segment]) -> str:
539
+ """Formats segments into a readable text representation.
540
+
541
+ Merges segments with the same label into coherent paragraphs
542
+ while preserving the original order of labels as they appear
543
+ in the document.
544
+ """
545
+ grouped = self._group_segments_by_type_and_label(segments)
546
+ formatted_parts: list[str] = []
547
+
548
+ self._render_prefix_sections(grouped, segments, formatted_parts)
549
+ self._render_body_sections(grouped, formatted_parts)
550
+ self._render_suffix_sections(grouped, formatted_parts)
551
+
552
+ return "\n".join(formatted_parts).rstrip()
553
+
554
+ def _group_segments_by_type_and_label(
555
+ self, segments: list[Segment]
556
+ ) -> collections.OrderedDict[tuple[ReportSectionType, str | None], list[str]]:
557
+ """Groups segments by (type, label) preserving insertion order.
558
+
559
+ Creates a dictionary keyed by (ReportSectionType, label) tuples
560
+ that maintains the order segments are first encountered.
561
+ Deduplicates content within each group while preserving
562
+ the original sequence of unique content items.
563
+
564
+ Args:
565
+ segments: List of Segment objects to group.
566
+
567
+ Returns:
568
+ OrderedDict mapping (type, label) tuples to lists of unique content strings.
569
+ """
570
+ grouped: collections.OrderedDict[
571
+ tuple[ReportSectionType, str | None], list[str]
572
+ ] = collections.OrderedDict()
573
+ for seg in segments:
574
+ key = (seg.type, seg.label)
575
+ grouped.setdefault(key, [])
576
+ if seg.content not in grouped[key]:
577
+ grouped[key].append(seg.content.strip())
578
+ return grouped
579
+
580
+ def _render_prefix_sections(
581
+ self,
582
+ grouped: collections.OrderedDict[
583
+ tuple[ReportSectionType, str | None], list[str]
584
+ ],
585
+ segments: list[Segment],
586
+ formatted_parts: list[str],
587
+ ) -> None:
588
+ """Renders PREFIX sections with appropriate headers."""
589
+ add = formatted_parts.append
590
+
591
+ def blank() -> None:
592
+ formatted_parts.append("")
593
+
594
+ structured_prefix_exists = any(
595
+ seg.type == ReportSectionType.PREFIX
596
+ and seg.label
597
+ and seg.label.lower() != PREFIX_LABEL
598
+ for seg in segments
599
+ )
600
+
601
+ if structured_prefix_exists:
602
+ for (stype, label), contents in grouped.items():
603
+ if stype is not ReportSectionType.PREFIX:
604
+ continue
605
+
606
+ if label and label.lower() == EXAMINATION_LABEL:
607
+ add(EXAMINATION_HEADER)
608
+ blank()
609
+ for c in contents:
610
+ stripped = self._strip_exam_prefix(c)
611
+ if stripped:
612
+ add(stripped)
613
+ blank()
614
+ elif label and label.lower() != PREFIX_LABEL:
615
+ for c in contents:
616
+ if c:
617
+ add(c)
618
+ blank()
619
+ else:
620
+ for c in contents:
621
+ if c:
622
+ add(c)
623
+ blank()
624
+ else:
625
+ plain_prefix = []
626
+ for (stype, _), contents in grouped.items():
627
+ if stype is ReportSectionType.PREFIX:
628
+ plain_prefix.extend(contents)
629
+ if plain_prefix:
630
+ add("\n\n".join(plain_prefix).rstrip())
631
+
632
+ def _render_body_sections(
633
+ self,
634
+ grouped: collections.OrderedDict[
635
+ tuple[ReportSectionType, str | None], list[str]
636
+ ],
637
+ formatted_parts: list[str],
638
+ ) -> None:
639
+ """Renders BODY (FINDINGS) sections."""
640
+ add = formatted_parts.append
641
+
642
+ def blank() -> None:
643
+ formatted_parts.append("")
644
+
645
+ body_items = [
646
+ (k, v) for k, v in grouped.items() if k[0] is ReportSectionType.BODY
647
+ ]
648
+ if body_items:
649
+ if formatted_parts:
650
+ blank()
651
+ add(FINDINGS_HEADER)
652
+ blank()
653
+ for (_, label), contents in body_items:
654
+ combined = " ".join(contents).strip()
655
+ if combined:
656
+ add(f"{label}: {combined}")
657
+ blank()
658
+
659
+ def _render_suffix_sections(
660
+ self,
661
+ grouped: collections.OrderedDict[
662
+ tuple[ReportSectionType, str | None], list[str]
663
+ ],
664
+ formatted_parts: list[str],
665
+ ) -> None:
666
+ """Renders SUFFIX (IMPRESSION) sections."""
667
+ add = formatted_parts.append
668
+
669
+ def blank() -> None:
670
+ formatted_parts.append("")
671
+
672
+ suffix_items = [
673
+ (k, v) for k, v in grouped.items() if k[0] is ReportSectionType.SUFFIX
674
+ ]
675
+ if suffix_items:
676
+ if formatted_parts and formatted_parts[-1].strip():
677
+ blank()
678
+ add(IMPRESSION_HEADER)
679
+ blank()
680
+ suffix_block = "\n".join(
681
+ itertools.chain.from_iterable(v for _, v in suffix_items)
682
+ ).rstrip()
683
+ add(suffix_block)
684
+
685
+ def _organize_segments_by_label(self, segments: list[Segment]) -> list[Segment]:
686
+ """Organizes segments into the correct order for presentation.
687
+
688
+ Orders segments by section type (prefix → body → suffix), groups
689
+ body segments by label while preserving original appearance order,
690
+ and maintains extraction order for segments with the same label.
691
+
692
+ Args:
693
+ segments: List of Segment objects to organize.
694
+
695
+ Returns:
696
+ List of segments in proper presentation order.
697
+ """
698
+ prefix_segments = [
699
+ segment for segment in segments if segment.type == ReportSectionType.PREFIX
700
+ ]
701
+ body_segments = [
702
+ segment for segment in segments if segment.type == ReportSectionType.BODY
703
+ ]
704
+ suffix_segments = [
705
+ segment for segment in segments if segment.type == ReportSectionType.SUFFIX
706
+ ]
707
+
708
+ body_segments_by_label: dict[str, list[Segment]] = {}
709
+ labels_in_order: list[str] = []
710
+
711
+ for segment in body_segments:
712
+ if segment.label:
713
+ if segment.label not in body_segments_by_label:
714
+ body_segments_by_label[segment.label] = []
715
+ labels_in_order.append(segment.label)
716
+ body_segments_by_label[segment.label].append(segment)
717
+
718
+ organized_segments = []
719
+ organized_segments.extend(prefix_segments)
720
+
721
+ for label in labels_in_order:
722
+ organized_segments.extend(body_segments_by_label[label])
723
+
724
+ organized_segments.extend(suffix_segments)
725
+
726
+ return organized_segments
727
+
728
+ def _strip_exam_prefix(self, text: str) -> str:
729
+ """Removes common examination prefixes from a string."""
730
+ upper = text.upper()
731
+ for prefix in EXAM_PREFIXES:
732
+ if upper.startswith(prefix):
733
+ return text[len(prefix) :].lstrip()
734
+ return text.strip()
templates/index.html ADDED
@@ -0,0 +1,524 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>Radiology Report Structuring</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+
7
+ <!-- Open Graph / Twitter Card meta tags for rich link previews -->
8
+ <meta
9
+ property="og:title"
10
+ content="RadExtract – Radiology Report Structuring Demo"
11
+ />
12
+ <meta
13
+ property="og:description"
14
+ content="Transform unstructured radiology reports into actionable, structured data instantly. See how Google's Gemini AI + LangExtract revolutionize medical documentation with real-time extraction of findings, impressions, and clinical insights."
15
+ />
16
+ <meta property="og:url" content="{{ share_url_for_sharing }}" />
17
+ <meta
18
+ property="og:image"
19
+ content="{{ share_url_for_sharing }}/static/radextract-preview.jpg"
20
+ />
21
+ <meta property="og:type" content="website" />
22
+ <meta property="og:video" content="{{ share_url_for_sharing }}/static/radextract-preview.mp4" />
23
+ <meta property="og:video:secure_url" content="{{ share_url_for_sharing }}/static/radextract-preview.mp4" />
24
+ <meta property="og:video:type" content="video/mp4" />
25
+ <meta property="og:video:width" content="1920" />
26
+ <meta property="og:video:height" content="1080" />
27
+
28
+ <meta name="twitter:card" content="player" />
29
+ <meta
30
+ name="twitter:title"
31
+ content="RadExtract – Radiology Report Structuring Demo"
32
+ />
33
+ <meta
34
+ name="twitter:description"
35
+ content="Transform unstructured radiology reports into actionable, structured data instantly. See how Google's Gemini AI + LangExtract revolutionize medical documentation with real-time extraction of findings, impressions, and clinical insights."
36
+ />
37
+ <meta
38
+ name="twitter:image"
39
+ content="{{ share_url_for_sharing }}/static/radextract-preview.jpg"
40
+ />
41
+ <meta name="twitter:player" content="{{ share_url_for_sharing }}/static/radextract-preview.mp4" />
42
+ <meta name="twitter:player:width" content="1920" />
43
+ <meta name="twitter:player:height" content="1080" />
44
+ <meta name="twitter:player:stream" content="{{ share_url_for_sharing }}/static/radextract-preview.mp4" />
45
+ <meta name="twitter:player:stream:content_type" content="video/mp4" />
46
+
47
+ <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
48
+ <link rel="shortcut icon" href="/static/favicon.svg" />
49
+ <link rel="apple-touch-icon" href="/static/favicon.svg" />
50
+
51
+ <link rel="stylesheet" href="/static/style.css?v=20250129-video-preview" />
52
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
53
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
54
+ <link
55
+ href="https://fonts.googleapis.com/css2?family=Google+Sans:wght@400;500;700&family=Google+Sans+Text:wght@400;500&display=swap"
56
+ rel="stylesheet"
57
+ />
58
+ <link
59
+ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght@300;400;500"
60
+ rel="stylesheet"
61
+ />
62
+ <link
63
+ rel="stylesheet"
64
+ href="https://cdn.jsdelivr.net/npm/[email protected]/dist/json-formatter.min.css"
65
+ />
66
+ </head>
67
+
68
+ <body class="page-wrapper">
69
+ <div class="header-container">
70
+ <h1>
71
+ <strong>RadExtract</strong>:
72
+ <span class="brand-split">Radiology Report Structuring&nbsp;Demo</span>
73
+ </h1>
74
+
75
+ <!-- Top share buttons -->
76
+ <div class="share-top">
77
+ <span>Share →</span>
78
+ <a
79
+ class="shr-btn shr-x"
80
+ href="https://twitter.com/intent/tweet?text={{ share_text }}"
81
+ target="_blank"
82
+ rel="noopener"
83
+ aria-label="Share on X"
84
+ >
85
+ <svg viewBox="0 0 24 24" width="20" aria-hidden="true">
86
+ <path
87
+ fill="currentColor"
88
+ d="M23 2.999a9.05 9.05 0 0 1-2.588.71A4.516 4.516 0 0 0 22.36.365a9.04 9.04 0 0 1-2.867 1.096 4.505 4.505 0 0 0-7.67 4.107A12.79 12.79 0 0 1 1.64.896a4.505 4.505 0 0 0 1.396 6.01 4.47 4.47 0 0 1-2.04-.563v.057a4.507 4.507 0 0 0 3.614 4.417 4.522 4.522 0 0 1-2.034.077 4.508 4.508 0 0 0 4.207 3.128A9.03 9.03 0 0 1 0 19.54a12.75 12.75 0 0 0 6.92 2.026c8.304 0 12.846-6.877 12.846-12.837 0-.196-.004-.392-.013-.586A9.17 9.17 0 0 0 23 2.999z"
89
+ />
90
+ </svg>
91
+ </a>
92
+ <a
93
+ class="shr-btn shr-li"
94
+ href="https://www.linkedin.com/shareArticle?mini=true&url={{ share_url_encoded }}&title={{ linkedin_title }}&summary={{ linkedin_summary }}&source=RadExtract"
95
+ target="_blank"
96
+ rel="noopener"
97
+ aria-label="Share on LinkedIn"
98
+ >
99
+ <svg viewBox="0 0 24 24" width="20" aria-hidden="true">
100
+ <path
101
+ fill="currentColor"
102
+ d="M4.98 3.5C4.98 5.43 3.43 7 1.5 7S-1.98 5.43-1.98 3.5 0.57 0 2.5 0 4.98 1.57 4.98 3.5zM.02 8h5V24h-5V8zM7.98 8h4.8v2.2h.07c.67-1.27 2.31-2.6 4.76-2.6 5.09 0 6.04 3.35 6.04 7.7V24h-5v-7.7c0-1.84-.03-4.21-2.57-4.21-2.57 0-2.96 1.99-2.96 4.07V24h-5V8z"
103
+ />
104
+ </svg>
105
+ </a>
106
+ </div>
107
+
108
+ <!-- Attribution block: subtitle + logo -->
109
+ <div class="attribution">
110
+ <p class="sub-header">
111
+ <strong>Powered by LangExtract + Gemini 2.5</strong>
112
+ </p>
113
+
114
+ <!-- Google Research logo -->
115
+ <div class="google-research-logo">
116
+ <img
117
+ src="/static/google-research-logo.svg"
118
+ alt="Google Research"
119
+ width="174"
120
+ height="25"
121
+ loading="lazy"
122
+ tabindex="-1"
123
+ />
124
+ </div>
125
+ </div>
126
+
127
+ <div class="disclaimer-container">
128
+ <div class="disclaimer-box">
129
+ <span class="material-symbols-outlined disclaimer-icon">warning</span>
130
+ <span class="disclaimer-text"
131
+ >This demonstration is for illustrative purposes only to show the
132
+ baseline capabilities of LangExtract, the library that powers this
133
+ demo. It does not represent a finished or approved product, is not
134
+ intended to diagnose or suggest treatment for any disease or
135
+ condition, and should not be used for medical advice.</span
136
+ >
137
+ </div>
138
+ <div class="citation-note">
139
+ <strong>License & Citation:</strong> If you use
140
+ RadExtract or LangExtract in production or
141
+ publications, please cite accordingly and acknowledge usage. Use is
142
+ subject to the Apache 2.0 License. See
143
+ <a
144
+ class="banner-link"
145
+ href="https://huggingface.co/spaces/google/radextract/blob/main/README.md#disclaimer"
146
+ target="_blank"
147
+ rel="noopener noreferrer"
148
+ >README</a
149
+ >&nbsp;for&nbsp;details.
150
+ </div>
151
+ </div>
152
+ <div class="banner card">
153
+ <p class="banner-description">
154
+ <a
155
+ class="banner-link"
156
+ href="https://github.com/google/langextract"
157
+ target="_blank"
158
+ rel="noopener noreferrer"
159
+ ><strong>LangExtract (LX)</strong></a
160
+ >
161
+ is a multi-purpose NLP extraction library that uses large language
162
+ models such as Gemini to convert free-text into schema-controlled
163
+ data. It learns from your few-shot examples (structured using
164
+ <strong>LX</strong>'s extraction schema) to identify and extract
165
+ information, with every datum linked back to its exact words in the
166
+ source.
167
+ </p>
168
+ <hr class="banner-divider" />
169
+ <h3 class="banner-section-title">Demo Overview</h3>
170
+ <p class="banner-description">
171
+ <strong>RadExtract</strong> uses
172
+ <a
173
+ class="banner-link"
174
+ href="https://github.com/google/langextract"
175
+ target="_blank"
176
+ rel="noopener noreferrer"
177
+ ><strong>LangExtract (LX)</strong></a
178
+ >
179
+ powered by
180
+ <a
181
+ id="model-link"
182
+ class="banner-link"
183
+ href="https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-5-flash"
184
+ target="_blank"
185
+ rel="noopener noreferrer"
186
+ ><span id="model-name">Gemini 2.5 Flash</span></a
187
+ >
188
+ or
189
+ <a
190
+ class="banner-link"
191
+ href="https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-5-pro"
192
+ target="_blank"
193
+ rel="noopener noreferrer"
194
+ >Gemini 2.5 Pro</a
195
+ >
196
+ to convert radiology report findings into structured, optimized
197
+ radiology reports with highlighted significant findings. By leveraging
198
+ a
199
+ <a
200
+ class="banner-link"
201
+ href="https://huggingface.co/spaces/google/radextract/blob/main/prompt_instruction.py"
202
+ target="_blank"
203
+ rel="noopener noreferrer"
204
+ >prompt</a
205
+ >
206
+ that describes the structuring task with <strong>LX</strong>'s schema
207
+ and a
208
+ <a
209
+ class="banner-link"
210
+ href="https://huggingface.co/spaces/google/radextract/blob/main/report_examples.py"
211
+ target="_blank"
212
+ rel="noopener noreferrer"
213
+ >few select examples</a
214
+ >, <strong>LX</strong> processes free text into the structured output
215
+ shown below. Leveraging Gemini's foundational knowledge,
216
+ <strong>RadExtract</strong> can also process imaging modalities beyond
217
+ those included in the prompt examples, such as the X-ray and
218
+ ultrasound samples available below.
219
+ </p>
220
+ <p class="banner-description">
221
+ <strong>Interactive Features</strong><br />
222
+ Each extracted finding is directly grounded by
223
+ <strong>LX</strong> (linked precisely back to its original words in
224
+ the source text); hover over any structured item to see this exact
225
+ textual origin highlighted. <strong>Clinical significance</strong> is
226
+ visually highlighted: general findings are marked with yellow
227
+ underlines, while significant findings have red&nbsp;underlines.
228
+ </p>
229
+ <p class="banner-description">
230
+ <strong>Clinical Background</strong><br />
231
+ Structured reporting helps ensure completeness, reduces ambiguity, and
232
+ facilitates data sharing in radiology. For background on the value of
233
+ structured radiology reports, see
234
+ <a
235
+ class="banner-link"
236
+ href="https://link.springer.com/article/10.1007/s13244-017-0588-8"
237
+ target="_blank"
238
+ rel="noopener noreferrer"
239
+ >this European Society of Radiology paper</a
240
+ >.
241
+ </p>
242
+ </div>
243
+ </div>
244
+
245
+ <div class="samples-container card">
246
+ <h3>Select a Report</h3>
247
+ <div class="samples-description">
248
+ <div class="instruction-step">
249
+ <span class="step-number">1</span> Select a sample or paste your
250
+ report
251
+ </div>
252
+ <div class="instruction-step">
253
+ <span class="step-number">2</span> Click "Process" to start for pasted
254
+ reports
255
+ </div>
256
+ <div class="instruction-step">
257
+ <span class="step-number">3</span> Hover output findings to highlight
258
+ source text
259
+ </div>
260
+ </div>
261
+ <div class="sample-buttons"></div>
262
+ <p class="samples-tip tip-desktop">
263
+ 💡 Try tweaking a sample (remove sections, add extra findings, or paste
264
+ your own report) to see how the demo responds.
265
+ </p>
266
+ <p class="mobile-tip" role="note">
267
+ <span class="icon">💡</span>
268
+ Tap any sample report to explore the structuring features. The keyboard
269
+ stays closed so you can easily scroll and interact with highlighted findings.<br><br>
270
+ <strong>Tip:</strong> Custom input is available only on desktop or laptop computers.
271
+ If you're viewing this on a mobile device, please switch to a computer to enter your own reports.
272
+ </p>
273
+ </div>
274
+
275
+ <div class="interface-options-panel card">
276
+ <div class="interface-options-header" data-action="toggle-interface">
277
+ <h4 class="interface-options-title">Interface Controls</h4>
278
+ <div class="interface-options-summary">
279
+ <span>LX Generated Prompt • LX Structured Output • Use Cache</span>
280
+ <span
281
+ class="material-symbols-outlined expand-icon"
282
+ id="interface-expand-icon"
283
+ >expand_more</span
284
+ >
285
+ </div>
286
+ </div>
287
+ <div
288
+ class="interface-options-content"
289
+ id="interface-options-content"
290
+ style="display: none"
291
+ >
292
+ <div class="interface-options-grid">
293
+ <div class="interface-option">
294
+ <div class="option-header">
295
+ <span class="material-symbols-outlined option-icon"
296
+ >visibility</span
297
+ >
298
+ <strong>LX Generated Prompt</strong>
299
+ </div>
300
+ <p class="option-description">
301
+ View the complete prompt sent to the model, including task
302
+ description, examples, and your input text
303
+ </p>
304
+ </div>
305
+ <div class="interface-option">
306
+ <div class="option-header">
307
+ <span class="material-symbols-outlined option-icon">code</span>
308
+ <strong>LX Structured Output</strong>
309
+ </div>
310
+ <p class="option-description">
311
+ Toggle between the formatted text view and raw LangExtract JSON
312
+ data with extraction details
313
+ </p>
314
+ </div>
315
+ <div class="interface-option">
316
+ <div class="option-header">
317
+ <span class="material-symbols-outlined option-icon">cached</span>
318
+ <strong>Use Cache</strong>
319
+ </div>
320
+ <p class="option-description">
321
+ Switch between live model inference and pre-generated Gemini 2.5
322
+ Pro cached results for faster testing
323
+ </p>
324
+ </div>
325
+ </div>
326
+ <div class="interface-options-note">
327
+ <span class="material-symbols-outlined note-icon">info</span>
328
+ <span
329
+ >These controls are located in the Input and Output headers below
330
+ for easy access during interaction.</span
331
+ >
332
+ </div>
333
+ </div>
334
+ </div>
335
+
336
+ <div class="text-area-container card">
337
+ <div class="text-area-wrapper input-wrapper">
338
+ <div class="input-header">
339
+ <h2>Input</h2>
340
+ <label class="prompt-toggle"
341
+ ><input type="checkbox" id="prompt-toggle" /> LX Prompt</label
342
+ >
343
+ </div>
344
+ <div id="input-text-container" class="input-container">
345
+ <textarea
346
+ id="input-text"
347
+ class="large-text-area"
348
+ placeholder="Enter radiology report here or load a sample from above..."
349
+ spellcheck="false"
350
+ autocomplete="off"
351
+ autocorrect="off"
352
+ autocapitalize="off"
353
+ inputmode="none"
354
+ ></textarea>
355
+ <button
356
+ id="clear-input"
357
+ class="clear-button-overlay"
358
+ title="Clear input"
359
+ >
360
+ <svg
361
+ width="20"
362
+ height="20"
363
+ viewBox="0 0 24 24"
364
+ fill="none"
365
+ stroke="currentColor"
366
+ stroke-width="2"
367
+ >
368
+ <line x1="18" y1="6" x2="6" y2="18"></line>
369
+ <line x1="6" y1="6" x2="18" y2="18"></line>
370
+ </svg>
371
+ </button>
372
+ </div>
373
+ <div id="prompt-output" class="raw-json" style="display: none"></div>
374
+ </div>
375
+ <div class="text-area-wrapper" id="output-container">
376
+ <div class="output-header">
377
+ <h2>Output</h2>
378
+ <label class="raw-toggle"
379
+ ><input type="checkbox" id="raw-toggle" /> LX Data</label
380
+ >
381
+ </div>
382
+ <div id="output-text-container" class="output-container">
383
+ <pre
384
+ id="output-text"
385
+ class="large-text-area output-text"
386
+ placeholder="Structured output will appear here..."
387
+ ></pre>
388
+ <div
389
+ id="raw-output"
390
+ class="raw-json output-text"
391
+ style="display: none"
392
+ ></div>
393
+ <button
394
+ id="copy-output"
395
+ class="copy-button-overlay"
396
+ title="Copy output to clipboard"
397
+ >
398
+ <svg
399
+ width="20"
400
+ height="20"
401
+ viewBox="0 0 24 24"
402
+ fill="none"
403
+ stroke="currentColor"
404
+ stroke-width="2"
405
+ >
406
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
407
+ <path
408
+ d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
409
+ ></path>
410
+ </svg>
411
+ </button>
412
+ <div
413
+ id="loading-overlay"
414
+ class="loading-overlay"
415
+ style="display: none"
416
+ >
417
+ <div class="spinner"></div>
418
+ <div class="loader-text">
419
+ <span class="loader-message"
420
+ >Running LangExtract with Gemini 2.5 Flash</span
421
+ >
422
+ </div>
423
+ </div>
424
+ </div>
425
+ </div>
426
+ <div class="panel-controls">
427
+ <!-- ROW 1 -->
428
+ <div class="model-select-container">
429
+ <label for="model-select">Model:</label>
430
+ <select id="model-select">
431
+ <option value="gemini-2.5-flash" selected>Gemini 2.5 Flash</option>
432
+ <option value="gemini-2.5-pro">Gemini 2.5 Pro</option>
433
+ </select>
434
+ </div>
435
+
436
+ <label class="cache-toggle">
437
+ <input type="checkbox" id="cache-toggle" checked />
438
+ Use Cache
439
+ <span class="cache-status" id="cache-status"></span>
440
+ </label>
441
+
442
+ <!-- ROW 2 (mobile only) -->
443
+ <label class="prompt-toggle mobile-toggle">
444
+ <input type="checkbox" id="prompt-toggle-mobile" /> LX Prompt
445
+ </label>
446
+
447
+ <label class="raw-toggle mobile-toggle">
448
+ <input type="checkbox" id="raw-toggle-mobile" /> LX Data
449
+ </label>
450
+ </div>
451
+ </div>
452
+
453
+ <div class="action-bar">
454
+ <button id="predict-button">Process</button>
455
+
456
+ <div class="clinical-significance-legend">
457
+ <span class="legend-title">Findings:</span>
458
+ <span class="legend-item"
459
+ ><span class="legend-line minor"></span>General</span
460
+ >
461
+ <span class="legend-item"
462
+ ><span class="legend-line major"></span>Significant</span
463
+ >
464
+ <span class="legend-item"
465
+ ><span class="legend-line grounding"></span>Grounding</span
466
+ >
467
+ </div>
468
+ </div>
469
+
470
+ <div class="instructions"></div>
471
+
472
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/json-formatter.umd.js"></script>
473
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/marked.min.js"></script>
474
+
475
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
476
+ <script
477
+ type="module"
478
+ src="/static/script.js?v=20250125-refactored"
479
+ ></script>
480
+
481
+ <!-- Bottom share placement (minimal space above footer) -->
482
+ <div class="share-bottom">
483
+ <span>Share&nbsp;this&nbsp;demo →</span>
484
+ <a
485
+ class="shr-btn shr-x"
486
+ href="https://twitter.com/intent/tweet?text={{ share_text }}"
487
+ target="_blank"
488
+ rel="noopener"
489
+ aria-label="Share on X"
490
+ >
491
+ <svg viewBox="0 0 24 24" width="24" aria-hidden="true">
492
+ <path
493
+ fill="currentColor"
494
+ d="M23 2.999a9.05 9.05 0 0 1-2.588.71A4.516 4.516 0 0 0 22.36.365a9.04 9.04 0 0 1-2.867 1.096 4.505 4.505 0 0 0-7.67 4.107A12.79 12.79 0 0 1 1.64.896a4.505 4.505 0 0 0 1.396 6.01 4.47 4.47 0 0 1-2.04-.563v.057a4.507 4.507 0 0 0 3.614 4.417 4.522 4.522 0 0 1-2.034.077 4.508 4.508 0 0 0 4.207 3.128A9.03 9.03 0 0 1 0 19.54a12.75 12.75 0 0 0 6.92 2.026c8.304 0 12.846-6.877 12.846-12.837 0-.196-.004-.392-.013-.586A9.17 9.17 0 0 0 23 2.999z"
495
+ />
496
+ </svg>
497
+ </a>
498
+ <a
499
+ class="shr-btn shr-li"
500
+ href="https://www.linkedin.com/shareArticle?mini=true&url={{ share_url_encoded }}&title={{ linkedin_title }}&summary={{ linkedin_summary }}&source=RadExtract"
501
+ target="_blank"
502
+ rel="noopener"
503
+ aria-label="Share on LinkedIn"
504
+ >
505
+ <svg viewBox="0 0 24 24" width="24" aria-hidden="true">
506
+ <path
507
+ fill="currentColor"
508
+ d="M4.98 3.5C4.98 5.43 3.43 7 1.5 7S-1.98 5.43-1.98 3.5 0.57 0 2.5 0 4.98 1.57 4.98 3.5zM.02 8h5V24h-5V8zM7.98 8h4.8v2.2h.07c.67-1.27 2.31-2.6 4.76-2.6 5.09 0 6.04 3.35 6.04 7.7V24h-5v-7.7c0-1.84-.03-4.21-2.57-4.21-2.57 0-2.96 1.99-2.96 4.07V24h-5V8z"
509
+ />
510
+ </svg>
511
+ </a>
512
+ </div>
513
+
514
+ <div class="footer-note">
515
+ View this demo’s source on&nbsp;<a
516
+ class="banner-link"
517
+ href="https://huggingface.co/spaces/google/radextract/tree/main"
518
+ target="_blank"
519
+ rel="noopener noreferrer"
520
+ >Hugging&nbsp;Face&nbsp;Spaces</a
521
+ >&nbsp;🤗
522
+ </div>
523
+ </body>
524
+ </html>
test_app.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Test suite for Flask application endpoints and integration.
2
+
3
+ This module provides comprehensive tests for the Flask application including
4
+ route testing, model integration, caching behavior, and error handling.
5
+
6
+ Run with: python test_app.py or pytest test_app.py
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import unittest
12
+ from unittest import mock
13
+
14
+ # Mock the environment before importing app to avoid initialization errors
15
+ with mock.patch.dict(os.environ, {'KEY': 'test_api_key_for_import'}):
16
+ from app import Model, app, setup_cache
17
+
18
+
19
+ class TestFlaskApplication(unittest.TestCase):
20
+
21
+ @classmethod
22
+ def setUpClass(cls):
23
+ cls.test_client = app.test_client()
24
+ app.config['TESTING'] = True
25
+
26
+ def test_index_route_returns_html(self):
27
+ response = self.test_client.get('/')
28
+ self.assertEqual(response.status_code, 200)
29
+ self.assertIn('text/html', response.content_type)
30
+
31
+ def test_cache_stats_route(self):
32
+ response = self.test_client.get('/cache/stats')
33
+ self.assertEqual(response.status_code, 200)
34
+ self.assertEqual(response.content_type, 'application/json')
35
+
36
+ data = json.loads(response.data)
37
+ self.assertIsInstance(data, dict)
38
+
39
+ @mock.patch('app.model.predict')
40
+ def test_predict_route_with_valid_data(self, mock_predict):
41
+ mock_predict.return_value = {
42
+ 'segments': [{'type': 'body', 'content': 'test'}],
43
+ 'text': 'test output',
44
+ }
45
+
46
+ response = self.test_client.post('/predict', data='FINDINGS: Normal chest CT')
47
+ self.assertEqual(response.status_code, 200)
48
+
49
+ data = json.loads(response.data)
50
+ self.assertIn('segments', data)
51
+ self.assertIn('text', data)
52
+
53
+ def test_predict_route_with_empty_data(self):
54
+ response = self.test_client.post('/predict', data='')
55
+ self.assertEqual(response.status_code, 400)
56
+
57
+ data = json.loads(response.data)
58
+ self.assertIn('error', data)
59
+ self.assertEqual(data['error'], 'Empty input')
60
+ self.assertIn('message', data)
61
+ self.assertEqual(data['message'], 'Input text is required')
62
+ self.assertIn('max_length', data)
63
+
64
+ @mock.patch('app.model.predict')
65
+ def test_predict_with_custom_headers(self, mock_predict):
66
+ mock_predict.return_value = {'segments': [], 'text': 'test'}
67
+
68
+ headers = {
69
+ 'X-Use-Cache': 'false',
70
+ 'X-Sample-ID': 'test_sample',
71
+ 'X-Model-ID': 'gemini-2.5-flash',
72
+ }
73
+
74
+ response = self.test_client.post(
75
+ '/predict', data='Test report', headers=headers
76
+ )
77
+ self.assertEqual(response.status_code, 200)
78
+ mock_predict.assert_called_once_with('Test report', model_id='gemini-2.5-flash')
79
+
80
+ @mock.patch('app.cache_manager.get_cached_result')
81
+ def test_predict_with_cache_hit(self, mock_get_cached):
82
+ cached_response = {
83
+ 'segments': [{'type': 'body', 'content': 'cached'}],
84
+ 'text': 'cached result',
85
+ }
86
+ mock_get_cached.return_value = cached_response
87
+
88
+ response = self.test_client.post(
89
+ '/predict', data='Test report', headers={'X-Use-Cache': 'true'}
90
+ )
91
+
92
+ data = json.loads(response.data)
93
+ self.assertTrue(data.get('from_cache'))
94
+ self.assertIn('segments', data)
95
+
96
+
97
+ class TestModelClass(unittest.TestCase):
98
+
99
+ @mock.patch.dict(os.environ, {'KEY': 'test_api_key'})
100
+ def test_model_initialization_with_api_key(self):
101
+ model = Model()
102
+ self.assertEqual(model.gemini_api_key, 'test_api_key')
103
+ self.assertIn('gemini-2.5-flash', model._structurers)
104
+
105
+ @mock.patch.dict(os.environ, {}, clear=True)
106
+ def test_model_initialization_without_api_key(self):
107
+ with self.assertRaises(ValueError) as context:
108
+ Model()
109
+ self.assertIn('KEY environment variable not set', str(context.exception))
110
+
111
+ @mock.patch.dict(os.environ, {'KEY': 'test_key', 'MODEL_ID': 'custom-model'})
112
+ def test_model_initialization_with_custom_model(self):
113
+ model = Model()
114
+ self.assertIn('custom-model', model._structurers)
115
+
116
+ @mock.patch.dict(os.environ, {'KEY': 'test_key'})
117
+ @mock.patch('app.RadiologyReportStructurer')
118
+ def test_get_structurer_creates_new_instance(self, mock_structurer_class):
119
+ model = Model()
120
+ model._get_structurer('new-model')
121
+
122
+ # Should be called twice: once for default, once for new model
123
+ self.assertEqual(mock_structurer_class.call_count, 2)
124
+
125
+ @mock.patch.dict(os.environ, {'KEY': 'test_key'})
126
+ @mock.patch('app.RadiologyReportStructurer')
127
+ def test_predict_calls_structurer(self, mock_structurer_class):
128
+ mock_instance = mock.Mock()
129
+ mock_instance.predict.return_value = {'result': 'test'}
130
+ mock_structurer_class.return_value = mock_instance
131
+
132
+ model = Model()
133
+ result = model.predict('test data', 'test-model')
134
+
135
+ mock_instance.predict.assert_called_once_with('test data')
136
+ self.assertEqual(result, {'result': 'test'})
137
+
138
+
139
+ class TestCacheSetup(unittest.TestCase):
140
+
141
+ @mock.patch('os.path.exists')
142
+ @mock.patch('shutil.copy2')
143
+ @mock.patch('os.makedirs')
144
+ def test_setup_cache_copies_existing_file(
145
+ self, mock_makedirs, mock_copy, mock_exists
146
+ ):
147
+ mock_exists.return_value = True
148
+
149
+ cache_dir = setup_cache()
150
+
151
+ mock_makedirs.assert_called_once_with('/tmp/cache', exist_ok=True)
152
+ mock_copy.assert_called_once()
153
+ self.assertEqual(cache_dir, '/tmp/cache')
154
+
155
+ @mock.patch('os.path.exists')
156
+ @mock.patch('os.makedirs')
157
+ def test_setup_cache_handles_missing_source(self, mock_makedirs, mock_exists):
158
+ mock_exists.return_value = False
159
+
160
+ cache_dir = setup_cache()
161
+
162
+ mock_makedirs.assert_called_once_with('/tmp/cache', exist_ok=True)
163
+ self.assertEqual(cache_dir, '/tmp/cache')
164
+
165
+
166
+ class TestErrorHandling(unittest.TestCase):
167
+
168
+ @classmethod
169
+ def setUpClass(cls):
170
+ cls.test_client = app.test_client()
171
+ app.config['TESTING'] = True
172
+
173
+ def setUp(self):
174
+ # Suppress all logging during error tests to reduce noise
175
+ import logging
176
+
177
+ logging.disable(logging.CRITICAL)
178
+
179
+ def tearDown(self):
180
+ # Re-enable logging
181
+ import logging
182
+
183
+ logging.disable(logging.NOTSET)
184
+
185
+ @mock.patch('app.model.predict')
186
+ @mock.patch('app.logger')
187
+ def test_predict_handles_type_error(self, mock_logger, mock_predict):
188
+ mock_predict.side_effect = TypeError('Invalid type')
189
+
190
+ response = self.test_client.post('/predict', data='Test data')
191
+ self.assertEqual(response.status_code, 500)
192
+
193
+ data = json.loads(response.data)
194
+ self.assertIn('Processing error', data['error'])
195
+
196
+ @mock.patch('app.model.predict')
197
+ @mock.patch('app.logger')
198
+ def test_predict_handles_general_exception(self, mock_logger, mock_predict):
199
+ mock_predict.side_effect = Exception('General error')
200
+
201
+ response = self.test_client.post('/predict', data='Test data')
202
+ self.assertEqual(response.status_code, 500)
203
+
204
+ data = json.loads(response.data)
205
+ self.assertIn('General error', data['error'])
206
+
207
+
208
+ if __name__ == '__main__':
209
+ unittest.main()
test_validation.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """End-to-end validation tests for radiology report structuring.
3
+
4
+ This module provides focused validation tests that verify the complete
5
+ RadiologyReportStructurer pipeline by comparing actual processing
6
+ results against known good cached outputs.
7
+
8
+ Typical usage example:
9
+
10
+ # Run with unittest (built-in)
11
+ python test_validation.py
12
+ python -m unittest test_validation.py -v
13
+
14
+ # Run with pytest (recommended for CI/CD)
15
+ pytest test_validation.py -v
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import sys
21
+ import unittest
22
+ from typing import Any
23
+ from unittest import mock
24
+
25
+ # Add the current directory to path for imports
26
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
+
28
+ from structure_report import RadiologyReportStructurer
29
+
30
+
31
+ class TestRadiologyReportEndToEnd(unittest.TestCase):
32
+ """End-to-end tests for complete RadiologyReportStructurer pipeline."""
33
+
34
+ cache_file: str
35
+ sample_data: dict[str, Any]
36
+ structurer: RadiologyReportStructurer
37
+
38
+ @classmethod
39
+ def setUpClass(cls):
40
+ cls.cache_file = 'cache/sample_cache.json'
41
+ cls.sample_data = cls._load_sample_cache()
42
+ cls.structurer = RadiologyReportStructurer(
43
+ api_key='test_key', model_id='gemini-2.5-flash'
44
+ )
45
+
46
+ @classmethod
47
+ def _load_sample_cache(cls) -> dict[str, Any]:
48
+ if not os.path.exists(cls.cache_file):
49
+ raise FileNotFoundError(f'Sample cache file not found: {cls.cache_file}')
50
+
51
+ with open(cls.cache_file, 'r', encoding='utf-8') as f:
52
+ return json.load(f)
53
+
54
+ def _validate_response_structure(self, response: dict[str, Any]) -> None:
55
+ self.assertIn('segments', response)
56
+ self.assertIn('text', response)
57
+ self.assertIsInstance(response['segments'], list)
58
+ self.assertIsInstance(response['text'], str)
59
+
60
+ def _validate_successful_response(self, response: dict[str, Any]) -> None:
61
+ self._validate_response_structure(response)
62
+ self.assertGreater(len(response['segments']), 0)
63
+ self.assertGreater(len(response['text']), 0)
64
+
65
+ for segment in response['segments']:
66
+ self._validate_segment_structure(segment)
67
+
68
+ def _validate_segment_structure(self, segment: dict[str, Any]) -> None:
69
+ required_fields = ['type', 'label', 'content', 'intervals']
70
+ for field in required_fields:
71
+ self.assertIn(field, segment)
72
+
73
+ valid_types = ['prefix', 'body', 'suffix']
74
+ self.assertIn(segment['type'], valid_types)
75
+
76
+ if segment['intervals']:
77
+ for interval in segment['intervals']:
78
+ self.assertIn('startPos', interval)
79
+ self.assertIn('endPos', interval)
80
+ self.assertGreaterEqual(interval['startPos'], 0)
81
+ self.assertGreater(interval['endPos'], interval['startPos'])
82
+
83
+ @mock.patch('structure_report.lx.extract')
84
+ def test_end_to_end_processing_pipeline(self, mock_extract):
85
+ mock_result = mock.MagicMock()
86
+ mock_result.extractions = []
87
+ mock_extract.return_value = mock_result
88
+
89
+ input_text = 'EXAMINATION: Chest CT\n\nFINDINGS: Normal lungs.\n\nIMPRESSION: No acute findings.'
90
+
91
+ response = self.structurer.predict(input_text)
92
+
93
+ self._validate_response_structure(response)
94
+
95
+ mock_extract.assert_called_once()
96
+ call_args = mock_extract.call_args
97
+ self.assertEqual(call_args[1]['text_or_documents'], input_text)
98
+ self.assertEqual(call_args[1]['model_id'], 'gemini-2.5-flash')
99
+
100
+ def test_all_cached_samples_validation(self):
101
+ self.assertGreater(len(self.sample_data), 0, 'No samples found in cache')
102
+
103
+ for sample_key, sample in self.sample_data.items():
104
+ with self.subTest(sample=sample_key):
105
+ self._validate_successful_response(sample)
106
+
107
+ def test_error_handling_with_invalid_input(self):
108
+ with self.assertRaises(ValueError) as context:
109
+ self.structurer.predict('')
110
+ self.assertIn('Report text cannot be empty', str(context.exception))
111
+
112
+ with self.assertRaises(ValueError):
113
+ self.structurer.predict(' \n\t ')
114
+
115
+ def test_error_handling_with_no_api_key(self):
116
+ error_structurer = RadiologyReportStructurer(api_key=None)
117
+
118
+ response = error_structurer.predict('EXAMINATION: Test')
119
+
120
+ self._validate_response_structure(response)
121
+ self.assertEqual(len(response['segments']), 0)
122
+ self.assertIn('Error processing report', response['text'])
123
+
124
+ def test_patch_initialization_on_first_use(self):
125
+ new_structurer = RadiologyReportStructurer()
126
+
127
+ self.assertFalse(new_structurer._patches_initialized)
128
+
129
+ new_structurer._ensure_patches_initialized()
130
+ self.assertTrue(new_structurer._patches_initialized)
131
+
132
+ def test_section_mapping_core_functionality(self):
133
+ self.assertEqual(
134
+ self.structurer._map_section('findings_prefix'),
135
+ self.structurer._map_section('findings_prefix'),
136
+ )
137
+
138
+ self.assertIsNone(self.structurer._map_section('invalid_section'))
139
+ self.assertIsNone(self.structurer._map_section(''))
140
+
141
+ def test_exam_prefix_stripping(self):
142
+ self.assertEqual(
143
+ self.structurer._strip_exam_prefix('EXAMINATION: Chest CT'), 'Chest CT'
144
+ )
145
+
146
+ self.assertEqual(
147
+ self.structurer._strip_exam_prefix('Normal findings'), 'Normal findings'
148
+ )
149
+
150
+
151
+ if __name__ == '__main__':
152
+ unittest.main(verbosity=2)
tools/rebuild_cache.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Utility script to rebuild the demonstration cache with current structurer output.
3
+
4
+ This development tool rebuilds the cache using the current
5
+ RadiologyReportStructurer implementation, ensuring that cached results
6
+ include the latest features such as raw_prompt data. The script processes
7
+ all sample reports from the static JSON file and caches their structured
8
+ results for improved demo performance.
9
+
10
+ The script requires the KEY environment variable to be set with a valid
11
+ Gemini API key and optionally accepts MODEL_ID to specify which model
12
+ to use for processing.
13
+
14
+ Usage:
15
+ export KEY=your_gemini_api_key_here
16
+ export MODEL_ID=gemini-2.5-pro # optional, defaults to gemini-2.5-pro
17
+ python tools/rebuild_cache.py
18
+ """
19
+ import json
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ # Add parent directory to path to import modules
25
+ sys.path.append(str(Path(__file__).parent.parent))
26
+
27
+ from cache_manager import CacheManager
28
+ from structure_report import RadiologyReportStructurer
29
+
30
+ API_KEY = os.environ.get("KEY")
31
+ if not API_KEY:
32
+ sys.exit("KEY environment variable not set. Export KEY before running.")
33
+
34
+ SAMPLES_PATH = Path("static/sample_reports.json")
35
+ if not SAMPLES_PATH.exists():
36
+ sys.exit("static/sample_reports.json not found")
37
+
38
+ samples = json.loads(SAMPLES_PATH.read_text())["samples"]
39
+
40
+ MODEL_ID = os.environ.get("MODEL_ID", "gemini-2.5-pro")
41
+ structurer = RadiologyReportStructurer(api_key=API_KEY, model_id=MODEL_ID)
42
+
43
+ import time
44
+
45
+ cache = CacheManager(cache_dir="cache")
46
+
47
+ print("Clearing existing cache...")
48
+ cache.clear_cache()
49
+
50
+ print(f"Processing {len(samples)} samples with {MODEL_ID}...")
51
+ for s in samples:
52
+ sid = s["id"]
53
+ text = s["text"]
54
+ print(f" Processing {sid}...")
55
+
56
+ retries = 0
57
+ while retries < 5:
58
+ try:
59
+ result = structurer.predict(text)
60
+ cache.cache_result(text, result, sample_id=sid)
61
+ break
62
+ except Exception as e:
63
+ retries += 1
64
+ print(f" Warning: {e}. Retry {retries}/5...")
65
+ time.sleep(5)
66
+ else:
67
+ print(f" Error: Failed to process {sid} after 5 retries, skipping.")
68
+ time.sleep(3) # base throttle
69
+
70
+ print("Cache rebuild completed successfully.")
view_logs_endpoint.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Example endpoint to add to app.py for viewing logs
2
+ # Only add this if you need remote log access
3
+
4
+ @app.route("/logs/recent")
5
+ def view_recent_logs():
6
+ """View recent log entries (protected endpoint)."""
7
+ # Check for authentication
8
+ auth_token = request.args.get('token') or request.headers.get('X-Log-Token')
9
+ expected_token = os.environ.get('LOG_ACCESS_TOKEN')
10
+
11
+ if not expected_token or auth_token != expected_token:
12
+ return jsonify({"error": "Unauthorized"}), 401
13
+
14
+ try:
15
+ # Check if persistent storage exists
16
+ if not os.path.exists("/data/logs"):
17
+ return jsonify({"error": "No persistent storage available"}), 404
18
+
19
+ # Get today's log file
20
+ today = datetime.now().strftime("%Y-%m-%d")
21
+ log_file = f"/data/logs/radextract-{today}.log"
22
+
23
+ if not os.path.exists(log_file):
24
+ return jsonify({"error": "No logs for today"}), 404
25
+
26
+ # Read last 100 lines
27
+ with open(log_file, 'r') as f:
28
+ lines = f.readlines()
29
+ recent_lines = lines[-100:] if len(lines) > 100 else lines
30
+
31
+ # Filter for request logs
32
+ request_logs = [
33
+ line.strip() for line in recent_lines
34
+ if "[Req " in line and ("🔴" in line or "🟢" in line)
35
+ ]
36
+
37
+ return jsonify({
38
+ "log_file": log_file,
39
+ "total_lines": len(lines),
40
+ "recent_requests": request_logs
41
+ })
42
+
43
+ except Exception as e:
44
+ return jsonify({"error": str(e)}), 500