goelak commited on
Commit
2031e3f
·
1 Parent(s): 038f956

Harden demo requests

Browse files
Files changed (7) hide show
  1. app.py +103 -23
  2. pyproject.toml +3 -3
  3. static/script.js +13 -4
  4. structure_report.py +11 -43
  5. templates/index.html +5 -0
  6. test_app.py +278 -4
  7. test_validation.py +56 -9
app.py CHANGED
@@ -15,13 +15,14 @@ Typical usage example:
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
@@ -32,13 +33,19 @@ from sanitize import preprocess_report
32
  from social_sharing import SocialSharingConfig
33
  from structure_report import (
34
  DEFAULT_MODEL_ID,
 
35
  RadiologyReportStructurer,
 
36
  ResponseDict,
37
  resolve_model_id,
38
  )
39
 
40
  # Configuration constants
41
  MAX_INPUT_LENGTH = 3000
 
 
 
 
42
 
43
  logging.basicConfig(
44
  level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s"
@@ -46,6 +53,31 @@ logging.basicConfig(
46
  logger = logging.getLogger(__name__)
47
 
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  class Model:
50
  """Manages RadiologyReportStructurer instances for different Gemini model IDs.
51
 
@@ -110,10 +142,10 @@ class Model:
110
  Returns:
111
  Dictionary containing the structured prediction results.
112
  """
113
- logger.info(f"Processing prediction with model: {model_id}")
114
  structurer = self._get_structurer(model_id)
115
  result = structurer.predict(data)
116
- logger.info(f"Result preview: {str(result)[:500]}...")
117
  return result
118
 
119
 
@@ -227,7 +259,11 @@ def predict():
227
  jsonify(
228
  {
229
  "error": "Input too long",
230
- "message": f"Input length ({len(data)} characters) exceeds maximum allowed length of {MAX_INPUT_LENGTH} characters",
 
 
 
 
231
  "max_length": MAX_INPUT_LENGTH,
232
  }
233
  ),
@@ -235,22 +271,38 @@ def predict():
235
  )
236
 
237
  use_cache = request.headers.get("X-Use-Cache", "true").lower() == "true"
238
- sample_id = request.headers.get("X-Sample-ID")
239
  model_id = resolve_model_id(
240
  request.headers.get(
241
  "X-Model-ID", os.environ.get("MODEL_ID", DEFAULT_MODEL_ID)
242
  )
243
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
244
  processed_data = preprocess_report(data)
 
245
 
246
- if use_cache:
247
  cached_result = cache_manager.get_cached_result(processed_data, sample_id)
248
  if cached_result:
249
  req_id = hashlib.md5(
250
  f"{request.remote_addr}{int(time.time()/3600)}".encode()
251
  ).hexdigest()[:8]
252
  logger.info(
253
- f"🟢 CACHE HIT [Req {req_id}] [Worker {os.getpid()}] - Returning cached result (no API call)"
 
 
254
  )
255
  return jsonify({"from_cache": True, **cached_result})
256
 
@@ -259,30 +311,58 @@ def predict():
259
  f"{request.remote_addr}{int(time.time()/3600)}".encode()
260
  ).hexdigest()[:8]
261
  logger.info(
262
- f"��� API CALL [Req {req_id}] [Worker {os.getpid()}] - Processing with Gemini model: {model_id}"
 
 
 
263
  )
264
  result = model.predict(processed_data, model_id=model_id)
265
-
266
- # Cache if it's a predefined sample
267
- if use_cache and sample_id:
268
- cache_manager.cache_result(processed_data, result, sample_id)
269
-
270
  result["sanitized_input"] = processed_data
271
 
 
 
 
 
 
 
 
 
272
  return jsonify(result)
273
 
274
- except TypeError as te:
275
- error_msg = str(te)
276
- logger.error(f"TypeError in prediction: {error_msg}", exc_info=True)
 
 
 
 
277
 
278
  return (
279
- jsonify({"error": "Processing error. Please try a different input."}),
280
- 500,
 
 
 
 
 
 
 
 
281
  )
282
- except Exception as e:
283
- logger.error(f"Prediction error: {str(e)}", exc_info=True)
284
-
285
- return jsonify({"error": str(e)}), 500
 
 
 
 
 
 
 
 
 
 
286
 
287
 
288
  @app.errorhandler(429)
 
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
 
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
  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
 
 
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
  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
  )
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
  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)
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>=0.1.3",
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 = ["tests"]
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>=1.6.0,<2.0.0",
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 = ["test_app.py", "test_validation.py"]
85
+ python_files = ["test_*.py", "*_test.py"]
static/script.js CHANGED
@@ -569,8 +569,11 @@ document.addEventListener('DOMContentLoaded', function () {
569
  // Check if JSONFormatter is available
570
  if (typeof window.JSONFormatter === 'undefined') {
571
  console.error('JSONFormatter library not loaded, using fallback');
572
- rawOutput.innerHTML = '<pre style="white-space: pre-wrap; word-wrap: break-word;">' +
573
- JSON.stringify(rawData, null, 2) + '</pre>';
 
 
 
574
  rawOutput._jsonData = rawData;
575
  return;
576
  }
@@ -616,9 +619,15 @@ document.addEventListener('DOMContentLoaded', function () {
616
 
617
  if (promptOutput) {
618
  const promptText = data.raw_prompt || 'Prompt data not available.';
619
- if (typeof marked !== 'undefined' && data.raw_prompt) {
 
 
 
 
620
  // Render markdown with syntax highlighting support
621
- promptOutput.innerHTML = marked.parse(promptText);
 
 
622
  } else {
623
  // Fallback to plain text
624
  promptOutput.textContent = promptText;
 
569
  // Check if JSONFormatter is available
570
  if (typeof window.JSONFormatter === 'undefined') {
571
  console.error('JSONFormatter library not loaded, using fallback');
572
+ const pre = document.createElement('pre');
573
+ pre.style.whiteSpace = 'pre-wrap';
574
+ pre.style.wordWrap = 'break-word';
575
+ pre.textContent = JSON.stringify(rawData, null, 2);
576
+ rawOutput.replaceChildren(pre);
577
  rawOutput._jsonData = rawData;
578
  return;
579
  }
 
619
 
620
  if (promptOutput) {
621
  const promptText = data.raw_prompt || 'Prompt data not available.';
622
+ if (
623
+ typeof marked !== 'undefined' &&
624
+ typeof DOMPurify !== 'undefined' &&
625
+ data.raw_prompt
626
+ ) {
627
  // Render markdown with syntax highlighting support
628
+ promptOutput.innerHTML = DOMPurify.sanitize(
629
+ marked.parse(promptText),
630
+ );
631
  } else {
632
  // Fallback to plain text
633
  promptOutput.textContent = promptText;
structure_report.py CHANGED
@@ -24,7 +24,6 @@ import collections
24
  import dataclasses
25
  import itertools
26
  from enum import Enum
27
- from functools import wraps
28
  from typing import Any, TypedDict
29
 
30
  import langextract as lx
@@ -47,6 +46,10 @@ def resolve_model_id(model_id: str) -> str:
47
  return LEGACY_MODEL_ALIASES.get(model_id, model_id)
48
 
49
 
 
 
 
 
50
  class FrontendIntervalDict(TypedDict):
51
  """Character interval for frontend with startPos and endPos."""
52
 
@@ -101,29 +104,6 @@ SIGNIFICANCE_SIGNIFICANT = "significant"
101
  SIGNIFICANCE_NOT_APPLICABLE = "not_applicable"
102
 
103
 
104
- def _initialize_langextract_patches():
105
- """Initialize LangExtract patches for proper alignment behavior.
106
-
107
- 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.
108
-
109
- Note: This is a temporary workaround until LangExtract exposes
110
- accept_match_lesser and fuzzy_alignment_threshold parameters via its public API.
111
- """
112
- # Store original method
113
- original_align = lx.resolver.Resolver.align
114
-
115
- @wraps(original_align)
116
- def _align_patched(self, *args, **kwargs):
117
- # Set default if not explicitly provided
118
- kwargs.setdefault("accept_match_lesser", False)
119
- # Set fuzzy matching threshold to 0.50
120
- kwargs.setdefault("fuzzy_alignment_threshold", 0.50)
121
- return original_align(self, *args, **kwargs)
122
-
123
- # Apply the patch
124
- lx.resolver.Resolver.align = _align_patched
125
-
126
-
127
  class ReportSectionType(Enum):
128
  """Enum representing sections of a radiology report with their extraction class names."""
129
 
@@ -182,7 +162,6 @@ class RadiologyReportStructurer:
182
  model_id: str
183
  temperature: float
184
  examples: list[langextract.data.ExampleData]
185
- _patches_initialized: bool
186
 
187
  def __init__(
188
  self,
@@ -201,13 +180,6 @@ class RadiologyReportStructurer:
201
  self.model_id = resolve_model_id(model_id)
202
  self.temperature = temperature
203
  self.examples = report_examples.get_examples_for_model()
204
- self._patches_initialized = False
205
-
206
- def _ensure_patches_initialized(self):
207
- """Ensure LangExtract patches are initialized before use."""
208
- if not self._patches_initialized:
209
- _initialize_langextract_patches()
210
- self._patches_initialized = True
211
 
212
  def _generate_formatted_prompt_with_examples(
213
  self, input_text: str | None = None
@@ -247,14 +219,9 @@ class RadiologyReportStructurer:
247
 
248
  try:
249
  result = self._perform_langextract(report_text, max_char_buffer)
250
- return self._build_response(result, report_text)
251
- except (ValueError, TypeError, AttributeError) as e:
252
- return ResponseDict(
253
- text=f"Error processing report: {str(e)}",
254
- segments=[],
255
- annotated_document_json={},
256
- raw_prompt="",
257
- )
258
 
259
  def _perform_langextract(
260
  self, report_text: str, max_char_buffer: int
@@ -272,7 +239,6 @@ class RadiologyReportStructurer:
272
  ValueError: If LangExtract processing fails.
273
  TypeError: If invalid parameters are provided.
274
  """
275
- self._ensure_patches_initialized()
276
  return lx.extract(
277
  text_or_documents=report_text,
278
  prompt_description=prompt_instruction.PROMPT_INSTRUCTION.split(
@@ -283,8 +249,10 @@ class RadiologyReportStructurer:
283
  api_key=self.api_key,
284
  max_char_buffer=max_char_buffer,
285
  temperature=self.temperature,
286
- # accept_match_lesser handled via monkey-patch
287
- # (Resolver.align patched at import time)
 
 
288
  )
289
 
290
  def _build_response(
 
24
  import dataclasses
25
  import itertools
26
  from enum import Enum
 
27
  from typing import Any, TypedDict
28
 
29
  import langextract as lx
 
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
  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
  model_id: str
163
  temperature: float
164
  examples: list[langextract.data.ExampleData]
 
165
 
166
  def __init__(
167
  self,
 
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
 
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
  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
  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(
templates/index.html CHANGED
@@ -479,6 +479,11 @@
479
 
480
  <script src="https://cdn.jsdelivr.net/npm/json-formatter-js@2.3.4/dist/json-formatter.umd.js"></script>
481
  <script src="https://cdn.jsdelivr.net/npm/marked@9.1.6/marked.min.js"></script>
 
 
 
 
 
482
 
483
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
484
  <script
 
479
 
480
  <script src="https://cdn.jsdelivr.net/npm/json-formatter-js@2.3.4/dist/json-formatter.umd.js"></script>
481
  <script src="https://cdn.jsdelivr.net/npm/marked@9.1.6/marked.min.js"></script>
482
+ <script
483
+ src="https://cdn.jsdelivr.net/npm/dompurify@3.4.12/dist/purify.min.js"
484
+ integrity="sha384-piCcpDdJ7qVeK4Tv8Z6Hpcr3ZBIgP16TxQTPVfsLFdZ5uDgwc3Y8Ho7oUnqf12qu"
485
+ crossorigin="anonymous"
486
+ ></script>
487
 
488
  <script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
489
  <script
test_app.py CHANGED
@@ -8,15 +8,20 @@ Run with: python test_app.py or pytest test_app.py
8
 
9
  import json
10
  import os
 
11
  import tempfile
12
  import unittest
13
  from unittest import mock
14
 
15
  # Mock the environment before importing app to avoid initialization errors
16
  with mock.patch.dict(os.environ, {'KEY': 'test_api_key_for_import'}):
 
17
  from app import Model, app, setup_cache
18
 
19
 
 
 
 
20
  class TestFlaskApplication(unittest.TestCase):
21
 
22
  @classmethod
@@ -82,6 +87,8 @@ class TestFlaskApplication(unittest.TestCase):
82
 
83
  @mock.patch('app.cache_manager.get_cached_result')
84
  def test_predict_with_cache_hit(self, mock_get_cached):
 
 
85
  cached_response = {
86
  'segments': [{'type': 'body', 'content': 'cached'}],
87
  'text': 'cached result',
@@ -89,13 +96,214 @@ class TestFlaskApplication(unittest.TestCase):
89
  mock_get_cached.return_value = cached_response
90
 
91
  response = self.test_client.post(
92
- '/predict', data='Test report', headers={'X-Use-Cache': 'true'}
 
 
 
 
 
93
  )
94
 
95
  data = json.loads(response.data)
96
  self.assertTrue(data.get('from_cache'))
97
  self.assertIn('segments', data)
98
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
  class TestModelClass(unittest.TestCase):
101
 
@@ -138,6 +346,19 @@ class TestModelClass(unittest.TestCase):
138
  mock_instance.predict.assert_called_once_with('test data')
139
  self.assertEqual(result, {'result': 'test'})
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  class TestCacheSetup(unittest.TestCase):
143
 
@@ -199,18 +420,71 @@ class TestErrorHandling(unittest.TestCase):
199
  self.assertEqual(response.status_code, 500)
200
 
201
  data = json.loads(response.data)
202
- self.assertIn('Processing error', data['error'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
 
204
  @mock.patch('app.model.predict')
205
  @mock.patch('app.logger')
206
  def test_predict_handles_general_exception(self, mock_logger, mock_predict):
207
- mock_predict.side_effect = Exception('General error')
208
 
209
  response = self.test_client.post('/predict', data='Test data')
210
  self.assertEqual(response.status_code, 500)
211
 
212
  data = json.loads(response.data)
213
- self.assertIn('General error', data['error'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
 
215
 
216
  if __name__ == '__main__':
 
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
 
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
  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
 
 
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
 
 
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_frontend_sanitizes_prompt_and_raw_fallback(self):
461
+ script = Path('static/script.js').read_text()
462
+ template = Path('templates/index.html').read_text()
463
+
464
+ self.assertIn('DOMPurify.sanitize', script)
465
+ self.assertIn('dompurify', template.lower())
466
+ self.assertIn('pre.textContent', script)
467
+ self.assertNotIn("rawOutput.innerHTML = '<pre", script)
468
+
469
+ def test_langextract_floor_supports_resolver_params(self):
470
+ project = Path('pyproject.toml').read_text()
471
+
472
+ self.assertIn('"langextract>=1.6.0,<2.0.0"', project)
473
+
474
+ def test_sample_preprocessing_is_idempotent_and_cache_is_complete(self):
475
+ samples = json.loads(Path('static/sample_reports.json').read_text())['samples']
476
+ cache = json.loads(Path('cache/sample_cache.json').read_text())
477
+
478
+ for sample in samples:
479
+ with self.subTest(sample=sample['id']):
480
+ normalized = app_module.preprocess_report(sample['text'])
481
+ self.assertEqual(app_module.preprocess_report(normalized), normalized)
482
+ self.assertIn(f"sample_{sample['id']}", cache)
483
+
484
+ def test_pytest_discovers_root_test_files_without_fallback(self):
485
+ project = Path('pyproject.toml').read_text()
486
+
487
+ self.assertIn('testpaths = ["test_app.py", "test_validation.py"]', project)
488
 
489
 
490
  if __name__ == '__main__':
test_validation.py CHANGED
@@ -22,12 +22,17 @@ import unittest
22
  from typing import Any
23
  from unittest import mock
24
 
 
 
25
  # Add the current directory to path for imports
26
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
27
 
28
  from structure_report import RadiologyReportStructurer
29
 
30
 
 
 
 
31
  class TestRadiologyReportEndToEnd(unittest.TestCase):
32
  """End-to-end tests for complete RadiologyReportStructurer pipeline."""
33
 
@@ -96,6 +101,13 @@ class TestRadiologyReportEndToEnd(unittest.TestCase):
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-flash-latest')
 
 
 
 
 
 
 
99
 
100
  def test_legacy_pro_model_resolves_to_rolling_alias(self):
101
  structurer = RadiologyReportStructurer(
@@ -129,19 +141,54 @@ class TestRadiologyReportEndToEnd(unittest.TestCase):
129
  def test_error_handling_with_no_api_key(self):
130
  error_structurer = RadiologyReportStructurer(api_key=None)
131
 
132
- response = error_structurer.predict('EXAMINATION: Test')
 
 
 
 
 
 
 
133
 
134
- self._validate_response_structure(response)
135
- self.assertEqual(len(response['segments']), 0)
136
- self.assertIn('Error processing report', response['text'])
137
-
138
- def test_patch_initialization_on_first_use(self):
139
  new_structurer = RadiologyReportStructurer()
140
 
141
- self.assertFalse(new_structurer._patches_initialized)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
- new_structurer._ensure_patches_initialized()
144
- self.assertTrue(new_structurer._patches_initialized)
 
 
 
145
 
146
  def test_section_mapping_core_functionality(self):
147
  self.assertEqual(
 
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
 
 
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(
 
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(