From ec3d94eb06ecd03104d05bbc91420726d46edde8 Mon Sep 17 00:00:00 2001 From: attaboy11 Date: Sun, 31 May 2026 07:31:04 +0100 Subject: [PATCH] Add lay summary safety guard --- lay-summary-safety-guard/README.md | 41 +++ lay-summary-safety-guard/demo.js | 24 ++ lay-summary-safety-guard/index.js | 294 ++++++++++++++++++ lay-summary-safety-guard/render-video.js | 130 ++++++++ lay-summary-safety-guard/reports/demo.mp4 | Bin 0 -> 23817 bytes .../reports/rewrite-packet.md | 44 +++ .../reports/summary-safety-audit.json | 189 +++++++++++ lay-summary-safety-guard/reports/summary.svg | 19 ++ lay-summary-safety-guard/sample-data.js | 47 +++ lay-summary-safety-guard/test.js | 62 ++++ 10 files changed, 850 insertions(+) create mode 100644 lay-summary-safety-guard/README.md create mode 100644 lay-summary-safety-guard/demo.js create mode 100644 lay-summary-safety-guard/index.js create mode 100644 lay-summary-safety-guard/render-video.js create mode 100644 lay-summary-safety-guard/reports/demo.mp4 create mode 100644 lay-summary-safety-guard/reports/rewrite-packet.md create mode 100644 lay-summary-safety-guard/reports/summary-safety-audit.json create mode 100644 lay-summary-safety-guard/reports/summary.svg create mode 100644 lay-summary-safety-guard/sample-data.js create mode 100644 lay-summary-safety-guard/test.js diff --git a/lay-summary-safety-guard/README.md b/lay-summary-safety-guard/README.md new file mode 100644 index 00000000..e831a899 --- /dev/null +++ b/lay-summary-safety-guard/README.md @@ -0,0 +1,41 @@ +# Layperson Summary Safety Guard + +Self-contained reviewer slice for SCIBASE issue #13, AI-Assisted Research Tools. + +This guard focuses on the paper summarizer's layperson explanation mode. It checks whether a generated public-facing summary is safe to share with patients, funders, collaborators, or non-specialist readers before publication or one-click export. + +It audits: + +- unsupported public benefit claims +- over-strong causal language +- medical or treatment-advice wording +- missing uncertainty and limitations +- unexplained jargon +- reading-level drift above the intended audience +- sentence-to-evidence overlap + +The module uses synthetic data only and does not call LLMs, citation services, medical systems, external APIs, or credentialed services. + +## Files + +- `index.js` - lay summary audit engine and report renderers +- `sample-data.js` - unsafe and safe synthetic summary packets +- `test.js` - deterministic regression tests +- `demo.js` - generates JSON, Markdown, and SVG reviewer artifacts +- `render-video.js` - renders the required short MP4 demo +- `reports/` - generated reviewer packet + +## Validation + +```bash +node lay-summary-safety-guard/test.js +node lay-summary-safety-guard/demo.js +node lay-summary-safety-guard/render-video.js +node --check lay-summary-safety-guard/index.js +node --check lay-summary-safety-guard/sample-data.js +node --check lay-summary-safety-guard/test.js +node --check lay-summary-safety-guard/demo.js +node --check lay-summary-safety-guard/render-video.js +git diff --check +ffprobe -v error -select_streams v:0 -show_entries stream=pix_fmt,width,height -show_entries format=duration,size -of default=nw=1 lay-summary-safety-guard/reports/demo.mp4 +``` diff --git a/lay-summary-safety-guard/demo.js b/lay-summary-safety-guard/demo.js new file mode 100644 index 00000000..96503ab5 --- /dev/null +++ b/lay-summary-safety-guard/demo.js @@ -0,0 +1,24 @@ +const path = require('path'); +const { evaluateSummary, writeReports } = require('./index'); +const { unsafePacket } = require('./sample-data'); + +function runDemo(outputDir = path.join(__dirname, 'reports')) { + const audit = evaluateSummary(unsafePacket); + const reports = writeReports(audit, outputDir); + console.log(`Decision: ${audit.decision}`); + console.log(`Sentences checked: ${audit.summary.sentences}`); + console.log(`Reading grade: ${audit.summary.readingGrade}`); + console.log(`Blockers: ${audit.summary.blockers}`); + console.log(`Holds: ${audit.summary.holds}`); + console.log('Reports written:'); + console.log(`- ${reports.jsonPath}`); + console.log(`- ${reports.markdownPath}`); + console.log(`- ${reports.svgPath}`); + return { audit, reports }; +} + +if (require.main === module) { + runDemo(); +} + +module.exports = { runDemo }; diff --git a/lay-summary-safety-guard/index.js b/lay-summary-safety-guard/index.js new file mode 100644 index 00000000..e43cd7fc --- /dev/null +++ b/lay-summary-safety-guard/index.js @@ -0,0 +1,294 @@ +const fs = require('fs'); +const path = require('path'); + +const MEDICAL_ADVICE_RE = /\b(ask your doctor to|stop taking|cure|guaranteed|safe for everyone|replace treatment|take this|prescribe)\b/i; +const CAUSAL_OVERSTATEMENT_RE = /\b(proves|will prevent|will cure|guarantees|eliminates|causes everyone|definitely|certainty)\b/i; +const BENEFIT_RE = /\b(cure|prevent|prevents|reduces|improves|saves|protects|treats|benefit|works)\b/i; +const UNCERTAINTY_RE = /\b(may|might|could|suggests|associated|limited|uncertain|early|small|preliminary|not yet known)\b/i; + +function tokenize(text) { + return String(text || '') + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, ' ') + .split(/\s+/) + .filter(Boolean); +} + +function splitSentences(text) { + return String(text || '') + .split(/(?<=[.!?])\s+/) + .map((sentence) => sentence.trim()) + .filter(Boolean); +} + +function countSyllables(word) { + const cleaned = String(word || '').toLowerCase().replace(/[^a-z]/g, ''); + if (!cleaned) return 0; + const matches = cleaned.replace(/e$/, '').match(/[aeiouy]+/g); + return Math.max(1, matches ? matches.length : 1); +} + +function fleschKincaidGrade(text) { + const sentences = Math.max(1, splitSentences(text).length); + const words = tokenize(text); + const wordCount = Math.max(1, words.length); + const syllables = words.reduce((total, word) => total + countSyllables(word), 0); + return Number((0.39 * (wordCount / sentences) + 11.8 * (syllables / wordCount) - 15.59).toFixed(1)); +} + +function evidenceCoverage(sentence, evidenceItems) { + const words = new Set(tokenize(sentence).filter((word) => word.length > 3)); + let best = { id: null, score: 0, matchedTerms: [] }; + + for (const item of evidenceItems) { + const terms = new Set([...(item.terms || []), ...tokenize(item.text)].filter((term) => term.length > 3)); + const matchedTerms = [...words].filter((word) => terms.has(word)); + const score = terms.size === 0 ? 0 : matchedTerms.length / Math.min(words.size || 1, terms.size); + if (score > best.score) { + best = { id: item.id, score: Number(score.toFixed(2)), matchedTerms }; + } + } + + return best; +} + +function addFinding(findings, severity, code, message, evidence = {}) { + findings.push({ severity, code, message, evidence }); +} + +function evaluateSummary(packet) { + const evidenceItems = packet.sourceEvidence || []; + const findings = []; + const sentenceReports = []; + const sentences = splitSentences(packet.summary); + const grade = fleschKincaidGrade(packet.summary); + const targetGrade = packet.audience?.maxGradeLevel || 8; + + if (grade > targetGrade) { + addFinding( + findings, + 'hold', + 'reading-level-too-high', + `Lay summary reads at grade ${grade}, above the target grade ${targetGrade}.`, + { grade, targetGrade }, + ); + } + + for (const jargon of packet.audience?.jargonTerms || []) { + const regex = new RegExp(`\\b${escapeRegExp(jargon)}\\b`, 'i'); + if (regex.test(packet.summary)) { + addFinding(findings, 'hold', 'jargon-not-explained', `Jargon term needs plain-language explanation: ${jargon}`, { + term: jargon, + }); + } + } + + for (const sentence of sentences) { + const coverage = evidenceCoverage(sentence, evidenceItems); + const sentenceFindings = []; + + if (BENEFIT_RE.test(sentence) && coverage.score < 0.2) { + sentenceFindings.push('benefit-claim-weakly-supported'); + addFinding( + findings, + 'blocker', + 'benefit-claim-weakly-supported', + 'A public-facing benefit claim lacks enough source-evidence overlap.', + { sentence, bestEvidence: coverage }, + ); + } + + if (CAUSAL_OVERSTATEMENT_RE.test(sentence)) { + sentenceFindings.push('causal-overstatement'); + addFinding( + findings, + 'blocker', + 'causal-overstatement', + 'Lay summary uses stronger causal wording than the evidence supports.', + { sentence }, + ); + } + + if (MEDICAL_ADVICE_RE.test(sentence)) { + sentenceFindings.push('medical-advice-risk'); + addFinding( + findings, + 'blocker', + 'medical-advice-risk', + 'Lay summary risks sounding like clinical or medical advice.', + { sentence }, + ); + } + + if (BENEFIT_RE.test(sentence) && !UNCERTAINTY_RE.test(sentence)) { + sentenceFindings.push('uncertainty-missing'); + addFinding( + findings, + 'hold', + 'uncertainty-missing', + 'Benefit-oriented sentence should preserve uncertainty for a public audience.', + { sentence }, + ); + } + + sentenceReports.push({ sentence, bestEvidence: coverage, findings: sentenceFindings }); + } + + for (const required of packet.requiredLimitations || []) { + const terms = tokenize(required); + const summaryWords = new Set(tokenize(packet.summary)); + const matched = terms.filter((term) => summaryWords.has(term)); + if (matched.length < Math.min(2, terms.length)) { + addFinding(findings, 'hold', 'required-limitation-missing', `Required limitation is missing: ${required}`, { + limitation: required, + }); + } + } + + const blockers = findings.filter((finding) => finding.severity === 'blocker'); + const holds = findings.filter((finding) => finding.severity === 'hold'); + const decision = blockers.length ? 'block-public-share' : holds.length ? 'hold-for-rewrite' : 'approve-lay-summary'; + + return { + title: packet.title, + audience: packet.audience, + decision, + shareable: decision === 'approve-lay-summary', + summary: { + sentences: sentences.length, + readingGrade: grade, + blockers: blockers.length, + holds: holds.length, + evidenceItems: evidenceItems.length, + }, + findings, + sentenceReports, + rewriteActions: buildRewriteActions(findings), + }; +} + +function buildRewriteActions(findings) { + const actions = []; + const has = (code) => findings.some((finding) => finding.code === code); + + if (has('benefit-claim-weakly-supported')) { + actions.push('Remove or narrow unsupported benefit claims and attach a specific evidence span.'); + } + if (has('causal-overstatement')) { + actions.push('Replace causal verbs such as "proves" or "will prevent" with cautious language such as "suggests" or "may".'); + } + if (has('medical-advice-risk')) { + actions.push('Remove treatment instructions and add a human-clinician disclaimer when health content is present.'); + } + if (has('uncertainty-missing')) { + actions.push('Add uncertainty, study-size, and population context to benefit-oriented sentences.'); + } + if (has('reading-level-too-high') || has('jargon-not-explained')) { + actions.push('Rewrite with shorter sentences and define scientific terms in everyday language.'); + } + if (has('required-limitation-missing')) { + actions.push('Add the missing study limitations before the summary is shared with non-specialists.'); + } + + return actions; +} + +function renderMarkdown(audit) { + const lines = [ + '# Layperson Summary Safety Guard', + '', + `Title: ${audit.title}`, + `Decision: ${audit.decision}`, + `Audience: ${audit.audience?.name || 'public'}`, + '', + '## Summary', + '', + `- Sentences checked: ${audit.summary.sentences}`, + `- Reading grade: ${audit.summary.readingGrade}`, + `- Evidence items: ${audit.summary.evidenceItems}`, + `- Blockers: ${audit.summary.blockers}`, + `- Holds: ${audit.summary.holds}`, + '', + '## Findings', + '', + ]; + + if (audit.findings.length === 0) { + lines.push('No findings. The lay summary is ready for public sharing.'); + } else { + for (const finding of audit.findings) { + lines.push(`- ${finding.severity.toUpperCase()} ${finding.code}: ${finding.message}`); + } + } + + lines.push('', '## Rewrite Actions', ''); + for (const action of audit.rewriteActions) { + lines.push(`- ${action}`); + } + + lines.push('', '## Sentence Evidence Map', ''); + for (const report of audit.sentenceReports) { + const evidence = report.bestEvidence.id || 'none'; + lines.push(`- ${report.findings.length ? 'REWRITE' : 'OK'} evidence=${evidence} score=${report.bestEvidence.score}: ${report.sentence}`); + } + + return `${lines.join('\n')}\n`; +} + +function renderSvg(audit) { + const statusColor = audit.decision === 'approve-lay-summary' ? '#16a34a' : audit.decision === 'hold-for-rewrite' ? '#ca8a04' : '#dc2626'; + const rows = audit.findings.slice(0, 7).map((finding, index) => { + const y = 188 + index * 36; + const color = finding.severity === 'blocker' ? '#dc2626' : '#ca8a04'; + return `${finding.severity.toUpperCase()}${escapeXml(finding.code)}`; + }).join('\n'); + + return ` + + + + Layperson Summary Safety Guard + ${escapeXml(audit.title)} + + ${escapeXml(audit.decision)} + Grade ${audit.summary.readingGrade}; ${audit.summary.blockers} blockers; ${audit.summary.holds} holds; ${audit.summary.evidenceItems} evidence items + ${rows} + + Blocks unsupported public claims before summaries are shared outside the research team. + +`; +} + +function writeReports(audit, outputDir = path.join(__dirname, 'reports')) { + fs.mkdirSync(outputDir, { recursive: true }); + const jsonPath = path.join(outputDir, 'summary-safety-audit.json'); + const markdownPath = path.join(outputDir, 'rewrite-packet.md'); + const svgPath = path.join(outputDir, 'summary.svg'); + fs.writeFileSync(jsonPath, `${JSON.stringify(audit, null, 2)}\n`); + fs.writeFileSync(markdownPath, renderMarkdown(audit)); + fs.writeFileSync(svgPath, renderSvg(audit)); + return { jsonPath, markdownPath, svgPath }; +} + +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function escapeXml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +module.exports = { + tokenize, + splitSentences, + fleschKincaidGrade, + evidenceCoverage, + evaluateSummary, + renderMarkdown, + renderSvg, + writeReports, +}; diff --git a/lay-summary-safety-guard/render-video.js b/lay-summary-safety-guard/render-video.js new file mode 100644 index 00000000..200e49f5 --- /dev/null +++ b/lay-summary-safety-guard/render-video.js @@ -0,0 +1,130 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { runDemo } = require('./demo'); + +const FONT = { + A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], + B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'], + C: ['01111', '10000', '10000', '10000', '10000', '10000', '01111'], + D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], + E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], + F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'], + G: ['01111', '10000', '10000', '10111', '10001', '10001', '01111'], + H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'], + I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'], + J: ['00111', '00010', '00010', '00010', '10010', '10010', '01100'], + K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], + L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'], + M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], + N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'], + O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], + P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'], + Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'], + R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], + S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], + T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], + U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], + V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'], + W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'], + X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'], + Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'], + Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'], + 0: ['01110', '10001', '10011', '10101', '11001', '10001', '01110'], + 1: ['00100', '01100', '00100', '00100', '00100', '00100', '01110'], + 2: ['01110', '10001', '00001', '00010', '00100', '01000', '11111'], + 3: ['11110', '00001', '00001', '01110', '00001', '00001', '11110'], + 4: ['00010', '00110', '01010', '10010', '11111', '00010', '00010'], + 5: ['11111', '10000', '10000', '11110', '00001', '00001', '11110'], + 6: ['01111', '10000', '10000', '11110', '10001', '10001', '01110'], + 7: ['11111', '00001', '00010', '00100', '01000', '01000', '01000'], + 8: ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], + 9: ['01110', '10001', '10001', '01111', '00001', '00001', '11110'], + '-': ['00000', '00000', '00000', '11111', '00000', '00000', '00000'], + ':': ['00000', '00100', '00100', '00000', '00100', '00100', '00000'], + ' ': ['00000', '00000', '00000', '00000', '00000', '00000', '00000'], +}; + +function rgb(hex) { + const value = hex.replace('#', ''); + return [parseInt(value.slice(0, 2), 16), parseInt(value.slice(2, 4), 16), parseInt(value.slice(4, 6), 16)]; +} + +function fillRect(buffer, width, height, x, y, rectWidth, rectHeight, color) { + const [r, g, b] = color; + for (let py = Math.max(0, y); py < Math.min(height, y + rectHeight); py += 1) { + for (let px = Math.max(0, x); px < Math.min(width, x + rectWidth); px += 1) { + const offset = (py * width + px) * 3; + buffer[offset] = r; + buffer[offset + 1] = g; + buffer[offset + 2] = b; + } + } +} + +function drawText(buffer, width, height, text, x, y, scale, color) { + let cursor = x; + for (const raw of String(text).toUpperCase()) { + const glyph = FONT[raw] || FONT[' ']; + for (let row = 0; row < glyph.length; row += 1) { + for (let col = 0; col < glyph[row].length; col += 1) { + if (glyph[row][col] === '1') { + fillRect(buffer, width, height, cursor + col * scale, y + row * scale, scale, scale, color); + } + } + } + cursor += 6 * scale; + } +} + +function writePpm(filePath, buffer, width, height) { + fs.writeFileSync(filePath, Buffer.concat([Buffer.from(`P6\n${width} ${height}\n255\n`), buffer])); +} + +function renderFrame(filePath, audit, frameIndex, totalFrames) { + const width = 960; + const height = 540; + const buffer = Buffer.alloc(width * height * 3); + const navy = rgb('#0f172a'); + const paper = rgb('#f8fafc'); + const slate = rgb('#334155'); + const red = rgb('#dc2626'); + const amber = rgb('#ca8a04'); + const green = rgb('#16a34a'); + fillRect(buffer, width, height, 0, 0, width, height, navy); + fillRect(buffer, width, height, 46, 42, 868, 456, paper); + fillRect(buffer, width, height, 46, 42, 868, 78, rgb('#e2e8f0')); + fillRect(buffer, width, height, 76, 390, 808, 22, rgb('#cbd5e1')); + fillRect(buffer, width, height, 76, 390, Math.floor(808 * frameIndex / Math.max(1, totalFrames - 1)), 22, red); + drawText(buffer, width, height, 'LAY SUMMARY SAFETY GUARD', 76, 72, 5, navy); + drawText(buffer, width, height, `DECISION ${audit.decision.replace(/-/g, ' ')}`, 76, 146, 4, red); + drawText(buffer, width, height, `${audit.summary.blockers} BLOCKERS ${audit.summary.holds} HOLDS`, 76, 206, 4, slate); + drawText(buffer, width, height, `GRADE ${audit.summary.readingGrade}`, 76, 256, 4, amber); + drawText(buffer, width, height, 'EVIDENCE JARGON UNCERTAINTY', 76, 318, 3, green); + drawText(buffer, width, height, 'JSON MD SVG MP4 GENERATED', 76, 440, 3, slate); + writePpm(filePath, buffer, width, height); +} + +function renderVideo() { + const outputDir = path.join(__dirname, 'reports'); + fs.mkdirSync(outputDir, { recursive: true }); + const { audit } = runDemo(outputDir); + const outputPath = path.join(outputDir, 'demo.mp4'); + const framesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lay-summary-video-')); + const frameCount = 120; + for (let i = 0; i < frameCount; i += 1) { + renderFrame(path.join(framesDir, `frame-${String(i).padStart(3, '0')}.ppm`), audit, i, frameCount); + } + const result = spawnSync('ffmpeg', ['-y', '-framerate', '30', '-i', path.join(framesDir, 'frame-%03d.ppm'), '-an', '-pix_fmt', 'yuv420p', outputPath], { encoding: 'utf8' }); + fs.rmSync(framesDir, { recursive: true, force: true }); + if (result.status !== 0) throw new Error(`ffmpeg failed:\n${result.stderr}`); + console.log(`Video written: ${outputPath}`); + return outputPath; +} + +if (require.main === module) { + renderVideo(); +} + +module.exports = { renderVideo }; diff --git a/lay-summary-safety-guard/reports/demo.mp4 b/lay-summary-safety-guard/reports/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..23a7a34feef82ce8ca9d780909beb41c791cd7fb GIT binary patch literal 23817 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 + + + + Layperson Summary Safety Guard + Early biomarker screen for inflammatory flare risk + + block-public-share + Grade 13.8; 4 blockers; 9 holds; 3 evidence items + HOLDreading-level-too-high +HOLDjargon-not-explained +HOLDjargon-not-explained +HOLDjargon-not-explained +BLOCKERbenefit-claim-weakly-supported +BLOCKERcausal-overstatement +HOLDuncertainty-missing + + Blocks unsupported public claims before summaries are shared outside the research team. + diff --git a/lay-summary-safety-guard/sample-data.js b/lay-summary-safety-guard/sample-data.js new file mode 100644 index 00000000..5cc63a79 --- /dev/null +++ b/lay-summary-safety-guard/sample-data.js @@ -0,0 +1,47 @@ +const unsafePacket = { + title: 'Early biomarker screen for inflammatory flare risk', + audience: { + name: 'patient and funder lay audience', + maxGradeLevel: 8, + jargonTerms: ['cytokine', 'proteomic', 'longitudinal'], + }, + sourceEvidence: [ + { + id: 'E1', + text: 'In a small observational cohort of 42 adults, cytokine panel changes were associated with later flare risk.', + terms: ['small', 'observational', 'cohort', 'cytokine', 'associated', 'flare', 'risk'], + }, + { + id: 'E2', + text: 'The study did not test treatment decisions and did not include children or pregnant patients.', + terms: ['not', 'treatment', 'children', 'pregnant', 'patients', 'limitations'], + }, + { + id: 'E3', + text: 'External validation is pending, and the authors recommend cautious interpretation before clinical use.', + terms: ['validation', 'pending', 'cautious', 'interpretation', 'clinical'], + }, + ], + requiredLimitations: [ + 'small observational cohort', + 'external validation pending', + 'not a treatment recommendation', + ], + summary: + 'This proteomic cytokine breakthrough proves doctors can prevent inflammatory flares before they happen. Patients should ask their doctor to replace treatment with this simple blood test because it works for everyone. The longitudinal model identifies immune dysregulation and delivers actionable clinical certainty.', +}; + +const safePacket = { + title: 'Early biomarker screen for inflammatory flare risk', + audience: { + name: 'patient and funder lay audience', + maxGradeLevel: 8, + jargonTerms: ['cytokine', 'proteomic', 'longitudinal'], + }, + sourceEvidence: unsafePacket.sourceEvidence, + requiredLimitations: unsafePacket.requiredLimitations, + summary: + 'A small observational study found blood changes that may be linked with later flare risk. Outside validation is still pending. This is not a treatment recommendation, and patients should not change care based on this result alone.', +}; + +module.exports = { unsafePacket, safePacket }; diff --git a/lay-summary-safety-guard/test.js b/lay-summary-safety-guard/test.js new file mode 100644 index 00000000..a1819e82 --- /dev/null +++ b/lay-summary-safety-guard/test.js @@ -0,0 +1,62 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { + evaluateSummary, + evidenceCoverage, + fleschKincaidGrade, + renderMarkdown, + renderSvg, + splitSentences, + tokenize, + writeReports, +} = require('./index'); +const { safePacket, unsafePacket } = require('./sample-data'); + +function runTests() { + assert.deepStrictEqual(tokenize('Blood-signal changes!'), ['blood-signal', 'changes']); + assert.strictEqual(splitSentences('One. Two? Three!').length, 3); + assert(fleschKincaidGrade('This is a short plain sentence.') < 8); + + const coverage = evidenceCoverage('The cohort had cytokine flare risk signals.', unsafePacket.sourceEvidence); + assert.strictEqual(coverage.id, 'E1'); + assert(coverage.score > 0.2); + + const unsafeAudit = evaluateSummary(unsafePacket); + assert.strictEqual(unsafeAudit.decision, 'block-public-share'); + assert.strictEqual(unsafeAudit.shareable, false); + const unsafeCodes = new Set(unsafeAudit.findings.map((finding) => finding.code)); + assert(unsafeCodes.has('causal-overstatement')); + assert(unsafeCodes.has('medical-advice-risk')); + assert(unsafeCodes.has('uncertainty-missing')); + assert(unsafeCodes.has('jargon-not-explained')); + assert(unsafeCodes.has('required-limitation-missing')); + + const safeAudit = evaluateSummary(safePacket); + assert.strictEqual(safeAudit.decision, 'approve-lay-summary'); + assert.strictEqual(safeAudit.findings.length, 0); + + const markdown = renderMarkdown(unsafeAudit); + assert(markdown.includes('Layperson Summary Safety Guard')); + assert(markdown.includes('block-public-share')); + + const svg = renderSvg(unsafeAudit); + assert(svg.includes('