Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ jobs:
run: python -m pytest tests/unit/test_user_mri_dataset.py tests/unit/test_user_collection_hook.py -v

- name: Automatic report evidence and source preservation
run: python -m pytest tests/unit/test_report_evidence.py tests/unit/test_report_measurements.py tests/unit/test_report_measurement_cache.py tests/unit/test_quiz_report_questions.py -v
run: python -m pytest tests/unit/test_report_evidence.py tests/unit/test_report_consistency.py tests/unit/test_report_measurements.py tests/unit/test_report_measurement_cache.py tests/unit/test_quiz_report_questions.py -v

- name: Path-safety unit tests
run: python -m pytest tests/unit/test_path_safety.py -v
Expand Down
31 changes: 31 additions & 0 deletions PanTS-Demo/src/components/ReportScreen/SourceConsistencyChecks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { CONSISTENCY_EXPLANATION, getConsistencyItems } from '../../helpers/reportConsistency';
import type { ReportData } from '../../helpers/reportFindings';

// Where the report's own sources disagree. A discrepancy asks a person to compare
// sources; it is never a finding (docs/report-evidence.md, "Source consistency checks").
export default function SourceConsistencyChecks({ data, dark = false }: { data: ReportData; dark?: boolean }) {
const items = getConsistencyItems(data.consistency);
if (items.length === 0) return null;
const discrepancies = items.filter(item => item.status === 'discrepancy');
const consistent = items.length - discrepancies.length;
const border = dark ? 'rgba(255,255,255,0.16)' : '#d9e0eb';
const summary = discrepancies.length
? `Source consistency: ${discrepancies.length} ${discrepancies.length === 1 ? 'discrepancy' : 'discrepancies'} to review`
: `Source consistency: no discrepancies in ${items.length} ${items.length === 1 ? 'check' : 'checks'}`;
return (
<details data-testid="source-consistency" style={{ borderTop: `1px solid ${border}`, paddingTop: 14, marginBottom: 14 }}>
<summary style={{ cursor: 'pointer', fontSize: 15, fontWeight: 700 }}>{summary}</summary>
<p style={{ margin: '10px 0 8px', fontSize: 13.5 }}>{CONSISTENCY_EXPLANATION}</p>
{discrepancies.length > 0 && (
<ul aria-label="Source discrepancies" style={{ paddingLeft: 20, margin: '0 0 8px' }}>
{discrepancies.map(item => <li key={item.text} style={{ marginBottom: 4 }}>{item.text}</li>)}
</ul>
)}
{consistent > 0 && (
<p style={{ margin: 0, fontSize: 13, opacity: 0.8 }}>
{consistent} other {consistent === 1 ? 'check agrees' : 'checks agree'} across sources.
</p>
)}
</details>
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { getCoverageLimitations, getSourceReportText, REPORT_ASSESSMENT_SCOPE } from '../../helpers/reportFindings';
import type { ReportData } from '../../helpers/reportFindings';
import SourceConsistencyChecks from './SourceConsistencyChecks';

export default function SourceReportDetails({ data, dark = false }: { data: ReportData; dark?: boolean }) {
const sourceText = getSourceReportText(data);
Expand All @@ -27,6 +28,7 @@ export default function SourceReportDetails({ data, dark = false }: { data: Repo
{getCoverageLimitations(data).map(limitation => <li key={limitation}>{limitation}</li>)}
</ul>
</details>
<SourceConsistencyChecks data={data} dark={dark} />
{sourceText ? (
<details open={referenceOpen} style={{ borderTop: `1px solid ${dark ? 'rgba(255,255,255,0.16)' : '#d9e0eb'}`, paddingTop: 14, marginBottom: 18 }}>
<summary onClick={event => { event.preventDefault(); setExpandedReference(referenceOpen ? null : data); }} style={{ cursor: 'pointer', fontSize: 16, fontWeight: 700 }}>
Expand Down
119 changes: 119 additions & 0 deletions PanTS-Demo/src/helpers/reportConsistency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Source consistency checks (flask-server/services/report_consistency.py).
// Display text is built here from typed fields only; incoming prose is never
// shown, matching how coverage limitations are handled (docs/report-evidence.md).

export type ConsistencyKind = 'source_volume' | 'parts_whole' | 'recorded_sex';
export type ConsistencyStatus = 'discrepancy' | 'consistent';

export interface ConsistencyCheck {
kind: ConsistencyKind;
status: ConsistencyStatus;
structures: string[];
measured_volume?: number | null;
source_volume?: number | null;
parts_volume?: number | null;
whole_volume?: number | null;
difference_percent?: number | null;
recorded_sex?: string | null;
}

export interface ReportConsistency {
schema_version?: string;
checks: ConsistencyCheck[];
discrepancy_count?: number;
}

export interface ConsistencyItem {
kind: ConsistencyKind;
status: ConsistencyStatus;
text: string;
}

export const CONSISTENCY_EXPLANATION =
'These checks compare the sources this report already holds: segmentation measurements, the stored source '
+ 'reference, and case metadata. A discrepancy means a person should compare the sources. It does not indicate '
+ 'disease, and it does not say which source is correct.';

const KINDS: ConsistencyKind[] = ['source_volume', 'parts_whole', 'recorded_sex'];
const STRUCTURE = /^[a-z][a-z0-9_]*$/;

export function structureLabel(name: string): string {
const side = /^(.*)_(left|right)$/.exec(name);
return (side ? `${side[2]} ${side[1]}` : name).replace(/_/g, ' ');
}

function cc(value: unknown): string | null {
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? `${value.toFixed(1)} cc` : null;
}

function describe(check: ConsistencyCheck): string | null {
const [first, ...rest] = check.structures;
const name = structureLabel(first);
if (check.kind === 'source_volume') {
const measured = cc(check.measured_volume);
const source = cc(check.source_volume);
if (!measured || !source) return null;
return check.status === 'discrepancy'
? `Measured ${name} volume (${measured}) and the volume stated in the source reference (${source}) do not match.`
: `Measured ${name} volume matches the source reference (${measured} vs. ${source}).`;
}
if (check.kind === 'parts_whole') {
const parts = cc(check.parts_volume);
if (!parts || rest.length === 0) return null;
const partNames = rest.map(structureLabel).join(', ');
const whole = cc(check.whole_volume);
if (check.status === 'discrepancy') {
return whole
? `The ${name} measures ${whole}, but its parts (${partNames}) measure ${parts} in total.`
: `No ${name} measurement is available, but its parts (${partNames}) measure ${parts} in total.`;
}
return whole ? `The ${name} (${whole}) matches the sum of its parts (${parts}).` : null;
}
const measured = cc(check.measured_volume);
const sex = check.recorded_sex === 'F' ? 'female' : check.recorded_sex === 'M' ? 'male' : null;
if (!measured || !sex) return null;
return check.status === 'discrepancy'
? `A ${name} mask (${measured}) is present, but the case metadata records sex as ${sex}. The metadata, the structure name, or the mask may be wrong.`
: `The ${name} mask matches the recorded sex.`;
}

const NUMBER_FIELDS = ['measured_volume', 'source_volume', 'parts_volume', 'whole_volume', 'difference_percent'] as const;

/** Keep only typed fields from a server payload; prose such as `message` is discarded. */
export function normalizeConsistency(value: unknown): ReportConsistency | undefined {
const checks = (value as { checks?: unknown } | null | undefined)?.checks;
if (!value || typeof value !== 'object' || !Array.isArray(checks)) return undefined;
const kept: ConsistencyCheck[] = [];
for (const raw of checks) {
if (!raw || typeof raw !== 'object') continue;
const check = raw as Record<string, unknown>;
const copy: ConsistencyCheck = {
kind: check.kind as ConsistencyKind,
status: check.status as ConsistencyStatus,
structures: Array.isArray(check.structures) ? check.structures.filter(name => typeof name === 'string') : [],
recorded_sex: typeof check.recorded_sex === 'string' ? check.recorded_sex : null,
};
for (const field of NUMBER_FIELDS) {
const number = check[field];
copy[field] = typeof number === 'number' && Number.isFinite(number) ? number : null;
}
kept.push(copy);
}
return { checks: kept };
}

/** Validated display items, discrepancies first. Malformed checks are dropped. */
export function getConsistencyItems(consistency: unknown): ConsistencyItem[] {
const checks = (consistency as ReportConsistency | null | undefined)?.checks;
if (!Array.isArray(checks)) return [];
const items: ConsistencyItem[] = [];
for (const check of checks as ConsistencyCheck[]) {
if (!check || !KINDS.includes(check.kind)) continue;
if (check.status !== 'discrepancy' && check.status !== 'consistent') continue;
if (!Array.isArray(check.structures) || check.structures.length === 0
|| !check.structures.every(name => typeof name === 'string' && STRUCTURE.test(name))) continue;
const text = describe(check);
if (text) items.push({ kind: check.kind, status: check.status, text });
}
return items.sort((a, b) => Number(b.status === 'discrepancy') - Number(a.status === 'discrepancy'));
}
5 changes: 5 additions & 0 deletions PanTS-Demo/src/helpers/reportFindings.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
// Report text and segmentation measurements are separate evidence sources.
// Neither absent flags nor an in-range measurement establishes clinical normality.
import { normalizeConsistency } from './reportConsistency';
import type { ReportConsistency } from './reportConsistency';

export interface OrganData {
volume: number | null;
mean_hu: number | null;
Expand Down Expand Up @@ -49,6 +52,7 @@ export interface ReportData {
image_review_performed: boolean;
};
coverage?: { status: string; measured_structures: number; limitations: string[] };
consistency?: ReportConsistency;
}

const DEFAULT_LIMITATIONS = [
Expand Down Expand Up @@ -125,6 +129,7 @@ export function normalizeReportData(value: unknown): ReportData | null {
status: 'limited', measured_structures: Object.keys(organ_volumes).length,
limitations: trustedCoverageLimitations(Object.keys(organ_volumes).length, source.load_failed === true),
},
consistency: normalizeConsistency(value.consistency),
};
}

Expand Down
97 changes: 97 additions & 0 deletions PanTS-Demo/src/test/sourceConsistency.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import ReportScreen from '../components/ReportScreen/ReportScreen';
import SourceReportDetails from '../components/ReportScreen/SourceReportDetails';
import { getConsistencyItems, structureLabel } from '../helpers/reportConsistency';
import { normalizeReportData } from '../helpers/reportFindings';
import type { ReportData } from '../helpers/reportFindings';

// Synthetic values only; no patient data.
const checks = [
{ kind: 'source_volume', status: 'consistent', structures: ['liver'], measured_volume: 1020, source_volume: 1000, difference_percent: 2 },
{ kind: 'source_volume', status: 'discrepancy', structures: ['spleen'], measured_volume: 440, source_volume: 100, difference_percent: 340 },
{ kind: 'parts_whole', status: 'discrepancy', structures: ['pancreas', 'pancreas_head', 'pancreas_body', 'pancreas_tail'], parts_volume: 113, whole_volume: null },
{ kind: 'recorded_sex', status: 'discrepancy', structures: ['prostate'], measured_volume: 144.5, recorded_sex: 'F' },
];

function report(consistency: unknown): ReportData {
return {
case_id: 'synthetic-7', patient: { age: 'Unknown', sex: 'Unknown' }, organ_volumes: {},
comments: '', impression: [], consistency: consistency as ReportData['consistency'],
source_report: { available: true, source: 'Synthetic report fixture', text: 'Synthetic source.', findings: '', impression: [], reviewed_by_clinician: false },
};
}

describe('source consistency helper', () => {
it('labels sided structures readably', () => {
expect(structureLabel('kidney_left')).toBe('left kidney');
expect(structureLabel('adrenal_gland_right')).toBe('right adrenal gland');
expect(structureLabel('pancreas_head')).toBe('pancreas head');
});

it('builds text from typed fields, discrepancies first, and ignores incoming prose', () => {
const items = getConsistencyItems({ checks: checks.map(check => ({ ...check, message: 'All organs healthy.' })) });
expect(items.map(item => item.status)).toEqual(['discrepancy', 'discrepancy', 'discrepancy', 'consistent']);
expect(items[0].text).toBe('Measured spleen volume (440.0 cc) and the volume stated in the source reference (100.0 cc) do not match.');
expect(items[1].text).toBe('No pancreas measurement is available, but its parts (pancreas head, pancreas body, pancreas tail) measure 113.0 cc in total.');
expect(items.map(item => item.text).join(' ')).not.toMatch(/healthy/);
});

it('drops malformed or unknown checks', () => {
expect(getConsistencyItems(undefined)).toEqual([]);
expect(getConsistencyItems({ checks: 'nope' })).toEqual([]);
expect(getConsistencyItems({ checks: [
{ kind: 'diagnosis', status: 'discrepancy', structures: ['spleen'], measured_volume: 1, source_volume: 2 },
{ kind: 'source_volume', status: 'abnormal', structures: ['spleen'], measured_volume: 1, source_volume: 2 },
{ kind: 'source_volume', status: 'discrepancy', structures: ['<script>'], measured_volume: 1, source_volume: 2 },
{ kind: 'source_volume', status: 'discrepancy', structures: ['spleen'], measured_volume: Number.NaN, source_volume: 2 },
{ kind: 'recorded_sex', status: 'discrepancy', structures: ['prostate'], measured_volume: 5, recorded_sex: 'unknown' },
] })).toEqual([]);
});

it('uses no clinical wording or em-dashes', () => {
const text = getConsistencyItems({ checks }).map(item => item.text).join(' ');
expect(text).not.toMatch(/\b(normal|abnormal|enlarged|healthy|tumou?r|cancer|diagnos)/i);
expect(text).not.toContain('—');
});
});

describe('source consistency from a fetched report', () => {
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });

it('survives normalization without keeping incoming prose', () => {
const normalized = normalizeReportData({ ...report({ checks: checks.map(check => ({ ...check, message: 'All organs healthy.' })) }) });
expect(normalized?.consistency?.checks).toHaveLength(4);
expect(JSON.stringify(normalized?.consistency)).not.toContain('healthy');
expect(normalizeReportData({ ...report(undefined) })?.consistency).toBeUndefined();
});

it('appears in the report walkthrough loaded from the server', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => report({ checks }) } as Response));
render(<ReportScreen id="synthetic-consistency" onClose={vi.fn()} onViewChange={vi.fn()} />);
expect(await screen.findByText('Source consistency: 3 discrepancies to review', { selector: 'summary' })).toBeInTheDocument();
});
});

