File size: 15,700 Bytes
d6f5fcf
 
 
 
469c3b7
d6f5fcf
469c3b7
d6f5fcf
 
 
 
 
 
 
 
 
 
 
469c3b7
 
d6f5fcf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469c3b7
 
 
 
 
 
 
 
 
d6f5fcf
 
469c3b7
 
d6f5fcf
 
469c3b7
 
 
 
d6f5fcf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469c3b7
 
d6f5fcf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469c3b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6f5fcf
 
469c3b7
 
 
 
 
d6f5fcf
 
 
 
469c3b7
 
 
 
d6f5fcf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469c3b7
d6f5fcf
 
469c3b7
 
d6f5fcf
469c3b7
d6f5fcf
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
// Round 3, pages side: the start screen → SessionCreate mapping, the learner remembered on this device, and the
// stored result of one read for the review page. Pure functions first (unit-tested in sessionOptions.test.ts);
// storage and fetch at the bottom. The finding library has its own module (api/reference.ts).
import { enoughReads, MIN_READS } from '../dashboard/helpers';
import type { Health, Modality, Mode, SessionCreate, SubmitResult } from '../types/contracts';
import { request } from './client';
import { isModality, MODALITIES, MODALITY_DISPLAY } from './labels';

/** The backend serves the curated six-film set to this display name. Never shown to a learner. */
export const SAMPLE_NAME = 'Demo';
export const SAMPLE_LABEL = 'Sample set';
export const ANONYMOUS_NAME = 'Anonymous';
export const COUNTS = [5, 10, 20] as const;
export type Count = (typeof COUNTS)[number];
export const TEST_FILMS = 20;

/** What the learner picks under "What do you want to practice?". */
export type Practice = 'mixed' | 'weak' | 'finding' | 'test';
/** `modality` is the scan type chosen first on the page; absent = Chest X-ray (the contract's default). */
export type StartForm = { name: string; practice: Practice; finding: string; count: Count; modality?: Modality };
export type TestSet = 'A' | 'B';
/** Kept in localStorage so a returning learner resumes the same reading log on this device. */
export type Remembered = { learnerId: string; name: string; tests: TestSet[] };
export type StartContext = { remembered: Remembered | null; reads: number | null; projector?: boolean };

/** A personal code such as "MK-2041": the server finds the same learner by it from any computer. */
export const looksLikeCode = (v: string) => /^[A-Za-z]{0,4}[-_]?\d{2,6}$/.test(v.trim());

const fold = (v: string) => v.trim().toLowerCase();

/** The same person as last time: the name field still holds the remembered name (blank = the anonymous learner). */
export function sameLearner(r: Remembered | null, name: string): r is Remembered {
  return !!r && fold(r.name) === fold(name);
}

/** "My weak spots" needs a history to draw on. */
export const weakSpotsReady = (reads: number | null | undefined) => enoughReads(reads);

/** What one case is called: an X-ray is a film; a CT or MR is a study. */
export const caseNoun = (modality: Modality | string | null | undefined, n = 1): string => {
  const w = modality == null || modality === 'cxr' ? 'film' : 'study';
  return n === 1 ? w : w === 'film' ? 'films' : 'studies';
};

export function weakSpotsNote(reads: number | null | undefined, modality: Modality = 'cxr'): string {
  const noun = caseNoun(modality, 2);
  if (weakSpotsReady(reads)) return `${noun.charAt(0).toUpperCase() + noun.slice(1)} like the ones you have missed most.`;
  const n = Math.max(0, Math.floor(reads ?? 0));
  const left = MIN_READS - n;
  const scope = modality === 'cxr' ? '' : ' of this scan type';
  return `Opens after ${MIN_READS} ${noun}${scope}, so there is something to go on. You have read ${n}; ${left} to go.`;
}

/** "Test myself" exists for chest films only: the fixed test sets are X-ray sets. */
export const testAvailable = (modality: Modality | undefined) => modality == null || modality === 'cxr';
export const TEST_UNAVAILABLE = 'Not yet available for this scan type.';

/** The test sets come in order: A first, then B. With both taken, A comes round again. */
export function nextTest(done: readonly TestSet[]): { mode: Extract<Mode, 'assess_A' | 'assess_B'>; set: TestSet; repeat: boolean } {
  if (done.includes('A') && !done.includes('B')) return { mode: 'assess_B', set: 'B', repeat: false };
  return { mode: 'assess_A', set: 'A', repeat: done.includes('A') };
}

