Spaces:
Running
on
CPU Upgrade
Running
on
CPU Upgrade
Commit
·
fab8051
0
Parent(s):
Initial commit for RadExtract
Browse files- .dockerignore +54 -0
- .gitattributes +35 -0
- .gitignore +30 -0
- .prettierrc.json +15 -0
- Dockerfile +35 -0
- LICENSE +202 -0
- README.md +159 -0
- app.py +293 -0
- cache/sample_cache.json +0 -0
- cache_manager.py +285 -0
- env.list.example +3 -0
- prompt_instruction.py +121 -0
- prompt_lib.py +101 -0
- pyproject.toml +85 -0
- report_examples.py +645 -0
- run_docker.sh +58 -0
- run_local.sh +45 -0
- sanitize.py +104 -0
- social_sharing.py +53 -0
- start.sh +27 -0
- static/copy.js +177 -0
- static/favicon.svg +21 -0
- static/google-research-logo.svg +61 -0
- static/reset.js +103 -0
- static/sample_reports.json +64 -0
- static/script.js +1320 -0
- static/style.css +2239 -0
- structure_report.py +734 -0
- templates/index.html +524 -0
- test_app.py +209 -0
- test_validation.py +152 -0
- tools/rebuild_cache.py +70 -0
- view_logs_endpoint.py +44 -0
.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 |
+
[](https://huggingface.co/spaces/google/radextract)
|
| 23 |
+
[](https://github.com/google/langextract)
|
| 24 |
+
[](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 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 |
+
> for 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 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 this 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 <a
|
| 516 |
+
class="banner-link"
|
| 517 |
+
href="https://huggingface.co/spaces/google/radextract/tree/main"
|
| 518 |
+
target="_blank"
|
| 519 |
+
rel="noopener noreferrer"
|
| 520 |
+
>Hugging Face Spaces</a
|
| 521 |
+
> 🤗
|
| 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
|