|
Download specs/4_fix_parser.md from KalbeDigitalLab/cxr-report-generation: direct link, hf CLI and curl.
- Browser
- Download file 21.3 kB
-
https://huggingface.co/spaces/KalbeDigitalLab/cxr-report-generation/resolve/main/specs/4_fix_parser.md
- Command line
-
hf download hf://spaces/KalbeDigitalLab/cxr-report-generation/specs/4_fix_parser.md
-
curl -L -o 4_fix_parser.md https://huggingface.co/spaces/KalbeDigitalLab/cxr-report-generation/resolve/main/specs/4_fix_parser.md
21.3 kB
| 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. |