describe('source consistency panel', () => {
it('shows a closed summary that expands to the discrepancies', () => {
render(<SourceReportDetails data={report({ checks })} />);
const summary = screen.getByText('Source consistency: 3 discrepancies to review', { selector: 'summary' });
expect(summary.closest('details')).not.toHaveAttribute('open');
fireEvent.click(summary);
expect(screen.getByRole('list', { name: 'Source discrepancies' }).children).toHaveLength(3);
expect(screen.getByText('1 other check agrees across sources.')).toBeInTheDocument();
expect(screen.getByText(/does not say which source is correct/)).toBeInTheDocument();
});

it('reports agreement when nothing disagrees', () => {
render(<SourceReportDetails data={report({ checks: [checks[0]] })} />);
expect(screen.getByText('Source consistency: no discrepancies in 1 check', { selector: 'summary' })).toBeInTheDocument();
});

it('renders nothing for older payloads without checks', () => {
render(<SourceReportDetails data={report(undefined)} />);
expect(screen.queryByTestId('source-consistency')).not.toBeInTheDocument();
expect(screen.getByText('Unverified source reference', { selector: 'summary' })).toBeInTheDocument();
});
});
14 changes: 13 additions & 1 deletion docs/report-evidence.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ conventions; without verified structure identities they are not used to produce
organ measurements. Combined-only cases therefore retain their source report
but show unavailable measurements.