function identity(name: string, ctx: StartContext): Pick<SessionCreate, 'display_name' | 'participant_code'> & { learner_id?: string } {
  const typed = name.trim();
  // A learner who really is called "Demo" must not land on the shared sample learner.
  const display = !typed ? ANONYMOUS_NAME : fold(typed) === fold(SAMPLE_NAME) ? `${typed} (reader)` : typed;
  return {
    display_name: display,
    participant_code: looksLikeCode(typed) ? typed : null,
    ...(sameLearner(ctx.remembered, typed) ? { learner_id: ctx.remembered.learnerId } : {}),
  };
}

/** Start screen → POST /sessions body. `level` stays in the contract but is no longer asked: always "other". */
export function toSessionCreate(form: StartForm, ctx: StartContext): SessionCreate {
  const { learner_id, ...who } = identity(form.name, ctx);
  const base: Record<string, unknown> = { ...(ctx.projector ? { projector: true } : {}), ...(learner_id ? { learner_id } : {}) };
  // Chest X-ray is the contract's default and the X-ray body stays byte-identical; CT / MR say so and name the region.
  if (form.modality && form.modality !== 'cxr') Object.assign(base, { modality: form.modality, body_region: MODALITY_DISPLAY[form.modality].region });
  if (form.practice === 'test') {
    const done = sameLearner(ctx.remembered, form.name) ? ctx.remembered.tests : [];
    return { ...who, level: 'other', mode: nextTest(done).mode, settings: base };
  }
  if (form.practice === 'finding') {
    return { ...who, level: 'other', mode: 'drill', settings: { ...base, case_count: form.count, selection: 'adaptive', label: form.finding } };
  }
  // "My weak spots" without enough history quietly becomes a mixed set (the option is disabled in the form anyway).
  const selection = form.practice === 'weak' && weakSpotsReady(ctx.reads) ? 'weak_areas' : 'adaptive';
  return { ...who, level: 'other', mode: 'practice', settings: { ...base, case_count: form.count, selection } };
}

/** "Try a sample set": the curated six films, read as the shared sample learner. Never tied to the remembered learner. */
export function sampleSessionCreate(ctx: Pick<StartContext, 'projector'> = {}): SessionCreate {
  return { display_name: SAMPLE_NAME, level: 'other', participant_code: null, mode: 'practice', settings: ctx.projector ? { projector: true } : {} };
}

export const isSampleName = (name: string | null | undefined) => !!name && fold(name) === fold(SAMPLE_NAME);

/** How a learner is named on a page: the sample learner is "Sample set", never "Demo". */
export function learnerLabel(name: string | null | undefined): string {
  if (isSampleName(name)) return SAMPLE_LABEL;
  if (!name || fold(name) === fold(ANONYMOUS_NAME)) return 'Anonymous reader';
  return name;
}

// ---------- the learner remembered on this device ----------
const LEARNER_KEY = 'bs_learner';
/** Set by the reading-room tutorial when it is finished or skipped (not by this file). */
export const TUTORIAL_KEY = 'bs_tutorial_done';

type Obj = Record<string, unknown>;
const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v);
const str = (v: unknown): string | null => (typeof v === 'string' ? v : null);
const num = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);

export function parseRemembered(raw: string | null): Remembered | null {
  if (!raw) return null;
  try {
    const o: unknown = JSON.parse(raw);
    if (!isObj(o) || typeof o.learnerId !== 'string' || !o.learnerId) return null;
    return { learnerId: o.learnerId, name: str(o.name) ?? '', tests: arr(o.tests).filter((t): t is TestSet => t === 'A' || t === 'B') };
  } catch {
    return null;
  }
}

export function loadRemembered(): Remembered | null {
  try { return parseRemembered(localStorage.getItem(LEARNER_KEY)); } catch { return null; }
}

/** Keeps the tests already taken when the same learner starts again; a different learner starts clean. */
export function remember(learnerId: string, name: string): Remembered {
  const prev = loadRemembered();
  const next: Remembered = { learnerId, name: name.trim(), tests: prev?.learnerId === learnerId ? prev.tests : [] };
  try { localStorage.setItem(LEARNER_KEY, JSON.stringify(next)); } catch { /* storage blocked: this visit still works */ }
  return next;
}

