103 lines
4.7 KiB
JavaScript
103 lines
4.7 KiB
JavaScript
const COLORS = new Set(['brown', 'green', 'yellow', 'pale', 'red', 'black']);
|
|
const QUALITIES = new Set(['good', 'fair', 'poor']);
|
|
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
|
|
function asObject(raw) {
|
|
if (raw && typeof raw === 'object' && !Array.isArray(raw)) return raw;
|
|
if (typeof raw !== 'string') throw new Error('Invalid AI response.');
|
|
const cleaned = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
try {
|
|
const parsed = JSON.parse(cleaned);
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error();
|
|
return parsed;
|
|
} catch {
|
|
throw new Error('Invalid AI response.');
|
|
}
|
|
}
|
|
|
|
export function parseVisionResponse(raw) {
|
|
const value = asObject(raw);
|
|
if (typeof value.isStool !== 'boolean') throw new Error('Invalid AI response: isStool is required.');
|
|
const confidence = Number(value.confidence);
|
|
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1) throw new Error('Invalid AI response: confidence is required.');
|
|
if (!value.isStool) return { status: 'needs_user_input', isStool: false, reason: 'The image does not clearly show stool.' };
|
|
if (confidence < 0.55) return { status: 'needs_user_input', isStool: true, reason: 'The image is too uncertain to prefill safely.' };
|
|
const bristolType = Number(value.bristolType);
|
|
const color = String(value.color || '').toLowerCase();
|
|
const imageQuality = String(value.imageQuality || '').toLowerCase();
|
|
if (!Number.isInteger(bristolType) || bristolType < 1 || bristolType > 7 || !COLORS.has(color) || !QUALITIES.has(imageQuality)) {
|
|
throw new Error('Invalid AI response: visual fields are out of range.');
|
|
}
|
|
return {
|
|
status: 'suggestion',
|
|
isStool: true,
|
|
bristolType,
|
|
color,
|
|
confidence: Math.round(confidence * 100) / 100,
|
|
imageQuality,
|
|
observations: String(value.observations || '').trim().slice(0, 240),
|
|
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
|
|
};
|
|
}
|
|
|
|
export function mergeVisualSuggestion(form, suggestion) {
|
|
if (suggestion?.status !== 'suggestion') return { ...form };
|
|
return { ...form, bristolType: suggestion.bristolType, color: suggestion.color };
|
|
}
|
|
|
|
export function validatePhotoPayload(payload = {}) {
|
|
if (payload.consent !== true) throw new Error('Explicit consent is required before AI analysis.');
|
|
const match = /^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/.exec(String(payload.imageDataUrl || ''));
|
|
if (!match) throw new Error('Upload a JPEG, PNG, or WebP photo.');
|
|
const padding = (match[2].match(/=*$/) || [''])[0].length;
|
|
const bytes = Math.floor(match[2].length * 3 / 4) - padding;
|
|
if (bytes <= 0) throw new Error('The photo is empty.');
|
|
if (bytes > MAX_IMAGE_BYTES) throw new Error('The photo is too large. Use an image under 4 MB.');
|
|
return { imageDataUrl: payload.imageDataUrl, mime: match[1], bytes };
|
|
}
|
|
|
|
export function buildVisionRequest({ imageDataUrl, model }) {
|
|
return {
|
|
model,
|
|
temperature: 0,
|
|
max_tokens: 500,
|
|
messages: [{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: [
|
|
'You are a conservative visual form-suggestion tool for a bowel diary, not a clinician.',
|
|
'First decide whether the image clearly shows human stool. If it does not, set isStool=false.',
|
|
'If it does, suggest only the closest Bristol Stool Form Scale type (1-7), visible color (brown, green, yellow, pale, red, or black), image quality, confidence, and a short neutral visual observation.',
|
|
'Do not infer urgency, discomfort, pain, symptoms, bleeding, disease, diet safety, cause, or treatment from the image.',
|
|
'Red or black is a visible color description only and is not a diagnosis. Be uncertain when lighting or visibility is poor.',
|
|
'The user must confirm every suggestion. Return only the requested JSON.',
|
|
].join(' '),
|
|
},
|
|
{ type: 'image_url', image_url: { url: imageDataUrl } },
|
|
],
|
|
}],
|
|
response_format: {
|
|
type: 'json_schema',
|
|
json_schema: {
|
|
name: 'timmy_visual_suggestion',
|
|
strict: true,
|
|
schema: {
|
|
type: 'object',
|
|
additionalProperties: false,
|
|
required: ['isStool', 'bristolType', 'color', 'confidence', 'imageQuality', 'observations'],
|
|
properties: {
|
|
isStool: { type: 'boolean' },
|
|
bristolType: { anyOf: [{ type: 'integer', minimum: 1, maximum: 7 }, { type: 'null' }] },
|
|
color: { anyOf: [{ type: 'string', enum: [...COLORS] }, { type: 'null' }] },
|
|
confidence: { type: 'number', minimum: 0, maximum: 1 },
|
|
imageQuality: { type: 'string', enum: [...QUALITIES] },
|
|
observations: { type: 'string', maxLength: 240 },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
}
|