## Source consistency checks

`consistency` compares sources the payload already holds and reports where they
disagree: a volume stated in the stored source report against the measured
volume of the same named structure, a whole structure against the sum of its
parts (pancreas against head, body and tail), and a sex-specific structure
against the recorded sex. A discrepancy asks a person to compare the sources. It
is not a finding, it does not say which source is correct, and it never uses
size or wording to suggest disease. The only value read from source text is a
volume stated inside that structure's own section. Thresholds (5% and a 1 cc
presence minimum) are placeholders for review. Logic: `services/report_consistency.py`.

## What this fixes

- Unsupported counts of normal or healthy organs based on segmentation status.
Expand Down Expand Up @@ -69,7 +81,7 @@ reference preserves the original wording. Do not add patient reports, scans,
credentials or production databases to fixtures or pull requests.

Run `npm test` and `npm run build` in `PanTS-Demo`. In `flask-server`, run
`python -m pytest tests/unit/test_report_evidence.py tests/unit/test_report_measurements.py tests/unit/test_report_measurement_cache.py tests/unit/test_quiz_report_questions.py`.
`python -m pytest tests/unit/test_report_evidence.py tests/unit/test_report_consistency.py tests/unit/test_report_measurements.py tests/unit/test_report_measurement_cache.py tests/unit/test_quiz_report_questions.py`.
The backend tests use generated files and isolate report functions from model
services, production data, and database initialization.

Expand Down
Loading