export function forgetLearner() {
  try { localStorage.removeItem(LEARNER_KEY); } catch { /* nothing stored */ }
}

export function markTestDone(learnerId: string, mode: string) {
  const set: TestSet | null = mode === 'assess_A' ? 'A' : mode === 'assess_B' ? 'B' : null;
  const r = loadRemembered();
  if (!set || !r || r.learnerId !== learnerId || r.tests.includes(set)) return;
  try { localStorage.setItem(LEARNER_KEY, JSON.stringify({ ...r, tests: [...r.tests, set] })); } catch { /* storage blocked */ }
}

/** First session in this browser: the reading room opens its tutorial when asked with ?tutorial=1. */
export function needsTutorial(): boolean {
  try { return localStorage.getItem(TUTORIAL_KEY) == null; } catch { return false; }
}

export const readPath = () => (needsTutorial() ? '/read?tutorial=1' : '/read');

/** Scan types the library holds right now (health `cases_by_modality`); an older server has chest films only. */
export function availableModalities(h: { cases_by_modality?: Record<string, number> | null; modalities?: Modality[] | null } | null | undefined): Modality[] {
  const by = h?.cases_by_modality;
  if (by) return MODALITIES.filter((m) => (by[m] ?? 0) > 0);
  if (h?.modalities?.length) return MODALITIES.filter((m) => h.modalities!.includes(m));
  return ['cxr'];
}

/** "Library: 520 chest films with radiologist outlines." or, with volumes, "…, 12 abdominal CT and 6 brain MRI studies, with expert outlines." */
export function libraryLine(h: Health): string {
  const by = h.cases_by_modality;
  const ct = by?.ct ?? 0;
  const mr = by?.mr ?? 0;
  if (!by || (!ct && !mr)) return `Library: ${h.cases.toLocaleString()} chest films with radiologist outlines.`;
  const cxr = by.cxr ?? Math.max(0, h.cases - ct - mr);
  const vols = [ct ? `${ct.toLocaleString()} abdominal CT` : '', mr ? `${mr.toLocaleString()} brain MRI` : ''].filter(Boolean).join(' and ');
  return `Library: ${cxr.toLocaleString()} chest films, ${vols} ${ct + mr === 1 ? 'study' : 'studies'}, with expert outlines.`;
}

// ---------- the scan type remembered on this device ----------
const MODALITY_KEY = 'bs_modality';
export function loadModality(): Modality {
  try { const v = localStorage.getItem(MODALITY_KEY); return isModality(v) ? v : 'cxr'; } catch { return 'cxr'; }
}
export function saveModality(m: Modality) {
  try { localStorage.setItem(MODALITY_KEY, m); } catch { /* storage blocked */ }
}

// Until the reading log counts reads per scan type (dashboard `n_by_modality`), CT / MR reads are counted on this
// device: the studies of every CT / MR set started here. An estimate, and the start screen says so.
const LOCAL_READS_KEY = 'bs_reads_by_modality';
export type LocalReads = Partial<Record<Modality, number>>;
export function parseLocalReads(raw: string | null): LocalReads {
  if (!raw) return {};
  try {
    const o: unknown = JSON.parse(raw);
    if (!isObj(o)) return {};
    return Object.fromEntries(Object.entries(o).filter(([k, v]) => isModality(k) && num(v) && v >= 0).map(([k, v]) => [k, Math.floor(v as number)]));
  } catch {
    return {};
  }
}
export function loadLocalReads(): LocalReads {
  try { return parseLocalReads(localStorage.getItem(LOCAL_READS_KEY)); } catch { return {}; }
}
export function addLocalReads(m: Modality, n: number) {
  const cur = loadLocalReads();
  try { localStorage.setItem(LOCAL_READS_KEY, JSON.stringify({ ...cur, [m]: (cur[m] ?? 0) + n })); } catch { /* storage blocked */ }
}

/** Reads of one scan type: the server's per-modality count when it sends one; an X-ray log without one is all
 *  X-ray (every case before the volumetric round was a chest film); CT / MR fall back to the local estimate. */
