jl-update-mri-example

#4
by jl-abc - opened
README.md CHANGED
@@ -37,7 +37,7 @@ Transform unstructured radiology reports into structured data with highlighted f
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
- - **Current Model Alias**: Gemini Flash Latest automatically follows a supported Flash release
41
 
42
  ## Quick Start
43
 
@@ -68,7 +68,7 @@ Access at: http://localhost:7870
68
  ### Example Request
69
  ```bash
70
  curl -X POST \
71
- -H 'X-Model-ID: gemini-flash-latest' \
72
  -H 'X-Use-Cache: true' \
73
  -d 'FINDINGS: Normal heart and lungs. IMPRESSION: Normal study.' \
74
  http://localhost:7870/predict
@@ -93,7 +93,7 @@ curl -X POST \
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 Model**: Google Gemini Flash via the `gemini-flash-latest` rolling alias
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
@@ -156,3 +156,4 @@ Apache License 2.0 - see [LICENSE](LICENSE) for details.
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).
 
 
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
 
 
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
 
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
 
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 CHANGED
@@ -9,20 +9,19 @@ Typical usage example:
9
 
10
  # Set environment variables
11
  export KEY=your_gemini_api_key_here
12
- export MODEL_ID=gemini-flash-latest
13
 
14
  # Run the application
15
  python app.py
16
  """
17
 
18
- import hashlib
19
- import json
20
  import logging
21
  import os
22
- from pathlib import Path
23
  import shutil
24
  import tempfile
25
  import time
 
 
26
 
27
  from flask import Flask, jsonify, render_template, request
28
  from flask_limiter import Limiter
@@ -31,21 +30,10 @@ from flask_limiter.util import get_remote_address
31
  from cache_manager import CacheManager
32
  from sanitize import preprocess_report
33
  from social_sharing import SocialSharingConfig
34
- from structure_report import (
35
- DEFAULT_MODEL_ID,
36
- LEGACY_MODEL_ALIASES,
37
- RadiologyReportStructurer,
38
- ReportProcessingError,
39
- ResponseDict,
40
- resolve_model_id,
41
- )
42
 
43
  # Configuration constants
44
  MAX_INPUT_LENGTH = 3000
45
- _PUBLIC_MODEL_IDS = frozenset({DEFAULT_MODEL_ID, *LEGACY_MODEL_ALIASES.values()})
46
- _SAMPLE_REPORTS_PATH = (
47
- Path(__file__).resolve().parent / "static" / "sample_reports.json"
48
- )
49
 
50
  logging.basicConfig(
51
  level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s"
@@ -53,31 +41,6 @@ logging.basicConfig(
53
  logger = logging.getLogger(__name__)
54
 
55
 
56
- def _load_sample_catalog() -> dict[str, str]:
57
- """Loads normalized sample reports keyed by their public sample ID."""
58
- sample_data = json.loads(_SAMPLE_REPORTS_PATH.read_text(encoding="utf-8"))
59
- return {
60
- sample["id"]: preprocess_report(sample["text"])
61
- for sample in sample_data["samples"]
62
- }
63
-
64
-
65
- _SAMPLE_CATALOG = _load_sample_catalog()
66
-
67
-
68
- def _allowed_request_models() -> frozenset[str]:
69
- """Returns model IDs that callers may select through the public API."""
70
- configured_model = resolve_model_id(os.environ.get("MODEL_ID", DEFAULT_MODEL_ID))
71
- return _PUBLIC_MODEL_IDS | {configured_model}
72
-
73
-
74
- def _verified_sample_id(sample_id: str | None, report_text: str) -> str | None:
75
- """Returns a sample ID only when its normalized catalog text matches."""
76
- if sample_id and _SAMPLE_CATALOG.get(sample_id) == report_text:
77
- return sample_id
78
- return None
79
-
80
-
81
  class Model:
82
  """Manages RadiologyReportStructurer instances for different Gemini model IDs.
83
 
@@ -102,9 +65,7 @@ class Model:
102
 
103
  self._structurers: dict[str, RadiologyReportStructurer] = {}
104
 
105
- default_model_id = resolve_model_id(
106
- os.environ.get("MODEL_ID", DEFAULT_MODEL_ID)
107
- )
108
  self._structurers[default_model_id] = RadiologyReportStructurer(
109
  api_key=self.gemini_api_key,
110
  model_id=default_model_id,
@@ -123,14 +84,13 @@ class Model:
123
  Returns:
124
  RadiologyReportStructurer instance for the specified model.
125
  """
126
- resolved_model_id = resolve_model_id(model_id)
127
- if resolved_model_id not in self._structurers:
128
- logger.info(f"Creating structurer for model: {resolved_model_id}")
129
- self._structurers[resolved_model_id] = RadiologyReportStructurer(
130
  api_key=self.gemini_api_key,
131
- model_id=resolved_model_id,
132
  )
133
- return self._structurers[resolved_model_id]
134
 
135
  def predict(self, data: str, model_id: str) -> ResponseDict:
136
  """Processes prediction request using the specified model.
@@ -142,10 +102,10 @@ class Model:
142
  Returns:
143
  Dictionary containing the structured prediction results.
144
  """
145
- logger.info("Processing prediction with model: %s", model_id)
146
  structurer = self._get_structurer(model_id)
147
  result = structurer.predict(data)
148
- logger.info("Prediction completed with model: %s", structurer.model_id)
149
  return result
150
 
151
 
@@ -259,11 +219,7 @@ def predict():
259
  jsonify(
260
  {
261
  "error": "Input too long",
262
- "message": (
263
- f"Input length ({len(data)} characters) exceeds "
264
- "maximum allowed length of "
265
- f"{MAX_INPUT_LENGTH} characters"
266
- ),
267
  "max_length": MAX_INPUT_LENGTH,
268
  }
269
  ),
@@ -271,38 +227,20 @@ def predict():
271
  )
272
 
273
  use_cache = request.headers.get("X-Use-Cache", "true").lower() == "true"
274
- requested_sample_id = request.headers.get("X-Sample-ID")
275
- model_id = resolve_model_id(
276
- request.headers.get(
277
- "X-Model-ID", os.environ.get("MODEL_ID", DEFAULT_MODEL_ID)
278
- )
279
  )
280
- if model_id not in _allowed_request_models():
281
- return (
282
- jsonify(
283
- {
284
- "error": "Unsupported model",
285
- "message": (
286
- "The requested model is not available in this demo."
287
- ),
288
- }
289
- ),
290
- 400,
291
- )
292
-
293
  processed_data = preprocess_report(data)
294
- sample_id = _verified_sample_id(requested_sample_id, processed_data)
295
 
296
- if use_cache and sample_id:
297
  cached_result = cache_manager.get_cached_result(processed_data, sample_id)
298
  if cached_result:
299
  req_id = hashlib.md5(
300
  f"{request.remote_addr}{int(time.time()/3600)}".encode()
301
  ).hexdigest()[:8]
302
  logger.info(
303
- "CACHE HIT [Req %s] [Worker %s] - Returning prebuilt result",
304
- req_id,
305
- os.getpid(),
306
  )
307
  return jsonify({"from_cache": True, **cached_result})
308
 
@@ -311,58 +249,29 @@ def predict():
311
  f"{request.remote_addr}{int(time.time()/3600)}".encode()
312
  ).hexdigest()[:8]
313
  logger.info(
314
- "API CALL [Req %s] [Worker %s] - Processing with model %s",
315
- req_id,
316
- os.getpid(),
317
- model_id,
318
  )
319
  result = model.predict(processed_data, model_id=model_id)
320
- result["sanitized_input"] = processed_data
321
 
322
- logger.info(
323
- "Prediction succeeded [Req %s] [Worker %s] with model %s in %.2fs",
324
- req_id,
325
- os.getpid(),
326
- model_id,
327
- time.time() - start_time,
328
- )
329
 
330
  return jsonify(result)
331
 
332
- except ReportProcessingError as error:
333
- cause_type = (
334
- type(error.__cause__).__name__
335
- if error.__cause__ is not None
336
- else type(error).__name__
337
- )
338
- logger.error("Prediction provider failure: %s", cause_type)
339
 
340
  return (
341
- jsonify(
342
- {
343
- "error": "Processing unavailable",
344
- "message": (
345
- "The report could not be processed right now. "
346
- "Please try again later."
347
- ),
348
- }
349
- ),
350
- 502,
351
  )
352
- except Exception as error:
353
- logger.error("Prediction failure: %s", type(error).__name__)
354
-
355
- return (
356
- jsonify(
357
- {
358
- "error": "Internal processing error",
359
- "message": (
360
- "The request could not be completed. Please try again later."
361
- ),
362
- }
363
- ),
364
- 500,
365
- )
366
 
367
 
368
  @app.errorhandler(429)
 
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
 
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"
 
41
  logger = logging.getLogger(__name__)
42
 
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  class Model:
45
  """Manages RadiologyReportStructurer instances for different Gemini model IDs.
46
 
 
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,
 
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.
 
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
 
 
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
  ),
 
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
 
 
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)
env.list.example CHANGED
@@ -1,3 +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-flash-latest
 
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 CHANGED
@@ -107,14 +107,6 @@ PROMPT_INSTRUCTION = textwrap.dedent(
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
- Clinical significance levels:
112
- - **"significant"**: Findings requiring medical attention, follow-up, or intervention. Any finding that would typically require follow-up imaging or clinical correlation should be marked as significant.
113
- - **"minor"**: Benign or incidental findings without immediate clinical impact that do not require follow-up
114
- - **"normal"**: No abnormality detected
115
- - **"not_applicable"**: When significance cannot be determined
116
-
117
- **Important**: Clinical significance must be based solely on medical content, not text quality or grammar. Minor typos or grammatical errors in the input should not affect significance classification.
118
 
119
  ---
120
 
 
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
 
pyproject.toml CHANGED
@@ -30,7 +30,7 @@ dependencies = [
30
  "Flask>=3.1.0",
31
  "Flask-Limiter>=3.5.0",
32
  "gunicorn>=23.0.0",
33
- "langextract>=1.6.0,<2.0.0",
34
  "pandas>=1.3.0",
35
  "numpy>=1.20.0",
36
  "ml-collections>=0.1.0",
@@ -81,5 +81,5 @@ disable = [
81
  ]
82
 
83
  [tool.pytest.ini_options]
84
- testpaths = ["test_app.py", "test_validation.py"]
85
- python_files = ["test_*.py", "*_test.py"]
 
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",
 
81
  ]
82
 
83
  [tool.pytest.ini_options]
84
+ testpaths = ["tests"]
85
+ python_files = ["test_*.py", "*_test.py"]
report_examples.py CHANGED
@@ -466,7 +466,7 @@ def get_examples_for_model() -> list[lx.data.ExampleData]:
466
 
467
  The ventricular system is normal in size and configuration.
468
 
469
- No midline shift is present.
470
 
471
  IMPRESSION:
472
  Normal brain MRI.
@@ -503,10 +503,10 @@ def get_examples_for_model() -> list[lx.data.ExampleData]:
503
  },
504
  ),
505
  lx.data.Extraction(
506
- extraction_text="No midline shift is present.",
507
  extraction_class="findings_body",
508
  attributes={
509
- "section": "Brain Parenchyma",
510
  "clinical_significance": "normal",
511
  },
512
  ),
 
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.
 
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
  ),
static/script.js CHANGED
@@ -14,12 +14,19 @@ import { initClearButton, updateClearButtonState } from './reset.js';
14
 
15
  document.addEventListener('DOMContentLoaded', function () {
16
  // === CONFIGURATION CONSTANTS ===
17
- const MAX_LABEL_LENGTH = 60;
 
 
 
 
 
 
 
 
18
 
19
  const UI_CONFIG = {
20
  SCROLL_SMOOTH_BEHAVIOR: 'smooth',
21
  SCROLL_OFFSET_BUFFER: 100,
22
- DEFAULT_SAMPLE_ID: 'chest_xray',
23
  };
24
 
25
  // === GLOBAL STATE ===
@@ -44,15 +51,12 @@ document.addEventListener('DOMContentLoaded', function () {
44
  span.classList.remove('highlight');
45
  span.dataset.highlighted = 'false';
46
  });
47
- document.querySelectorAll('.source-excerpt').forEach((excerpt) => {
48
- excerpt.hidden = true;
49
- });
50
  clearInputHighlight();
51
  }
52
 
53
  // Add global click handler to clear highlights when clicking outside on mobile
54
  document.addEventListener('click', function (e) {
55
- if (isTouchDevice() && !e.target.closest('.text-span')) {
56
  clearAllHighlights();
57
  }
58
  });
@@ -60,20 +64,10 @@ document.addEventListener('DOMContentLoaded', function () {
60
  const predictButton = document.getElementById('predict-button');
61
  const inputText = document.getElementById('input-text');
62
  const outputTextContainer = document.getElementById('output-text');
63
- const findingsCards = document.getElementById('findings-cards');
64
- const rawOutput = document.getElementById('raw-output');
65
- const promptOutput = document.getElementById('prompt-output');
66
- const studioState = document.getElementById('studio-state');
67
- const outputViewTabs = Array.from(
68
- document.querySelectorAll('[data-output-view]'),
69
- );
70
- let preferredStructuredView = 'report';
71
  const instructionsEl = document.querySelector('.instructions');
72
  const loadingOverlay = document.getElementById('loading-overlay');
73
  let processingLoadingTimer = null;
74
  let originalInputText = '';
75
- let sampleRunQueued = false;
76
- let sampleLoadTimer = null;
77
 
78
  // Disable virtual keyboard on mobile devices
79
  let allowInputFocus = false;
@@ -92,6 +86,7 @@ document.addEventListener('DOMContentLoaded', function () {
92
 
93
  // Model dropdown elements
94
  const modelSelect = document.getElementById('model-select');
 
95
  const modelLink = document.getElementById('model-link');
96
 
97
  /**
@@ -99,9 +94,13 @@ document.addEventListener('DOMContentLoaded', function () {
99
  * @const {Object<string, {text: string, link: string}>}
100
  */
101
  const modelInfo = {
102
- 'gemini-flash-latest': {
103
- text: 'Gemini Flash (latest)',
104
- link: 'https://ai.google.dev/gemini-api/docs/models',
 
 
 
 
105
  },
106
  };
107
 
@@ -110,11 +109,14 @@ document.addEventListener('DOMContentLoaded', function () {
110
  */
111
  function updateModelInfo() {
112
  const selectedModel = modelSelect.value;
 
 
113
  if (modelLink) modelLink.href = modelInfo[selectedModel].link;
114
  }
115
 
116
  if (modelSelect) {
117
  modelSelect.addEventListener('change', updateModelInfo);
 
118
  }
119
 
120
  // Cache optimization elements
@@ -264,7 +266,6 @@ document.addEventListener('DOMContentLoaded', function () {
264
  const button = document.createElement('button');
265
  button.className = 'sample-button';
266
  button.setAttribute('data-sample-id', sample.id);
267
- button.setAttribute('aria-pressed', 'false');
268
 
269
  button.innerHTML = `
270
  <div class="sample-button-content">
@@ -282,77 +283,93 @@ document.addEventListener('DOMContentLoaded', function () {
282
 
283
  button.addEventListener('click', function () {
284
  loadSampleReport(sample);
285
- document.querySelectorAll('.sample-button.active').forEach((btn) => {
286
- btn.classList.remove('active');
287
- btn.setAttribute('aria-pressed', 'false');
288
- });
289
  this.classList.add('active');
290
- this.setAttribute('aria-pressed', 'true');
291
  });
292
 
293
  sampleButtonsContainer.appendChild(button);
294
  });
295
 
296
- sampleButtonsContainer.addEventListener('keydown', handleSampleRailKeydown);
297
-
298
- const hasIncomingState = inputText.value.trim().length > 0;
299
- const defaultSample = sortedSamples.find(
300
- (sample) => sample.id === UI_CONFIG.DEFAULT_SAMPLE_ID,
301
- );
302
- if (!hasIncomingState && defaultSample) {
303
- const defaultButton = sampleButtonsContainer.querySelector(
304
- `[data-sample-id="${UI_CONFIG.DEFAULT_SAMPLE_ID}"]`,
305
- );
306
- if (defaultButton) {
307
- defaultButton.classList.add('active');
308
- defaultButton.setAttribute('aria-pressed', 'true');
309
- if (window.matchMedia('(max-width: 768px)').matches) {
310
- sampleButtonsContainer.scrollLeft = Math.max(
311
- 0,
312
- defaultButton.offsetLeft - sampleButtonsContainer.clientWidth / 2,
313
- );
314
- }
315
- }
316
- loadSampleReport(defaultSample, { scroll: false });
317
- }
318
  }
