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

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 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 {