cxr-report-generation / specs /4_fix_parser.md
adhisetiawan's picture
Deploy CXR report generation demo
16d6749
|
Raw
History Blame Contribute Delete
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.