319
 
320
  /**
321
- * Supports arrow-key navigation across the horizontal sample rail.
322
- * @param {KeyboardEvent} event - Keyboard event from the rail
 
323
  */
324
- function handleSampleRailKeydown(event) {
325
- if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
 
 
 
 
326
 
327
- const buttons = Array.from(
328
- event.currentTarget.querySelectorAll('.sample-button'),
 
 
 
 
 
 
 
 
 
 
 
 
329
  );
330
- const currentIndex = buttons.indexOf(document.activeElement);
331
- if (currentIndex < 0) return;
332
-
333
- event.preventDefault();
334
- let nextIndex = currentIndex;
335
- if (event.key === 'ArrowLeft') nextIndex = Math.max(0, currentIndex - 1);
336
- if (event.key === 'ArrowRight') {
337
- nextIndex = Math.min(buttons.length - 1, currentIndex + 1);
338
  }
339
- if (event.key === 'Home') nextIndex = 0;
340
- if (event.key === 'End') nextIndex = buttons.length - 1;
341
- buttons[nextIndex].focus();
342
- buttons[nextIndex].scrollIntoView({ block: 'nearest', inline: 'center' });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  }
344
 
345
  /**
346
  * Loads a sample report into the input area and automatically processes it.
347
  * @param {Object} sample - The sample report data object
348
  */