export function readsFor(dash: unknown, modality: Modality, local: LocalReads): { n: number; estimated: boolean } {
  const d = isObj(dash) ? dash : {};
  const by = isObj(d.n_by_modality) ? d.n_by_modality : null;
  if (by && num(by[modality])) return { n: by[modality] as number, estimated: false };
  if (by) return { n: 0, estimated: false };
  if (modality === 'cxr') return { n: num(d.n_attempts) ? (d.n_attempts as number) : 0, estimated: false };
  return { n: local[modality] ?? 0, estimated: true };
}

// ---------- stored result for one read (GET /attempts/{aid}/result) ----------
export type ReviewMark = { mark_id: string; x: number; y: number; label: string; confidence: number };
export type ReviewFilm = {
  case_id: string; image_url: string | null; width: number | null; height: number | null;
  /** The NextCase-shaped `volume` block (shape, spacing, window, data_url) when the read was a CT / MR; passed through as sent. */
  volume: Record<string, unknown> | null;
};
export type AttemptReview = {
  result: SubmitResult;
  /** Which film it was, when the server says so. */
  film: ReviewFilm | null;
  /** cxr | ct | mr, from the result, the reveal or the case block; null on an older server (an X-ray). */
  modality: Modality | null;
  /** The '___ by ___' block, when the server sends one (reveal.provenance or case.provenance). */
  provenance: unknown;
  /** Where the learner marked; null when the server does not send the submitted read. */
  marks: ReviewMark[] | null;
  patterns: { label: string; confidence: number }[];
  declaredNormal: boolean | null;
};

function marksOf(v: unknown): ReviewMark[] | null {
  if (!Array.isArray(v)) return null;
  return v.flatMap((m) => (isObj(m) && num(m.x) && num(m.y)
    ? [{ mark_id: str(m.mark_id) ?? '?', x: m.x, y: m.y, label: str(m.label) ?? 'not_sure', confidence: num(m.confidence) ? m.confidence : 3 }]
    : []));
}

/** Throws on a payload that is not a SubmitResult; reads the optional `case` and `submitted` blocks when present. */
export function guardAttemptResult(v: unknown): AttemptReview {
  if (!isObj(v) || !isObj(v.reveal) || !Array.isArray(v.reveal.findings) || !Array.isArray(v.outcomes)) throw new Error('malformed result');
  const reveal = v.reveal;
  const result = {
    ...v,
    score: num(v.score) ? v.score : 0,
    success: v.success === true,
    reveal: { ...reveal, marks: arr(reveal.marks), arrows: arr(reveal.arrows), search: isObj(reveal.search) ? reveal.search : { lung_coverage_pct: 0, unvisited_review_areas: [] } },
    facts_card: isObj(v.facts_card) && typeof v.facts_card.headline === 'string' ? { headline: v.facts_card.headline, lines: arr(v.facts_card.lines).filter((l): l is string => typeof l === 'string') } : { headline: '', lines: [] },
  } as unknown as SubmitResult;
  const c = isObj(v.case) ? v.case : null;
  const sub = isObj(v.submitted) ? v.submitted : null;
  const mod = [v.modality, reveal.modality, c?.modality].find(isModality) ?? null;
  return {
    result,
    modality: mod,
    provenance: isObj(reveal.provenance) ? reveal.provenance : isObj(c?.provenance) ? c.provenance : null,
    film: c && typeof c.case_id === 'string' && c.case_id
      ? { case_id: c.case_id, image_url: str(c.image_url), width: num(c.width) ? c.width : null, height: num(c.height) ? c.height : null, volume: isObj(c.volume) ? c.volume : null }
      : null,
    marks: marksOf(sub?.marks) ?? marksOf(v.marks),
    patterns: arr(sub?.patterns ?? v.patterns).flatMap((p) => (isObj(p) && typeof p.label === 'string' ? [{ label: p.label, confidence: num(p.confidence) ? p.confidence : 3 }] : [])),
    declaredNormal: typeof (sub?.declared_normal ?? v.declared_normal) === 'boolean' ? ((sub?.declared_normal ?? v.declared_normal) as boolean) : null,
  };
}

export const fetchAttemptResult = async (aid: string) => guardAttemptResult(await request<unknown>(`/attempts/${encodeURIComponent(aid)}/result`));