| Read the current repository completely before modifying any code. |
|
|
| Pay special attention to: |
|
|
| - src/prompts.py |
| - src/parsing.py |
| - src/inference.py |
| - src/schemas.py |
| - src/model.py |
| - app.py |
| - design-level-spec.md |
| - code-level-spec.md |
|
|
| Do not make unrelated architectural changes. |
|
|
| The current architecture is already: |
|
|
| multi-view study images |
| ↓ |
| one MedGemma study-level finding discovery call |
| ↓ |
| consolidated findings |
| ↓ |
| per-finding × per-view localization |
| ↓ |
| per-view bounding boxes |
| |
| Keep this architecture. |
|
|
| The current problem is FINDING DISCOVERY OUTPUT NORMALIZATION AND PARSING. |
|
|
| We observed the following real MedGemma response: |
|
|
| FINDINGS: |
| There is a small nodule in the right upper lobe. |
| There is a small nodule in the left upper lobe. |
| The heart size is normal. |
| The mediastinum is normal. |
| The lungs are clear. |
| There is no pleural effusion. |
| There is no pneumothorax. |
| There is no fracture. |
|
|
| Consolidated list of findings: |
| [ |
| {"finding": "nodule", "anatomical_location": "right upper lobe", "certainty": "positive"}, |
| {"finding": "nodule", "anatomical_location": "left upper lobe", "certainty": "positive"} |
| ] |
|
|
| The model successfully identified findings. |
|
|
| However, the application ultimately produced: |
|
|
| { |
| "findings": [] |
| } |
|
|
| This is incorrect. |
|
|
| The system must preserve and parse the model findings instead of silently converting a parsing or schema mismatch into an empty finding result. |
|
|
| The expected normalized payload for the real response above is: |
|
|
| { |
| "findings": [ |
| { |
| "finding": "nodule", |
| "anatomical_location": "right upper lobe", |
| "certainty": "positive" |
| }, |
| { |
| "finding": "nodule", |
| "anatomical_location": "left upper lobe", |
| "certainty": "positive" |
| } |
| ] |
| } |
| |
| Implement the following changes. |
|
|
| ================================================== |
| 1. REPLACE THE MEDGEMMA FINDING DISCOVERY PROMPT |
| ================================================== |
|
|
| In `src/prompts.py`, replace the current `FINDING_DISCOVERY_PROMPT` with a stricter multi-view study-level prompt. |
|
|
| Use this prompt content: |
|
|
| Instructions: |
| Review all provided chest X-ray images from the same radiographic study and identify visible positive abnormal radiographic findings. |
|
|
| Study interpretation rules: |
| 1. All provided images belong to the same patient and the same radiographic study. |
| 2. Treat all provided images as complementary views of one examination. |
| 3. Integrate visual evidence across all provided views. |
| 4. Do not treat each image as a separate patient or separate study. |
| 5. Return one consolidated study-level list of findings. |
| 6. Do not duplicate the same finding only because it is visible in multiple views. |
| 7. A finding does not need to be equally visible in every view. |
| 8. If a finding is clearly visible or reasonably supported in at least one provided view, it may be included even when it is subtle, obscured, or not confidently visible in another view. |
| 9. Use additional views as complementary evidence, not as a veto against a finding visible in one view. |
| 10. Analyze image evidence only. |
| 11. Do not assume access to a radiology report, prior report, patient history, laboratory data, or clinical diagnosis. |
| 12. The provided images are different views from the same examination and are not longitudinal studies. |
| 13. Do not infer temporal descriptions such as "new", "increased", "recurrent", "improved", or "worsened" unless actual prior-study images are explicitly supplied separately. |
|
|
| Finding rules: |
| 1. Do not generate a radiology report. |
| 2. Do not provide a final clinical diagnosis. |
| 3. Prefer radiographic finding terminology over disease diagnosis terminology. |
| 4. Examples of preferred findings include: |
| - pneumothorax |
| - pleural effusion |
| - focal airspace opacity |
| - consolidation |
| - atelectatic opacity |
| - pulmonary nodule |
| - hilar enlargement |
| - visible fracture |
| 5. Include only positive or suspected visible abnormal findings. |
| 6. Do not include normal anatomical observations. |
| 7. Do not include negative findings such as: |
| - no pneumothorax |
| - no pleural effusion |
| - lungs are clear |
| - heart size is normal |
| 8. For "certainty", use exactly one of: |
| - positive |
| - probable |
| - questionable |
| 9. "Left" and "right" refer to the patient's anatomical left and right. |
| 10. Keep the anatomical location concise and image-grounded. |
| 11. If no positive abnormal finding is visible across the study, return: |
| {"findings":[]} |
| |
| STRICT OUTPUT CONTRACT: |
| Your entire response MUST be exactly one raw JSON object. |
|
|
| The first non-whitespace character MUST be "{" |
| and the last non-whitespace character MUST be "}". |
|
|
| Do not output: |
| - reasoning |
| - analysis |
| - explanations |
| - headings |
| - markdown |
| - code fences |
| - "FINDINGS:" |
| - "Consolidated list of findings:" |
| - text before the JSON |
| - text after the JSON |
|
|
| Do not return a top-level JSON list. |
|
|
| Incorrect output: |
|
|
| [ |
| { |
| "finding": "nodule", |
| "anatomical_location": "right upper lobe", |
| "certainty": "positive" |
| } |
| ] |
| |
| Correct output: |
|
|
| { |
| "findings": [ |
| { |
| "finding": "nodule", |
| "anatomical_location": "right upper lobe", |
| "certainty": "positive" |
| } |
| ] |
| } |
| |
| Return exactly this schema: |
|
|
| { |
| "findings": [ |
| { |
| "finding": "radiographic finding", |
| "anatomical_location": "concise anatomical location", |
| "certainty": "positive" |
| } |
| ] |
| } |
| |
| Review all provided views and return only the raw JSON object. |
|
|
| Important implementation note: |
|
|
| Do NOT ask MedGemma to provide reasoning before the final answer. |
|
|
| Do NOT include phrases such as: |
|
|
| "Don't give a final answer without reasoning" |
| |
| for finding discovery. |
|
|
| The finding discovery response is machine-consumed structured output. |
|
|
| ================================================== |
| 2. ADD A DISCOVERY-SPECIFIC NORMALIZATION PARSER |
| ================================================== |
|
|
| Do not rely only on the generic: |
|
|
| extract_json_payload(response, dict) |
| |
| for finding discovery. |
|
|
| Create a new function in `src/parsing.py`: |
|
|
| def extract_finding_discovery_payload( |
| response: str, |
| ) -> dict[str, Any]: |
| ... |
| |
| The parser must support the expected output as well as known MedGemma format deviations. |
|
|
| Required behavior: |
|
|
| STEP 1 |
| Strip the MedGemma thinking trace using the existing: |
|
|
| strip_medgemma_thinking_trace(...) |
| |
| Then call: |
|
|
| cleaned = cleaned.strip() |
| |
| STEP 2 |
| Try parsing the entire cleaned response using: |
|
|
| json.loads(cleaned) |
| |
| If the payload is: |
|
|
| {"findings": [...]} |
| |
| return it. |
|
|
| If the payload is a top-level list: |
|
|
| [ |
| {"finding": ...}, |
| ... |
| ] |
| |
| normalize it to: |
|
|
| { |
| "findings": payload |
| } |
| |
| Do not return an empty list merely because the top-level type differs from the expected type. |
|
|
| STEP 3 |
| Support fenced JSON when the model ignores the raw-JSON instruction. |
|
|
| For every `json` code fence found in the response: |
|
|
| ```json |
| ... |
| ``` |
| |
| attempt `json.loads`. |
|
|
| If the parsed value is a dict containing `findings`, return it. |
|
|
| If the parsed value is a list of finding-like objects, normalize it to: |
|
|
| {"findings": parsed_list} |
| |
| STEP 4 |
| Support the real observed MedGemma response: |
|
|
| Consolidated list of findings: |
| [ |
| {...}, |
| {...} |
| ] |
| |
| If the marker: |
|
|
| "Consolidated list of findings:" |
| |
| exists, inspect the content after the marker. |
|
|
| Extract and parse the first valid JSON array after the marker. |
|
|
| Normalize: |
|
|
| [finding objects] |
| |
| to: |
|
|
| { |
| "findings": finding_objects |
| } |
| |
| STEP 5 |
| Implement a robust JSON candidate scanner. |
|
|
| Do not use only: |
|
|
| first "{" |
| ... |
| last "}" |
| |
| because prose responses may contain multiple independent JSON objects inside a JSON array. |
|
|
| Use: |
|
|
| json.JSONDecoder().raw_decode(...) |
| |
| to scan possible JSON values beginning at every `{` or `[` position. |
|
|
| Conceptually: |
|
|
| decoder = json.JSONDecoder() |
| |
| for index, character in enumerate(cleaned): |
| if character not in "{[": |
| continue |
| |
| try: |
| payload, end = decoder.raw_decode(cleaned[index:]) |
| except json.JSONDecodeError: |
| continue |
| |
| evaluate payload as a discovery candidate |
| |
| Candidate normalization rules: |
|
|
| A valid discovery dict is: |
|
|
| isinstance(payload, dict) |
| and "findings" in payload |
| and isinstance(payload["findings"], list) |
| |
| A valid discovery list is: |
|
|
| isinstance(payload, list) |
| |
| and every item is a dict containing: |
|
|
| "finding" |
| "anatomical_location" |
| "certainty" |
| |
| Normalize a valid list to: |
|
|
| {"findings": payload} |
| |
| Prefer candidates in this order: |
|
|
| 1. exact full-response JSON; |
| 2. explicit dict with a `findings` key; |
| 3. payload following "Consolidated list of findings:"; |
| 4. fenced JSON; |
| 5. valid finding list discovered by JSON scanning. |
|
|
| Do not treat an individual finding object such as: |
|
|
| { |
| "finding": "nodule", |
| "anatomical_location": "right upper lobe", |
| "certainty": "positive" |
| } |
| |
| as a complete discovery response by itself if a surrounding list with multiple findings exists elsewhere in the response. |
|
|
| STEP 6 |
| If no valid discovery payload can be extracted: |
|
|
| raise ValueError |
| |
| with an error message containing a concise preview of the raw response. |
|
|
| For example: |
|
|
| raise ValueError( |
| "Could not extract finding discovery JSON from MedGemma response. " |
| f"Response preview: {cleaned[:500]!r}" |
| ) |
| |
| CRITICAL: |
|
|
| DO NOT silently convert parsing failure to: |
|
|
| {"findings": []} |
| |
| An empty finding list is valid ONLY when the model explicitly returns: |
|
|
| {"findings":[]} |
| |
| or: |
|
|
| [] |
| |
| A parser failure must remain a parser failure. |
|
|
| ================================================== |
| 3. ADD FINDING-LIKE OBJECT VALIDATION |
| ================================================== |
|
|
| In `src/parsing.py`, add a small internal helper such as: |
|
|
| def _is_finding_item(value: Any) -> bool: |
| ... |
| |
| It should return True only when: |
|
|
| isinstance(value, dict) |
| |
| and the keys: |
|
|
| finding |
| anatomical_location |
| certainty |
| |
| are present. |
|
|
| Add another helper if useful: |
|
|
| def _normalize_discovery_candidate( |
| payload: Any, |
| ) -> dict[str, Any] | None: |
| ... |
| |
| Expected behavior: |
|
|
| {"findings": [...]} -> return unchanged |
| |
| [{finding item}, ...] -> {"findings": [...]} |
| |
| single finding dict -> None |
| |
| unrelated dict -> None |
| |
| unrelated list -> None |
| |
| Use these helpers inside `extract_finding_discovery_payload`. |
|
|
| Keep the implementation readable. |
|
|
| Do not build one huge regex-based parser. |
|
|
| ================================================== |
| 4. MODIFY discover_findings() |
| ================================================== |
| |
| In `src/inference.py`, update: |
| |
| CXRFindingsLocalizer.discover_findings(...) |
| |
| to use: |
| |
| extract_finding_discovery_payload |
| |
| instead of the generic dict extractor. |
| |
| Required implementation behavior: |
| |
| def discover_findings( |
| self, |
| images: Sequence[Image.Image], |
| ) -> FindingDiscovery: |
| response = self.medgemma.generate( |
| images=images, |
| prompt=FINDING_DISCOVERY_PROMPT, |
| max_new_tokens=DISCOVERY_MAX_NEW_TOKENS, |
| ) |
| |
| print("\n=== RAW FINDING DISCOVERY RESPONSE ===") |
| print(response) |
| print("======================================\n") |
| |
| payload = extract_finding_discovery_payload(response) |
| |
| print("\n=== NORMALIZED FINDING DISCOVERY PAYLOAD ===") |
| print(json.dumps(payload, indent=2, ensure_ascii=False)) |
| print("============================================\n") |
| |
| return FindingDiscovery.model_validate(payload) |
| |
| Import: |
| |
| import json |
| |
| and: |
| |
| from src.parsing import extract_finding_discovery_payload |
| |
| Keep the raw response debug output for the current development phase. |
| |
| Keep the normalized payload debug output as well. |
| |
| Do not print the hidden IU X-Ray reference here. |
| |
| ================================================== |
| 5. REVIEW FOR SILENT EMPTY-FINDING FALLBACKS |
| ================================================== |
| |
| Search the entire repository for code that can convert: |
| |
| - JSON parsing failure; |
| - Pydantic validation failure; |
| - malformed discovery output; |
| - model formatting mismatch |
| |
| into: |
| |
| FindingDiscovery(findings=[]) |
| |
| or: |
| |
| {"findings": []} |
| |
| or any equivalent empty normal result. |
| |
| Remove such fallback behavior from the FINDING DISCOVERY stage. |
| |
| Discovery errors must fail visibly. |
| |
| Localization remains different: |
| |
| A localization parse failure may continue to use: |
| |
| boxes = [] |
| |
| with a warning for that finding/view pair. |
| |
| This distinction is required: |
| |
| DISCOVERY PARSE FAILURE: |
| fail visibly |
| |
| VALID MODEL OUTPUT {"findings":[]}: |
| valid no-finding result |
| |
| LOCALIZATION PARSE FAILURE: |
| warning + boxes=[] + continue |
| |
| Do not conflate the three states. |
| |
| ================================================== |
| 6. KEEP LOCALIZATION PARSING SEPARATE |
| ================================================== |
| |
| Do not replace the localization parser with the finding discovery parser. |
| |
| Localization still expects: |
| |
| [ |
| { |
| "box_2d": [y0, x0, y1, x1], |
| "label": "finding" |
| } |
| ] |
| |
| or: |
| |
| [] |
| |
| Keep the existing localization parsing and Pydantic validation unless there is a clearly identified bug. |
| |
| The current issue is specifically finding discovery output normalization. |
| |
| ================================================== |
| 7. ADD UNIT TESTS FOR THE REAL FAILURE |
| ================================================== |
|
|
| Add parser tests. |
|
|
| Use the repository's existing test pattern if present. |
|
|
| Otherwise create: |
|
|
| tests/test_parsing.py |
| |
| Do not require MedGemma or GPU for these tests. |
|
|
| TEST A — strict expected dict |
|
|
| Input: |
|
|
| { |
| "findings": [ |
| { |
| "finding": "nodule", |
| "anatomical_location": "right upper lobe", |
| "certainty": "positive" |
| } |
| ] |
| } |
| |
| Expected: |
|
|
| { |
| "findings": [ |
| ... |
| ] |
| } |
| |
| TEST B — top-level list |
|
|
| Input: |
|
|
| [ |
| { |
| "finding": "nodule", |
| "anatomical_location": "right upper lobe", |
| "certainty": "positive" |
| } |
| ] |
| |
| Expected normalized result: |
|
|
| { |
| "findings": [ |
| ... |
| ] |
| } |
| |
| TEST C — exact real MedGemma response |
|
|
| Use this exact string: |
|
|
| FINDINGS: |
| There is a small nodule in the right upper lobe. |
| There is a small nodule in the left upper lobe. |
| The heart size is normal. |
| The mediastinum is normal. |
| The lungs are clear. |
| There is no pleural effusion. |
| There is no pneumothorax. |
| There is no fracture. |
| |
| Consolidated list of findings: |
| [ |
| {"finding": "nodule", "anatomical_location": "right upper lobe", "certainty": "positive"}, |
| {"finding": "nodule", "anatomical_location": "left upper lobe", "certainty": "positive"} |
| ] |
| |
| Expected: |
|
|
| { |
| "findings": [ |
| { |
| "finding": "nodule", |
| "anatomical_location": "right upper lobe", |
| "certainty": "positive" |
| }, |
| { |
| "finding": "nodule", |
| "anatomical_location": "left upper lobe", |
| "certainty": "positive" |
| } |
| ] |
| } |
| |
| Assert: |
|
|
| len(payload["findings"]) == 2 |
| |
| and: |
|
|
| payload["findings"][0]["anatomical_location"] == "right upper lobe" |
| |
| and: |
|
|
| payload["findings"][1]["anatomical_location"] == "left upper lobe" |
| |
| TEST D — fenced JSON |
|
|
| Input: |
|
|
| Here is the result: |
| ```json |
| { |
| "findings": [ |
| { |
| "finding": "pneumothorax", |
| "anatomical_location": "right apex", |
| "certainty": "positive" |
| } |
| ] |
| } |
| ``` |
| |
| Expected one finding. |
|
|
| TEST E — explicit empty findings |
|
|
| Input: |
|
|
| {"findings":[]} |
| |
| Expected: |
|
|
| {"findings":[]} |
| |
| This is a valid no-finding result. |
|
|
| TEST F — top-level empty list |
|
|
| Input: |
|
|
| [] |
| |
| Expected normalized result: |
|
|
| {"findings":[]} |
| |
| TEST G — malformed response |
|
|
| Input: |
|
|
| FINDINGS: |
| The right upper lobe may contain a nodule. |
| |
| Expected: |
|
|
| ValueError |
| |
| It MUST NOT return: |
|
|
| {"findings":[]} |
| |
| TEST H — multiple standalone finding objects inside prose |
|
|
| Input: |
|
|
| First: |
| {"finding":"nodule","anatomical_location":"right upper lobe","certainty":"positive"} |
| |
| Second: |
| {"finding":"nodule","anatomical_location":"left upper lobe","certainty":"positive"} |
| |
| Expected: |
|
|
| ValueError |
| |
| Do not incorrectly select one standalone finding object as the complete study-level response. |
|
|
| ================================================== |
| 8. ADD AN INFERENCE NORMALIZATION TEST |
| ================================================== |
|
|
| Create a mocked MedGemma client returning the exact real response: |
|
|
| FINDINGS: |
| ... |
| Consolidated list of findings: |
| [ |
| {"finding": "nodule", "anatomical_location": "right upper lobe", "certainty": "positive"}, |
| {"finding": "nodule", "anatomical_location": "left upper lobe", "certainty": "positive"} |
| ] |
| |
| Call: |
|
|
| CXRFindingsLocalizer.discover_findings(...) |
| |
| with two dummy PIL images. |
|
|
| Verify: |
|
|
| len(discovery.findings) == 2 |
| |
| Verify: |
|
|
| discovery.findings[0].finding == "nodule" |
| |
| Verify: |
|
|
| discovery.findings[0].anatomical_location == "right upper lobe" |
| |
| Verify: |
|
|
| discovery.findings[1].anatomical_location == "left upper lobe" |
| |
| This test must demonstrate that the previously failing real response is now preserved. |
|
|
| ================================================== |
| 9. UPDATE THE SPEC DOCUMENTS |
| ================================================== |
|
|
| Update: |
|
|
| - design-level-spec.md |
| - code-level-spec.md |
|
|
| Document that: |
|
|
| 1. MedGemma finding discovery is prompted to return strict raw JSON. |
| 2. Model output formatting is still treated as non-deterministic. |
| 3. A discovery-specific normalization layer accepts: |
| - expected `{"findings":[...]}` output; |
| - top-level finding lists; |
| - fenced JSON; |
| - prose followed by an explicit consolidated finding list. |
| 4. The normalized internal discovery contract is always: |
|
|
| {"findings": [...]} |
| |
| 5. Parser failure is not equivalent to "no abnormal finding". |
| 6. `findings=[]` is accepted only when explicitly returned by the model. |
| 7. Pydantic validation occurs after normalization. |
| 8. The hidden IU X-Ray reference remains completely separate from discovery parsing. |
|
|
| Do not claim this parser verifies whether the findings are medically correct. |
|
|
| The parser only preserves and normalizes MedGemma's structured predictions. |
|
|
| ================================================== |
| 10. UPDATE README |
| ================================================== |
|
|
| Add a short development note explaining: |
|
|
| MedGemma is instructed to return strict JSON, but generative models may still occasionally emit prose or a top-level list. |
|
|
| The application normalizes known structural variations before Pydantic validation. |
|
|
| Example: |
|
|
| MedGemma output: |
|
|
| Consolidated list of findings: |
| [ |
| {"finding": "nodule", ...} |
| ] |
| |
| Normalized internal representation: |
|
|
| { |
| "findings": [ |
| {"finding": "nodule", ...} |
| ] |
| } |
| |
| Clarify: |
|
|
| This normalization does not create findings and does not use the IU X-Ray reference report. |
|
|
| ================================================== |
| 11. ACCEPTANCE CRITERIA |
| ================================================== |
|
|
| The implementation is complete when the following real response: |
|
|
| FINDINGS: |
| There is a small nodule in the right upper lobe. |
| There is a small nodule in the left upper lobe. |
| The heart size is normal. |
| The mediastinum is normal. |
| The lungs are clear. |
| There is no pleural effusion. |
| There is no pneumothorax. |
| There is no fracture. |
|
|
| Consolidated list of findings: |
| [ |
| {"finding": "nodule", "anatomical_location": "right upper lobe", "certainty": "positive"}, |
| {"finding": "nodule", "anatomical_location": "left upper lobe", "certainty": "positive"} |
| ] |
|
|
| produces: |
|
|
| { |
| "findings": [ |
| { |
| "finding": "nodule", |
| "anatomical_location": "right upper lobe", |
| "certainty": "positive" |
| }, |
| { |
| "finding": "nodule", |
| "anatomical_location": "left upper lobe", |
| "certainty": "positive" |
| } |
| ] |
| } |
| |
| and the CLI no longer prints: |
|
|
| No positive abnormal radiographic finding was returned. |
| |
| for this response. |
|
|
| Instead it must continue to per-view localization for both predicted findings. |
|
|
| ================================================== |
| 12. IMPLEMENTATION CONSTRAINTS |
| ================================================== |
|
|
| - Do not add MedSAM yet. |
| - Do not add report generation yet. |
| - Do not add automatic reference matching. |
| - Do not use reference findings to repair model output. |
| - Do not create a finding when MedGemma did not structurally output one. |
| - Do not silently treat parser failure as normal. |
| - Do not change the multi-view discovery architecture. |
| - Do not change per-view localization. |
| - Do not change the bbox coordinate convention. |
| - Keep MedGemma loaded once. |
| - Avoid unrelated refactoring. |
|
|
| Before editing: |
|
|
| 1. inspect the exact current parser implementation; |
| 2. identify why the real response was converted to an empty finding result; |
| 3. summarize the root cause; |
| 4. list every file you plan to modify. |
|
|
| Then implement. |
|
|
| After implementation: |
|
|
| 1. run Python syntax checks; |
| 2. run all parser tests; |
| 3. run the mocked real-response inference test; |
| 4. inspect for silent empty-finding fallbacks; |
| 5. verify the hidden-reference leakage boundary; |
| 6. list every modified file; |
| 7. report exact test results. |
|
|
| Do not claim real MedGemma inference was rerun unless you actually executed the model. |