349
- function loadSampleReport(sample, options = {}) {
350
- if (options.scroll !== false) scrollToOutput();
351
-
352
- if (sampleLoadTimer) {
353
- clearTimeout(sampleLoadTimer);
354
- sampleLoadTimer = null;
355
- }
356
 
357
  // Normalize line endings for sample text
358
  inputText.value = sample.text.replace(/\r\n?/g, '\n');
@@ -361,9 +378,8 @@ document.addEventListener('DOMContentLoaded', function () {
361
  updateClearButtonState();
362
 
363
  outputTextContainer.innerHTML = '';
364
- instructionsEl.style.display = 'none';
365
  currentSampleId = sample.id;
366
- if (studioState) studioState.textContent = 'Loading cached example…';
367
 
368
  // Automatically enable cache for sample reports
369
  if (cacheToggle) {
@@ -372,23 +388,21 @@ document.addEventListener('DOMContentLoaded', function () {
372
  updateModelDropdownState();
373
  }
374
 
375
- if (predictButton.disabled) {
376
- sampleRunQueued = true;
377
- return;
378
- }
379
-
380
- sampleLoadTimer = setTimeout(() => {
381
- sampleLoadTimer = null;
382
- if (predictButton.disabled) {
383
- sampleRunQueued = true;
384
- return;
385
- }
386
  predictButton.click();
387
  }, 100);
388
  }
389
 
390
  loadSampleReports();
391
 
 
 
 
 
 
 
 
 
392
  /**
393
  * Updates the cache status display in the UI.
394
  * @returns {Promise<void>}
@@ -438,13 +452,6 @@ document.addEventListener('DOMContentLoaded', function () {
438
  predictButton.disabled = true;
439
  predictButton.textContent = 'Processing...';
440
  const cacheEnabled = cacheToggle ? cacheToggle.checked : true;
441
- if (studioState) {
442
- studioState.textContent =
443
- cacheEnabled && currentSampleId
444
- ? 'Loading cached example…'
445
- : 'Running live extraction…';
446
- }
447
- resetStudioMetrics();
448
  if (processingLoadingTimer) clearTimeout(processingLoadingTimer);
449
 
450
  // Show loading overlay after 200ms
@@ -455,7 +462,7 @@ document.addEventListener('DOMContentLoaded', function () {
455
  if (loaderMessage) {
456
  const modelText =
457
  (modelSelect && modelInfo[modelSelect.value]?.text) ||
458
- 'Gemini Flash (latest)';
459
  loaderMessage.textContent = `Running LangExtract with ${modelText}...`;
460
  }
461
 
@@ -468,17 +475,6 @@ document.addEventListener('DOMContentLoaded', function () {
468
 
469
  originalInputText = inputText.value;
470
  outputTextContainer.innerHTML = '';
471
- rawOutput?.replaceChildren();
472
- promptOutput?.replaceChildren();
473
- if (findingsCards) findingsCards.textContent = 'Structuring report…';
474
- if (promptToggle) {
475
- promptToggle.checked = false;
476
- showPromptView(false);
477
- }
478
- const mobilePromptToggle = document.getElementById('prompt-toggle-mobile');
479
- if (mobilePromptToggle) mobilePromptToggle.checked = false;
480
- selectOutputView('cards');
481
- setOutputViewsAvailable(false);
482
  updateLXToggleStates(); // Disable toggles when output is cleared
483
  updateCopyButtonState();
484
 
@@ -504,9 +500,6 @@ document.addEventListener('DOMContentLoaded', function () {
504
  body: originalInputText,
505
  });
506
 
507
- // A newer sample selection is waiting; discard this response and run it next.
508
- if (sampleRunQueued) return;
509
-
510
  if (!response.ok) {
511
  const errorText = await response.text();
512
  let errorJson;
@@ -529,23 +522,32 @@ document.addEventListener('DOMContentLoaded', function () {
529
 
530
  const data = await response.json();
531
 
532
- // A sample can be selected while response parsing is in progress.
533
- if (sampleRunQueued) return;
534
-
535
- // Cached samples should feel instant and never simulate provider latency.
536
  if (data.from_cache) {
 
 
 
 
 
 
 
 
 
537
  const loaderMessage = document.querySelector('.loader-message');
538
  if (loaderMessage) {
539
  loaderMessage.textContent =
540
  'Loading LangExtract Result from Cache...';
541
  }
 
 
 
 
542
  }
543
 
544
  if (data.sanitized_input && data.sanitized_input !== originalInputText) {
545
  const inputText = document.getElementById('input-text');
546
  if (inputText) {
547
  inputText.value = data.sanitized_input;
548
- originalInputText = data.sanitized_input;
549
  updateClearButtonState();
550
  }
551
  }
@@ -557,9 +559,6 @@ document.addEventListener('DOMContentLoaded', function () {
557
  data.segments.length > 0
558
  ) {
559
  renderSegments(data.segments);
560
- renderFindingCards(data.segments);
561
- updateStudioMetrics(data);
562
- setOutputViewsAvailable(true);
563
  updateLXToggleStates(); // Enable/update toggles when output is generated
564
  updateCopyButtonState();
565
 
@@ -575,66 +574,50 @@ document.addEventListener('DOMContentLoaded', function () {
575
 
576
  rawOutput.innerHTML = '';
577
 
578
- // Check if JSONFormatter is available
579
- if (typeof window.JSONFormatter === 'undefined') {
580
- console.error('JSONFormatter library not loaded, using fallback');
581
- const pre = document.createElement('pre');
582
- pre.style.whiteSpace = 'pre-wrap';
583
- pre.style.wordWrap = 'break-word';
584
- pre.textContent = JSON.stringify(rawData, null, 2);
585
- rawOutput.replaceChildren(pre);
586
- rawOutput._jsonData = rawData;
587
- } else {
588
- const formatter = new window.JSONFormatter(rawData, {
589
- hoverPreviewEnabled: true,
590
- animateOpen: false,
591
- animateClose: false,
592
- theme: 'light',
593
- open: true,
594
- });
595
 
596
- const renderedElement = formatter.render();
597
- rawOutput.appendChild(renderedElement);
598
- rawOutput._jsonFormatter = formatter;
599
- rawOutput._jsonData = rawData;
600
 
601
- setTimeout(() => {
 
 
 
 
 
 
 
 
 
 
 
 
602
  try {
603
- if (formatter.openAtDepth) {
604
- formatter.openAtDepth(3);
605
- }
606
  } catch (e) {
607
- // Ignore errors if formatter doesn't support openAtDepth
608
  }
609
-
610
- const togglers = rawOutput.querySelectorAll(
611
- '.json-formatter-toggler',
612
- );
613
- togglers.forEach((toggler) => {
614
- try {
615
- toggler.click();
616
- } catch (e) {
617
- // Ignore click errors on JSON formatter togglers
618
- }
619
- });
620
- }, 10);
621
- }
622
 
623
  rawToggle.checked = false;
624
  rawOutput.style.display = 'none';
 
625
  }
626
 
627
  if (promptOutput) {
628
  const promptText = data.raw_prompt || 'Prompt data not available.';
629
- if (
630
- typeof marked !== 'undefined' &&
631
- typeof DOMPurify !== 'undefined' &&
632
- data.raw_prompt
633
- ) {
634
  // Render markdown with syntax highlighting support
635
- promptOutput.innerHTML = DOMPurify.sanitize(
636
- marked.parse(promptText),
637
- );
638
  } else {
639
  // Fallback to plain text
640
  promptOutput.textContent = promptText;
@@ -643,52 +626,24 @@ document.addEventListener('DOMContentLoaded', function () {
643
  showPromptView(false);
644
  }
645
 
646
- selectOutputView(preferredStructuredView);
647
-
648
  const hasIntervals = data.segments.some(
649
  (segment) => segment.intervals && segment.intervals.length > 0,
650
  );
651
 
652
- instructionsEl.replaceChildren();
653
  if (!hasIntervals) {
654
  instructionsEl.innerHTML =
655
  '<p><strong>Note:</strong> Hover functionality is not available for this result.</p>';
656
- instructionsEl.style.display = 'block';
657
- } else {
658
- instructionsEl.style.display = 'none';
659
- }
660
- if (studioState) {
661
- studioState.textContent = data.from_cache
662
- ? 'Cached sample · ready'
663
- : 'Live extraction · ready';
664
  }
665
  } else {
666
  outputTextContainer.textContent = data.text;
667
- if (findingsCards) {
668
- findingsCards.textContent = data.text;
669
- }
670
- selectOutputView('cards');
671
- resetStudioMetrics();
672
- setOutputViewsAvailable(false);
673
- if (studioState) {
674
- studioState.textContent = 'Response returned · no structured items';
675
- }
676
  instructionsEl.style.display = 'none';
677
  }
678
  } else {
679
  outputTextContainer.textContent = 'No content returned from server.';
680
- if (findingsCards) {
681
- findingsCards.textContent = 'No content returned from server.';
682
- }
683
- selectOutputView('cards');
684
- resetStudioMetrics();
685
- setOutputViewsAvailable(false);
686
- if (studioState)
687
- studioState.textContent = 'No structured result returned';
688
  instructionsEl.style.display = 'none';
689
  }
690
  } catch (error) {
691
- if (sampleRunQueued) return;
692
  if (error.details && typeof error.details === 'object') {
693
  if (error.details.error === 'Empty input') {
694
  const friendlyMessage = [
@@ -729,13 +684,6 @@ document.addEventListener('DOMContentLoaded', function () {
729
  } else {
730
  outputTextContainer.textContent = `Error: ${error.message}`;
731
  }
732
- if (findingsCards) {
733
- findingsCards.textContent = outputTextContainer.textContent;
734
- }
735
- selectOutputView('cards');
736
- resetStudioMetrics();
737
- setOutputViewsAvailable(false);
738
- if (studioState) studioState.textContent = 'Unable to structure report';
739
  instructionsEl.style.display = 'none';
740
  } finally {
741
  if (processingLoadingTimer) {
@@ -746,7 +694,7 @@ document.addEventListener('DOMContentLoaded', function () {
746
 
747
  const message = document.querySelector('.loader-message');
748
  const spinner = document.querySelector('.spinner');
749
- if (message && spinner && typeof gsap !== 'undefined') {
750
  gsap.killTweensOf([message, spinner]);
751
  gsap.set([message, spinner], { clearProps: 'all' });
752
  }
@@ -754,11 +702,6 @@ document.addEventListener('DOMContentLoaded', function () {
754
  predictButton.textContent = 'Process';
755
 
756
  updateCacheStatus();
757
-
758
- if (sampleRunQueued) {
759
- sampleRunQueued = false;
760
- setTimeout(() => predictButton.click(), 0);
761
- }
762
  }
763
  });
764
 
@@ -894,104 +837,6 @@ document.addEventListener('DOMContentLoaded', function () {
894
  }
895
  }
896
 
897
- /**
898
- * Renders every returned segment exactly once as a scan-friendly card.
899
- * @param {Array<Object>} segments - Ordered segments from the API response
900
- */
901
- function renderFindingCards(segments) {
902
- if (!findingsCards) return;
903
-
904
- findingsCards.innerHTML = '';
905
- const cardsFragment = document.createDocumentFragment();
906
-
907
- segments.forEach((segment, index) => {
908
- const card = document.createElement('article');
909
- card.className = 'finding-card';
910
- card.dataset.segmentIndex = String(index);
911
- card.dataset.segmentType = segment.type || 'body';
912
-
913
- const cardHeader = document.createElement('div');
914
- cardHeader.className = 'finding-card-header';
915
-
916
- const label = document.createElement('span');
917
- label.className = 'finding-card-label';
918
- label.textContent = segment.label || segment.type || 'Finding';
919
- cardHeader.appendChild(label);
920
-
921
- const badges = document.createElement('span');
922
- badges.className = 'finding-card-badges';
923
-
924
- const significanceLevel = String(
925
- segment.significance || '',
926
- ).toLowerCase();
927
- if (
928
- significanceLevel === 'significant' ||
929
- significanceLevel === 'minor'
930
- ) {
931
- const significance = document.createElement('span');
932
- significance.className = `finding-badge significance-${significanceLevel}`;
933
- significance.textContent = significanceLevel;
934
- badges.appendChild(significance);
935
- }
936
-
937
- const grounded = Boolean(segment.intervals && segment.intervals.length);
938
- const grounding = document.createElement('span');
939
- grounding.className = `finding-badge ${grounded ? 'is-grounded' : 'is-ungrounded'}`;
940
- grounding.textContent = grounded ? 'Source grounded' : 'No source span';
941
- badges.appendChild(grounding);
942
- cardHeader.appendChild(badges);
943
- card.appendChild(cardHeader);
944
-
945
- const content = document.createElement('p');
946
- content.className = 'finding-card-content';
947
- content.appendChild(createContentWithIntervalSpans(segment));
948
- card.appendChild(content);
949
-
950
- const sourceExcerpt = document.createElement('div');
951
- sourceExcerpt.className = 'source-excerpt';
952
- sourceExcerpt.hidden = true;
953
- sourceExcerpt.setAttribute('aria-live', 'polite');
954
- card.appendChild(sourceExcerpt);
955
-
956
- cardsFragment.appendChild(card);
957
- });
958
-
959
- findingsCards.appendChild(cardsFragment);
960
- }
961
-
962
- /**
963
- * Updates factual counters derived directly from the extraction response.
964
- * @param {Object} data - Successful API response
965
- */
966
- function updateStudioMetrics(data) {
967
- const segments = Array.isArray(data.segments) ? data.segments : [];
968
- const counts = {
969
- items: segments.length,
970
- grounded: segments.filter((segment) => segment.intervals?.length).length,
971
- significant: segments.filter(
972
- (segment) =>
973
- String(segment.significance || '').toLowerCase() === 'significant',
974
- ).length,
975
- minor: segments.filter(
976
- (segment) =>
977
- String(segment.significance || '').toLowerCase() === 'minor',
978
- ).length,
979
- };
980
-
981
- Object.entries(counts).forEach(([name, value]) => {
982
- const metric = document.getElementById(`metric-${name}`);
983
- if (metric) metric.textContent = String(value);
984
- });
985
- }
986
-
987
- /** Clears counters while a request is running or has no structured result. */
988
- function resetStudioMetrics() {
989
- ['items', 'grounded', 'significant', 'minor'].forEach((name) => {
990
- const metric = document.getElementById(`metric-${name}`);
991
- if (metric) metric.textContent = '—';
992
- });
993
- }
994
-
995
  /**
996
  * Helper function to create section headers.
997
  * @param {string} text - The header text to display
@@ -1051,12 +896,6 @@ document.addEventListener('DOMContentLoaded', function () {
1051
  const interval = segment.intervals[0];
1052
  const contentSpan = document.createElement('span');
1053
  contentSpan.classList.add('text-span');
1054
- contentSpan.tabIndex = 0;
1055
- contentSpan.setAttribute('role', 'button');
1056
- contentSpan.setAttribute(
1057
- 'aria-label',
1058
- `Show source for ${segment.label || segment.type || 'extracted item'}`,
1059
- );
1060
 
1061
  // Set data attributes for position tracking
1062
  contentSpan.dataset.startPos = interval.startPos;
@@ -1085,7 +924,8 @@ document.addEventListener('DOMContentLoaded', function () {
1085
  */
1086
  function extractLabelInfo(content) {
1087
  const colonIndex = content.indexOf(':');
1088
- const hasLabel = colonIndex > 0 && colonIndex < MAX_LABEL_LENGTH;
 
1089
 
1090
  return {
1091
  hasLabel,
@@ -1155,55 +995,33 @@ document.addEventListener('DOMContentLoaded', function () {
1155
  function addIntervalEventListeners(contentSpan) {
1156
  const isDesktop = !isTouchDevice();
1157
 
1158
- const getSourceExcerpt = () =>
1159
- contentSpan.closest('.finding-card')?.querySelector('.source-excerpt');
1160
-
1161
- const showGrounding = ({ focusInput = true } = {}) => {
1162
- contentSpan.classList.add('highlight');
1163
- const startPos = parseInt(contentSpan.dataset.startPos);
1164
- const endPos = parseInt(contentSpan.dataset.endPos);
1165
- if (!isNaN(startPos) && !isNaN(endPos)) {
1166
- highlightInputText(startPos, endPos, { focusInput });
1167
- const sourceExcerpt = getSourceExcerpt();
1168
- if (!focusInput && sourceExcerpt) {
1169
- sourceExcerpt.textContent = originalInputText.slice(startPos, endPos);
1170
- sourceExcerpt.hidden = false;
1171
- }
1172
- }
1173
- };
1174
-
1175
- const hideGrounding = () => {
1176
- contentSpan.classList.remove('highlight');
1177
- const sourceExcerpt = getSourceExcerpt();
1178
- if (sourceExcerpt) sourceExcerpt.hidden = true;
1179
- clearInputHighlight();
1180
- };
1181
-
1182
  if (isDesktop) {
1183
  // Desktop: Hover-based highlighting
1184
- contentSpan.addEventListener('mouseenter', showGrounding);
1185
- contentSpan.addEventListener('mouseleave', hideGrounding);
1186
- contentSpan.addEventListener('focus', () =>
1187
- showGrounding({ focusInput: false }),
1188
- );
1189
- contentSpan.addEventListener('blur', hideGrounding);
 
 
 
 
 
 
 
1190
  } else {
1191
- // Mobile: one click event avoids a touchstart/click double-toggle.
1192
- contentSpan.addEventListener('click', function (e) {
1193
  e.preventDefault();
1194
  handleMobileHighlight(contentSpan);
1195
  });
1196
- }
1197
 
1198
- contentSpan.addEventListener('keydown', function (event) {
1199
- if (event.key !== 'Enter' && event.key !== ' ') return;
1200
- event.preventDefault();
1201
- if (isTouchDevice()) {
1202
  handleMobileHighlight(contentSpan);
1203
- } else {
1204
- showGrounding({ focusInput: false });
1205
- }
1206
- });
1207
  }
1208
 
1209
  /**
@@ -1225,16 +1043,7 @@ document.addEventListener('DOMContentLoaded', function () {
1225
  const startPos = parseInt(span.dataset.startPos);
1226
  const endPos = parseInt(span.dataset.endPos);
1227
  if (!isNaN(startPos) && !isNaN(endPos)) {
1228
- const card = span.closest('.finding-card');
1229
- if (card && window.matchMedia('(max-width: 768px)').matches) {
1230
- const excerpt = card.querySelector('.source-excerpt');
1231
- if (excerpt) {
1232
- excerpt.textContent = originalInputText.slice(startPos, endPos);
1233
- excerpt.hidden = false;
1234
- }
1235
- } else {
1236
- highlightInputText(startPos, endPos);
1237
- }
1238
  }
1239
  } else {
1240
  // If it was highlighted, just clear (already done above)
@@ -1246,22 +1055,21 @@ document.addEventListener('DOMContentLoaded', function () {
1246
  * Highlights text in the input textarea based on character positions.
1247
  * @param {number} startPos - Starting character position
1248
  * @param {number} endPos - Ending character position
1249
- * @param {{focusInput?: boolean}} options - Whether to move focus to the source
1250
  */
1251
- function highlightInputText(startPos, endPos, { focusInput = true } = {}) {
1252
  // Enable focus for programmatic text selection
1253
- if (focusInput && isTouchDevice()) {
1254
  allowInputFocus = true;
1255
  }
1256
 
1257
- if (focusInput) inputText.focus();
1258
  if (typeof inputText.setSelectionRange === 'function') {
1259
  inputText.setSelectionRange(startPos, endPos);
1260
  scrollInputToRange(startPos, endPos); // Centre the selection in viewport
1261
  }
1262
 
1263
  // Restore focus prevention
1264
- if (focusInput && isTouchDevice()) {
1265
  allowInputFocus = false;
1266
  }
1267
  }
@@ -1317,9 +1125,7 @@ document.addEventListener('DOMContentLoaded', function () {
1317
 
1318
  inputText.scrollTo({
1319
  top: targetScrollTop,
1320
- behavior: prefersReducedMotion()
1321
- ? 'auto'
1322
- : UI_CONFIG.SCROLL_SMOOTH_BEHAVIOR,
1323
  });
1324
  } finally {
1325
  // Always cleanup the clone element
@@ -1336,12 +1142,6 @@ document.addEventListener('DOMContentLoaded', function () {
1336
 
1337
  if (!message || !spinner) return;
1338
 
1339
- if (prefersReducedMotion()) {
1340
- gsap.killTweensOf([message, spinner]);
1341
- gsap.set([message, spinner], { clearProps: 'all' });
1342
- return;
1343
- }
1344
-
1345
  gsap.killTweensOf([message, spinner]);
1346
  gsap.set([message, spinner], { clearProps: 'all' });
1347
 
@@ -1386,85 +1186,8 @@ document.addEventListener('DOMContentLoaded', function () {
1386
  }
1387
  }
1388
 
1389
- /**
1390
- * Selects one of the three output representations.
1391
- * @param {'cards'|'report'|'raw'} view - View to make visible
1392
- */
1393
- function selectOutputView(view, { remember = false } = {}) {
1394
- const requestedTab = outputViewTabs.find(
1395
- (tab) => tab.dataset.outputView === view,
1396
- );
1397
- if (requestedTab?.disabled) view = 'cards';
1398
-
1399
- if (findingsCards) {
1400
- findingsCards.style.display = view === 'cards' ? 'grid' : 'none';
1401
- findingsCards.setAttribute('aria-hidden', String(view !== 'cards'));
1402
- }
1403
- if (outputTextContainer) {
1404
- outputTextContainer.style.display = view === 'report' ? 'block' : 'none';
1405
- outputTextContainer.setAttribute(
1406
- 'aria-hidden',
1407
- String(view !== 'report'),
1408
- );
1409
- }
1410
- if (rawOutput) {
1411
- rawOutput.style.display = view === 'raw' ? 'block' : 'none';
1412
- rawOutput.setAttribute('aria-hidden', String(view !== 'raw'));
1413
- }
1414
- if (rawToggle) rawToggle.checked = view === 'raw';
1415
- const mobileRawToggle = document.getElementById('raw-toggle-mobile');
1416
- if (mobileRawToggle) mobileRawToggle.checked = view === 'raw';
1417
-
1418
- outputViewTabs.forEach((tab) => {
1419
- const selected = tab.dataset.outputView === view;
1420
- tab.classList.toggle('active', selected);
1421
- tab.setAttribute('aria-selected', String(selected));
1422
- tab.tabIndex = selected ? 0 : -1;
1423
- });
1424
-
1425
- if (remember) preferredStructuredView = view;
1426
- }
1427
-
1428
- /** Enables alternate representations only when structured output exists. */
1429
- function setOutputViewsAvailable(available) {
1430
- outputViewTabs.forEach((tab) => {
1431
- if (tab.dataset.outputView === 'cards') return;
1432
- tab.disabled = !available;
1433
- tab.setAttribute('aria-disabled', String(!available));
1434
- });
1435
- }
1436
-
1437
- outputViewTabs.forEach((tab) => {
1438
- tab.tabIndex = tab.getAttribute('aria-selected') === 'true' ? 0 : -1;
1439
- tab.addEventListener('click', () =>
1440
- selectOutputView(tab.dataset.outputView, { remember: true }),
1441
- );
1442
- tab.addEventListener('keydown', (event) => {
1443
- if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key))
1444
- return;
1445
- event.preventDefault();
1446
- const enabledTabs = outputViewTabs.filter(
1447
- (candidate) => !candidate.disabled,
1448
- );
1449
- const enabledIndex = enabledTabs.indexOf(tab);
1450
- let nextIndex = enabledIndex;
1451
- if (event.key === 'ArrowLeft') {
1452
- nextIndex =
1453
- (enabledIndex - 1 + enabledTabs.length) % enabledTabs.length;
1454
- }
1455
- if (event.key === 'ArrowRight') {
1456
- nextIndex = (enabledIndex + 1) % enabledTabs.length;
1457
- }
1458
- if (event.key === 'Home') nextIndex = 0;
1459
- if (event.key === 'End') nextIndex = enabledTabs.length - 1;
1460
- enabledTabs[nextIndex].focus();
1461
- selectOutputView(enabledTabs[nextIndex].dataset.outputView, {
1462
- remember: true,
1463
- });
1464
- });
1465
- });
1466
-
1467
- setOutputViewsAvailable(false);
1468
 
1469
  /**
1470
  * Shows or hides the prompt view panel.
@@ -1479,7 +1202,8 @@ document.addEventListener('DOMContentLoaded', function () {
1479
  if (rawToggle) {
1480
  rawToggle.addEventListener('change', () => {
1481
  const showRaw = rawToggle.checked;
1482
- selectOutputView(showRaw ? 'raw' : 'cards', { remember: true });
 
1483
 
1484
  const mobileRawToggle = document.getElementById('raw-toggle-mobile');
1485
  if (mobileRawToggle) {
@@ -1556,11 +1280,6 @@ document.addEventListener('DOMContentLoaded', function () {
1556
  }
1557
  });
1558
 
1559
- /** Returns whether the user has asked interfaces to minimize motion. */
1560
- function prefersReducedMotion() {
1561
- return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1562
- }
1563
-
1564
  /**
1565
  * Scrolls to the output panel to direct user focus to the results area.
1566
  * Provides improved navigation experience for sample report selection workflow.
@@ -1571,7 +1290,7 @@ function scrollToOutput() {
1571
  if (outputContainer) {
1572
  // Smooth scroll to the output area
1573
  outputContainer.scrollIntoView({
1574
- behavior: prefersReducedMotion() ? 'auto' : 'smooth',
1575
  block: 'center',
1576
  });
1577
  }
 
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 ===
 
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
  });
 
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;
 
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
  /**
 
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
 
 
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
 
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">
 
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');
 
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) {
 
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>}
 
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
 
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
 
 
475
 
476
  originalInputText = inputText.value;
477
  outputTextContainer.innerHTML = '';
 
 
 
 
 
 
 
 
 
 
 
478
  updateLXToggleStates(); // Disable toggles when output is cleared
479
  updateCopyButtonState();
480
 
 
500
  body: originalInputText,
501
  });
502
 
 
 
 
503
  if (!response.ok) {
504
  const errorText = await response.text();
505
  let errorJson;
 
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
  }
 
559
  data.segments.length > 0
560
  ) {
561
  renderSegments(data.segments);
 
 
 
562
  updateLXToggleStates(); // Enable/update toggles when output is generated
563
  updateCopyButtonState();
564
 
 
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;
 
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 = [
 
684
  } else {
685
  outputTextContainer.textContent = `Error: ${error.message}`;
686
  }
 
 
 
 
 
 
 
687
  instructionsEl.style.display = 'none';
688
  } finally {
689
  if (processingLoadingTimer) {
 
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
  }
 
702
  predictButton.textContent = 'Process';
703
 
704
  updateCacheStatus();
 
 
 
 
 
705
  }
706
  });
707
 
 
837
  }
838
  }
839
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
840
  /**
841
  * Helper function to create section headers.
842
  * @param {string} text - The header text to display
 
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;
 
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,
 
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
  /**
 
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)
 
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
  }
 
1125
 
1126
  inputText.scrollTo({
1127
  top: targetScrollTop,
1128
+ behavior: UI_CONFIG.SCROLL_SMOOTH_BEHAVIOR,
 
 
1129
  });
1130
  } finally {
1131
  // Always cleanup the clone element
 
1142
 
1143
  if (!message || !spinner) return;
1144
 
 
 
 
 
 
 
1145
  gsap.killTweensOf([message, spinner]);
1146
  gsap.set([message, spinner], { clearProps: 'all' });
1147
 
 
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.
 
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) {
 
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.
 
1290
  if (outputContainer) {
1291
  // Smooth scroll to the output area
1292
  outputContainer.scrollIntoView({
1293
+ behavior: 'smooth',
1294
  block: 'center',
1295
  });
1296
  }
static/style.css CHANGED
@@ -1,3 +1,5 @@
 
 
1
  /* === Google Material Palette === */
2
  :root {
3
  --google-blue: #1a73e8;
@@ -576,7 +578,6 @@ body {
576
  width: 100%;
577
  height: clamp(300px, 50vh, 500px);
578
  min-height: 300px;
579
- resize: vertical;
580
  overflow: auto;
581
  border: 2px solid #e9ecef;
582
  border-radius: 8px;
@@ -2277,873 +2278,3 @@ textarea::selection {
2277
 
2278
  /* Copy and clear button overlays always use light mode styling */
2279
  /* Error messages always use light mode styling */
2280
-
2281
- /* === Transformation Studio ==================================== */
2282
- :root {
2283
- --studio-ink: #172033;
2284
- --studio-muted: #637083;
2285
- --studio-surface: rgba(255, 255, 255, 0.94);
2286
- --studio-line: #dfe5ee;
2287
- --studio-blue-soft: #edf4ff;
2288
- --studio-blue: #0b57d0;
2289
- --studio-violet: #6d4aff;
2290
- }
2291
-
2292
- html {
2293
- scroll-behavior: smooth;
2294
- overflow-x: hidden;
2295
- }
2296
-
2297
- body.page-wrapper {
2298
- overflow-x: hidden;
2299
- background:
2300
- radial-gradient(
2301
- circle at 10% -10%,
2302
- rgba(66, 133, 244, 0.16),
2303
- transparent 34rem
2304
- ),
2305
- radial-gradient(
2306
- circle at 94% 8%,
2307
- rgba(109, 74, 255, 0.1),
2308
- transparent 28rem
2309
- ),
2310
- #f6f8fc;
2311
- color: var(--studio-ink);
2312
- }
2313
-
2314
- .studio-hero {
2315
- display: grid;
2316
- grid-template-columns: minmax(0, 1fr) auto;
2317
- align-items: end;
2318
- gap: 20px 36px;
2319
- margin: 18px 0 16px;
2320
- text-align: left;
2321
- }
2322
-
2323
- .hero-eyebrow,
2324
- .section-kicker {
2325
- margin: 0 0 6px;
2326
- color: var(--studio-blue);
2327
- font-family: 'Google Sans', sans-serif;
2328
- font-size: 12px;
2329
- font-weight: 700;
2330
- letter-spacing: 0.11em;
2331
- text-transform: uppercase;
2332
- }
2333
-
2334
- .studio-hero h1 {
2335
- margin: 0;
2336
- color: var(--studio-ink);
2337
- font-size: clamp(2.45rem, 6vw, 4.75rem);
2338
- font-weight: 700;
2339
- letter-spacing: -0.065em;
2340
- line-height: 0.94;
2341
- }
2342
-
2343
- .studio-hero h1 strong {
2344
- background: linear-gradient(120deg, #0b57d0 5%, #6d4aff 82%);
2345
- -webkit-background-clip: text;
2346
- background-clip: text;
2347
- color: transparent;
2348
- }
2349
-
2350
- .hero-description {
2351
- max-width: 690px;
2352
- margin: 12px 0 0;
2353
- color: var(--studio-muted);
2354
- font-family: 'Google Sans', sans-serif;
2355
- font-size: clamp(1rem, 1.5vw, 1.25rem);
2356
- line-height: 1.45;
2357
- }
2358
-
2359
- .hero-attribution {
2360
- display: grid;
2361
- justify-items: end;
2362
- gap: 7px;
2363
- color: var(--studio-muted);
2364
- font-size: 12px;
2365
- font-weight: 500;
2366
- }
2367
-
2368
- .hero-attribution img {
2369
- width: 154px;
2370
- height: auto;
2371
- }
2372
-
2373
- .hero-attribution a {
2374
- display: inline-flex;
2375
- align-items: center;
2376
- gap: 3px;
2377
- color: var(--studio-blue);
2378
- text-decoration: none;
2379
- }
2380
-
2381
- .hero-links {
2382
- display: flex;
2383
- justify-content: flex-end;
2384
- flex-wrap: wrap;
2385
- gap: 5px 13px;
2386
- }
2387
-
2388
- .hero-attribution a:hover {
2389
- text-decoration: underline;
2390
- text-underline-offset: 3px;
2391
- }
2392
-
2393
- .hero-attribution a:focus-visible {
2394
- border-radius: 4px;
2395
- outline: 3px solid rgba(26, 115, 232, 0.35);
2396
- outline-offset: 3px;
2397
- }
2398
-
2399
- .hero-attribution .material-symbols-outlined {
2400
- font-size: 14px;
2401
- }
2402
-
2403
- .safety-strip {
2404
- grid-column: 1 / -1;
2405
- display: flex;
2406
- align-items: center;
2407
- gap: 9px;
2408
- min-height: 40px;
2409
- padding: 8px 13px;
2410
- border: 1px solid #f0d77c;
2411
- border-radius: 10px;
2412
- background: rgba(255, 251, 235, 0.92);
2413
- color: #5f4a10;
2414
- font-size: 13px;
2415
- line-height: 1.4;
2416
- }
2417
-
2418
- .safety-strip .material-symbols-outlined {
2419
- flex: 0 0 auto;
2420
- color: #b06000;
2421
- font-size: 18px;
2422
- }
2423
-
2424
- .samples-container.card {
2425
- margin-bottom: 14px;
2426
- padding: 14px 16px;
2427
- border-color: rgba(66, 133, 244, 0.16);
2428
- background: var(--studio-surface);
2429
- box-shadow: 0 10px 30px rgba(23, 32, 51, 0.06);
2430
- }
2431
-
2432
- .sample-heading-row {
2433
- display: flex;
2434
- align-items: end;
2435
- justify-content: space-between;
2436
- gap: 24px;
2437
- margin-bottom: 10px;
2438
- }
2439
-
2440
- .sample-heading-row h2,
2441
- .studio-heading h2,
2442
- .supporting-heading h2,
2443
- .full-disclaimer h2 {
2444
- margin: 0;
2445
- color: var(--studio-ink);
2446
- font-family: 'Google Sans', sans-serif;
2447
- font-size: 21px;
2448
- letter-spacing: -0.025em;
2449
- }
2450
-
2451
- .sample-heading-row > p {
2452
- margin: 0;
2453
- color: var(--studio-muted);
2454
- font-size: 13px;
2455
- }
2456
-
2457
- .sample-rail-shell {
2458
- position: relative;
2459
- min-width: 0;
2460
- }
2461
-
2462
- .sample-rail-shell::after {
2463
- content: none;
2464
- }
2465
-
2466
- .sample-buttons {
2467
- display: grid;
2468
- grid-template-columns: repeat(5, minmax(0, 1fr));
2469
- gap: 8px;
2470
- margin: 0;
2471
- padding: 2px;
2472
- }
2473
-
2474
- .sample-button {
2475
- min-width: 0;
2476
- padding: 9px 11px;
2477
- border-radius: 10px;
2478
- box-shadow: none;
2479
- }
2480
-
2481
- .sample-title {
2482
- min-height: 2.4em;
2483
- margin-bottom: 4px;
2484
- font-size: 13px;
2485
- line-height: 1.2;
2486
- }
2487
-
2488
- .sample-modality {
2489
- font-size: 9px;
2490
- }
2491
-
2492
- .sample-button:focus-visible,
2493
- .output-view-tab:focus-visible,
2494
- .text-span:focus-visible {
2495
- outline: 3px solid rgba(26, 115, 232, 0.35);
2496
- outline-offset: 2px;
2497
- }
2498
-
2499
- .samples-container .samples-tip,
2500
- .samples-container .mobile-tip {
2501
- margin: 7px 2px 0;
2502
- padding: 0;
2503
- border: 0;
2504
- background: transparent;
2505
- color: var(--studio-muted);
2506
- font-size: 12px;
2507
- }
2508
-
2509
- .transformation-studio.text-area-container {
2510
- display: grid;
2511
- grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
2512
- gap: 14px 18px;
2513
- padding: 18px;
2514
- border: 1px solid rgba(66, 133, 244, 0.2);
2515
- border-radius: 20px;
2516
- background: var(--studio-surface);
2517
- box-shadow: 0 22px 55px rgba(23, 32, 51, 0.1);
2518
- }
2519
-
2520
- .studio-heading {
2521
- grid-column: 1 / -1;
2522
- display: flex;
2523
- align-items: end;
2524
- justify-content: space-between;
2525
- gap: 24px;
2526
- }
2527
-
2528
- .studio-heading p:not(.section-kicker) {
2529
- margin: 4px 0 0;
2530
- color: var(--studio-muted);
2531
- font-size: 13px;
2532
- }
2533
-
2534
- .studio-state {
2535
- display: inline-flex;
2536
- align-items: center;
2537
- flex: 0 0 auto;
2538
- min-height: 29px;
2539
- padding: 5px 10px;
2540
- border: 1px solid #c8ddff;
2541
- border-radius: 999px;
2542
- background: var(--studio-blue-soft);
2543
- color: var(--studio-blue);
2544
- font-size: 12px;
2545
- font-weight: 600;
2546
- }
2547
-
2548
- .studio-state::before {
2549
- width: 7px;
2550
- height: 7px;
2551
- margin-right: 6px;
2552
- border-radius: 50%;
2553
- background: #34a853;
2554
- content: '';
2555
- box-shadow: 0 0 0 3px rgba(52, 168, 83, 0.12);
2556
- }
2557
-
2558
- .studio-metrics {
2559
- grid-column: 1 / -1;
2560
- display: grid;
2561
- grid-template-columns: repeat(4, minmax(0, 1fr));
2562
- gap: 1px;
2563
- overflow: hidden;
2564
- border: 1px solid var(--studio-line);
2565
- border-radius: 12px;
2566
- background: var(--studio-line);
2567
- }
2568
-
2569
- .metric {
2570
- display: flex;
2571
- align-items: baseline;
2572
- gap: 8px;
2573
- min-width: 0;
2574
- padding: 9px 12px;
2575
- background: #fbfcff;
2576
- }
2577
-
2578
- .metric span {
2579
- color: var(--studio-ink);
2580
- font-family: 'Google Sans', sans-serif;
2581
- font-size: 19px;
2582
- font-weight: 700;
2583
- }
2584
-
2585
- .metric small {
2586
- overflow: hidden;
2587
- color: var(--studio-muted);
2588
- font-size: 10px;
2589
- letter-spacing: 0.02em;
2590
- text-overflow: ellipsis;
2591
- white-space: nowrap;
2592
- }
2593
-
2594
- .transformation-studio .text-area-wrapper {
2595
- min-width: 0;
2596
- }
2597
-
2598
- .input-header,
2599
- .output-header {
2600
- display: flex;
2601
- align-items: center;
2602
- justify-content: space-between;
2603
- min-height: 39px;
2604
- margin-bottom: 8px;
2605
- gap: 10px;
2606
- }
2607
-
2608
- .input-header > div:first-child,
2609
- .output-header > div:first-child {
2610
- display: flex;
2611
- align-items: baseline;
2612
- gap: 8px;
2613
- }
2614
-
2615
- .input-header h2,
2616
- .output-header h2 {
2617
- margin: 0;
2618
- padding: 0;
2619
- border: 0;
2620
- color: var(--studio-ink);
2621
- font-family: 'Google Sans', sans-serif;
2622
- font-size: 15px;
2623
- font-weight: 600;
2624
- }
2625
-
2626
- .panel-number {
2627
- color: var(--studio-blue);
2628
- font-size: 10px;
2629
- font-weight: 700;
2630
- letter-spacing: 0.08em;
2631
- }
2632
-
2633
- .transformation-studio .large-text-area,
2634
- .transformation-studio .output-container,
2635
- .transformation-studio #prompt-output {
2636
- height: clamp(390px, 52vh, 560px);
2637
- min-height: 390px;
2638
- border: 1px solid var(--studio-line);
2639
- border-radius: 13px;
2640
- background: #fbfcff;
2641
- }
2642
-
2643
- .transformation-studio .output-container {
2644
- height: auto;
2645
- overflow: visible;
2646
- resize: none;
2647
- }
2648
-
2649
- .transformation-studio .large-text-area {
2650
- padding: 17px;
2651
- color: #374151;
2652
- font-size: 13px;
2653
- line-height: 1.65;
2654
- }
2655
-
2656
- .output-view-tabs {
2657
- display: inline-flex;
2658
- padding: 3px;
2659
- border: 1px solid var(--studio-line);
2660
- border-radius: 9px;
2661
- background: #f3f6fa;
2662
- }
2663
-
2664
- .output-view-tab {
2665
- padding: 5px 8px;
2666
- border: 0;
2667
- border-radius: 6px;
2668
- background: transparent;
2669
- color: var(--studio-muted);
2670
- cursor: pointer;
2671
- font-family: 'Google Sans Text', sans-serif;
2672
- font-size: 11px;
2673
- font-weight: 600;
2674
- }
2675
-
2676
- .output-view-tab.active {
2677
- background: #fff;
2678
- color: var(--studio-blue);
2679
- box-shadow: 0 1px 4px rgba(23, 32, 51, 0.12);
2680
- }
2681
-
2682
- .output-view-tab:disabled {
2683
- color: #a1a9b7;
2684
- cursor: not-allowed;
2685
- opacity: 0.6;
2686
- }
2687
-
2688
- .visually-hidden {
2689
- position: absolute !important;
2690
- width: 1px !important;
2691
- height: 1px !important;
2692
- padding: 0 !important;
2693
- margin: -1px !important;
2694
- overflow: hidden !important;
2695
- clip: rect(0 0 0 0) !important;
2696
- white-space: nowrap !important;
2697
- border: 0 !important;
2698
- }
2699
-
2700
- .findings-cards {
2701
- display: grid;
2702
- gap: 9px;
2703
- min-height: 388px;
2704
- padding: 12px;
2705
- overflow: visible;
2706
- align-content: start;
2707
- }
2708
-
2709
- .cards-placeholder {
2710
- display: grid;
2711
- place-items: center;
2712
- min-height: 100%;
2713
- color: var(--studio-muted);
2714
- text-align: center;
2715
- }
2716
-
2717
- .cards-placeholder .material-symbols-outlined {
2718
- color: var(--studio-violet);
2719
- font-size: 30px;
2720
- }
2721
-
2722
- .finding-card {
2723
- position: relative;
2724
- padding: 11px 12px 12px;
2725
- overflow: hidden;
2726
- border: 1px solid var(--studio-line);
2727
- border-radius: 11px;
2728
- background: #fff;
2729
- box-shadow: 0 2px 7px rgba(23, 32, 51, 0.045);
2730
- }
2731
-
2732
- .finding-card::before {
2733
- position: absolute;
2734
- inset: 0 auto 0 0;
2735
- width: 3px;
2736
- background: #9aa6b6;
2737
- content: '';
2738
- }
2739
-
2740
- .finding-card[data-segment-type='body']::before {
2741
- background: var(--studio-blue);
2742
- }
2743
-
2744
- .finding-card[data-segment-type='suffix']::before {
2745
- background: var(--studio-violet);
2746
- }
2747
-
2748
- .finding-card-header {
2749
- display: flex;
2750
- align-items: center;
2751
- justify-content: space-between;
2752
- gap: 10px;
2753
- margin-bottom: 6px;
2754
- }
2755
-
2756
- .finding-card-label {
2757
- overflow: hidden;
2758
- color: var(--studio-ink);
2759
- font-family: 'Google Sans', sans-serif;
2760
- font-size: 11px;
2761
- font-weight: 700;
2762
- letter-spacing: 0.035em;
2763
- text-overflow: ellipsis;
2764
- text-transform: uppercase;
2765
- white-space: nowrap;
2766
- }
2767
-
2768
- .finding-card-badges {
2769
- display: flex;
2770
- flex: 0 0 auto;
2771
- gap: 5px;
2772
- }
2773
-
2774
- .finding-badge {
2775
- padding: 3px 6px;
2776
- border-radius: 999px;
2777
- background: #f1f3f4;
2778
- color: var(--studio-muted);
2779
- font-size: 9px;
2780
- font-weight: 700;
2781
- letter-spacing: 0.025em;
2782
- text-transform: capitalize;
2783
- }
2784
-
2785
- .finding-badge.is-grounded {
2786
- background: #e8f0fe;
2787
- color: #174ea6;
2788
- }
2789
-
2790
- .finding-badge.is-ungrounded {
2791
- background: #f3f4f6;
2792
- color: #6b7280;
2793
- }
2794
-
2795
- .finding-badge.significance-significant {
2796
- background: #fce8e6;
2797
- color: #a50e0e;
2798
- }
2799
-
2800
- .finding-badge.significance-minor {
2801
- background: #fef7e0;
2802
- color: #8a5b00;
2803
- }
2804
-
2805
- .finding-card-content {
2806
- margin: 0;
2807
- color: #3b4658;
2808
- font-size: 12px;
2809
- line-height: 1.52;
2810
- }
2811
-
2812
- .finding-card .text-span {
2813
- display: inline;
2814
- padding: 2px;
2815
- border-radius: 4px;
2816
- }
2817
-
2818
- .source-excerpt {
2819
- margin-top: 9px;
2820
- padding: 9px 10px;
2821
- border-left: 3px solid var(--studio-blue);
2822
- border-radius: 0 7px 7px 0;
2823
- background: var(--studio-blue-soft);
2824
- color: #29466f;
2825
- font-size: 11px;
2826
- line-height: 1.5;
2827
- }
2828
-
2829
- .source-excerpt[hidden] {
2830
- display: none !important;
2831
- }
2832
-
2833
- .source-excerpt::before {
2834
- display: block;
2835
- margin-bottom: 3px;
2836
- color: var(--studio-blue);
2837
- content: 'Exact source';
2838
- font-size: 9px;
2839
- font-weight: 700;
2840
- letter-spacing: 0.08em;
2841
- text-transform: uppercase;
2842
- }
2843
-
2844
- .transformation-studio .panel-controls {
2845
- grid-column: 1 / -1;
2846
- margin: 0;
2847
- padding: 10px 12px;
2848
- border: 1px solid var(--studio-line);
2849
- border-radius: 11px;
2850
- background: #f8faff;
2851
- }
2852
-
2853
- .action-bar {
2854
- margin: 14px 0 0;
2855
- }
2856
-
2857
- .action-bar #predict-button {
2858
- min-width: 150px;
2859
- border-radius: 999px;
2860
- }
2861
-
2862
- .instructions:empty {
2863
- display: none;
2864
- }
2865
-
2866
- .supporting-details {
2867
- margin-top: 54px;
2868
- }
2869
-
2870
- .supporting-heading {
2871
- margin-bottom: 15px;
2872
- }
2873
-
2874
- .supporting-grid {
2875
- display: grid;
2876
- grid-template-columns: repeat(3, minmax(0, 1fr));
2877
- gap: 14px;
2878
- }
2879
-
2880
- .support-card {
2881
- padding: 20px;
2882
- border: 1px solid var(--studio-line);
2883
- border-radius: 15px;
2884
- background: rgba(255, 255, 255, 0.78);
2885
- }
2886
-
2887
- .support-icon {
2888
- color: var(--studio-blue);
2889
- }
2890
-
2891
- .support-card h3 {
2892
- margin: 9px 0 7px;
2893
- font-family: 'Google Sans', sans-serif;
2894
- font-size: 15px;
2895
- }
2896
-
2897
- .support-card p,
2898
- .full-disclaimer p {
2899
- margin: 0;
2900
- color: var(--studio-muted);
2901
- font-size: 13px;
2902
- line-height: 1.6;
2903
- }
2904
-
2905
- .interface-options-panel {
2906
- margin-top: 14px;
2907
- }
2908
-
2909
- .full-disclaimer {
2910
- display: grid;
2911
- grid-template-columns: auto 1fr;
2912
- gap: 15px;
2913
- margin-top: 14px;
2914
- background: #fffdf6;
2915
- }
2916
-
2917
- .full-disclaimer h2 {
2918
- margin-bottom: 8px;
2919
- font-size: 17px;
2920
- }
2921
-
2922
- .full-disclaimer p + p {
2923
- margin-top: 7px;
2924
- }
2925
-
2926
- @media (max-width: 768px) {
2927
- .studio-hero {
2928
- grid-template-columns: 1fr;
2929
- gap: 14px;
2930
- margin-top: 8px;
2931
- }
2932
-
2933
- .studio-hero h1 {
2934
- margin-top: 0;
2935
- }
2936
-
2937
- .hero-attribution {
2938
- display: flex;
2939
- align-items: center;
2940
- justify-content: flex-start;
2941
- flex-wrap: wrap;
2942
- gap: 8px 13px;
2943
- }
2944
-
2945
- .hero-attribution img {
2946
- width: 130px;
2947
- }
2948
-
2949
- .hero-links {
2950
- width: 100%;
2951
- justify-content: flex-start;
2952
- }
2953
-
2954
- .safety-strip {
2955
- align-items: flex-start;
2956
- }
2957
-
2958
- .sample-heading-row {
2959
- align-items: start;
2960
- flex-direction: column;
2961
- gap: 4px;
2962
- }
2963
-
2964
- .sample-heading-row > p {
2965
- display: none;
2966
- }
2967
-
2968
- .sample-rail-shell::after {
2969
- position: absolute;
2970
- top: 0;
2971
- right: 0;
2972
- bottom: 7px;
2973
- width: 34px;
2974
- background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.96));
2975
- content: '';
2976
- pointer-events: none;
2977
- }
2978
-
2979
- .sample-buttons {
2980
- display: flex;
2981
- justify-content: flex-start;
2982
- padding: 2px 28px 7px 2px;
2983
- overflow-x: auto;
2984
- overscroll-behavior-inline: contain;
2985
- scrollbar-color: #c5d0e1 transparent;
2986
- scrollbar-width: thin;
2987
- }
2988
-
2989
- .sample-button {
2990
- flex: 0 0 auto;
2991
- min-width: 136px;
2992
- }
2993
-
2994
- .sample-title {
2995
- min-height: 0;
2996
- overflow: hidden;
2997
- text-overflow: ellipsis;
2998
- white-space: nowrap;
2999
- }
3000
-
3001
- .transformation-studio.text-area-container {
3002
- display: flex !important;
3003
- flex-direction: column;
3004
- padding: 12px;
3005
- border-radius: 15px;
3006
- }
3007
-
3008
- .studio-heading {
3009
- align-items: flex-start;
3010
- flex-direction: column;
3011
- gap: 8px;
3012
- }
3013
-
3014
- .studio-metrics {
3015
- grid-template-columns: repeat(2, minmax(0, 1fr));
3016
- }
3017
-
3018
- .metric {
3019
- padding: 8px 9px;
3020
- }
3021
-
3022
- .output-wrapper {
3023
- order: 1;
3024
- }
3025
-
3026
- .input-wrapper {
3027
- order: 2;
3028
- }
3029
-
3030
- .transformation-studio .panel-controls {
3031
- order: 3;
3032
- }
3033
-
3034
- .transformation-studio .output-container {
3035
- height: auto;
3036
- min-height: 0;
3037
- max-height: none;
3038
- overflow: visible;
3039
- }
3040
-
3041
- .findings-cards {
3042
- height: auto;
3043
- max-height: none;
3044
- overflow: visible;
3045
- }
3046
-
3047
- .transformation-studio .large-text-area,
3048
- .transformation-studio #prompt-output {
3049
- height: 320px;
3050
- min-height: 280px;
3051
- }
3052
-
3053
- .transformation-studio #output-text {
3054
- height: auto;
3055
- min-height: 0;
3056
- overflow: visible;
3057
- resize: none;
3058
- }
3059
-
3060
- .output-header {
3061
- align-items: stretch !important;
3062
- flex-direction: column !important;
3063
- }
3064
-
3065
- .output-view-tabs {
3066
- align-self: stretch;
3067
- }
3068
-
3069
- .output-view-tab {
3070
- flex: 1;
3071
- }
3072
-
3073
- .clinical-significance-legend {
3074
- display: none;
3075
- }
3076
-
3077
- .action-bar {
3078
- margin-top: 12px;
3079
- }
3080
-
3081
- .supporting-grid {
3082
- grid-template-columns: 1fr;
3083
- }
3084
-
3085
- .supporting-details {
3086
- margin-top: 38px;
3087
- }
3088
- }
3089
-
3090
- @media (max-width: 430px) {
3091
- .page-wrapper {
3092
- padding: 10px;
3093
- }
3094
-
3095
- .studio-hero h1 {
3096
- font-size: 2.65rem;
3097
- }
3098
-
3099
- .hero-description {
3100
- font-size: 15px;
3101
- }
3102
-
3103
- .hero-attribution > span:first-child {
3104
- width: 100%;
3105
- }
3106
-
3107
- .safety-strip {
3108
- font-size: 11px;
3109
- }
3110
-
3111
- .samples-container.card,
3112
- .transformation-studio.text-area-container {
3113
- padding: 11px;
3114
- }
3115
-
3116
- .sample-button {
3117
- min-width: 126px;
3118
- }
3119
-
3120
- .studio-heading h2,
3121
- .sample-heading-row h2 {
3122
- font-size: 18px;
3123
- }
3124
-
3125
- .finding-card-header {
3126
- align-items: flex-start;
3127
- flex-direction: column;
3128
- gap: 5px;
3129
- }
3130
-
3131
- .finding-card-badges {
3132
- flex-wrap: wrap;
3133
- }
3134
-
3135
- .full-disclaimer {
3136
- grid-template-columns: 1fr;
3137
- }
3138
- }
3139
-
3140
- @media (prefers-reduced-motion: reduce) {
3141
- *,
3142
- *::before,
3143
- *::after {
3144
- scroll-behavior: auto !important;
3145
- transition-duration: 0.01ms !important;
3146
- animation-duration: 0.01ms !important;
3147
- animation-iteration-count: 1 !important;
3148
- }
3149
- }
 
1
+
2
+
3
  /* === Google Material Palette === */
4
  :root {
5
  --google-blue: #1a73e8;
 
578
  width: 100%;
579
  height: clamp(300px, 50vh, 500px);
580
  min-height: 300px;
 
581
  overflow: auto;
582
  border: 2px solid #e9ecef;
583
  border-radius: 8px;
 
2278
 
2279
  /* Copy and clear button overlays always use light mode styling */
2280
  /* Error messages always use light mode styling */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
structure_report.py CHANGED
@@ -15,7 +15,7 @@ Example usage:
15
 
16
  structurer = RadiologyReportStructurer(
17
  api_key="your_api_key",
18
- model_id="gemini-flash-latest"
19
  )
20
  result = structurer.predict("FINDINGS: Normal chest CT...")
21
  """
@@ -24,6 +24,7 @@ import collections
24
  import dataclasses
25
  import itertools
26
  from enum import Enum
 
27
  from typing import Any, TypedDict
28
 
29
  import langextract as lx
@@ -34,22 +35,6 @@ import prompt_lib
34
  import report_examples
35
 
36
 
37
- DEFAULT_MODEL_ID = "gemini-flash-latest"
38
- LEGACY_MODEL_ALIASES = {
39
- "gemini-2.5-flash": DEFAULT_MODEL_ID,
40
- "gemini-2.5-pro": "gemini-pro-latest",
41
- }
42
-
43
-
44
- def resolve_model_id(model_id: str) -> str:
45
- """Resolves retired Gemini model IDs to their rolling aliases."""
46
- return LEGACY_MODEL_ALIASES.get(model_id, model_id)
47
-
48
-
49
- class ReportProcessingError(RuntimeError):
50
- """Raised when the language-model extraction step cannot complete."""
51
-
52
-
53
  class FrontendIntervalDict(TypedDict):
54
  """Character interval for frontend with startPos and endPos."""
55
 
@@ -104,6 +89,29 @@ SIGNIFICANCE_SIGNIFICANT = "significant"
104
  SIGNIFICANCE_NOT_APPLICABLE = "not_applicable"
105
 
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  class ReportSectionType(Enum):
108
  """Enum representing sections of a radiology report with their extraction class names."""
109
 
@@ -162,11 +170,12 @@ class RadiologyReportStructurer:
162
  model_id: str
163
  temperature: float
164
  examples: list[langextract.data.ExampleData]
 
165
 
166
  def __init__(
167
  self,
168
  api_key: str | None = None,
169
- model_id: str = DEFAULT_MODEL_ID,
170
  temperature: float = 0.0,
171
  ):
172
  """Initializes the RadiologyReportStructurer.
@@ -177,9 +186,16 @@ class RadiologyReportStructurer:
177
  temperature: Sampling temperature for model generation.
178
  """
179
  self.api_key = api_key
180
- self.model_id = resolve_model_id(model_id)
181
  self.temperature = temperature
182
  self.examples = report_examples.get_examples_for_model()
 
 
 
 
 
 
 
183
 
184
  def _generate_formatted_prompt_with_examples(
185
  self, input_text: str | None = None
@@ -219,9 +235,14 @@ class RadiologyReportStructurer:
219
 
220
  try:
221
  result = self._perform_langextract(report_text, max_char_buffer)
222
- except Exception as error:
223
- raise ReportProcessingError("Report processing failed") from error
224
- return self._build_response(result, report_text)
 
 
 
 
 
225
 
226
  def _perform_langextract(
227
  self, report_text: str, max_char_buffer: int
@@ -239,6 +260,7 @@ class RadiologyReportStructurer:
239
  ValueError: If LangExtract processing fails.
240
  TypeError: If invalid parameters are provided.
241
  """
 
242
  return lx.extract(
243
  text_or_documents=report_text,
244
  prompt_description=prompt_instruction.PROMPT_INSTRUCTION.split(
@@ -249,10 +271,8 @@ class RadiologyReportStructurer:
249
  api_key=self.api_key,
250
  max_char_buffer=max_char_buffer,
251
  temperature=self.temperature,
252
- resolver_params={
253
- "accept_match_lesser": False,
254
- "fuzzy_alignment_threshold": 0.50,
255
- },
256
  )
257
 
258
  def _build_response(
 
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
  """
 
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
 
35
  import report_examples
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  class FrontendIntervalDict(TypedDict):
39
  """Character interval for frontend with startPos and endPos."""
40
 
 
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
 
 
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.
 
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
 
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
 
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(
 
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(
templates/index.html CHANGED
@@ -19,14 +19,8 @@
19
  content="{{ share_url_for_sharing }}/static/radextract-preview.jpg"
20
  />
21
  <meta property="og:type" content="website" />
22
- <meta
23
- property="og:video"
24
- content="{{ share_url_for_sharing }}/static/radextract-preview.mp4"
25
- />
26
- <meta
27
- property="og:video:secure_url"
28
- content="{{ share_url_for_sharing }}/static/radextract-preview.mp4"
29
- />
30
  <meta property="og:video:type" content="video/mp4" />
31
  <meta property="og:video:width" content="1920" />
32
  <meta property="og:video:height" content="1080" />
@@ -44,23 +38,17 @@
44
  name="twitter:image"
45
  content="{{ share_url_for_sharing }}/static/radextract-preview.jpg"
46
  />
47
- <meta
48
- name="twitter:player"
49
- content="{{ share_url_for_sharing }}/static/radextract-preview.mp4"
50
- />
51
  <meta name="twitter:player:width" content="1920" />
52
  <meta name="twitter:player:height" content="1080" />
53
- <meta
54
- name="twitter:player:stream"
55
- content="{{ share_url_for_sharing }}/static/radextract-preview.mp4"
56
- />
57
  <meta name="twitter:player:stream:content_type" content="video/mp4" />
58
 
59
  <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
60
  <link rel="shortcut icon" href="/static/favicon.svg" />
61
  <link rel="apple-touch-icon" href="/static/favicon.svg" />
62
 
63
- <link rel="stylesheet" href="/static/style.css?v=20260721-studio" />
64
  <link rel="preconnect" href="https://fonts.googleapis.com" />
65
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
66
  <link
@@ -78,112 +66,292 @@
78
  </head>
79
 
80
  <body class="page-wrapper">
81
- <header class="header-container studio-hero">
82
- <div class="hero-copy">
83
- <p class="hero-eyebrow">Grounded radiology extraction</p>
84
- <h1><strong>RadExtract</strong></h1>
85
- <p class="hero-description">
86
- Turn a free-text radiology report into structured findings, with every
87
- extraction linked to its exact source words.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  </p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  </div>
90
- <div class="hero-attribution" aria-label="Technology attribution">
91
- <span>Powered by LangExtract + Gemini Flash</span>
92
- <img
93
- src="/static/google-research-logo.svg"
94
- alt="Google Research"
95
- width="174"
96
- height="25"
97
- />
98
- <div class="hero-links">
99
  <a
 
100
  href="https://github.com/google/langextract"
101
  target="_blank"
102
  rel="noopener noreferrer"
103
- >LangExtract on GitHub
104
- <span class="material-symbols-outlined" aria-hidden="true"
105
- >open_in_new</span
106
- ></a
107
  >
 
 
 
 
 
 
 
 
 
 
 
108
  <a
109
- href="https://developers.googleblog.com/en/introducing-langextract-a-gemini-powered-information-extraction-library"
 
110
  target="_blank"
111
  rel="noopener noreferrer"
112
- >LangExtract release blog
113
- <span class="material-symbols-outlined" aria-hidden="true"
114
- >open_in_new</span
115
- ></a
116
  >
117
- </div>
118
- </div>
119
- <div class="safety-strip" role="note">
120
- <span class="material-symbols-outlined" aria-hidden="true"
121
- >warning</span
122
- >
123
- <span
124
- ><strong>Research demonstration — not for clinical use.</strong>
125
- RadExtract does not diagnose, recommend treatment, or provide medical
126
- advice.</span
127
- >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  </div>
129
- </header>
130
 
131
- <section class="samples-container card" aria-labelledby="sample-heading">
132
- <div class="sample-heading-row">
133
- <div>
134
- <p class="section-kicker">Start with a study</p>
135
- <h2 id="sample-heading">Explore sample reports</h2>
 
 
 
 
 
 
 
 
 
136
  </div>
137
- <p>Choose a study. Built-in examples can use saved or live results.</p>
138
- </div>
139
- <div class="sample-rail-shell">
140
- <div class="sample-buttons" aria-label="Sample radiology reports"></div>
141
  </div>
 
142
  <p class="samples-tip tip-desktop">
143
- Select a finding to trace it to the source. Edit the report and choose
144
- <strong>Process</strong> to run a live extraction.
145
  </p>
146
  <p class="mobile-tip" role="note">
147
- Tap a finding to reveal its grounded source excerpt. Custom report input
148
- remains available on desktop and laptop computers.
 
 
 
149
  </p>
150
- </section>
151
 
152
- <main
153
- id="transformation-studio"
154
- class="text-area-container card transformation-studio"
155
- aria-labelledby="studio-heading"
156
- >
157
- <div class="studio-heading">
158
- <div>
159
- <p class="section-kicker">Source → structure</p>
160
- <h2 id="studio-heading">Transformation Studio</h2>
161
- <p>Focus a structured item to illuminate its exact source span.</p>
162
- </div>
163
- <div class="studio-state" id="studio-state" aria-live="polite">
164
- Choose a sample or enter a report
165
  </div>
166
  </div>
167
- <div id="studio-metrics" class="studio-metrics" aria-live="polite">
168
- <div class="metric">
169
- <span id="metric-items">—</span><small>Extracted items</small>
170
- </div>
171
- <div class="metric">
172
- <span id="metric-grounded">—</span><small>Source-grounded</small>
173
- </div>
174
- <div class="metric">
175
- <span id="metric-significant">—</span><small>Significant</small>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  </div>
177
- <div class="metric">
178
- <span id="metric-minor"></span><small>Minor</small>
 
 
 
 
179
  </div>
180
  </div>
 
 
 
181
  <div class="text-area-wrapper input-wrapper">
182
  <div class="input-header">
183
- <div>
184
- <span class="panel-number">01</span>
185
- <h2>Source report</h2>
186
- </div>
187
  <label class="prompt-toggle"
188
  ><input type="checkbox" id="prompt-toggle" /> LX Prompt</label
189
  >
@@ -219,84 +387,22 @@
219
  </div>
220
  <div id="prompt-output" class="raw-json" style="display: none"></div>
221
  </div>
222
- <div class="text-area-wrapper output-wrapper" id="output-container">
223
  <div class="output-header">
224
- <div>
225
- <span class="panel-number">02</span>
226
- <h2>Structured extraction</h2>
227
- </div>
228
- <div class="output-view-tabs" role="tablist" aria-label="Output view">
229
- <button
230
- class="output-view-tab"
231
- id="report-tab"
232
- data-output-view="report"
233
- role="tab"
234
- aria-selected="false"
235
- aria-controls="output-text"
236
- aria-disabled="true"
237
- disabled
238
- >
239
- Report
240
- </button>
241
- <button
242
- class="output-view-tab active"
243
- id="findings-tab"
244
- data-output-view="cards"
245
- role="tab"
246
- aria-selected="true"
247
- aria-controls="findings-cards"
248
- >
249
- Findings
250
- </button>
251
- <button
252
- class="output-view-tab"
253
- id="raw-tab"
254
- data-output-view="raw"
255
- role="tab"
256
- aria-selected="false"
257
- aria-controls="raw-output"
258
- aria-disabled="true"
259
- disabled
260
- >
261
- LX Data
262
- </button>
263
- </div>
264
- <label class="raw-toggle visually-hidden" aria-hidden="true"
265
- ><input type="checkbox" id="raw-toggle" tabindex="-1" /> LX
266
- Data</label
267
  >
268
  </div>
269
  <div id="output-text-container" class="output-container">
270
- <div
271
- id="findings-cards"
272
- class="findings-cards"
273
- role="tabpanel"
274
- aria-labelledby="findings-tab"
275
- aria-hidden="false"
276
- aria-live="polite"
277
- >
278
- <div class="cards-placeholder">
279
- <span class="material-symbols-outlined" aria-hidden="true"
280
- >auto_awesome</span
281
- >
282
- <p>Select a sample to begin exploring grounded findings.</p>
283
- </div>
284
- </div>
285
  <pre
286
  id="output-text"
287
  class="large-text-area output-text"
288
- role="tabpanel"
289
- aria-labelledby="report-tab"
290
- aria-hidden="true"
291
- style="display: none"
292
  placeholder="Structured output will appear here..."
293
  ></pre>
294
  <div
295
  id="raw-output"
296
  class="raw-json output-text"
297
- role="tabpanel"
298
- aria-labelledby="raw-tab"
299
- aria-hidden="true"
300
  style="display: none"
301
  ></div>
302
  <button
@@ -326,7 +432,7 @@
326
  <div class="spinner"></div>
327
  <div class="loader-text">
328
  <span class="loader-message"
329
- >Running LangExtract with Gemini Flash (latest)</span
330
  >
331
  </div>
332
  </div>
@@ -337,9 +443,8 @@
337
  <div class="model-select-container">
338
  <label for="model-select">Model:</label>
339
  <select id="model-select">
340
- <option value="gemini-flash-latest" selected>
341
- Gemini Flash (latest)
342
- </option>
343
  </select>
344
  </div>
345
 
@@ -358,7 +463,7 @@
358
  <input type="checkbox" id="raw-toggle-mobile" /> LX Data
359
  </label>
360
  </div>
361
- </main>
362
 
363
  <div class="action-bar">
364
  <button id="predict-button">Process</button>
@@ -379,179 +484,14 @@
379
 
380
  <div class="instructions"></div>
381
 
382
- <section
383
- id="how-it-works"
384
- class="supporting-details"
385
- aria-labelledby="details-heading"
386
- >
387
- <div class="supporting-heading">
388
- <p class="section-kicker">Go deeper</p>
389
- <h2 id="details-heading">How RadExtract works</h2>
390
- </div>
391
- <div class="supporting-grid">
392
- <article class="support-card">
393
- <span
394
- class="material-symbols-outlined support-icon"
395
- aria-hidden="true"
396
- >link</span
397
- >
398
- <h3>Grounded by LangExtract</h3>
399
- <p>
400
- <a
401
- class="banner-link"
402
- href="https://github.com/google/langextract"
403
- target="_blank"
404
- rel="noopener noreferrer"
405
- >LangExtract</a
406
- >
407
- converts free text into schema-controlled data learned from a
408
- task-specific
409
- <a
410
- class="banner-link"
411
- href="https://huggingface.co/spaces/google/radextract/blob/main/prompt_instruction.py"
412
- target="_blank"
413
- rel="noopener noreferrer"
414
- >prompt</a
415
- >
416
- and
417
- <a
418
- class="banner-link"
419
- href="https://huggingface.co/spaces/google/radextract/blob/main/report_examples.py"
420
- target="_blank"
421
- rel="noopener noreferrer"
422
- >few-shot examples</a
423
- >. Each extracted item retains a direct link to its source words.
424
- </p>
425
- </article>
426
- <article class="support-card">
427
- <span
428
- class="material-symbols-outlined support-icon"
429
- aria-hidden="true"
430
- >view_in_ar</span
431
- >
432
- <h3>Explore every layer</h3>
433
- <p>
434
- Switch between scan-friendly findings, the complete formatted
435
- report, raw LangExtract data, and the generated prompt. General and
436
- significant findings use distinct visual markers without implying a
437
- diagnostic confidence score.
438
- </p>
439
- </article>
440
- <article class="support-card">
441
- <span
442
- class="material-symbols-outlined support-icon"
443
- aria-hidden="true"
444
- >clinical_notes</span
445
- >
446
- <h3>Clinical background</h3>
447
- <p>
448
- Structured reporting can improve completeness, reduce ambiguity, and
449
- facilitate data sharing. Read the
450
- <a
451
- class="banner-link"
452
- href="https://link.springer.com/article/10.1007/s13244-017-0588-8"
453
- target="_blank"
454
- rel="noopener noreferrer"
455
- >European Society of Radiology background paper</a
456
- >.
457
- </p>
458
- </article>
459
- </div>
460
- </section>
461
-
462
- <div class="interface-options-panel card">
463
- <div class="interface-options-header" data-action="toggle-interface">
464
- <h4 class="interface-options-title">Under the hood</h4>
465
- <div class="interface-options-summary">
466
- <span
467
- >LX Generated Prompt • LX Structured Output • Cache controls</span
468
- >
469
- <span
470
- class="material-symbols-outlined expand-icon"
471
- id="interface-expand-icon"
472
- >expand_more</span
473
- >
474
- </div>
475
- </div>
476
- <div
477
- class="interface-options-content"
478
- id="interface-options-content"
479
- style="display: none"
480
- >
481
- <div class="interface-options-grid">
482
- <div class="interface-option">
483
- <div class="option-header">
484
- <span class="material-symbols-outlined option-icon"
485
- >visibility</span
486
- >
487
- <strong>LX Generated Prompt</strong>
488
- </div>
489
- <p class="option-description">
490
- Inspect the task description, examples, and input sent to the
491
- model.
492
- </p>
493
- </div>
494
- <div class="interface-option">
495
- <div class="option-header">
496
- <span class="material-symbols-outlined option-icon">code</span>
497
- <strong>LX Structured Output</strong>
498
- </div>
499
- <p class="option-description">
500
- Inspect the raw extraction data and its grounding intervals.
501
- </p>
502
- </div>
503
- <div class="interface-option">
504
- <div class="option-header">
505
- <span class="material-symbols-outlined option-icon">cached</span>
506
- <strong>Use Cache</strong>
507
- </div>
508
- <p class="option-description">
509
- Built-in samples use pre-generated results; edited reports use
510
- live inference.
511
- </p>
512
- </div>
513
- </div>
514
- </div>
515
- </div>
516
-
517
- <section class="full-disclaimer card" aria-labelledby="safety-heading">
518
- <span class="material-symbols-outlined disclaimer-icon" aria-hidden="true"
519
- >health_and_safety</span
520
- >
521
- <div>
522
- <h2 id="safety-heading">Safety, license, and citation</h2>
523
- <p>
524
- This demonstration is for illustrative purposes only to show the
525
- baseline capabilities of LangExtract. It does not represent a finished
526
- or approved product, is not intended to diagnose or suggest treatment
527
- for any disease or condition, and should not be used for medical
528
- advice.
529
- </p>
530
- <p>
531
- If you use RadExtract or LangExtract in production or publications,
532
- please cite accordingly and acknowledge usage. Use is subject to the
533
- Apache 2.0 License. See the
534
- <a
535
- class="banner-link"
536
- href="https://huggingface.co/spaces/google/radextract/blob/main/README.md#disclaimer"
537
- target="_blank"
538
- rel="noopener noreferrer"
539
- >README for details</a
540
- >.
541
- </p>
542
- </div>
543
- </section>
544
-
545
  <script src="https://cdn.jsdelivr.net/npm/json-formatter-js@2.3.4/dist/json-formatter.umd.js"></script>
546
  <script src="https://cdn.jsdelivr.net/npm/marked@9.1.6/marked.min.js"></script>
547
- <script
548
- src="https://cdn.jsdelivr.net/npm/dompurify@3.4.12/dist/purify.min.js"
549
- integrity="sha384-piCcpDdJ7qVeK4Tv8Z6Hpcr3ZBIgP16TxQTPVfsLFdZ5uDgwc3Y8Ho7oUnqf12qu"
550
- crossorigin="anonymous"
551
- ></script>
552
 
553
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
554
- <script type="module" src="/static/script.js?v=20260721-studio"></script>
 
 
 
555
 
556
  <!-- Bottom share placement (minimal space above footer) -->
557
  <div class="share-bottom">
 
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" />
 
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
 
66
  </head>
67
 
68
  <body class="page-wrapper">
69
+ <div class="header-container">
70
+ <h1>
71
+ <strong>RadExtract</strong>:
72
+ <span class="brand-split">Radiology Report Structuring&nbsp;Demo</span>
73
+ </h1>
74
+
75
+ <!-- Top share buttons -->
76
+ <div class="share-top">
77
+ <span>Share →</span>
78
+ <a
79
+ class="shr-btn shr-x"
80
+ href="https://twitter.com/intent/tweet?text={{ share_text }}"
81
+ target="_blank"
82
+ rel="noopener"
83
+ aria-label="Share on X"
84
+ >
85
+ <svg viewBox="0 0 24 24" width="20" aria-hidden="true">
86
+ <path
87
+ fill="currentColor"
88
+ d="M23 2.999a9.05 9.05 0 0 1-2.588.71A4.516 4.516 0 0 0 22.36.365a9.04 9.04 0 0 1-2.867 1.096 4.505 4.505 0 0 0-7.67 4.107A12.79 12.79 0 0 1 1.64.896a4.505 4.505 0 0 0 1.396 6.01 4.47 4.47 0 0 1-2.04-.563v.057a4.507 4.507 0 0 0 3.614 4.417 4.522 4.522 0 0 1-2.034.077 4.508 4.508 0 0 0 4.207 3.128A9.03 9.03 0 0 1 0 19.54a12.75 12.75 0 0 0 6.92 2.026c8.304 0 12.846-6.877 12.846-12.837 0-.196-.004-.392-.013-.586A9.17 9.17 0 0 0 23 2.999z"
89
+ />
90
+ </svg>
91
+ </a>
92
+ <a
93
+ class="shr-btn shr-li"
94
+ href="https://www.linkedin.com/shareArticle?mini=true&url={{ share_url_encoded }}&title={{ linkedin_title }}&summary={{ linkedin_summary }}&source=RadExtract"
95
+ target="_blank"
96
+ rel="noopener"
97
+ aria-label="Share on LinkedIn"
98
+ >
99
+ <svg viewBox="0 0 24 24" width="20" aria-hidden="true">
100
+ <path
101
+ fill="currentColor"
102
+ d="M4.98 3.5C4.98 5.43 3.43 7 1.5 7S-1.98 5.43-1.98 3.5 0.57 0 2.5 0 4.98 1.57 4.98 3.5zM.02 8h5V24h-5V8zM7.98 8h4.8v2.2h.07c.67-1.27 2.31-2.6 4.76-2.6 5.09 0 6.04 3.35 6.04 7.7V24h-5v-7.7c0-1.84-.03-4.21-2.57-4.21-2.57 0-2.96 1.99-2.96 4.07V24h-5V8z"
103
+ />
104
+ </svg>
105
+ </a>
106
+ </div>
107
+
108
+ <!-- Attribution block: subtitle + logo -->
109
+ <div class="attribution">
110
+ <p class="sub-header">
111
+ <strong>Powered by LangExtract + Gemini 2.5</strong>
112
  </p>
113
+
114
+ <!-- Google Research logo -->
115
+ <div class="google-research-logo">
116
+ <img
117
+ src="/static/google-research-logo.svg"
118
+ alt="Google Research"
119
+ width="174"
120
+ height="25"
121
+ loading="lazy"
122
+ tabindex="-1"
123
+ />
124
+ </div>
125
+
126
+ <!-- Blog link -->
127
+ <div class="blog-link-container">
128
+ <a
129
+ class="blog-link"
130
+ href="https://developers.googleblog.com/en/introducing-langextract-a-gemini-powered-information-extraction-library"
131
+ target="_blank"
132
+ rel="noopener noreferrer"
133
+ aria-label="Read about LangExtract on Google for Developers"
134
+ >
135
+ <span class="material-symbols-outlined blog-icon">article</span>
136
+ See the LangExtract Blog Post in Google for Developers
137
+ <span class="material-symbols-outlined external-icon">open_in_new</span>
138
+ </a>
139
+ </div>
140
+ </div>
141
+
142
+ <div class="disclaimer-container">
143
+ <div class="disclaimer-box">
144
+ <span class="material-symbols-outlined disclaimer-icon">warning</span>
145
+ <span class="disclaimer-text"
146
+ >This demonstration is for illustrative purposes only to show the
147
+ baseline capabilities of LangExtract, the library that powers this
148
+ demo. It does not represent a finished or approved product, is not
149
+ intended to diagnose or suggest treatment for any disease or
150
+ condition, and should not be used for medical advice.</span
151
+ >
152
+ </div>
153
+ <div class="citation-note">
154
+ <strong>License & Citation:</strong> If you use
155
+ RadExtract or LangExtract in production or
156
+ publications, please cite accordingly and acknowledge usage. Use is
157
+ subject to the Apache 2.0 License. See
158
+ <a
159
+ class="banner-link"
160
+ href="https://huggingface.co/spaces/google/radextract/blob/main/README.md#disclaimer"
161
+ target="_blank"
162
+ rel="noopener noreferrer"
163
+ >README</a
164
+ >&nbsp;for&nbsp;details.
165
+ </div>
166
  </div>
167
+ <div class="banner card">
168
+ <p class="banner-description">
 
 
 
 
 
 
 
169
  <a
170
+ class="banner-link"
171
  href="https://github.com/google/langextract"
172
  target="_blank"
173
  rel="noopener noreferrer"
174
+ ><strong>LangExtract (LX)</strong></a
 
 
 
175
  >
176
+ is a multi-purpose NLP extraction library that uses large language
177
+ models such as Gemini to convert free-text into schema-controlled
178
+ data. It learns from your few-shot examples (structured using
179
+ <strong>LX</strong>'s extraction schema) to identify and extract
180
+ information, with every datum linked back to its exact words in the
181
+ source.
182
+ </p>
183
+ <hr class="banner-divider" />
184
+ <h3 class="banner-section-title">Demo Overview</h3>
185
+ <p class="banner-description">
186
+ <strong>RadExtract</strong> uses
187
  <a
188
+ class="banner-link"
189
+ href="https://github.com/google/langextract"
190
  target="_blank"
191
  rel="noopener noreferrer"
192
+ ><strong>LangExtract (LX)</strong></a
 
 
 
193
  >
194
+ powered by
195
+ <a
196
+ id="model-link"
197
+ class="banner-link"
198
+ href="https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-5-flash"
199
+ target="_blank"
200
+ rel="noopener noreferrer"
201
+ ><span id="model-name">Gemini 2.5 Flash</span></a
202
+ >
203
+ or
204
+ <a
205
+ class="banner-link"
206
+ href="https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini/2-5-pro"
207
+ target="_blank"
208
+ rel="noopener noreferrer"
209
+ >Gemini 2.5 Pro</a
210
+ >
211
+ to convert radiology report findings into structured, optimized
212
+ radiology reports with highlighted significant findings. By leveraging
213
+ a
214
+ <a
215
+ class="banner-link"
216
+ href="https://huggingface.co/spaces/google/radextract/blob/main/prompt_instruction.py"
217
+ target="_blank"
218
+ rel="noopener noreferrer"
219
+ >prompt</a
220
+ >
221
+ that describes the structuring task with <strong>LX</strong>'s schema
222
+ and a
223
+ <a
224
+ class="banner-link"
225
+ href="https://huggingface.co/spaces/google/radextract/blob/main/report_examples.py"
226
+ target="_blank"
227
+ rel="noopener noreferrer"
228
+ >few select examples</a
229
+ >, <strong>LX</strong> processes free text into the structured output
230
+ shown below. Leveraging Gemini's foundational knowledge,
231
+ <strong>RadExtract</strong> can also process imaging modalities beyond
232
+ those included in the prompt examples, such as the X-ray and
233
+ ultrasound samples available below.
234
+ </p>
235
+ <p class="banner-description">
236
+ <strong>Interactive Features</strong><br />
237
+ Each extracted finding is directly grounded by
238
+ <strong>LX</strong> (linked precisely back to its original words in
239
+ the source text); hover over any structured item to see this exact
240
+ textual origin highlighted. <strong>Clinical significance</strong> is
241
+ visually highlighted: general findings are marked with yellow
242
+ underlines, while significant findings have red&nbsp;underlines.
243
+ </p>
244
+ <p class="banner-description">
245
+ <strong>Clinical Background</strong><br />
246
+ Structured reporting helps ensure completeness, reduces ambiguity, and
247
+ facilitates data sharing in radiology. For background on the value of
248
+ structured radiology reports, see
249
+ <a
250
+ class="banner-link"
251
+ href="https://link.springer.com/article/10.1007/s13244-017-0588-8"
252
+ target="_blank"
253
+ rel="noopener noreferrer"
254
+ >this European Society of Radiology paper</a
255
+ >.
256
+ </p>
257
  </div>
258
+ </div>
259
 
260
+ <div class="samples-container card">
261
+ <h3>Select a Report</h3>
262
+ <div class="samples-description">
263
+ <div class="instruction-step">
264
+ <span class="step-number">1</span> Select a sample or paste your
265
+ report
266
+ </div>
267
+ <div class="instruction-step">
268
+ <span class="step-number">2</span> Click "Process" to start for pasted
269
+ reports
270
+ </div>
271
+ <div class="instruction-step">
272
+ <span class="step-number">3</span> Hover output findings to highlight
273
+ source text
274
  </div>
 
 
 
 
275
  </div>
276
+ <div class="sample-buttons"></div>
277
  <p class="samples-tip tip-desktop">
278
+ 💡 Try tweaking a sample (remove sections, add extra findings, or paste
279
+ your own report) to see how the demo responds.
280
  </p>
281
  <p class="mobile-tip" role="note">
282
+ <span class="icon">💡</span>
283
+ Tap any sample report to explore the structuring features. The keyboard
284
+ stays closed so you can easily scroll and interact with highlighted findings.<br><br>
285
+ <strong>Tip:</strong> Custom input is available only on desktop or laptop computers.
286
+ If you're viewing this on a mobile device, please switch to a computer to enter your own reports.
287
  </p>
288
+ </div>
289
 
290
+ <div class="interface-options-panel card">
291
+ <div class="interface-options-header" data-action="toggle-interface">
292
+ <h4 class="interface-options-title">Interface Controls</h4>
293
+ <div class="interface-options-summary">
294
+ <span>LX Generated Prompt • LX Structured Output • Use Cache</span>
295
+ <span
296
+ class="material-symbols-outlined expand-icon"
297
+ id="interface-expand-icon"
298
+ >expand_more</span
299
+ >
 
 
 
300
  </div>
301
  </div>
302
+ <div
303
+ class="interface-options-content"
304
+ id="interface-options-content"
305
+ style="display: none"
306
+ >
307
+ <div class="interface-options-grid">
308
+ <div class="interface-option">
309
+ <div class="option-header">
310
+ <span class="material-symbols-outlined option-icon"
311
+ >visibility</span
312
+ >
313
+ <strong>LX Generated Prompt</strong>
314
+ </div>
315
+ <p class="option-description">
316
+ View the complete prompt sent to the model, including task
317
+ description, examples, and your input text
318
+ </p>
319
+ </div>
320
+ <div class="interface-option">
321
+ <div class="option-header">
322
+ <span class="material-symbols-outlined option-icon">code</span>
323
+ <strong>LX Structured Output</strong>
324
+ </div>
325
+ <p class="option-description">
326
+ Toggle between the formatted text view and raw LangExtract JSON
327
+ data with extraction details
328
+ </p>
329
+ </div>
330
+ <div class="interface-option">
331
+ <div class="option-header">
332
+ <span class="material-symbols-outlined option-icon">cached</span>
333
+ <strong>Use Cache</strong>
334
+ </div>
335
+ <p class="option-description">
336
+ Switch between live model inference and pre-generated Gemini 2.5
337
+ Pro cached results for faster testing
338
+ </p>
339
+ </div>
340
  </div>
341
+ <div class="interface-options-note">
342
+ <span class="material-symbols-outlined note-icon">info</span>
343
+ <span
344
+ >These controls are located in the Input and Output headers below
345
+ for easy access during interaction.</span
346
+ >
347
  </div>
348
  </div>
349
+ </div>
350
+
351
+ <div class="text-area-container card">
352
  <div class="text-area-wrapper input-wrapper">
353
  <div class="input-header">
354
+ <h2>Input</h2>
 
 
 
355
  <label class="prompt-toggle"
356
  ><input type="checkbox" id="prompt-toggle" /> LX Prompt</label
357
  >
 
387
  </div>
388
  <div id="prompt-output" class="raw-json" style="display: none"></div>
389
  </div>
390
+ <div class="text-area-wrapper" id="output-container">
391
  <div class="output-header">
392
+ <h2>Output</h2>
393
+ <label class="raw-toggle"
394
+ ><input type="checkbox" id="raw-toggle" /> LX Data</label
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
  >
396
  </div>
397
  <div id="output-text-container" class="output-container">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
398
  <pre
399
  id="output-text"
400
  class="large-text-area output-text"
 
 
 
 
401
  placeholder="Structured output will appear here..."
402
  ></pre>
403
  <div
404
  id="raw-output"
405
  class="raw-json output-text"
 
 
 
406
  style="display: none"
407
  ></div>
408
  <button
 
432
  <div class="spinner"></div>
433
  <div class="loader-text">
434
  <span class="loader-message"
435
+ >Running LangExtract with Gemini 2.5 Flash</span
436
  >
437
  </div>
438
  </div>
 
443
  <div class="model-select-container">
444
  <label for="model-select">Model:</label>
445
  <select id="model-select">
446
+ <option value="gemini-2.5-flash" selected>Gemini 2.5 Flash</option>
447
+ <option value="gemini-2.5-pro">Gemini 2.5 Pro</option>
 
448
  </select>
449
  </div>
450
 
 
463
  <input type="checkbox" id="raw-toggle-mobile" /> LX Data
464
  </label>
465
  </div>
466
+ </div>
467
 
468
  <div class="action-bar">
469
  <button id="predict-button">Process</button>
 
484
 
485
  <div class="instructions"></div>
486
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  <script src="https://cdn.jsdelivr.net/npm/json-formatter-js@2.3.4/dist/json-formatter.umd.js"></script>
488
  <script src="https://cdn.jsdelivr.net/npm/marked@9.1.6/marked.min.js"></script>
 
 
 
 
 
489
 
490
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
491
+ <script
492
+ type="module"
493
+ src="/static/script.js?v=20250125-refactored"
494
+ ></script>
495
 
496
  <!-- Bottom share placement (minimal space above footer) -->
497
  <div class="share-bottom">
test_app.py CHANGED
@@ -8,20 +8,14 @@ Run with: python test_app.py or pytest test_app.py
8
 
9
  import json
10
  import os
11
- from pathlib import Path
12
- import tempfile
13
  import unittest
14
  from unittest import mock
15
 
16
  # Mock the environment before importing app to avoid initialization errors
17
  with mock.patch.dict(os.environ, {'KEY': 'test_api_key_for_import'}):
18
- import app as app_module
19
  from app import Model, app, setup_cache
20
 
21
 
22
- _SENSITIVE_REPORT_TEXT = 'PRIVATE PATIENT FINDING'
23
-
24
-
25
  class TestFlaskApplication(unittest.TestCase):
26
 
27
  @classmethod
@@ -81,14 +75,10 @@ class TestFlaskApplication(unittest.TestCase):
81
  '/predict', data='Test report', headers=headers
82
  )
83
  self.assertEqual(response.status_code, 200)
84
- mock_predict.assert_called_once_with(
85
- 'Test report', model_id='gemini-flash-latest'
86
- )
87
 
88
  @mock.patch('app.cache_manager.get_cached_result')
89
  def test_predict_with_cache_hit(self, mock_get_cached):
90
- sample_data = json.loads(Path('static/sample_reports.json').read_text())
91
- sample = sample_data['samples'][0]
92
  cached_response = {
93
  'segments': [{'type': 'body', 'content': 'cached'}],
94
  'text': 'cached result',
@@ -96,214 +86,13 @@ class TestFlaskApplication(unittest.TestCase):
96
  mock_get_cached.return_value = cached_response
97
 
98
  response = self.test_client.post(
99
- '/predict',
100
- data=sample['text'],
101
- headers={
102
- 'X-Use-Cache': 'true',
103
- 'X-Sample-ID': sample['id'],
104
- },
105
  )
106
 
107
  data = json.loads(response.data)
108
  self.assertTrue(data.get('from_cache'))
109
  self.assertIn('segments', data)
110
 
111
- def test_matching_catalog_sample_uses_prebuilt_cache(self):
112
- sample_data = json.loads(Path('static/sample_reports.json').read_text())
113
- sample = sample_data['samples'][0]
114
- cached_response = {'segments': [], 'text': 'cached result'}
115
-
116
- with (
117
- mock.patch.object(
118
- app_module.cache_manager,
119
- 'get_cached_result',
120
- autospec=True,
121
- return_value=cached_response,
122
- ) as mock_get_cached,
123
- mock.patch.object(
124
- app_module.model, 'predict', autospec=True
125
- ) as mock_predict,
126
- ):
127
- response = self.test_client.post(
128
- '/predict',
129
- data=sample['text'],
130
- headers={
131
- 'X-Use-Cache': 'true',
132
- 'X-Sample-ID': sample['id'],
133
- },
134
- )
135
-
136
- self.assertEqual(response.status_code, 200)
137
- self.assertTrue(response.get_json()['from_cache'])
138
- mock_get_cached.assert_called_once()
139
- mock_predict.assert_not_called()
140
-
141
- def test_mismatched_catalog_sample_bypasses_cache(self):
142
- cached_response = {'segments': [], 'text': 'wrong cached result'}
143
- live_response = {'segments': [], 'text': 'live result'}
144
-
145
- with (
146
- mock.patch.object(
147
- app_module.cache_manager,
148
- 'get_cached_result',
149
- autospec=True,
150
- return_value=cached_response,
151
- ) as mock_get_cached,
152
- mock.patch.object(
153
- app_module.cache_manager, 'cache_result', autospec=True
154
- ) as mock_cache_result,
155
- mock.patch.object(
156
- app_module.model,
157
- 'predict',
158
- autospec=True,
159
- return_value=live_response,
160
- ) as mock_predict,
161
- ):
162
- response = self.test_client.post(
163
- '/predict',
164
- data='EXAMINATION: A report that is not the chest sample.',
165
- headers={
166
- 'X-Use-Cache': 'true',
167
- 'X-Sample-ID': 'chest_xray',
168
- },
169
- )
170
-
171
- self.assertEqual(response.status_code, 200)
172
- self.assertFalse(response.get_json().get('from_cache', False))
173
- mock_get_cached.assert_not_called()
174
- mock_predict.assert_called_once()
175
- mock_cache_result.assert_not_called()
176
-
177
- def test_unknown_sample_id_is_never_cached(self):
178
- live_response = {'segments': [], 'text': 'live result'}
179
-
180
- with (
181
- mock.patch.object(
182
- app_module.cache_manager,
183
- 'get_cached_result',
184
- autospec=True,
185
- ) as mock_get_cached,
186
- mock.patch.object(
187
- app_module.cache_manager, 'cache_result', autospec=True
188
- ) as mock_cache_result,
189
- mock.patch.object(
190
- app_module.model,
191
- 'predict',
192
- autospec=True,
193
- return_value=live_response,
194
- ),
195
- ):
196
- response = self.test_client.post(
197
- '/predict',
198
- data='EXAMINATION: Unknown sample.',
199
- headers={
200
- 'X-Use-Cache': 'true',
201
- 'X-Sample-ID': 'not_in_catalog',
202
- },
203
- )
204
-
205
- self.assertEqual(response.status_code, 200)
206
- mock_get_cached.assert_not_called()
207
- mock_cache_result.assert_not_called()
208
-
209
- def test_catalog_cache_miss_is_not_written_at_runtime(self):
210
- sample_data = json.loads(Path('static/sample_reports.json').read_text())
211
- sample = sample_data['samples'][0]
212
- live_response = {'segments': [], 'text': 'live result'}
213
-
214
- with (
215
- mock.patch.object(
216
- app_module.cache_manager,
217
- 'get_cached_result',
218
- autospec=True,
219
- return_value=None,
220
- ),
221
- mock.patch.object(
222
- app_module.cache_manager, 'cache_result', autospec=True
223
- ) as mock_cache_result,
224
- mock.patch.object(
225
- app_module.model,
226
- 'predict',
227
- autospec=True,
228
- return_value=live_response,
229
- ),
230
- ):
231
- response = self.test_client.post(
232
- '/predict',
233
- data=sample['text'],
234
- headers={
235
- 'X-Use-Cache': 'true',
236
- 'X-Sample-ID': sample['id'],
237
- },
238
- )
239
-
240
- self.assertEqual(response.status_code, 200)
241
- mock_cache_result.assert_not_called()
242
-
243
- def test_unapproved_model_returns_stable_client_error(self):
244
- with mock.patch.object(
245
- app_module.model,
246
- 'predict',
247
- autospec=True,
248
- return_value={'segments': [], 'text': 'result'},
249
- ) as mock_predict:
250
- response = self.test_client.post(
251
- '/predict',
252
- data='FINDINGS: Normal chest.',
253
- headers={
254
- 'X-Use-Cache': 'false',
255
- 'X-Model-ID': 'unapproved-model',
256
- },
257
- )
258
-
259
- self.assertEqual(response.status_code, 400)
260
- self.assertEqual(response.get_json()['error'], 'Unsupported model')
261
- self.assertIn('message', response.get_json())
262
- mock_predict.assert_not_called()
263
-
264
- @mock.patch.dict(os.environ, {'MODEL_ID': 'configured-current-model'})
265
- def test_environment_configured_model_is_allowed(self):
266
- with mock.patch.object(
267
- app_module.model,
268
- 'predict',
269
- autospec=True,
270
- return_value={'segments': [], 'text': 'result'},
271
- ) as mock_predict:
272
- response = self.test_client.post(
273
- '/predict',
274
- data='FINDINGS: Normal chest.',
275
- headers={
276
- 'X-Use-Cache': 'false',
277
- 'X-Model-ID': 'configured-current-model',
278
- },
279
- )
280
-
281
- self.assertEqual(response.status_code, 200)
282
- mock_predict.assert_called_once_with(
283
- 'FINDINGS: Normal chest.', model_id='configured-current-model'
284
- )
285
-
286
- def test_legacy_pro_model_resolves_at_http_boundary(self):
287
- with mock.patch.object(
288
- app_module.model,
289
- 'predict',
290
- autospec=True,
291
- return_value={'segments': [], 'text': 'result'},
292
- ) as mock_predict:
293
- response = self.test_client.post(
294
- '/predict',
295
- data='FINDINGS: Normal chest.',
296
- headers={
297
- 'X-Use-Cache': 'false',
298
- 'X-Model-ID': 'gemini-2.5-pro',
299
- },
300
- )
301
-
302
- self.assertEqual(response.status_code, 200)
303
- mock_predict.assert_called_once_with(
304
- 'FINDINGS: Normal chest.', model_id='gemini-pro-latest'
305
- )
306
-
307
 
308
  class TestModelClass(unittest.TestCase):
309
 
@@ -311,7 +100,7 @@ class TestModelClass(unittest.TestCase):
311
  def test_model_initialization_with_api_key(self):
312
  model = Model()
313
  self.assertEqual(model.gemini_api_key, 'test_api_key')
314
- self.assertIn('gemini-flash-latest', model._structurers)
315
 
316
  @mock.patch.dict(os.environ, {}, clear=True)
317
  def test_model_initialization_without_api_key(self):
@@ -346,19 +135,6 @@ class TestModelClass(unittest.TestCase):
346
  mock_instance.predict.assert_called_once_with('test data')
347
  self.assertEqual(result, {'result': 'test'})
348
 
349
- @mock.patch.dict(os.environ, {'KEY': 'test_key'})
350
- @mock.patch('app.RadiologyReportStructurer')
351
- def test_predict_does_not_log_report_content(self, mock_structurer_class):
352
- mock_instance = mock.Mock()
353
- mock_instance.predict.return_value = {'text': _SENSITIVE_REPORT_TEXT}
354
- mock_structurer_class.return_value = mock_instance
355
- model = Model()
356
-
357
- with mock.patch.object(app_module.logger, 'info', autospec=True) as log:
358
- model.predict(_SENSITIVE_REPORT_TEXT, 'gemini-flash-latest')
359
-
360
- self.assertNotIn(_SENSITIVE_REPORT_TEXT, str(log.call_args_list))
361
-
362
 
363
  class TestCacheSetup(unittest.TestCase):
364
 
@@ -368,17 +144,13 @@ class TestCacheSetup(unittest.TestCase):
368
  def test_setup_cache_copies_existing_file(
369
  self, mock_makedirs, mock_copy, mock_exists
370
  ):
371
- mock_exists.side_effect = [True, False]
372
 
373
- with mock.patch.object(os.path, 'getsize', autospec=True, return_value=123):
374
- cache_dir = setup_cache()
375
 
376
- expected_cache_dir = tempfile.gettempdir() + '/cache'
377
- mock_makedirs.assert_called_once_with(expected_cache_dir, exist_ok=True)
378
- mock_copy.assert_called_once_with(
379
- 'cache/sample_cache.json', expected_cache_dir + '/sample_cache.json'
380
- )
381
- self.assertEqual(cache_dir, expected_cache_dir)
382
 
383
  @mock.patch('os.path.exists')
384
  @mock.patch('os.makedirs')
@@ -387,9 +159,8 @@ class TestCacheSetup(unittest.TestCase):
387
 
388
  cache_dir = setup_cache()
389
 
390
- expected_cache_dir = tempfile.gettempdir() + '/cache'
391
- mock_makedirs.assert_called_once_with(expected_cache_dir, exist_ok=True)
392
- self.assertEqual(cache_dir, expected_cache_dir)
393
 
394
 
395
  class TestErrorHandling(unittest.TestCase):
@@ -420,187 +191,18 @@ class TestErrorHandling(unittest.TestCase):
420
  self.assertEqual(response.status_code, 500)
421
 
422
  data = json.loads(response.data)
423
- self.assertEqual(data['error'], 'Internal processing error')
424
- self.assertIn('message', data)
425
-
426
- @mock.patch('app.model.predict')
427
- @mock.patch('app.logger')
428
- def test_predict_handles_provider_error(self, mock_logger, mock_predict):
429
- provider_error = RuntimeError(_SENSITIVE_REPORT_TEXT)
430
- processing_error = app_module.ReportProcessingError('Report processing failed')
431
- processing_error.__cause__ = provider_error
432
- mock_predict.side_effect = processing_error
433
-
434
- response = self.test_client.post('/predict', data='Test data')
435
-
436
- self.assertEqual(response.status_code, 502)
437
- data = response.get_json()
438
- self.assertEqual(data['error'], 'Processing unavailable')
439
- self.assertIn('message', data)
440
- self.assertNotIn(_SENSITIVE_REPORT_TEXT, response.get_data(as_text=True))
441
- self.assertNotIn(_SENSITIVE_REPORT_TEXT, str(mock_logger.method_calls))
442
 
443
  @mock.patch('app.model.predict')
444
  @mock.patch('app.logger')
445
  def test_predict_handles_general_exception(self, mock_logger, mock_predict):
446
- mock_predict.side_effect = Exception(_SENSITIVE_REPORT_TEXT)
447
 
448
  response = self.test_client.post('/predict', data='Test data')
449
  self.assertEqual(response.status_code, 500)
450
 
451
  data = json.loads(response.data)
452
- self.assertEqual(data['error'], 'Internal processing error')
453
- self.assertIn('message', data)
454
- self.assertNotIn(_SENSITIVE_REPORT_TEXT, response.get_data(as_text=True))
455
- self.assertNotIn(_SENSITIVE_REPORT_TEXT, str(mock_logger.method_calls))
456
-
457
-
458
- class TestFrontendAndProjectConfiguration(unittest.TestCase):
459
-
460
- def test_demo_studio_contract_is_present_above_supporting_content(self):
461
- response = app.test_client().get('/')
462
- page = response.get_data(as_text=True)
463
-
464
- self.assertEqual(response.status_code, 200)
465
- self.assertIn('id="transformation-studio"', page)
466
- self.assertIn('id="findings-cards"', page)
467
- self.assertIn('id="studio-metrics"', page)
468
- self.assertIn('role="tablist"', page)
469
- self.assertIn('Research demonstration — not for clinical use', page)
470
- self.assertLess(
471
- page.index('id="transformation-studio"'),
472
- page.index('id="how-it-works"'),
473
- )
474
-
475
- def test_default_sample_load_is_cached_and_has_no_simulated_delay(self):
476
- script = Path('static/script.js').read_text()
477
-
478
- self.assertIn("DEFAULT_SAMPLE_ID: 'chest_xray'", script)
479
- self.assertIn('loadSampleReport(defaultSample, { scroll: false })', script)
480
- self.assertIn("headers['X-Use-Cache'] = 'true'", script)
481
- self.assertNotIn('Math.random() * 1000', script)
482
- self.assertNotIn('window.location.search.length', script)
483
- self.assertNotIn('window.location.hash.length', script)
484
-
485
- def test_finding_cards_are_lossless_and_keep_alternate_views(self):
486
- script = Path('static/script.js').read_text()
487
- template = Path('templates/index.html').read_text()
488
-
489
- self.assertIn('function renderFindingCards(segments)', script)
490
- self.assertIn('segments.forEach((segment, index)', script)
491
- self.assertIn('card.dataset.segmentIndex = String(index)', script)
492
- self.assertIn('No source span', script)
493
- self.assertIn('data-output-view="report"', template)
494
- self.assertIn('data-output-view="raw"', template)
495
- self.assertIn('id="prompt-toggle"', template)
496
-
497
- def test_langextract_links_and_sample_copy_are_professional(self):
498
- template = Path('templates/index.html').read_text()
499
- stylesheet = Path('static/style.css').read_text()
500
-
501
- self.assertIn('href="https://github.com/google/langextract"', template)
502
- self.assertIn('LangExtract on GitHub', template)
503
- self.assertIn('LangExtract release blog', template)
504
- self.assertGreaterEqual(template.count('rel="noopener noreferrer"'), 2)
505
- self.assertIn('<h2 id="sample-heading">Explore sample reports</h2>', template)
506
- self.assertIn('Choose a study.', template)
507
- self.assertNotIn('Explore a cached report', template)
508
- self.assertNotIn('Results appear instantly from the demo cache.', template)
509
- self.assertIn('.hero-attribution a:focus-visible', stylesheet)
510
-
511
- def test_sample_grid_and_report_default_preserve_responsive_fallbacks(self):
512
- script = Path('static/script.js').read_text()
513
- stylesheet = Path('static/style.css').read_text()
514
- template = Path('templates/index.html').read_text()
515
-
516
- self.assertIn('grid-template-columns: repeat(5, minmax(0, 1fr));', stylesheet)
517
- mobile_rules = stylesheet[stylesheet.index('@media (max-width: 768px)') :]
518
- self.assertIn('.sample-buttons {', mobile_rules)
519
- self.assertIn('display: flex;', mobile_rules)
520
- self.assertIn('overflow-x: auto;', mobile_rules)
521
- self.assertLess(
522
- template.index('id="report-tab"'), template.index('id="findings-tab"')
523
- )
524
- self.assertIn("let preferredStructuredView = 'report';", script)
525
- self.assertIn('selectOutputView(preferredStructuredView);', script)
526
- self.assertGreaterEqual(script.count('remember: true'), 3)
527
- self.assertIn(
528
- "selectOutputView(showRaw ? 'raw' : 'cards', { remember: true });",
529
- script,
530
- )
531
- self.assertIn("tab.getAttribute('aria-selected') === 'true'", script)
532
- self.assertLess(
533
- script.index(
534
- "selectOutputView('cards');",
535
- script.index('predictButton.addEventListener'),
536
- ),
537
- script.index(
538
- 'setOutputViewsAvailable(false);',
539
- script.index('predictButton.addEventListener'),
540
- ),
541
- )
542
- self.assertNotIn('balanceByColumnCount', script)
543
- self.assertNotIn('BALANCE_DELAY', script)
544
- self.assertNotIn('RESIZE_DEBOUNCE', script)
545
-
546
- def test_mobile_and_reduced_motion_studio_rules_are_defined(self):
547
- script = Path('static/script.js').read_text()
548
- stylesheet = Path('static/style.css').read_text()
549
-
550
- self.assertIn('@media (max-width: 768px)', stylesheet)
551
- self.assertIn('.output-wrapper {', stylesheet)
552
- self.assertIn('order: 1;', stylesheet)
553
- self.assertIn('.input-wrapper {', stylesheet)
554
- self.assertIn('order: 2;', stylesheet)
555
- self.assertIn('@media (prefers-reduced-motion: reduce)', stylesheet)
556
- self.assertIn('overflow-x: hidden;', stylesheet)
557
- self.assertIn('function prefersReducedMotion()', script)
558
- self.assertIn("behavior: prefersReducedMotion() ? 'auto' : 'smooth'", script)
559
-
560
- def test_studio_fallback_and_accessibility_guards_are_present(self):
561
- script = Path('static/script.js').read_text()
562
- template = Path('templates/index.html').read_text()
563
-
564
- self.assertIn('function resetStudioMetrics()', script)
565
- self.assertGreaterEqual(script.count('resetStudioMetrics();'), 4)
566
- self.assertIn('showGrounding({ focusInput: false })', script)
567
- self.assertIn('sampleRunQueued = true', script)
568
- self.assertGreaterEqual(script.count('if (sampleRunQueued) return;'), 2)
569
- self.assertIn('let sampleLoadTimer = null', script)
570
- self.assertIn('function setOutputViewsAvailable(available)', script)
571
- self.assertIn('aria-controls="findings-cards"', template)
572
- self.assertIn('role="tabpanel"', template)
573
- self.assertIn('id="raw-toggle" tabindex="-1"', template)
574
- self.assertIn('aria-disabled="true"', template)
575
-
576
- def test_frontend_sanitizes_prompt_and_raw_fallback(self):
577
- script = Path('static/script.js').read_text()
578
- template = Path('templates/index.html').read_text()
579
-
580
- self.assertIn('DOMPurify.sanitize', script)
581
- self.assertIn('dompurify', template.lower())
582
- self.assertIn('pre.textContent', script)
583
- self.assertNotIn("rawOutput.innerHTML = '<pre", script)
584
-
585
- def test_langextract_floor_supports_resolver_params(self):
586
- project = Path('pyproject.toml').read_text()
587
-
588
- self.assertIn('"langextract>=1.6.0,<2.0.0"', project)
589
-
590
- def test_sample_preprocessing_is_idempotent_and_cache_is_complete(self):
591
- samples = json.loads(Path('static/sample_reports.json').read_text())['samples']
592
- cache = json.loads(Path('cache/sample_cache.json').read_text())
593
-
594
- for sample in samples:
595
- with self.subTest(sample=sample['id']):
596
- normalized = app_module.preprocess_report(sample['text'])
597
- self.assertEqual(app_module.preprocess_report(normalized), normalized)
598
- self.assertIn(f"sample_{sample['id']}", cache)
599
-
600
- def test_pytest_discovers_root_test_files_without_fallback(self):
601
- project = Path('pyproject.toml').read_text()
602
-
603
- self.assertIn('testpaths = ["test_app.py", "test_validation.py"]', project)
604
 
605
 
606
  if __name__ == '__main__':
 
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
 
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',
 
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
 
 
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):
 
135
  mock_instance.predict.assert_called_once_with('test data')
136
  self.assertEqual(result, {'result': 'test'})
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
  class TestCacheSetup(unittest.TestCase):
140
 
 
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')
 
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):
 
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__':
test_validation.py CHANGED
@@ -22,17 +22,12 @@ import unittest
22
  from typing import Any
23
  from unittest import mock
24
 
25
- import langextract as lx
26
-
27
  # Add the current directory to path for imports
28
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
29
 
30
  from structure_report import RadiologyReportStructurer
31
 
32
 
33
- _ORIGINAL_RESOLVER_ALIGN = lx.resolver.Resolver.align
34
-
35
-
36
  class TestRadiologyReportEndToEnd(unittest.TestCase):
37
  """End-to-end tests for complete RadiologyReportStructurer pipeline."""
38
 
@@ -100,28 +95,7 @@ class TestRadiologyReportEndToEnd(unittest.TestCase):
100
  mock_extract.assert_called_once()
101
  call_args = mock_extract.call_args
102
  self.assertEqual(call_args[1]['text_or_documents'], input_text)
103
- self.assertEqual(call_args[1]['model_id'], 'gemini-flash-latest')
104
- self.assertEqual(
105
- call_args[1]['resolver_params'],
106
- {
107
- 'accept_match_lesser': False,
108
- 'fuzzy_alignment_threshold': 0.50,
109
- },
110
- )
111
-
112
- def test_legacy_pro_model_resolves_to_rolling_alias(self):
113
- structurer = RadiologyReportStructurer(
114
- api_key='test_key', model_id='gemini-2.5-pro'
115
- )
116
-
117
- self.assertEqual(structurer.model_id, 'gemini-pro-latest')
118
-
119
- def test_current_model_id_is_preserved(self):
120
- structurer = RadiologyReportStructurer(
121
- api_key='test_key', model_id='gemini-3.5-flash'
122
- )
123
-
124
- self.assertEqual(structurer.model_id, 'gemini-3.5-flash')
125
 
126
  def test_all_cached_samples_validation(self):
127
  self.assertGreater(len(self.sample_data), 0, 'No samples found in cache')
@@ -141,54 +115,19 @@ class TestRadiologyReportEndToEnd(unittest.TestCase):
141
  def test_error_handling_with_no_api_key(self):
142
  error_structurer = RadiologyReportStructurer(api_key=None)
143
 
144
- with mock.patch.object(
145
- lx,
146
- 'extract',
147
- autospec=True,
148
- side_effect=RuntimeError('sensitive provider detail'),
149
- ):
150
- with self.assertRaisesRegex(RuntimeError, 'Report processing failed'):
151
- error_structurer.predict('EXAMINATION: Test')
152
 
153
- def test_predict_does_not_patch_global_resolver(self):
154
  new_structurer = RadiologyReportStructurer()
155
 
156
- with mock.patch.object(lx, 'extract', autospec=True) as mock_extract:
157
- mock_result = mock.MagicMock()
158
- mock_result.extractions = []
159
- mock_extract.return_value = mock_result
160
- new_structurer.predict('EXAMINATION: Test')
161
-
162
- self.assertIs(lx.resolver.Resolver.align, _ORIGINAL_RESOLVER_ALIGN)
163
-
164
- def test_resolver_settings_reject_partial_exact_matches(self):
165
- source_text = 'No acute disease.'
166
- extraction_text = 'No acute disease absent invented words words words words.'
167
-
168
- accepted = list(
169
- lx.resolver.Resolver().align(
170
- [lx.data.Extraction('finding', extraction_text)],
171
- source_text,
172
- token_offset=0,
173
- accept_match_lesser=True,
174
- fuzzy_alignment_threshold=0.50,
175
- )
176
- )
177
- rejected = list(
178
- lx.resolver.Resolver().align(
179
- [lx.data.Extraction('finding', extraction_text)],
180
- source_text,
181
- token_offset=0,
182
- accept_match_lesser=False,
183
- fuzzy_alignment_threshold=0.50,
184
- )
185
- )
186
 
187
- self.assertEqual(
188
- accepted[0].alignment_status, lx.data.AlignmentStatus.MATCH_LESSER
189
- )
190
- self.assertIsNone(rejected[0].alignment_status)
191
- self.assertIsNone(rejected[0].char_interval)
192
 
193
  def test_section_mapping_core_functionality(self):
194
  self.assertEqual(
 
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
 
 
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')
 
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(
tools/rebuild_cache.py CHANGED
@@ -13,7 +13,7 @@ to use for processing.
13
 
14
  Usage:
15
  export KEY=your_gemini_api_key_here
16
- export MODEL_ID=gemini-flash-latest # optional, defaults to Gemini Flash
17
  python tools/rebuild_cache.py
18
  """
19
  import json
@@ -37,7 +37,7 @@ if not SAMPLES_PATH.exists():
37
 
38
  samples = json.loads(SAMPLES_PATH.read_text())["samples"]
39
 
40
- MODEL_ID = os.environ.get("MODEL_ID", "gemini-flash-latest")
41
  structurer = RadiologyReportStructurer(api_key=API_KEY, model_id=MODEL_ID)
42
 
43
  import time
 
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
 
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