v1 of protocol checker
All checks were successful
Deploy to Server / deploy (push) Successful in 34s

This commit is contained in:
Aniketh Kalagara
2026-04-14 19:08:02 -05:00
parent 2f69f57cf7
commit 4098453c97
7 changed files with 328 additions and 57 deletions

View File

@@ -66,6 +66,8 @@ const TABLE_COLUMNS: { label: string; key: keyof ChemicalInventory }[] = [
{ label: "Multiple CAS (comma delimited)", key: "multipleCAS" }, { label: "Multiple CAS (comma delimited)", key: "multipleCAS" },
]; ];
type ImportColumnMapping = keyof ChemicalInventory | "__skip__";
// ── helpers ──────────────────────────────────────────────────────────────── // ── helpers ────────────────────────────────────────────────────────────────
function daysUntil(dateStr: string) { function daysUntil(dateStr: string) {
@@ -144,7 +146,7 @@ export function Inventory() {
const [importStep, setImportStep] = useState<"upload" | "map" | "result">("upload"); const [importStep, setImportStep] = useState<"upload" | "map" | "result">("upload");
const [importHeaders, setImportHeaders] = useState<string[]>([]); const [importHeaders, setImportHeaders] = useState<string[]>([]);
const [importRows, setImportRows] = useState<string[][]>([]); const [importRows, setImportRows] = useState<string[][]>([]);
const [columnMapping, setColumnMapping] = useState<Record<string, keyof ChemicalInventory | "__skip__">>({}); const [columnMapping, setColumnMapping] = useState<Record<string, ImportColumnMapping>>({});
const [importResult, setImportResult] = useState<{ imported: number; errors: string[] } | null>(null); const [importResult, setImportResult] = useState<{ imported: number; errors: string[] } | null>(null);
const [isImporting, setIsImporting] = useState(false); const [isImporting, setIsImporting] = useState(false);
const [importError, setImportError] = useState(""); const [importError, setImportError] = useState("");
@@ -339,7 +341,7 @@ export function Inventory() {
return s.toLowerCase().replace(/[^a-z0-9]/g, ""); return s.toLowerCase().replace(/[^a-z0-9]/g, "");
} }
function fuzzyMatchColumn(header: string): keyof ChemicalInventory | "__skip__" { function fuzzyMatchColumn(header: string): ImportColumnMapping {
const norm = normalizeHeader(header); const norm = normalizeHeader(header);
if (!norm) return "__skip__"; if (!norm) return "__skip__";
for (const col of TABLE_COLUMNS) { for (const col of TABLE_COLUMNS) {
@@ -349,7 +351,7 @@ export function Inventory() {
const colNorm = normalizeHeader(col.label); const colNorm = normalizeHeader(col.label);
if (colNorm.startsWith(norm) || norm.startsWith(colNorm)) return col.key; if (colNorm.startsWith(norm) || norm.startsWith(colNorm)) return col.key;
} }
let bestKey: keyof ChemicalInventory | "__skip__" = "__skip__"; let bestKey: ImportColumnMapping = "__skip__";
let bestScore = 0.55; let bestScore = 0.55;
for (const col of TABLE_COLUMNS) { for (const col of TABLE_COLUMNS) {
const colNorm = normalizeHeader(col.label); const colNorm = normalizeHeader(col.label);
@@ -430,7 +432,7 @@ export function Inventory() {
console.log("[import] headers:", headers, "data rows:", dataRows.length); console.log("[import] headers:", headers, "data rows:", dataRows.length);
setImportHeaders(headers); setImportHeaders(headers);
setImportRows(dataRows); setImportRows(dataRows);
const mapping: Record<string, keyof ChemicalInventory | ""> = {}; const mapping: Record<string, ImportColumnMapping> = {};
for (const h of headers) mapping[h] = fuzzyMatchColumn(h); for (const h of headers) mapping[h] = fuzzyMatchColumn(h);
setColumnMapping(mapping); setColumnMapping(mapping);
console.log("[import] advancing to map step"); console.log("[import] advancing to map step");
@@ -662,7 +664,7 @@ export function Inventory() {
<td className="px-3 py-2"> <td className="px-3 py-2">
<Select <Select
value={columnMapping[header] ?? ""} value={columnMapping[header] ?? ""}
onValueChange={v => setColumnMapping(m => ({ ...m, [header]: v as keyof ChemicalInventory | "__skip__" }))} onValueChange={v => setColumnMapping(m => ({ ...m, [header]: v as ImportColumnMapping }))}
> >
<SelectTrigger className="h-7 text-xs w-48"> <SelectTrigger className="h-7 text-xs w-48">
<SelectValue placeholder="— skip —" /> <SelectValue placeholder="— skip —" />

View File

@@ -1,4 +1,4 @@
import { useState } from "react"; import { ChangeEvent, DragEvent, KeyboardEvent, useRef, useState } from "react";
import { protocolsApi } from "../lib/api"; import { protocolsApi } from "../lib/api";
import { Card } from "./ui/card"; import { Card } from "./ui/card";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
@@ -29,9 +29,13 @@ interface ChatMessage {
} }
export function ProtocolChecker() { export function ProtocolChecker() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [protocol, setProtocol] = useState(""); const [protocol, setProtocol] = useState("");
const [selectedFileName, setSelectedFileName] = useState("");
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analyzed, setAnalyzed] = useState(false); const [analyzed, setAnalyzed] = useState(false);
const [analysisError, setAnalysisError] = useState("");
const [issues, setIssues] = useState<SafetyIssue[]>([]);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([ const [chatMessages, setChatMessages] = useState<ChatMessage[]>([
{ {
id: "1", id: "1",
@@ -42,56 +46,71 @@ export function ProtocolChecker() {
const [currentMessage, setCurrentMessage] = useState(""); const [currentMessage, setCurrentMessage] = useState("");
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
const mockIssues: SafetyIssue[] = [ const loadProtocolFile = async (file: File) => {
{ setAnalyzed(false);
type: "critical", setAnalysisError("");
category: "PPE Requirements", setIssues([]);
message: "No mention of glove type for handling concentrated acids. Nitrile gloves are required for HCl handling.",
source: "OSHA 29 CFR 1910.132", if (file.size > 10 * 1024 * 1024) {
sourceUrl: "https://www.osha.gov/laws-regs/regulations/standardnumber/1910/1910.132" setAnalysisError("File must be 10MB or smaller.");
}, return;
{
type: "warning",
category: "Ventilation",
message: "Protocol should specify use of fume hood when working with volatile organic compounds.",
source: "OSHA Laboratory Standard 29 CFR 1910.1450",
sourceUrl: "https://www.osha.gov/laws-regs/regulations/standardnumber/1910/1910.1450"
},
{
type: "suggestion",
category: "Waste Disposal",
message: "Consider adding specific waste disposal instructions for halogenated solvents per EPA regulations.",
source: "EPA RCRA Guidelines",
sourceUrl: "https://www.epa.gov/rcra"
},
{
type: "warning",
category: "Emergency Procedures",
message: "Include location of nearest eyewash station and emergency shower as required by ANSI Z358.1.",
source: "ANSI Z358.1-2014",
sourceUrl: "https://www.ansi.org"
},
{
type: "suggestion",
category: "Chemical Storage",
message: "Specify incompatible chemicals that should not be stored together per NFPA guidelines.",
source: "NFPA 45: Fire Protection for Laboratories",
sourceUrl: "https://www.nfpa.org"
} }
];
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension !== "txt") {
setAnalysisError("For now, upload a TXT file or paste protocol text. PDF, DOC, and DOCX text extraction is not wired yet.");
return;
}
try {
const text = await file.text();
if (!text.trim()) {
setAnalysisError("That file does not contain any readable protocol text.");
return;
}
setProtocol(text);
setSelectedFileName(file.name);
} catch {
setAnalysisError("Could not read that file. Try saving it as a plain TXT file and upload again.");
}
};
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) await loadProtocolFile(file);
event.target.value = "";
};
const handleUploadKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
fileInputRef.current?.click();
}
};
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const file = event.dataTransfer.files?.[0];
if (file) await loadProtocolFile(file);
};
const handleAnalyze = async () => { const handleAnalyze = async () => {
if (!protocol.trim()) return; if (!protocol.trim()) return;
setIsAnalyzing(true); setIsAnalyzing(true);
setAnalyzed(false);
setAnalysisError("");
setIssues([]);
try { try {
const saved = await protocolsApi.createFromText( const saved = await protocolsApi.createFromText(
`Protocol ${new Date().toLocaleDateString()}`, selectedFileName || `Protocol ${new Date().toLocaleDateString()}`,
protocol protocol
); );
await protocolsApi.saveAnalysis(saved.id, mockIssues); const analyzedProtocol = await protocolsApi.analyze(saved.id);
setIssues(analyzedProtocol.analysis_results || []);
setAnalyzed(true); setAnalyzed(true);
} catch (err) { } catch (err) {
console.error("Failed to save protocol:", err); console.error("Failed to save protocol:", err);
setAnalysisError(err instanceof Error ? err.message : "Failed to analyze protocol");
} finally { } finally {
setIsAnalyzing(false); setIsAnalyzing(false);
} }
@@ -162,9 +181,9 @@ export function ProtocolChecker() {
} }
}; };
const criticalCount = mockIssues.filter(i => i.type === "critical").length; const criticalCount = issues.filter(i => i.type === "critical").length;
const warningCount = mockIssues.filter(i => i.type === "warning").length; const warningCount = issues.filter(i => i.type === "warning").length;
const suggestionCount = mockIssues.filter(i => i.type === "suggestion").length; const suggestionCount = issues.filter(i => i.type === "suggestion").length;
return ( return (
<div className="p-8"> <div className="p-8">
@@ -196,10 +215,29 @@ export function ProtocolChecker() {
<p className="text-muted-foreground">Paste your protocol text or upload a file</p> <p className="text-muted-foreground">Paste your protocol text or upload a file</p>
</div> </div>
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center mb-4 hover:border-primary transition-colors cursor-pointer"> <input
ref={fileInputRef}
type="file"
accept=".txt,text/plain"
className="hidden"
onChange={handleFileChange}
/>
<div
role="button"
tabIndex={0}
className="border-2 border-dashed border-border rounded-lg p-8 text-center mb-4 hover:border-primary transition-colors cursor-pointer"
onClick={() => fileInputRef.current?.click()}
onKeyDown={handleUploadKeyDown}
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
<Upload className="w-12 h-12 text-muted-foreground mx-auto mb-3" /> <Upload className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground mb-1">Click to upload or drag and drop</p> <p className="text-foreground mb-1">Click to upload or drag and drop</p>
<p className="text-muted-foreground">PDF, DOC, DOCX, TXT (max 10MB)</p> <p className="text-muted-foreground">TXT files for now (max 10MB)</p>
{selectedFileName && (
<p className="text-primary mt-2">{selectedFileName}</p>
)}
</div> </div>
<div className="relative mb-4"> <div className="relative mb-4">
@@ -241,6 +279,15 @@ Example:
</> </>
)} )}
</Button> </Button>
{analysisError && (
<Alert className="mt-4 border-red-200 bg-red-50">
<AlertTriangle className="h-4 w-4 text-red-600" />
<AlertDescription className="text-red-700">
{analysisError}
</AlertDescription>
</Alert>
)}
</Card> </Card>
{/* Results */} {/* Results */}
@@ -258,7 +305,12 @@ Example:
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{mockIssues.map((issue, idx) => { {issues.length === 0 && (
<p className="text-muted-foreground">
No safety issues were returned for this protocol.
</p>
)}
{issues.map((issue, idx) => {
const colors = getIssueColor(issue.type); const colors = getIssueColor(issue.type);
return ( return (
<div <div

View File

@@ -60,12 +60,10 @@ function Calendar({
...classNames, ...classNames,
}} }}
components={{ components={{
IconLeft: ({ className, ...props }) => ( Chevron: ({ className, orientation, ...props }) => {
<ChevronLeft className={cn("size-4", className)} {...props} /> const Icon = orientation === "left" ? ChevronLeft : ChevronRight;
), return <Icon className={cn("size-4", className)} {...props} />;
IconRight: ({ className, ...props }) => ( },
<ChevronRight className={cn("size-4", className)} {...props} />
),
}} }}
{...props} {...props}
/> />

View File

@@ -1,4 +1,4 @@
import type { ChemicalInventory, SafetyIssue } from '../shared/types'; import type { ChemicalInventory, Protocol, SafetyIssue } from '../shared/types';
async function apiFetch(path: string, options: RequestInit = {}): Promise<Response> { async function apiFetch(path: string, options: RequestInit = {}): Promise<Response> {
const res = await fetch(path, { const res = await fetch(path, {
@@ -64,6 +64,11 @@ export const protocolsApi = {
body: JSON.stringify({ analysis_results: results }), body: JSON.stringify({ analysis_results: results }),
}).then(r => r.json()), }).then(r => r.json()),
analyze: (id: string): Promise<Protocol> =>
apiFetch(`/api/protocols/${id}/analyze`, {
method: 'POST',
}).then(r => r.json()),
remove: (id: string): Promise<void> => remove: (id: string): Promise<void> =>
apiFetch(`/api/protocols/${id}`, { method: 'DELETE' }).then(() => undefined), apiFetch(`/api/protocols/${id}`, { method: 'DELETE' }).then(() => undefined),
}; };

6
server/.env.example Normal file
View File

@@ -0,0 +1,6 @@
DATABASE_URL=postgresql://labwise:labwise_dev_pw@localhost:5432/labwise_db
MAILGUN_API_KEY=your_mailgun_api_key_here
MAILGUN_DOMAIN=your_mailgun_domain_here
FROM_EMAIL=LabWise <postmaster@your_mailgun_domain_here>
OPENAI_API_KEY=your_openai_api_key_here
OPENAI_MODEL=gpt-5-mini

View File

@@ -0,0 +1,177 @@
const OPENAI_RESPONSES_URL = 'https://api.openai.com/v1/responses';
type SafetyIssue = {
type: 'critical' | 'warning' | 'suggestion';
category: string;
message: string;
source: string;
sourceUrl: string;
};
type ProtocolAnalysis = {
issues: SafetyIssue[];
};
const analysisSchema = {
type: 'object',
properties: {
issues: {
type: 'array',
description: 'Safety issues found in the protocol. Return an empty array when no issues are found.',
items: {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['critical', 'warning', 'suggestion'],
description: 'critical for immediate safety risks, warning for notable compliance or procedural gaps, suggestion for lower-risk improvements.',
},
category: {
type: 'string',
description: 'Short label such as PPE Requirements, Ventilation, Waste Disposal, Emergency Procedures, or Chemical Storage.',
},
message: {
type: 'string',
description: 'Clear, actionable finding written for lab staff.',
},
source: {
type: 'string',
description: 'Short source title, regulation, or guidance name.',
},
sourceUrl: {
type: 'string',
description: 'Clickable URL for the source.',
},
},
required: ['type', 'category', 'message', 'source', 'sourceUrl'],
additionalProperties: false,
},
},
},
required: ['issues'],
additionalProperties: false,
};
function getOutputText(response: any): string {
if (typeof response.output_text === 'string') return response.output_text;
const parts: string[] = [];
for (const item of response.output ?? []) {
for (const content of item.content ?? []) {
if (content.type === 'output_text' && typeof content.text === 'string') {
parts.push(content.text);
}
}
}
return parts.join('\n');
}
function normalizeAnalysis(value: unknown): SafetyIssue[] {
const parsed = value as Partial<ProtocolAnalysis>;
if (!Array.isArray(parsed.issues)) return [];
return parsed.issues
.filter((issue): issue is SafetyIssue =>
issue != null &&
['critical', 'warning', 'suggestion'].includes((issue as SafetyIssue).type) &&
typeof (issue as SafetyIssue).category === 'string' &&
typeof (issue as SafetyIssue).message === 'string' &&
typeof (issue as SafetyIssue).source === 'string' &&
typeof (issue as SafetyIssue).sourceUrl === 'string'
)
.map(issue => ({
type: issue.type,
category: issue.category.trim(),
message: issue.message.trim(),
source: issue.source.trim(),
sourceUrl: issue.sourceUrl.trim(),
}))
.filter(issue => issue.category && issue.message && issue.source && issue.sourceUrl);
}
export async function analyzeProtocolWithOpenAI(protocolText: string): Promise<SafetyIssue[]> {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('OPENAI_API_KEY is not configured');
}
const model = process.env.OPENAI_MODEL || 'gpt-5-mini';
const requestBody: Record<string, unknown> = {
model,
input: [
{
role: 'system',
content:
'You are a laboratory safety reviewer. Analyze protocols for practical safety and compliance gaps. ' +
'Return JSON only. Each finding must use one of: critical, warning, suggestion. ' +
'Cite authoritative sources such as OSHA, EPA, CDC/NIOSH, NFPA, ANSI, or university EHS guidance. ' +
'Do not invent source URLs; use a relevant official source URL for every finding.',
},
{
role: 'user',
content:
'Analyze this lab protocol. Focus on PPE, chemical compatibility, ventilation, waste disposal, emergency procedures, storage, and missing hazard controls.\n\n' +
protocolText,
},
],
tools: [
{
type: 'web_search',
filters: {
allowed_domains: [
'www.osha.gov',
'www.epa.gov',
'www.cdc.gov',
'www.nfpa.org',
'www.ansi.org',
'ehs.princeton.edu',
'ehs.stanford.edu',
'ehs.ucsf.edu',
],
},
},
],
tool_choice: 'required',
text: {
format: {
type: 'json_schema',
name: 'protocol_safety_analysis',
strict: true,
schema: analysisSchema,
},
},
};
if (model.startsWith('gpt-5')) {
requestBody.reasoning = { effort: 'low' };
}
const response = await fetch(OPENAI_RESPONSES_URL, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorBody = await response.text().catch(() => '');
throw new Error(`OpenAI protocol analysis failed (${response.status}): ${errorBody || response.statusText}`);
}
const data = await response.json();
const outputText = getOutputText(data);
if (!outputText) {
throw new Error('OpenAI returned an empty protocol analysis');
}
let parsed: unknown;
try {
parsed = JSON.parse(outputText);
} catch {
throw new Error('OpenAI returned protocol analysis that was not valid JSON');
}
return normalizeAnalysis(parsed);
}

View File

@@ -5,6 +5,7 @@ import multer from 'multer';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { analyzeProtocolWithOpenAI } from '../ai/protocolAnalysis.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -89,6 +90,36 @@ router.patch('/:id/analysis', async (req, res) => {
} }
}); });
// POST /api/protocols/:id/analyze
router.post('/:id/analyze', async (req, res) => {
try {
const protocol = await pool.query(
'SELECT * FROM protocols WHERE id = $1 AND user_id = $2',
[req.params.id, req.user!.id]
);
if (protocol.rows.length === 0) return res.status(404).json({ error: 'Not found' });
const content = protocol.rows[0].content;
if (!content || !content.trim()) {
return res.status(400).json({ error: 'Protocol content is required before analysis' });
}
const analysisResults = await analyzeProtocolWithOpenAI(content);
const result = await pool.query(
`UPDATE protocols SET analysis_results = $1::jsonb, updated_at = NOW()
WHERE id = $2 AND user_id = $3 RETURNING *`,
[JSON.stringify(analysisResults), req.params.id, req.user!.id]
);
res.json(result.rows[0]);
} catch (err) {
console.error(err);
const message = err instanceof Error ? err.message : 'Protocol analysis failed';
const status = message.includes('OPENAI_API_KEY') ? 500 : 502;
res.status(status).json({ error: message });
}
});
// DELETE /api/protocols/:id // DELETE /api/protocols/:id
router.delete('/:id', async (req, res) => { router.delete('/:id', async (req, res) => {
try { try {