diff --git a/sample-chain-of-custody-assistant/README.md b/sample-chain-of-custody-assistant/README.md new file mode 100644 index 00000000..533c4e7a --- /dev/null +++ b/sample-chain-of-custody-assistant/README.md @@ -0,0 +1,77 @@ +# Sample Chain-of-Custody Review Assistant + +This module adds a deterministic assistant for reviewing wet-lab or clinical sample +chain-of-custody packets before AI-generated research conclusions are released. + +It is scoped to synthetic/sample metadata only. It does not call external APIs, +does not require credentials, and does not process real participant records. + +## Why This Belongs In The AI Research Assistant Suite + +AI review and reproducibility tools can miss a basic scientific failure mode: +the sample used to support a claim may have incomplete custody evidence. This +assistant checks whether every sample has enough documented handoffs, consent +links, storage controls, assay-run alignment, and reviewer signoff before the +project relies on the result. + +The assistant emits a deterministic `release`, `revise`, or `hold` decision with +evidence-linked findings and remediation steps. + +## Checks + +- Missing consent or participant-link evidence. +- Custody events with missing actor or signature fields. +- Time gaps beyond the configured maximum transit window. +- Storage temperature excursions against the sample's storage spec. +- Blinding leaks in reviewer-visible metadata. +- Assay runs that use quarantined samples, mismatched instruments, or missing QC. +- Conclusions that rely on samples with unresolved high-risk custody findings. +- Required reviewer signoff before release. + +## Usage + +```bash +node sample-chain-of-custody-assistant/src/chain-custody-assistant.mjs \ + sample-chain-of-custody-assistant/fixtures/sample-custody-packet.json \ + --format markdown +``` + +JSON output: + +```bash +node sample-chain-of-custody-assistant/src/chain-custody-assistant.mjs \ + sample-chain-of-custody-assistant/fixtures/sample-custody-packet.json \ + --format json +``` + +## Validation + +```bash +node sample-chain-of-custody-assistant/test/run-tests.mjs +``` + +Expected result: + +```text +sample chain-of-custody assistant tests passed +``` + +## Demo Artifacts + +- `demo/sample-report.md` +- `demo/sample-report.json` +- `demo/decision-flow.svg` +- `demo/decision-flow.png` +- `demo/sample-chain-of-custody-demo.mp4` + +## Scope Boundaries + +This assistant intentionally avoids: + +- live LIMS, EHR, cloud storage, or instrument connections; +- real participant or patient data; +- credential handling; +- legal, medical, or regulatory advice; +- replacing required human review. + +It is a pre-release guardrail that highlights evidence gaps for a reviewer. diff --git a/sample-chain-of-custody-assistant/demo/decision-flow.png b/sample-chain-of-custody-assistant/demo/decision-flow.png new file mode 100644 index 00000000..dee9a71d Binary files /dev/null and b/sample-chain-of-custody-assistant/demo/decision-flow.png differ diff --git a/sample-chain-of-custody-assistant/demo/decision-flow.svg b/sample-chain-of-custody-assistant/demo/decision-flow.svg new file mode 100644 index 00000000..8d89e09c --- /dev/null +++ b/sample-chain-of-custody-assistant/demo/decision-flow.svg @@ -0,0 +1,30 @@ + + Sample chain-of-custody assistant decision flow + Diagram showing input custody packets flowing through deterministic checks to release revise or hold decisions. + + + + Custody packet + samples, events, claims + + + Deterministic checks + consent links + handoffs and time gaps + temperature excursions + assay QC and quarantine + + + RELEASE + + REVISE + + HOLD + Output: JSON, Markdown, evidence-linked findings, remediation, research gap prompts + + + + + + + diff --git a/sample-chain-of-custody-assistant/demo/sample-chain-of-custody-demo.mp4 b/sample-chain-of-custody-assistant/demo/sample-chain-of-custody-demo.mp4 new file mode 100644 index 00000000..5ad35845 Binary files /dev/null and b/sample-chain-of-custody-assistant/demo/sample-chain-of-custody-demo.mp4 differ diff --git a/sample-chain-of-custody-assistant/demo/sample-report.json b/sample-chain-of-custody-assistant/demo/sample-report.json new file mode 100644 index 00000000..af33f98e --- /dev/null +++ b/sample-chain-of-custody-assistant/demo/sample-report.json @@ -0,0 +1,119 @@ +{ + "projectId": "SCI-CHAIN-001", + "title": "Cytokine drift after cold-chain interruption", + "generatedAt": "2026-06-01T12:00:00Z", + "assistant": "sample-chain-of-custody-review", + "decision": "hold", + "severityCounts": { + "critical": 4, + "high": 6, + "medium": 4, + "low": 0 + }, + "findings": [ + { + "severity": "critical", + "sampleId": "S-1001,S-1002", + "check": "conclusion-release-gate", + "evidence": "Conclusion C-1 relies on sample(s) with critical custody findings.", + "remediation": "Hold this conclusion until critical sample custody issues are resolved." + }, + { + "severity": "critical", + "sampleId": "S-1002", + "check": "assay-qc", + "evidence": "assay-run@2026-05-22T18:20:00Z has qcStatus=fail.", + "remediation": "Do not release conclusions using this assay until QC failure is resolved or rerun." + }, + { + "severity": "critical", + "sampleId": "S-1002", + "check": "consent-link", + "evidence": "No consentId is attached to the sample metadata.", + "remediation": "Attach a verified consent record or remove this sample from AI-supported conclusions." + }, + { + "severity": "critical", + "sampleId": "S-1002", + "check": "open-quarantine", + "evidence": "Open quarantine: temperature excursion pending QA disposition.", + "remediation": "Resolve QA quarantine or remove this sample from conclusions." + }, + { + "severity": "high", + "sampleId": "S-1001", + "check": "blinding-leak", + "evidence": "Reviewer-visible fields include group/arm metadata while blindingGroup=exposed.", + "remediation": "Mask treatment-arm fields before AI review or document that the review is intentionally unblinded." + }, + { + "severity": "high", + "sampleId": "S-1002", + "check": "blinding-leak", + "evidence": "Reviewer-visible fields include group/arm metadata while blindingGroup=exposed.", + "remediation": "Mask treatment-arm fields before AI review or document that the review is intentionally unblinded." + }, + { + "severity": "high", + "sampleId": "S-1002", + "check": "reviewer-signoff", + "evidence": "No reviewerSignoff record is present.", + "remediation": "Obtain accountable QA/reviewer signoff before release." + }, + { + "severity": "high", + "sampleId": "S-1002", + "check": "temperature-excursion", + "evidence": "storage@2026-05-22T16:45:00Z recorded 11.7C outside 2C-8C.", + "remediation": "Open a QA disposition, document stability impact, and quarantine the sample until resolved." + }, + { + "severity": "high", + "sampleId": "S-1003", + "check": "conclusion-release-gate", + "evidence": "Conclusion C-2 relies on sample(s) with high-risk custody findings.", + "remediation": "Revise confidence language and require reviewer acceptance before release." + }, + { + "severity": "high", + "sampleId": "S-1003", + "check": "required-custody-event", + "evidence": "Missing required custody event: assay-run.", + "remediation": "Add a signed assay-run custody event or exclude this sample from release." + }, + { + "severity": "medium", + "sampleId": "S-1002", + "check": "custody-time-gap", + "evidence": "collect to accession gap is 7.8 hours.", + "remediation": "Add intermediate courier/storage handoff evidence or downgrade conclusions that rely on this sample." + }, + { + "severity": "medium", + "sampleId": "S-1002", + "check": "handoff-signature", + "evidence": "accession@2026-05-22T16:30:00Z is missing actor or signature evidence.", + "remediation": "Add a signed handoff record with accountable actor and timestamp." + }, + { + "severity": "medium", + "sampleId": "S-1002", + "check": "required-custody-event", + "evidence": "Missing required custody event: archive.", + "remediation": "Add a signed archive custody event or exclude this sample from release." + }, + { + "severity": "medium", + "sampleId": "S-1003", + "check": "custody-time-gap", + "evidence": "storage to archive gap is 6.5 hours.", + "remediation": "Add intermediate courier/storage handoff evidence or downgrade conclusions that rely on this sample." + } + ], + "researchGapPrompts": [ + "Which biomarkers remain stable after the observed cold-chain excursion window?", + "Would blinded AI review change the severity or interpretation of the reported effect?", + "What courier or intermediate storage metadata should be captured to close shipment evidence gaps?", + "Can the failed assay be rerun from archived aliquots, or should the conclusion be limited to unaffected samples?" + ] +} diff --git a/sample-chain-of-custody-assistant/demo/sample-report.md b/sample-chain-of-custody-assistant/demo/sample-report.md new file mode 100644 index 00000000..0869a202 --- /dev/null +++ b/sample-chain-of-custody-assistant/demo/sample-report.md @@ -0,0 +1,105 @@ +# Sample Chain-of-Custody Review: SCI-CHAIN-001 + +Title: Cytokine drift after cold-chain interruption +Decision: HOLD + +## Severity Counts + +- Critical: 4 +- High: 6 +- Medium: 4 +- Low: 0 + +## Findings + +### [CRITICAL] conclusion-release-gate + +- Sample/conclusion: S-1001,S-1002 +- Evidence: Conclusion C-1 relies on sample(s) with critical custody findings. +- Remediation: Hold this conclusion until critical sample custody issues are resolved. + +### [CRITICAL] assay-qc + +- Sample/conclusion: S-1002 +- Evidence: assay-run@2026-05-22T18:20:00Z has qcStatus=fail. +- Remediation: Do not release conclusions using this assay until QC failure is resolved or rerun. + +### [CRITICAL] consent-link + +- Sample/conclusion: S-1002 +- Evidence: No consentId is attached to the sample metadata. +- Remediation: Attach a verified consent record or remove this sample from AI-supported conclusions. + +### [CRITICAL] open-quarantine + +- Sample/conclusion: S-1002 +- Evidence: Open quarantine: temperature excursion pending QA disposition. +- Remediation: Resolve QA quarantine or remove this sample from conclusions. + +### [HIGH] blinding-leak + +- Sample/conclusion: S-1001 +- Evidence: Reviewer-visible fields include group/arm metadata while blindingGroup=exposed. +- Remediation: Mask treatment-arm fields before AI review or document that the review is intentionally unblinded. + +### [HIGH] blinding-leak + +- Sample/conclusion: S-1002 +- Evidence: Reviewer-visible fields include group/arm metadata while blindingGroup=exposed. +- Remediation: Mask treatment-arm fields before AI review or document that the review is intentionally unblinded. + +### [HIGH] reviewer-signoff + +- Sample/conclusion: S-1002 +- Evidence: No reviewerSignoff record is present. +- Remediation: Obtain accountable QA/reviewer signoff before release. + +### [HIGH] temperature-excursion + +- Sample/conclusion: S-1002 +- Evidence: storage@2026-05-22T16:45:00Z recorded 11.7C outside 2C-8C. +- Remediation: Open a QA disposition, document stability impact, and quarantine the sample until resolved. + +### [HIGH] conclusion-release-gate + +- Sample/conclusion: S-1003 +- Evidence: Conclusion C-2 relies on sample(s) with high-risk custody findings. +- Remediation: Revise confidence language and require reviewer acceptance before release. + +### [HIGH] required-custody-event + +- Sample/conclusion: S-1003 +- Evidence: Missing required custody event: assay-run. +- Remediation: Add a signed assay-run custody event or exclude this sample from release. + +### [MEDIUM] custody-time-gap + +- Sample/conclusion: S-1002 +- Evidence: collect to accession gap is 7.8 hours. +- Remediation: Add intermediate courier/storage handoff evidence or downgrade conclusions that rely on this sample. + +### [MEDIUM] handoff-signature + +- Sample/conclusion: S-1002 +- Evidence: accession@2026-05-22T16:30:00Z is missing actor or signature evidence. +- Remediation: Add a signed handoff record with accountable actor and timestamp. + +### [MEDIUM] required-custody-event + +- Sample/conclusion: S-1002 +- Evidence: Missing required custody event: archive. +- Remediation: Add a signed archive custody event or exclude this sample from release. + +### [MEDIUM] custody-time-gap + +- Sample/conclusion: S-1003 +- Evidence: storage to archive gap is 6.5 hours. +- Remediation: Add intermediate courier/storage handoff evidence or downgrade conclusions that rely on this sample. + +## Research Gap Prompts + +- Which biomarkers remain stable after the observed cold-chain excursion window? +- Would blinded AI review change the severity or interpretation of the reported effect? +- What courier or intermediate storage metadata should be captured to close shipment evidence gaps? +- Can the failed assay be rerun from archived aliquots, or should the conclusion be limited to unaffected samples? + diff --git a/sample-chain-of-custody-assistant/fixtures/sample-custody-packet.json b/sample-chain-of-custody-assistant/fixtures/sample-custody-packet.json new file mode 100644 index 00000000..625d32ba --- /dev/null +++ b/sample-chain-of-custody-assistant/fixtures/sample-custody-packet.json @@ -0,0 +1,176 @@ +{ + "projectId": "SCI-CHAIN-001", + "title": "Cytokine drift after cold-chain interruption", + "generatedAt": "2026-06-01T12:00:00Z", + "policy": { + "maxTransitGapHours": 4, + "requiredEvents": ["collect", "accession", "storage", "assay-run", "archive"], + "releaseRequiresReviewerSignoff": true + }, + "conclusions": [ + { + "id": "C-1", + "text": "IL-6 increased in the exposed cohort.", + "sampleIds": ["S-1001", "S-1002"] + }, + { + "id": "C-2", + "text": "Control samples remained stable across the shipment window.", + "sampleIds": ["S-1003"] + } + ], + "samples": [ + { + "id": "S-1001", + "participantId": "P-101", + "consentId": "CONS-778", + "visibleFields": ["sample_id", "collection_day", "blinded_arm"], + "blindingGroup": "exposed", + "storageSpec": { + "minCelsius": 2, + "maxCelsius": 8 + }, + "custodyEvents": [ + { + "type": "collect", + "timestamp": "2026-05-22T09:10:00Z", + "location": "Clinic A", + "actor": "phlebotomist-12", + "signature": "sig-collect-1001" + }, + { + "type": "accession", + "timestamp": "2026-05-22T10:20:00Z", + "location": "Lab intake", + "actor": "tech-07", + "signature": "sig-accession-1001" + }, + { + "type": "storage", + "timestamp": "2026-05-22T10:40:00Z", + "location": "Cold room 2", + "actor": "tech-07", + "signature": "sig-storage-1001", + "temperatureCelsius": 4.2 + }, + { + "type": "assay-run", + "timestamp": "2026-05-22T13:35:00Z", + "location": "ELISA bay", + "actor": "assay-operator-2", + "signature": "sig-assay-1001", + "instrumentId": "ELISA-04", + "qcStatus": "pass" + }, + { + "type": "archive", + "timestamp": "2026-05-22T16:00:00Z", + "location": "Freezer B", + "actor": "tech-14", + "signature": "sig-archive-1001", + "temperatureCelsius": -80 + } + ], + "reviewerSignoff": { + "reviewer": "qa-reviewer-1", + "timestamp": "2026-05-23T09:00:00Z" + } + }, + { + "id": "S-1002", + "participantId": "P-102", + "consentId": "", + "visibleFields": ["sample_id", "collection_day", "treatment_arm"], + "blindingGroup": "exposed", + "storageSpec": { + "minCelsius": 2, + "maxCelsius": 8 + }, + "custodyEvents": [ + { + "type": "collect", + "timestamp": "2026-05-22T08:45:00Z", + "location": "Clinic B", + "actor": "phlebotomist-09", + "signature": "sig-collect-1002" + }, + { + "type": "accession", + "timestamp": "2026-05-22T16:30:00Z", + "location": "Lab intake", + "actor": "tech-11", + "signature": "" + }, + { + "type": "storage", + "timestamp": "2026-05-22T16:45:00Z", + "location": "Cold room 2", + "actor": "tech-11", + "signature": "sig-storage-1002", + "temperatureCelsius": 11.7 + }, + { + "type": "assay-run", + "timestamp": "2026-05-22T18:20:00Z", + "location": "ELISA bay", + "actor": "assay-operator-2", + "signature": "sig-assay-1002", + "instrumentId": "ELISA-04", + "qcStatus": "fail" + } + ], + "quarantine": { + "status": "open", + "reason": "temperature excursion pending QA disposition" + }, + "reviewerSignoff": null + }, + { + "id": "S-1003", + "participantId": "P-103", + "consentId": "CONS-889", + "visibleFields": ["sample_id", "collection_day"], + "blindingGroup": "control", + "storageSpec": { + "minCelsius": -90, + "maxCelsius": -60 + }, + "custodyEvents": [ + { + "type": "collect", + "timestamp": "2026-05-22T09:30:00Z", + "location": "Clinic A", + "actor": "phlebotomist-12", + "signature": "sig-collect-1003" + }, + { + "type": "accession", + "timestamp": "2026-05-22T10:15:00Z", + "location": "Lab intake", + "actor": "tech-07", + "signature": "sig-accession-1003" + }, + { + "type": "storage", + "timestamp": "2026-05-22T10:30:00Z", + "location": "Freezer A", + "actor": "tech-07", + "signature": "sig-storage-1003", + "temperatureCelsius": -78 + }, + { + "type": "archive", + "timestamp": "2026-05-22T17:00:00Z", + "location": "Freezer B", + "actor": "tech-14", + "signature": "sig-archive-1003", + "temperatureCelsius": -75 + } + ], + "reviewerSignoff": { + "reviewer": "qa-reviewer-1", + "timestamp": "2026-05-23T09:05:00Z" + } + } + ] +} diff --git a/sample-chain-of-custody-assistant/src/chain-custody-assistant.mjs b/sample-chain-of-custody-assistant/src/chain-custody-assistant.mjs new file mode 100644 index 00000000..9616d08a --- /dev/null +++ b/sample-chain-of-custody-assistant/src/chain-custody-assistant.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + +const SEVERITY_SCORE = { + critical: 4, + high: 3, + medium: 2, + low: 1 +}; + +function hoursBetween(startIso, endIso) { + return (new Date(endIso).getTime() - new Date(startIso).getTime()) / 36e5; +} + +function finding(severity, sampleId, check, evidence, remediation) { + return { severity, sampleId, check, evidence, remediation }; +} + +function eventLabel(event) { + return `${event.type}@${event.timestamp || "missing-time"}`; +} + +function sortEvents(events) { + return [...(events || [])].sort((a, b) => { + return new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(); + }); +} + +function analyzeSample(sample, packet) { + const findings = []; + const policy = packet.policy || {}; + const requiredEvents = policy.requiredEvents || []; + const events = sortEvents(sample.custodyEvents); + const eventTypes = new Set(events.map((event) => event.type)); + + if (!sample.consentId) { + findings.push(finding( + "critical", + sample.id, + "consent-link", + "No consentId is attached to the sample metadata.", + "Attach a verified consent record or remove this sample from AI-supported conclusions." + )); + } + + for (const required of requiredEvents) { + if (!eventTypes.has(required)) { + findings.push(finding( + required === "assay-run" ? "high" : "medium", + sample.id, + "required-custody-event", + `Missing required custody event: ${required}.`, + `Add a signed ${required} custody event or exclude this sample from release.` + )); + } + } + + for (const event of events) { + if (!event.actor || !event.signature) { + findings.push(finding( + "medium", + sample.id, + "handoff-signature", + `${eventLabel(event)} is missing actor or signature evidence.`, + "Add a signed handoff record with accountable actor and timestamp." + )); + } + + if (typeof event.temperatureCelsius === "number" && sample.storageSpec) { + const { minCelsius, maxCelsius } = sample.storageSpec; + const isArchiveFreezer = event.type === "archive" && event.temperatureCelsius < minCelsius; + if (!isArchiveFreezer && (event.temperatureCelsius < minCelsius || event.temperatureCelsius > maxCelsius)) { + findings.push(finding( + "high", + sample.id, + "temperature-excursion", + `${eventLabel(event)} recorded ${event.temperatureCelsius}C outside ${minCelsius}C-${maxCelsius}C.`, + "Open a QA disposition, document stability impact, and quarantine the sample until resolved." + )); + } + } + } + + for (let index = 1; index < events.length; index += 1) { + const previous = events[index - 1]; + const current = events[index]; + const gap = hoursBetween(previous.timestamp, current.timestamp); + if (gap > (policy.maxTransitGapHours || 4)) { + findings.push(finding( + "medium", + sample.id, + "custody-time-gap", + `${previous.type} to ${current.type} gap is ${gap.toFixed(1)} hours.`, + "Add intermediate courier/storage handoff evidence or downgrade conclusions that rely on this sample." + )); + } + } + + if (sample.blindingGroup && (sample.visibleFields || []).some((field) => /arm|group|treatment/i.test(field))) { + findings.push(finding( + "high", + sample.id, + "blinding-leak", + `Reviewer-visible fields include group/arm metadata while blindingGroup=${sample.blindingGroup}.`, + "Mask treatment-arm fields before AI review or document that the review is intentionally unblinded." + )); + } + + const assayEvents = events.filter((event) => event.type === "assay-run"); + for (const assay of assayEvents) { + if (!assay.instrumentId) { + findings.push(finding( + "medium", + sample.id, + "instrument-link", + `${eventLabel(assay)} has no instrumentId.`, + "Link the assay event to the instrument run and calibration record." + )); + } + if (assay.qcStatus && assay.qcStatus !== "pass") { + findings.push(finding( + "critical", + sample.id, + "assay-qc", + `${eventLabel(assay)} has qcStatus=${assay.qcStatus}.`, + "Do not release conclusions using this assay until QC failure is resolved or rerun." + )); + } + } + + if (sample.quarantine?.status === "open") { + findings.push(finding( + "critical", + sample.id, + "open-quarantine", + `Open quarantine: ${sample.quarantine.reason || "reason not provided"}.`, + "Resolve QA quarantine or remove this sample from conclusions." + )); + } + + if (policy.releaseRequiresReviewerSignoff && !sample.reviewerSignoff) { + findings.push(finding( + "high", + sample.id, + "reviewer-signoff", + "No reviewerSignoff record is present.", + "Obtain accountable QA/reviewer signoff before release." + )); + } + + return findings; +} + +function conclusionFindings(packet, sampleRisk) { + const findings = []; + for (const conclusion of packet.conclusions || []) { + const linkedFindings = (conclusion.sampleIds || []).flatMap((sampleId) => sampleRisk.get(sampleId) || []); + const maxSeverity = linkedFindings.reduce((max, item) => { + return Math.max(max, SEVERITY_SCORE[item.severity] || 0); + }, 0); + + if (maxSeverity >= SEVERITY_SCORE.critical) { + findings.push(finding( + "critical", + conclusion.sampleIds.join(","), + "conclusion-release-gate", + `Conclusion ${conclusion.id} relies on sample(s) with critical custody findings.`, + "Hold this conclusion until critical sample custody issues are resolved." + )); + } else if (maxSeverity >= SEVERITY_SCORE.high) { + findings.push(finding( + "high", + conclusion.sampleIds.join(","), + "conclusion-release-gate", + `Conclusion ${conclusion.id} relies on sample(s) with high-risk custody findings.`, + "Revise confidence language and require reviewer acceptance before release." + )); + } + } + return findings; +} + +function summarizeDecision(findings) { + const counts = findings.reduce((acc, item) => { + acc[item.severity] = (acc[item.severity] || 0) + 1; + return acc; + }, {}); + + let decision = "release"; + if ((counts.critical || 0) > 0) { + decision = "hold"; + } else if ((counts.high || 0) > 0 || (counts.medium || 0) >= 3) { + decision = "revise"; + } + + return { + decision, + counts: { + critical: counts.critical || 0, + high: counts.high || 0, + medium: counts.medium || 0, + low: counts.low || 0 + } + }; +} + +function makeGapPrompts(packet, findings) { + const checks = new Set(findings.map((item) => item.check)); + const prompts = []; + if (checks.has("temperature-excursion")) { + prompts.push("Which biomarkers remain stable after the observed cold-chain excursion window?"); + } + if (checks.has("blinding-leak")) { + prompts.push("Would blinded AI review change the severity or interpretation of the reported effect?"); + } + if (checks.has("custody-time-gap")) { + prompts.push("What courier or intermediate storage metadata should be captured to close shipment evidence gaps?"); + } + if (checks.has("assay-qc")) { + prompts.push("Can the failed assay be rerun from archived aliquots, or should the conclusion be limited to unaffected samples?"); + } + if (prompts.length === 0) { + prompts.push(`What custody evidence would most improve reviewer confidence for ${packet.projectId}?`); + } + return prompts; +} + +export function analyzePacket(packet) { + const perSampleFindings = new Map(); + const findings = []; + + for (const sample of packet.samples || []) { + const sampleFindings = analyzeSample(sample, packet); + perSampleFindings.set(sample.id, sampleFindings); + findings.push(...sampleFindings); + } + + findings.push(...conclusionFindings(packet, perSampleFindings)); + const summary = summarizeDecision(findings); + + return { + projectId: packet.projectId, + title: packet.title, + generatedAt: packet.generatedAt, + assistant: "sample-chain-of-custody-review", + decision: summary.decision, + severityCounts: summary.counts, + findings: findings.sort((a, b) => { + return (SEVERITY_SCORE[b.severity] || 0) - (SEVERITY_SCORE[a.severity] || 0) + || a.sampleId.localeCompare(b.sampleId) + || a.check.localeCompare(b.check); + }), + researchGapPrompts: makeGapPrompts(packet, findings) + }; +} + +export function renderMarkdown(report) { + const lines = [ + `# Sample Chain-of-Custody Review: ${report.projectId}`, + "", + `Title: ${report.title}`, + `Decision: ${report.decision.toUpperCase()}`, + "", + "## Severity Counts", + "", + `- Critical: ${report.severityCounts.critical}`, + `- High: ${report.severityCounts.high}`, + `- Medium: ${report.severityCounts.medium}`, + `- Low: ${report.severityCounts.low}`, + "", + "## Findings", + "" + ]; + + for (const item of report.findings) { + lines.push(`### [${item.severity.toUpperCase()}] ${item.check}`); + lines.push(""); + lines.push(`- Sample/conclusion: ${item.sampleId}`); + lines.push(`- Evidence: ${item.evidence}`); + lines.push(`- Remediation: ${item.remediation}`); + lines.push(""); + } + + lines.push("## Research Gap Prompts", ""); + for (const prompt of report.researchGapPrompts) { + lines.push(`- ${prompt}`); + } + lines.push(""); + + return lines.join("\n"); +} + +function parseArgs(argv) { + const args = [...argv]; + const inputPath = args.find((arg) => !arg.startsWith("--")); + const formatIndex = args.indexOf("--format"); + const format = formatIndex >= 0 ? args[formatIndex + 1] : "markdown"; + return { inputPath, format }; +} + +function main() { + const { inputPath, format } = parseArgs(process.argv.slice(2)); + if (!inputPath) { + console.error("Usage: node chain-custody-assistant.mjs [--format markdown|json]"); + process.exit(2); + } + + const absolutePath = path.resolve(inputPath); + const packet = JSON.parse(fs.readFileSync(absolutePath, "utf8")); + const report = analyzePacket(packet); + + if (format === "json") { + console.log(JSON.stringify(report, null, 2)); + } else { + console.log(renderMarkdown(report)); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/sample-chain-of-custody-assistant/test/run-tests.mjs b/sample-chain-of-custody-assistant/test/run-tests.mjs new file mode 100644 index 00000000..ecf60526 --- /dev/null +++ b/sample-chain-of-custody-assistant/test/run-tests.mjs @@ -0,0 +1,56 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { analyzePacket, renderMarkdown } from "../src/chain-custody-assistant.mjs"; + +const fixturePath = path.resolve("sample-chain-of-custody-assistant/fixtures/sample-custody-packet.json"); +const packet = JSON.parse(fs.readFileSync(fixturePath, "utf8")); +const report = analyzePacket(packet); + +assert.equal(report.assistant, "sample-chain-of-custody-review"); +assert.equal(report.decision, "hold"); +assert.equal(report.severityCounts.critical, 4); +assert.equal(report.severityCounts.high, 6); +assert.ok(report.findings.some((finding) => finding.check === "consent-link" && finding.sampleId === "S-1002")); +assert.ok(report.findings.some((finding) => finding.check === "assay-qc" && finding.sampleId === "S-1002")); +assert.ok(report.findings.some((finding) => finding.check === "required-custody-event" && finding.sampleId === "S-1003")); +assert.ok(report.findings.some((finding) => finding.check === "conclusion-release-gate" && finding.sampleId.includes("S-1002"))); +assert.ok(report.researchGapPrompts.some((prompt) => prompt.includes("cold-chain"))); + +const markdown = renderMarkdown(report); +assert.match(markdown, /Decision: HOLD/); +assert.match(markdown, /Sample Chain-of-Custody Review/); +assert.match(markdown, /Research Gap Prompts/); + +const cleanPacket = { + projectId: "SCI-CLEAN", + title: "Clean custody packet", + policy: { + maxTransitGapHours: 4, + requiredEvents: ["collect", "accession", "storage", "assay-run", "archive"], + releaseRequiresReviewerSignoff: true + }, + conclusions: [{ id: "C-clean", sampleIds: ["S-clean"] }], + samples: [{ + id: "S-clean", + participantId: "P-clean", + consentId: "CONS-clean", + visibleFields: ["sample_id", "collection_day"], + storageSpec: { minCelsius: 2, maxCelsius: 8 }, + custodyEvents: [ + { type: "collect", timestamp: "2026-01-01T09:00:00Z", actor: "a", signature: "s", location: "clinic" }, + { type: "accession", timestamp: "2026-01-01T10:00:00Z", actor: "b", signature: "s", location: "lab" }, + { type: "storage", timestamp: "2026-01-01T10:15:00Z", actor: "b", signature: "s", location: "fridge", temperatureCelsius: 4 }, + { type: "assay-run", timestamp: "2026-01-01T11:15:00Z", actor: "c", signature: "s", location: "bench", instrumentId: "inst-1", qcStatus: "pass" }, + { type: "archive", timestamp: "2026-01-01T12:00:00Z", actor: "d", signature: "s", location: "archive", temperatureCelsius: -80 } + ], + reviewerSignoff: { reviewer: "qa", timestamp: "2026-01-01T13:00:00Z" } + }] +}; + +const cleanReport = analyzePacket(cleanPacket); +assert.equal(cleanReport.decision, "release"); +assert.equal(cleanReport.findings.length, 0); + +console.log("sample chain-of-custody assistant tests passed");