119 lines
5.6 KiB
JavaScript
119 lines
5.6 KiB
JavaScript
// Conversation text uses an exact character budget, not an estimated model token limit.
|
|
const DEFAULT_CONVERSATION_CHARS = 120000;
|
|
const MAX_SAVED_CHAT_BYTES = 8 * 1024 * 1024;
|
|
|
|
function failure(message, statusCode, code) {
|
|
return Object.assign(new Error(message), { statusCode, code });
|
|
}
|
|
|
|
function conversationLimit(value) {
|
|
if (value == null || value === '') return DEFAULT_CONVERSATION_CHARS;
|
|
const limit = Number(value);
|
|
if (!['string', 'number'].includes(typeof value) || !Number.isInteger(limit) || limit < 1000 || limit > 1000000) {
|
|
throw failure('Conversation budget must be an integer from 1,000 to 1,000,000 characters.', 400, 'INVALID_CONVERSATION_BUDGET');
|
|
}
|
|
return limit;
|
|
}
|
|
|
|
function conversationBudget(env = process.env) {
|
|
const name = 'CLINICAL_ASSISTANT_CONVERSATION_CHARS';
|
|
const value = env[name];
|
|
try {
|
|
return {
|
|
limit: conversationLimit(value), unit: 'characters', measure: 'UTF-16 code units',
|
|
env: name, source: value == null || value === '' ? 'default' : 'environment'
|
|
};
|
|
} catch (_) {
|
|
throw failure('Conversation budget environment is invalid. No request was sent to an AI provider.', 503, 'INVALID_CONVERSATION_BUDGET');
|
|
}
|
|
}
|
|
|
|
function validateMessages(messages) {
|
|
if (messages === undefined) return [];
|
|
if (!Array.isArray(messages)) throw failure('Conversation history must be a list.', 400, 'INVALID_CONVERSATION');
|
|
return messages.map(function(message) {
|
|
if (!message || !['user', 'assistant'].includes(message.role) || typeof message.content !== 'string') {
|
|
throw failure('Each conversation turn must have a user/assistant role and text content.', 400, 'INVALID_CONVERSATION');
|
|
}
|
|
return { role: message.role, content: message.content };
|
|
});
|
|
}
|
|
|
|
function checkConversation(history, message, limit, handoff) {
|
|
history = validateMessages(history);
|
|
if (typeof message !== 'string' || (!handoff && !message.trim())) {
|
|
throw failure('Question is required.', 400, 'INVALID_CONVERSATION');
|
|
}
|
|
const used = history.reduce((total, turn) => total + turn.content.length, message.length);
|
|
const budget = { used, limit, unit: 'characters', remaining: Math.max(0, limit - used) };
|
|
if (used > limit) {
|
|
const error = failure('Conversation limit reached. Nothing was truncated or sent to an AI provider. Save or download this chat, then start a new chat or explicitly request a handoff summary.', 413, 'CONVERSATION_LIMIT');
|
|
error.budget = budget;
|
|
throw error;
|
|
}
|
|
return { history, message, budget };
|
|
}
|
|
|
|
function savedSources(sources) {
|
|
if (sources === undefined) return [];
|
|
if (!Array.isArray(sources) || sources.some(source => !source || typeof source !== 'object' || Array.isArray(source))) {
|
|
throw failure('Invalid saved source metadata.', 400, 'INVALID_SAVED_CHAT');
|
|
}
|
|
return sources.map(function(source) {
|
|
const copy = { ...source };
|
|
delete copy.image_path;
|
|
delete copy.file_path;
|
|
return copy;
|
|
});
|
|
}
|
|
|
|
function savedImage(image) {
|
|
if (image == null || image === '') return '';
|
|
if (typeof image !== 'string') throw failure('Invalid saved image.', 400, 'INVALID_SAVED_CHAT');
|
|
const match = image.match(/^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$/);
|
|
if (match && image.length <= MAX_SAVED_CHAT_BYTES) {
|
|
const bytes = Buffer.from(match[2], 'base64');
|
|
const headerMatches = match[1] === 'png' ? bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) :
|
|
match[1] === 'jpeg' ? bytes.subarray(0, 3).equals(Buffer.from([255, 216, 255])) :
|
|
bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP';
|
|
if (headerMatches && bytes.toString('base64').replace(/=+$/, '') === match[2].replace(/=+$/, '')) return image;
|
|
}
|
|
try {
|
|
const url = new URL(image);
|
|
if (url.protocol === 'https:' || url.protocol === 'http:') return image;
|
|
} catch (_) {}
|
|
throw failure('Saved images must be PNG, JPEG, WebP or HTTP(S) image URLs.', 400, 'INVALID_SAVED_CHAT');
|
|
}
|
|
|
|
function savedChatPayload(body) {
|
|
const messages = validateMessages(body.messages).map(function(message, index) {
|
|
const original = body.messages[index];
|
|
const copy = { ...message, sources: savedSources(original.sources) };
|
|
// Preserve legacy loss provenance and an existing retained raw field across
|
|
// a v2 re-save/follow-up; neither is inference history or replacement text.
|
|
if (original.legacyClipped === true && message.content.length === 12000 && !/[\r\n]/.test(message.content)) {
|
|
copy.legacyClipped = true;
|
|
if (message.role === 'assistant' && typeof original.retainedAnswer === 'string' && original.retainedAnswer.length > 12000 &&
|
|
original.retainedAnswer.length <= 30000 && !/[\r\n]/.test(original.retainedAnswer) &&
|
|
original.retainedAnswer.startsWith(message.content)) copy.retainedAnswer = original.retainedAnswer;
|
|
}
|
|
return copy;
|
|
});
|
|
if (body.lastAnswer !== undefined && typeof body.lastAnswer !== 'string') {
|
|
throw failure('Invalid saved answer.', 400, 'INVALID_SAVED_CHAT');
|
|
}
|
|
const payload = {
|
|
version: 2,
|
|
messages,
|
|
sources: savedSources(body.sources),
|
|
lastAnswer: body.lastAnswer || '',
|
|
generatedImage: savedImage(body.generatedImage),
|
|
savedAt: new Date().toISOString()
|
|
};
|
|
if (Buffer.byteLength(JSON.stringify(payload), 'utf8') > MAX_SAVED_CHAT_BYTES) {
|
|
throw failure('Saved chat exceeds the 8 MiB storage limit. Nothing was saved or truncated; download the complete transcript instead.', 413, 'SAVED_CHAT_LIMIT');
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
module.exports = { DEFAULT_CONVERSATION_CHARS, MAX_SAVED_CHAT_BYTES, conversationLimit, conversationBudget, checkConversation, savedChatPayload };
|