From 4098453c97fb786acf14e6f4ef9fdff90ed63ec2 Mon Sep 17 00:00:00 2001 From: Aniketh Kalagara Date: Tue, 14 Apr 2026 19:08:02 -0500 Subject: [PATCH] v1 of protocol checker --- components/Inventory.tsx | 12 +- components/ProtocolChecker.tsx | 142 ++++++++++++++++-------- components/ui/calendar.tsx | 10 +- lib/api.ts | 7 +- server/.env.example | 6 + server/src/ai/protocolAnalysis.ts | 177 ++++++++++++++++++++++++++++++ server/src/routes/protocols.ts | 31 ++++++ 7 files changed, 328 insertions(+), 57 deletions(-) create mode 100644 server/.env.example create mode 100644 server/src/ai/protocolAnalysis.ts diff --git a/components/Inventory.tsx b/components/Inventory.tsx index 9d76fe8..034bbf0 100644 --- a/components/Inventory.tsx +++ b/components/Inventory.tsx @@ -66,6 +66,8 @@ const TABLE_COLUMNS: { label: string; key: keyof ChemicalInventory }[] = [ { label: "Multiple CAS (comma delimited)", key: "multipleCAS" }, ]; +type ImportColumnMapping = keyof ChemicalInventory | "__skip__"; + // ── helpers ──────────────────────────────────────────────────────────────── function daysUntil(dateStr: string) { @@ -144,7 +146,7 @@ export function Inventory() { const [importStep, setImportStep] = useState<"upload" | "map" | "result">("upload"); const [importHeaders, setImportHeaders] = useState([]); const [importRows, setImportRows] = useState([]); - const [columnMapping, setColumnMapping] = useState>({}); + const [columnMapping, setColumnMapping] = useState>({}); const [importResult, setImportResult] = useState<{ imported: number; errors: string[] } | null>(null); const [isImporting, setIsImporting] = useState(false); const [importError, setImportError] = useState(""); @@ -339,7 +341,7 @@ export function Inventory() { return s.toLowerCase().replace(/[^a-z0-9]/g, ""); } - function fuzzyMatchColumn(header: string): keyof ChemicalInventory | "__skip__" { + function fuzzyMatchColumn(header: string): ImportColumnMapping { const norm = normalizeHeader(header); if (!norm) return "__skip__"; for (const col of TABLE_COLUMNS) { @@ -349,7 +351,7 @@ export function Inventory() { const colNorm = normalizeHeader(col.label); if (colNorm.startsWith(norm) || norm.startsWith(colNorm)) return col.key; } - let bestKey: keyof ChemicalInventory | "__skip__" = "__skip__"; + let bestKey: ImportColumnMapping = "__skip__"; let bestScore = 0.55; for (const col of TABLE_COLUMNS) { const colNorm = normalizeHeader(col.label); @@ -430,7 +432,7 @@ export function Inventory() { console.log("[import] headers:", headers, "data rows:", dataRows.length); setImportHeaders(headers); setImportRows(dataRows); - const mapping: Record = {}; + const mapping: Record = {}; for (const h of headers) mapping[h] = fuzzyMatchColumn(h); setColumnMapping(mapping); console.log("[import] advancing to map step"); @@ -662,7 +664,7 @@ export function Inventory() { + +
fileInputRef.current?.click()} + onKeyDown={handleUploadKeyDown} + onDragOver={(event) => event.preventDefault()} + onDrop={handleDrop} + >

Click to upload or drag and drop

-

PDF, DOC, DOCX, TXT (max 10MB)

+

TXT files for now (max 10MB)

+ {selectedFileName && ( +

{selectedFileName}

+ )}
@@ -241,6 +279,15 @@ Example: )} + + {analysisError && ( + + + + {analysisError} + + + )} {/* Results */} @@ -258,7 +305,12 @@ Example:
- {mockIssues.map((issue, idx) => { + {issues.length === 0 && ( +

+ No safety issues were returned for this protocol. +

+ )} + {issues.map((issue, idx) => { const colors = getIssueColor(issue.type); return (
( - - ), - IconRight: ({ className, ...props }) => ( - - ), + Chevron: ({ className, orientation, ...props }) => { + const Icon = orientation === "left" ? ChevronLeft : ChevronRight; + return ; + }, }} {...props} /> diff --git a/lib/api.ts b/lib/api.ts index 050b6f9..872cae4 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -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 { const res = await fetch(path, { @@ -64,6 +64,11 @@ export const protocolsApi = { body: JSON.stringify({ analysis_results: results }), }).then(r => r.json()), + analyze: (id: string): Promise => + apiFetch(`/api/protocols/${id}/analyze`, { + method: 'POST', + }).then(r => r.json()), + remove: (id: string): Promise => apiFetch(`/api/protocols/${id}`, { method: 'DELETE' }).then(() => undefined), }; diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..13771b9 --- /dev/null +++ b/server/.env.example @@ -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 +OPENAI_API_KEY=your_openai_api_key_here +OPENAI_MODEL=gpt-5-mini diff --git a/server/src/ai/protocolAnalysis.ts b/server/src/ai/protocolAnalysis.ts new file mode 100644 index 0000000..a64579b --- /dev/null +++ b/server/src/ai/protocolAnalysis.ts @@ -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; + 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 { + 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 = { + 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); +} diff --git a/server/src/routes/protocols.ts b/server/src/routes/protocols.ts index dd01517..21e4eea 100644 --- a/server/src/routes/protocols.ts +++ b/server/src/routes/protocols.ts @@ -5,6 +5,7 @@ import multer from 'multer'; import path from 'path'; import fs from 'fs'; import { fileURLToPath } from 'url'; +import { analyzeProtocolWithOpenAI } from '../ai/protocolAnalysis.js'; 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 router.delete('/:id', async (req, res) => { try {