v1 of protocol checker
All checks were successful
Deploy to Server / deploy (push) Successful in 34s
All checks were successful
Deploy to Server / deploy (push) Successful in 34s
This commit is contained in:
6
server/.env.example
Normal file
6
server/.env.example
Normal 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
|
||||
177
server/src/ai/protocolAnalysis.ts
Normal file
177
server/src/ai/protocolAnalysis.ts
Normal 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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user