1543 lines
59 KiB
JavaScript
1543 lines
59 KiB
JavaScript
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
|
|
const OpenAI = require('openai');
|
|
const { parseCurlCommand } = require('./curlExecutor');
|
|
|
|
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
const BRAND_LLM_MODEL = 'openai/gpt-4o';
|
|
const TEMPLATE_LLM_MODEL = 'openai/gpt-4o';
|
|
const CURL_LLM_MODEL = 'openai/gpt-4o-mini';
|
|
const EDIT_CHECK_LLM_MODEL = 'openai/gpt-4o-mini';
|
|
|
|
const DLT_VARIABLE_SPECS = [
|
|
{
|
|
token: '{#numeric#}',
|
|
label: '#numeric',
|
|
purpose: 'Digits-only dynamic values such as OTPs, amounts, or numeric IDs.',
|
|
validation: 'Only digits are allowed.',
|
|
},
|
|
{
|
|
token: '{#url#}',
|
|
label: '#url',
|
|
purpose: 'Web links.',
|
|
validation: 'Must resolve to a valid registered HTTP(S) URL.',
|
|
},
|
|
{
|
|
token: '{#urlott#}',
|
|
label: '#urlott',
|
|
purpose: 'OTT or app-download links.',
|
|
validation: 'Must resolve to a valid registered OTT or APK URL.',
|
|
},
|
|
{
|
|
token: '{#cbn#}',
|
|
label: '#cbn',
|
|
purpose: 'Callback phone numbers.',
|
|
validation: 'Must resolve to a valid registered callback number.',
|
|
},
|
|
{
|
|
token: '{#email#}',
|
|
label: '#email',
|
|
purpose: 'Email addresses.',
|
|
validation: 'Must resolve to a syntactically valid email address.',
|
|
},
|
|
{
|
|
token: '{#alphanumeric#}',
|
|
label: '#alphanumeric',
|
|
purpose: 'Mixed letter-and-number values such as order IDs or booking references.',
|
|
validation: 'Letters and numbers only; avoid spaces and special characters.',
|
|
},
|
|
];
|
|
const LEGACY_DLT_VAR_TOKEN = '{#var#}';
|
|
const SUPPORTED_DLT_TOKENS = [LEGACY_DLT_VAR_TOKEN, ...DLT_VARIABLE_SPECS.map((spec) => spec.token)];
|
|
const SUPPORTED_DLT_TOKEN_SET = new Set(SUPPORTED_DLT_TOKENS);
|
|
const DLT_PLACEHOLDER_REGEX = /\{#(?:var|numeric|url|urlott|cbn|email|alphanumeric)#\}/g;
|
|
const DLT_PLACEHOLDER_LIKE_REGEX = /\{#[^{}]*#\}/g;
|
|
|
|
const TEMPLATE_HARD_RULE_LINES = [
|
|
'Keep the SMS within 160 characters.',
|
|
`Use only approved placeholders: ${SUPPORTED_DLT_TOKENS.join(', ')}.`,
|
|
'Do not use malformed or incomplete placeholder text.',
|
|
'Do not include exact blocked brand or tagline phrases when they are provided.',
|
|
];
|
|
const TEMPLATE_GUIDANCE_RULE_LINES = [
|
|
'Keep the message transactional and relevant to the selected event.',
|
|
'Start with clear order or event context when possible.',
|
|
`Use ${LEGACY_DLT_VAR_TOKEN} whenever the safest placeholder type is uncertain.`,
|
|
`Prefer typed placeholders (${DLT_VARIABLE_SPECS.map((spec) => spec.token).join(', ')}) only when the text makes that type obvious.`,
|
|
'Avoid raw URLs unless the template genuinely requires a link.',
|
|
'Avoid extra signoffs or provider suffixes at the end of the message.',
|
|
];
|
|
const TEMPLATE_HARD_RULES_TEXT = TEMPLATE_HARD_RULE_LINES.map((rule, index) => `${index + 1}) ${rule}`).join(' ');
|
|
const TEMPLATE_GUIDANCE_RULES_TEXT = TEMPLATE_GUIDANCE_RULE_LINES.map((rule, index) => `${index + 1}) ${rule}`).join(' ');
|
|
const TEMPLATE_VALIDATION_ISSUE_CODES = new Set([
|
|
'EMPTY_TEMPLATE',
|
|
'LENGTH_EXCEEDED',
|
|
'UNSUPPORTED_DLT_TOKEN',
|
|
'MALFORMED_DLT_TOKEN',
|
|
'BLOCKED_LITERAL_PHRASE',
|
|
]);
|
|
|
|
const BRAND_CONTEXT_TONE_OPTIONS = ['friendly', 'professional', 'formal', 'casual', 'energetic'];
|
|
const EVENT_DESCRIPTIONS = {
|
|
placed: 'The customer has successfully placed an order',
|
|
confirmed: 'The order has been confirmed by the seller/warehouse',
|
|
dp_assigned: 'A delivery partner has been assigned to deliver the order',
|
|
pack: 'The order has been packed and is ready for dispatch',
|
|
cancelled: 'The order has been cancelled',
|
|
delivery_done: 'The order has been successfully delivered to the customer',
|
|
};
|
|
|
|
let cachedClient = null;
|
|
const RESERVED_RUNTIME_TOKENS = {
|
|
toNumber: '__SMS_TO_NUMBER__',
|
|
content: '__SMS_CONTENT__',
|
|
templateId: '__SMS_TEMPLATE_ID__',
|
|
senderId: '__SMS_SENDER_ID__',
|
|
dltEntityId: '__SMS_DLT_ENTITY_ID__',
|
|
};
|
|
const REQUIRED_INPUT_SOURCES = new Set(['embedded', 'profile', 'runtime']);
|
|
const CURL_DATA_FLAGS = new Set(['--data', '--data-raw', '--data-binary', '-d']);
|
|
const CURL_UNSAFE_CHECKS = [
|
|
{ pattern: /`/, reason: 'Backticks are not supported in provider cURLs.' },
|
|
{ pattern: /\$\(/, reason: 'Command substitution is not supported in provider cURLs.' },
|
|
{ pattern: /&&|\|\|/, reason: 'Command chaining is not supported in provider cURLs.' },
|
|
{ pattern: /<\(|>\(/, reason: 'Process substitution is not supported in provider cURLs.' },
|
|
{ pattern: /(?:^|\s);\s*(?:curl\b|[A-Za-z0-9_./-]+)/, reason: 'Multiple shell commands are not supported in provider cURLs.' },
|
|
{ pattern: /(?:^|\s)(?:--config|-K)\b/, reason: 'Loading external curl config files is not supported.' },
|
|
{ pattern: /(?:--data(?:-raw|-binary)?|-d)\s+@/, reason: 'Reading request bodies from local files is not supported.' },
|
|
];
|
|
const INPUT_KEY_ALIASES = {
|
|
provider: 'providerName',
|
|
providerLabel: 'providerName',
|
|
smsProvider: 'providerName',
|
|
dltSenderId: 'senderId',
|
|
sender: 'senderId',
|
|
senderCode: 'senderId',
|
|
peId: 'dltEntityId',
|
|
entityId: 'dltEntityId',
|
|
dltPeId: 'dltEntityId',
|
|
dltEntity: 'dltEntityId',
|
|
apiKey: 'authKey',
|
|
apiAuthKey: 'authKey',
|
|
accessToken: 'authToken',
|
|
bearerToken: 'authToken',
|
|
destination: 'toNumber',
|
|
destinationNumber: 'toNumber',
|
|
mobile: 'toNumber',
|
|
phone: 'toNumber',
|
|
phoneNumber: 'toNumber',
|
|
to: 'toNumber',
|
|
message: 'content',
|
|
body: 'content',
|
|
smsBody: 'content',
|
|
smsContent: 'content',
|
|
text: 'content',
|
|
dltTemplate: 'templateId',
|
|
dltTemplateId: 'templateId',
|
|
template: 'templateId',
|
|
};
|
|
|
|
function normalizeText(value) {
|
|
return typeof value === 'string' ? value.trim() : '';
|
|
}
|
|
|
|
function toCamelCase(value) {
|
|
const normalized = normalizeText(String(value || ''))
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
.replace(/[^A-Za-z0-9]+/g, ' ')
|
|
.trim();
|
|
|
|
if (!normalized) return '';
|
|
|
|
const parts = normalized.split(/\s+/).filter(Boolean);
|
|
return parts
|
|
.map((part, index) => {
|
|
const lower = part.toLowerCase();
|
|
if (index === 0) return lower;
|
|
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
})
|
|
.join('');
|
|
}
|
|
|
|
function toUpperSnakeCase(value) {
|
|
return normalizeText(String(value || ''))
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
.replace(/[^A-Za-z0-9]+/g, '_')
|
|
.replace(/^_+|_+$/g, '')
|
|
.toUpperCase();
|
|
}
|
|
|
|
function humanizeKey(value) {
|
|
const camel = toCamelCase(value);
|
|
if (!camel) return '';
|
|
|
|
return camel
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
.replace(/^./, (char) => char.toUpperCase());
|
|
}
|
|
|
|
function canonicalizeInputKey(value) {
|
|
const camel = toCamelCase(value);
|
|
return INPUT_KEY_ALIASES[camel] || camel;
|
|
}
|
|
|
|
function getProfileToken(key) {
|
|
return `__PROFILE_${toUpperSnakeCase(key)}__`;
|
|
}
|
|
|
|
function getRuntimeToken(key) {
|
|
return RESERVED_RUNTIME_TOKENS[canonicalizeInputKey(key)] || '';
|
|
}
|
|
|
|
function getCurlUrl(rawCurl) {
|
|
const match = String(rawCurl || '').match(/https?:\/\/[^\s'"\\]+/i);
|
|
return match ? match[0] : '';
|
|
}
|
|
|
|
function detectUnsafeCurl(rawCurl) {
|
|
const normalized = String(rawCurl || '');
|
|
|
|
if (!normalizeText(normalized).toLowerCase().startsWith('curl')) {
|
|
return 'The provided input must begin with "curl".';
|
|
}
|
|
|
|
if (!getCurlUrl(normalized)) {
|
|
return 'The provided cURL must include an absolute http(s) URL.';
|
|
}
|
|
|
|
const matchedRule = CURL_UNSAFE_CHECKS.find(({ pattern }) => pattern.test(normalized));
|
|
return matchedRule ? matchedRule.reason : '';
|
|
}
|
|
|
|
function describeDltVariableTypes() {
|
|
return DLT_VARIABLE_SPECS
|
|
.map((spec) => `- ${spec.token}: ${spec.purpose} ${spec.validation}`)
|
|
.join('\n');
|
|
}
|
|
|
|
function getUnsupportedDltTokens(text) {
|
|
return (String(text).match(DLT_PLACEHOLDER_LIKE_REGEX) || [])
|
|
.filter((token) => !SUPPORTED_DLT_TOKEN_SET.has(token));
|
|
}
|
|
|
|
function hasMalformedDltFragments(text) {
|
|
const stripped = String(text).replace(DLT_PLACEHOLDER_LIKE_REGEX, '');
|
|
return stripped.includes('{#') || stripped.includes('#}');
|
|
}
|
|
|
|
function validateTemplateStructure(text) {
|
|
const normalized = normalizeText(text);
|
|
if (!normalized) return 'Template is empty.';
|
|
if (normalized.length > 160) return 'Template exceeds 160 characters.';
|
|
|
|
const unsupportedTokens = getUnsupportedDltTokens(normalized);
|
|
if (unsupportedTokens.length > 0) {
|
|
return `Template uses unsupported placeholders: ${unsupportedTokens.join(', ')}.`;
|
|
}
|
|
|
|
if (hasMalformedDltFragments(normalized)) {
|
|
return 'Template contains malformed placeholder text.';
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function escapeRegex(value) {
|
|
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
function buildPhraseRegex(phrase) {
|
|
const normalized = normalizeText(phrase).replace(/\s+/g, ' ');
|
|
if (!normalized) return null;
|
|
|
|
const parts = normalized.split(' ').filter(Boolean).map(escapeRegex);
|
|
if (parts.length === 0) return null;
|
|
|
|
return new RegExp(`(^|[^a-z0-9])${parts.join('\\s+')}([^a-z0-9]|$)`, 'i');
|
|
}
|
|
|
|
function getBlockedBrandPhrases(options = {}) {
|
|
const phrases = [
|
|
options?.brandName,
|
|
...(Array.isArray(options?.brandTaglines) ? options.brandTaglines : []),
|
|
]
|
|
.map((value) => normalizeText(value))
|
|
.filter(Boolean);
|
|
|
|
return [...new Set(phrases)];
|
|
}
|
|
|
|
function findBlockedBrandPhrase(text, options = {}) {
|
|
const normalizedText = normalizeText(text);
|
|
if (!normalizedText) return '';
|
|
|
|
return getBlockedBrandPhrases(options).find((phrase) => {
|
|
const matcher = buildPhraseRegex(phrase);
|
|
return matcher ? matcher.test(normalizedText) : false;
|
|
}) || '';
|
|
}
|
|
|
|
function describeEvent(eventSlug, eventLabel) {
|
|
const normalizedSlug = normalizeText(eventSlug).toLowerCase();
|
|
if (EVENT_DESCRIPTIONS[normalizedSlug]) {
|
|
return EVENT_DESCRIPTIONS[normalizedSlug];
|
|
}
|
|
|
|
const normalizedLabel = normalizeText(eventLabel) || normalizedSlug.replace(/_/g, ' ').trim();
|
|
if (!normalizedLabel) {
|
|
return 'This is a transactional order lifecycle update.';
|
|
}
|
|
|
|
const lower = `${normalizedSlug} ${normalizedLabel.toLowerCase()}`;
|
|
|
|
if (lower.includes('refund')) {
|
|
return `This is a refund-related transactional order update for "${normalizedLabel}".`;
|
|
}
|
|
|
|
if (lower.includes('return')) {
|
|
return `This is a return-related transactional order update for "${normalizedLabel}".`;
|
|
}
|
|
|
|
if (lower.includes('payment')) {
|
|
return `This is a payment-related transactional order update for "${normalizedLabel}".`;
|
|
}
|
|
|
|
if (lower.includes('cancel')) {
|
|
return `This is a cancellation-related transactional order update for "${normalizedLabel}".`;
|
|
}
|
|
|
|
if (lower.includes('delivery') || lower.includes('transit') || lower.includes('pickup') || lower.includes('dp')) {
|
|
return `This is a logistics or delivery status update for "${normalizedLabel}".`;
|
|
}
|
|
|
|
return `This is a transactional order lifecycle update for "${normalizedLabel}".`;
|
|
}
|
|
|
|
function summarizeRejectionReasons(reasons = [], limit = 6) {
|
|
const seen = new Set();
|
|
const summarized = [];
|
|
|
|
reasons.forEach((reason) => {
|
|
const normalized = normalizeText(reason);
|
|
if (!normalized) return;
|
|
|
|
const dedupeKey = normalized.toLowerCase();
|
|
if (seen.has(dedupeKey)) return;
|
|
|
|
seen.add(dedupeKey);
|
|
summarized.push(normalized);
|
|
});
|
|
|
|
return summarized.slice(-limit);
|
|
}
|
|
|
|
function buildTemplateValidationContext(options = {}) {
|
|
return {
|
|
eventSlug: normalizeText(options?.eventSlug),
|
|
eventLabel: normalizeText(options?.eventLabel),
|
|
eventDescription: describeEvent(options?.eventSlug, options?.eventLabel),
|
|
registeredSenderId: normalizeText(options?.senderId).toUpperCase(),
|
|
blockedBrandPhrases: getBlockedBrandPhrases({
|
|
brandName: options?.brandName,
|
|
brandTaglines: options?.brandTaglines,
|
|
}),
|
|
};
|
|
}
|
|
|
|
function createValidationIssue(code, message, evidence = '') {
|
|
const normalizedCode = normalizeText(code).toUpperCase();
|
|
if (!TEMPLATE_VALIDATION_ISSUE_CODES.has(normalizedCode)) return null;
|
|
|
|
return {
|
|
code: normalizedCode,
|
|
message: sanitizeString(message),
|
|
evidence: sanitizeString(evidence),
|
|
};
|
|
}
|
|
|
|
function appendValidationIssue(issues, issue) {
|
|
if (!Array.isArray(issues) || !issue?.code || !issue?.message) return;
|
|
const dedupeKey = `${issue.code}:${issue.evidence || issue.message}`.toLowerCase();
|
|
if (issues.some((entry) => `${entry.code}:${entry.evidence || entry.message}`.toLowerCase() === dedupeKey)) return;
|
|
issues.push(issue);
|
|
}
|
|
|
|
function getLiteralTemplateIssues(templateText, validationContext = {}) {
|
|
const normalizedTemplate = sanitizeString(templateText);
|
|
const issues = [];
|
|
|
|
if (!normalizedTemplate) {
|
|
appendValidationIssue(issues, createValidationIssue(
|
|
'EMPTY_TEMPLATE',
|
|
'Template is empty.',
|
|
));
|
|
return issues;
|
|
}
|
|
|
|
if (normalizedTemplate.length > 160) {
|
|
appendValidationIssue(issues, createValidationIssue(
|
|
'LENGTH_EXCEEDED',
|
|
`Template exceeds 160 characters (${normalizedTemplate.length}/160).`,
|
|
));
|
|
}
|
|
|
|
const unsupportedTokens = getUnsupportedDltTokens(normalizedTemplate);
|
|
if (unsupportedTokens.length > 0) {
|
|
appendValidationIssue(issues, createValidationIssue(
|
|
'UNSUPPORTED_DLT_TOKEN',
|
|
`Unsupported placeholder token${unsupportedTokens.length === 1 ? '' : 's'}: ${unsupportedTokens.join(', ')}.`,
|
|
unsupportedTokens.join(', '),
|
|
));
|
|
}
|
|
|
|
if (hasMalformedDltFragments(normalizedTemplate)) {
|
|
appendValidationIssue(issues, createValidationIssue(
|
|
'MALFORMED_DLT_TOKEN',
|
|
'Template contains malformed placeholder text.',
|
|
));
|
|
}
|
|
|
|
const blockedBrandPhrase = findBlockedBrandPhrase(normalizedTemplate, {
|
|
brandName: validationContext?.blockedBrandPhrases?.[0],
|
|
brandTaglines: validationContext?.blockedBrandPhrases?.slice(1),
|
|
});
|
|
if (blockedBrandPhrase) {
|
|
appendValidationIssue(issues, createValidationIssue(
|
|
'BLOCKED_LITERAL_PHRASE',
|
|
`Remove blocked literal phrase "${blockedBrandPhrase}" from the message body.`,
|
|
blockedBrandPhrase,
|
|
));
|
|
}
|
|
|
|
return issues;
|
|
}
|
|
|
|
function normalizeIssueCode(value) {
|
|
const normalized = toUpperSnakeCase(value);
|
|
return TEMPLATE_VALIDATION_ISSUE_CODES.has(normalized) ? normalized : '';
|
|
}
|
|
|
|
function normalizeValidationIssues(rawIssues, templateText, validationContext = {}) {
|
|
const normalizedTemplate = sanitizeString(templateText);
|
|
const issues = [];
|
|
const sourceIssues = Array.isArray(rawIssues)
|
|
? rawIssues
|
|
: (rawIssues && typeof rawIssues === 'object' ? [rawIssues] : []);
|
|
const literalIssues = getLiteralTemplateIssues(normalizedTemplate, validationContext);
|
|
const literalBlockedPhraseIssue = literalIssues.find((issue) => issue.code === 'BLOCKED_LITERAL_PHRASE') || null;
|
|
const unsupportedIssue = literalIssues.find((issue) => issue.code === 'UNSUPPORTED_DLT_TOKEN') || null;
|
|
|
|
sourceIssues.forEach((rawIssue) => {
|
|
const code = normalizeIssueCode(rawIssue?.code || rawIssue?.type || rawIssue?.category);
|
|
if (!code) return;
|
|
|
|
let evidence = sanitizeString(rawIssue?.evidence || rawIssue?.text || rawIssue?.phrase);
|
|
let message = sanitizeString(rawIssue?.message || rawIssue?.reason || rawIssue?.why);
|
|
|
|
if (code === 'BLOCKED_LITERAL_PHRASE') {
|
|
if (!literalBlockedPhraseIssue) return;
|
|
evidence = literalBlockedPhraseIssue.evidence;
|
|
message = message || literalBlockedPhraseIssue.message;
|
|
} else if (code === 'LENGTH_EXCEEDED' && normalizedTemplate.length <= 160) {
|
|
return;
|
|
} else if (code === 'EMPTY_TEMPLATE' && normalizedTemplate) {
|
|
return;
|
|
} else if (code === 'UNSUPPORTED_DLT_TOKEN') {
|
|
if (!unsupportedIssue) return;
|
|
evidence = unsupportedIssue.evidence;
|
|
message = message || unsupportedIssue.message;
|
|
} else if (code === 'MALFORMED_DLT_TOKEN' && !hasMalformedDltFragments(normalizedTemplate)) {
|
|
return;
|
|
}
|
|
|
|
appendValidationIssue(issues, createValidationIssue(code, message, evidence));
|
|
});
|
|
|
|
literalIssues.forEach((issue) => appendValidationIssue(issues, issue));
|
|
return issues;
|
|
}
|
|
|
|
function summarizeValidationIssues(issues = [], limit = 3) {
|
|
return summarizeRejectionReasons(
|
|
(Array.isArray(issues) ? issues : [])
|
|
.map((issue) => sanitizeString(issue?.message))
|
|
.filter(Boolean),
|
|
limit,
|
|
);
|
|
}
|
|
|
|
function requestId(prefix) {
|
|
return `${prefix}_${Date.now()}`;
|
|
}
|
|
|
|
function extractMessageText(content) {
|
|
if (typeof content === 'string') return content.trim();
|
|
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.map((entry) => {
|
|
if (typeof entry === 'string') return entry;
|
|
if (entry && typeof entry.text === 'string') return entry.text;
|
|
return '';
|
|
})
|
|
.join('')
|
|
.trim();
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function tryParseJson(text) {
|
|
const trimmed = normalizeText(text);
|
|
if (!trimmed) return null;
|
|
|
|
try {
|
|
return JSON.parse(trimmed);
|
|
} catch {
|
|
// fall through
|
|
}
|
|
|
|
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
if (fencedMatch?.[1]) {
|
|
try {
|
|
return JSON.parse(fencedMatch[1].trim());
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
|
|
const firstBrace = trimmed.indexOf('{');
|
|
const lastBrace = trimmed.lastIndexOf('}');
|
|
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
|
try {
|
|
return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isAbsoluteHttpUrl(value) {
|
|
if (!normalizeText(value)) return false;
|
|
try {
|
|
const parsed = new URL(value);
|
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getLlmClient() {
|
|
if (cachedClient) return cachedClient;
|
|
|
|
const apiKey = normalizeText(process.env.OPENROUTER_API_KEY);
|
|
if (!apiKey) {
|
|
throw new Error('OPENROUTER_API_KEY is not configured');
|
|
}
|
|
|
|
const referer = normalizeText(process.env.EXTENSION_BASE_URL);
|
|
const appName = 'SMS Extension';
|
|
const defaultHeaders = {};
|
|
|
|
if (referer) defaultHeaders['HTTP-Referer'] = referer;
|
|
if (appName) defaultHeaders['X-Title'] = appName;
|
|
|
|
cachedClient = new OpenAI({
|
|
apiKey,
|
|
baseURL: OPENROUTER_BASE_URL,
|
|
defaultHeaders,
|
|
});
|
|
|
|
return cachedClient;
|
|
}
|
|
|
|
async function requestStructuredJson({ model, taskName, systemPrompt, userPrompt, temperature = 0.2 }) {
|
|
try {
|
|
const client = getLlmClient();
|
|
const completion = await client.chat.completions.create({
|
|
model,
|
|
temperature,
|
|
response_format: { type: 'json_object' },
|
|
messages: [
|
|
{ role: 'system', content: systemPrompt },
|
|
{ role: 'user', content: userPrompt },
|
|
],
|
|
});
|
|
|
|
const text = extractMessageText(completion?.choices?.[0]?.message?.content);
|
|
const parsed = tryParseJson(text);
|
|
|
|
if (!parsed || typeof parsed !== 'object') {
|
|
throw new Error(`${taskName} returned unreadable JSON`);
|
|
}
|
|
|
|
return parsed;
|
|
} catch (error) {
|
|
const details = error.response?.data ? ` | response: ${JSON.stringify(error.response.data)}` : '';
|
|
throw new Error(`${taskName} failed: ${error.message}${details}`);
|
|
}
|
|
}
|
|
|
|
function sanitizeStringArray(value, options = {}) {
|
|
const { maxItems = Infinity, allowUrlsOnly = false } = options;
|
|
if (!Array.isArray(value)) return [];
|
|
|
|
const seen = new Set();
|
|
const items = [];
|
|
|
|
value.forEach((entry) => {
|
|
if (items.length >= maxItems) return;
|
|
const normalized = normalizeText(String(entry || ''));
|
|
if (!normalized) return;
|
|
if (allowUrlsOnly && !isAbsoluteHttpUrl(normalized)) return;
|
|
if (seen.has(normalized)) return;
|
|
seen.add(normalized);
|
|
items.push(normalized);
|
|
});
|
|
|
|
return items;
|
|
}
|
|
|
|
function sanitizeVariableMap(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
|
|
return Object.entries(value).reduce((accumulator, [key, rawValue]) => {
|
|
const normalizedKey = normalizeText(String(key || ''));
|
|
const normalizedValue = canonicalizeInputKey(rawValue);
|
|
if (!normalizedKey || !normalizedValue) return accumulator;
|
|
accumulator[normalizedKey] = normalizedValue;
|
|
return accumulator;
|
|
}, {});
|
|
}
|
|
|
|
function sanitizeString(value) {
|
|
return normalizeText(String(value || ''));
|
|
}
|
|
|
|
function sanitizeProvider(provider = {}) {
|
|
return {
|
|
providerName: sanitizeString(provider.providerName),
|
|
senderId: sanitizeString(provider.senderId).toUpperCase(),
|
|
dltEntityId: sanitizeString(provider.dltEntityId),
|
|
authKey: sanitizeString(provider.authKey),
|
|
};
|
|
}
|
|
|
|
function buildNamedFieldAlternation(fieldNames = []) {
|
|
return fieldNames
|
|
.map((name) => normalizeText(name))
|
|
.filter(Boolean)
|
|
.map((name) => name.split(/[_-]/).filter(Boolean).map(escapeRegex).join('[_-]?'))
|
|
.filter(Boolean)
|
|
.join('|');
|
|
}
|
|
|
|
function curlContainsNamedField(rawCurl = '', fieldNames = []) {
|
|
const alternation = buildNamedFieldAlternation(fieldNames);
|
|
if (!alternation) return false;
|
|
|
|
const normalizedCurl = String(rawCurl || '');
|
|
const patterns = [
|
|
new RegExp(`["'](?:${alternation})["']\\s*:`, 'i'),
|
|
new RegExp(`(?:\\?|&)(?:${alternation})=`, 'i'),
|
|
new RegExp(`(?:--header|-H)\\s+['"][^'"]*(?:${alternation})\\s*:`, 'i'),
|
|
];
|
|
|
|
return patterns.some((pattern) => pattern.test(normalizedCurl));
|
|
}
|
|
|
|
function hasExplicitAuthKeySlot(rawCurl = '') {
|
|
return curlContainsNamedField(rawCurl, [
|
|
'authKey',
|
|
'apiKey',
|
|
'apiAuthKey',
|
|
'x-api-key',
|
|
'x-auth-key',
|
|
]);
|
|
}
|
|
|
|
function hasPasswordSlot(rawCurl = '') {
|
|
return curlContainsNamedField(rawCurl, [
|
|
'password',
|
|
'passwd',
|
|
'passcode',
|
|
]);
|
|
}
|
|
|
|
function getCurlDataArguments(rawCurl = '') {
|
|
try {
|
|
const parsed = parseCurlCommand(rawCurl);
|
|
const dataArgs = [];
|
|
|
|
for (let index = 0; index < parsed.args.length; index += 1) {
|
|
const argument = parsed.args[index];
|
|
|
|
if (CURL_DATA_FLAGS.has(argument) && index + 1 < parsed.args.length) {
|
|
dataArgs.push(String(parsed.args[index + 1] || ''));
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
const dataFlagWithValue = Array.from(CURL_DATA_FLAGS).find((flag) => argument.startsWith(`${flag}=`));
|
|
if (dataFlagWithValue) {
|
|
dataArgs.push(argument.slice(dataFlagWithValue.length + 1));
|
|
}
|
|
}
|
|
|
|
return dataArgs;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function findValueInJsonPayload(payload, matcher) {
|
|
if (!payload || typeof payload !== 'object') return '';
|
|
if (Array.isArray(payload)) {
|
|
for (const entry of payload) {
|
|
const value = findValueInJsonPayload(entry, matcher);
|
|
if (value) return value;
|
|
}
|
|
return '';
|
|
}
|
|
|
|
for (const [key, value] of Object.entries(payload)) {
|
|
if (matcher(key) && value !== null && value !== undefined) {
|
|
return sanitizeString(value);
|
|
}
|
|
|
|
if (value && typeof value === 'object') {
|
|
const nestedValue = findValueInJsonPayload(value, matcher);
|
|
if (nestedValue) return nestedValue;
|
|
}
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
function normalizeExtractedSenderId(value) {
|
|
return sanitizeString(value)
|
|
.replace(/^['"]+|['"]+$/g, '')
|
|
.toUpperCase();
|
|
}
|
|
|
|
function extractDeterministicSenderId(rawCurl = '') {
|
|
const matchesSenderIdKey = (key) => {
|
|
const normalizedKey = normalizeText(String(key || ''));
|
|
return normalizedKey === 'sender_id'
|
|
|| normalizedKey === 'sender-id'
|
|
|| normalizedKey === 'senderId'
|
|
|| normalizedKey === 'dlt_sender_id'
|
|
|| normalizedKey === 'dlt-sender-id'
|
|
|| normalizedKey === 'dltSenderId';
|
|
};
|
|
|
|
for (const argument of getCurlDataArguments(rawCurl)) {
|
|
const trimmed = sanitizeString(argument);
|
|
if (!trimmed) continue;
|
|
|
|
if (
|
|
(trimmed.startsWith('{') && trimmed.endsWith('}'))
|
|
|| (trimmed.startsWith('[') && trimmed.endsWith(']'))
|
|
) {
|
|
try {
|
|
const parsed = JSON.parse(trimmed);
|
|
const jsonValue = findValueInJsonPayload(parsed, matchesSenderIdKey);
|
|
if (jsonValue) return normalizeExtractedSenderId(jsonValue);
|
|
} catch {
|
|
// Fall back to regex-based extraction below for partially dynamic payloads.
|
|
}
|
|
}
|
|
|
|
const directMatch = trimmed.match(/["'](?:sender[_-]?id|dlt[_-]?sender[_-]?id)["']\s*:\s*["']([^"']+)["']/i);
|
|
if (directMatch?.[1]) {
|
|
return normalizeExtractedSenderId(directMatch[1]);
|
|
}
|
|
}
|
|
|
|
const rawMatch = String(rawCurl || '').match(/["'](?:sender[_-]?id|dlt[_-]?sender[_-]?id)["']\s*:\s*["']([^"']+)["']/i);
|
|
return rawMatch?.[1] ? normalizeExtractedSenderId(rawMatch[1]) : '';
|
|
}
|
|
|
|
function sanitizeRequiredInput(value) {
|
|
if (!value || typeof value !== 'object') return null;
|
|
|
|
const key = canonicalizeInputKey(value.key || value.name || value.field || value.slot);
|
|
if (!key) return null;
|
|
|
|
const requestedSource = REQUIRED_INPUT_SOURCES.has(normalizeText(value.source).toLowerCase())
|
|
? normalizeText(value.source).toLowerCase()
|
|
: 'profile';
|
|
const source = ['toNumber', 'content', 'templateId'].includes(key)
|
|
? 'runtime'
|
|
: requestedSource;
|
|
|
|
const runtimeToken = getRuntimeToken(key);
|
|
const token = source === 'runtime'
|
|
? runtimeToken
|
|
: sanitizeString(value.token) || getProfileToken(key);
|
|
|
|
return {
|
|
key,
|
|
label: sanitizeString(value.label) || humanizeKey(key),
|
|
required: value.required !== false,
|
|
secret: value.secret === true,
|
|
source,
|
|
token,
|
|
currentValue: sanitizeString(value.currentValue || value.value),
|
|
};
|
|
}
|
|
|
|
function sanitizeRequiredInputs(value) {
|
|
if (!Array.isArray(value)) return [];
|
|
|
|
const seenKeys = new Set();
|
|
const inputs = [];
|
|
|
|
value.forEach((entry) => {
|
|
const sanitized = sanitizeRequiredInput(entry);
|
|
if (!sanitized) return;
|
|
const dedupeKey = `${sanitized.source}:${sanitized.key}`;
|
|
if (seenKeys.has(dedupeKey)) return;
|
|
seenKeys.add(dedupeKey);
|
|
inputs.push(sanitized);
|
|
});
|
|
|
|
return inputs;
|
|
}
|
|
|
|
function sanitizeSlotMap(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
|
|
return Object.entries(value).reduce((accumulator, [rawKey, rawValue]) => {
|
|
const key = canonicalizeInputKey(rawKey);
|
|
const mappedValue = canonicalizeInputKey(rawValue);
|
|
if (!key || !mappedValue) return accumulator;
|
|
accumulator[key] = mappedValue;
|
|
return accumulator;
|
|
}, {});
|
|
}
|
|
|
|
function sanitizeWarnings(value) {
|
|
return sanitizeStringArray(value, { maxItems: 10 });
|
|
}
|
|
|
|
function reconcileCredentialInputs(requiredInputs, provider, rawCurl = '') {
|
|
const inputs = sanitizeRequiredInputs(requiredInputs);
|
|
const normalizedProvider = sanitizeProvider(provider);
|
|
const usesPasswordField = hasPasswordSlot(rawCurl);
|
|
const usesAuthKeyField = hasExplicitAuthKeySlot(rawCurl);
|
|
|
|
if (!usesPasswordField || usesAuthKeyField) {
|
|
return {
|
|
provider: normalizedProvider,
|
|
requiredInputs: inputs,
|
|
};
|
|
}
|
|
|
|
const existingPasswordInput = inputs.find((input) => input.key === 'password') || null;
|
|
const existingAuthKeyInput = inputs.find((input) => input.key === 'authKey') || null;
|
|
const inferredPasswordValue = sanitizeString(
|
|
existingPasswordInput?.currentValue
|
|
|| existingAuthKeyInput?.currentValue
|
|
|| normalizedProvider.authKey
|
|
);
|
|
const nextInputs = inputs
|
|
.filter((input) => input.key !== 'authKey')
|
|
.map((input) => {
|
|
if (input.key !== 'password') return input;
|
|
|
|
return {
|
|
...input,
|
|
label: input.label || 'Password',
|
|
required: true,
|
|
secret: true,
|
|
token: sanitizeString(input.token) || getProfileToken('password'),
|
|
currentValue: sanitizeString(input.currentValue) || inferredPasswordValue,
|
|
};
|
|
});
|
|
|
|
if (!nextInputs.some((input) => input.key === 'password')) {
|
|
nextInputs.push({
|
|
key: 'password',
|
|
label: 'Password',
|
|
required: true,
|
|
secret: true,
|
|
source: 'profile',
|
|
token: getProfileToken('password'),
|
|
currentValue: inferredPasswordValue,
|
|
});
|
|
}
|
|
|
|
return {
|
|
provider: {
|
|
...normalizedProvider,
|
|
authKey: '',
|
|
},
|
|
requiredInputs: sanitizeRequiredInputs(nextInputs),
|
|
};
|
|
}
|
|
|
|
function injectMissingProviderInputs(requiredInputs, provider, rawCurl = '') {
|
|
const inputs = Array.isArray(requiredInputs) ? [...requiredInputs] : [];
|
|
const hasAuthKeyInput = inputs.some((input) => input?.key === 'authKey');
|
|
const providerAuthKey = sanitizeString(provider?.authKey);
|
|
|
|
if (!hasExplicitAuthKeySlot(rawCurl)) {
|
|
return sanitizeRequiredInputs(inputs);
|
|
}
|
|
|
|
if (!hasAuthKeyInput && providerAuthKey) {
|
|
inputs.push({
|
|
key: 'authKey',
|
|
label: 'Auth Key',
|
|
required: true,
|
|
secret: true,
|
|
source: 'embedded',
|
|
token: getProfileToken('authKey'),
|
|
currentValue: providerAuthKey,
|
|
});
|
|
}
|
|
|
|
return sanitizeRequiredInputs(inputs);
|
|
}
|
|
|
|
function applyRequiredInputTokensToCurlTemplate(rawCurlTemplate, requiredInputs = []) {
|
|
let output = sanitizeString(rawCurlTemplate);
|
|
if (!output) return '';
|
|
|
|
requiredInputs.forEach((input) => {
|
|
const currentValue = sanitizeString(input?.currentValue);
|
|
const token = sanitizeString(input?.token);
|
|
if (!currentValue || !token || !output.includes(currentValue)) return;
|
|
output = output.replace(new RegExp(escapeRegex(currentValue), 'g'), token);
|
|
});
|
|
|
|
return output;
|
|
}
|
|
|
|
function serializeExecutionRequiredInputs(requiredInputs = []) {
|
|
return sanitizeRequiredInputs(requiredInputs).map((input) => ({
|
|
key: input.key,
|
|
label: input.label,
|
|
required: input.required !== false,
|
|
secret: input.secret === true,
|
|
source: input.source,
|
|
token: input.token,
|
|
}));
|
|
}
|
|
|
|
function collectExecutionRuntimeKeys(requiredInputs = [], slotMap = {}, processedCurlTemplate = '') {
|
|
const runtimeKeys = new Set();
|
|
const normalizedSlotMap = sanitizeSlotMap(slotMap);
|
|
const normalizedCurlTemplate = sanitizeString(processedCurlTemplate);
|
|
|
|
serializeExecutionRequiredInputs(requiredInputs)
|
|
.filter((input) => input.source === 'runtime')
|
|
.forEach((input) => runtimeKeys.add(input.key));
|
|
|
|
Object.entries(normalizedSlotMap).forEach(([key, value]) => {
|
|
if (getRuntimeToken(key)) runtimeKeys.add(key);
|
|
if (getRuntimeToken(value)) runtimeKeys.add(value);
|
|
});
|
|
|
|
Object.entries(RESERVED_RUNTIME_TOKENS).forEach(([key, token]) => {
|
|
if (normalizedCurlTemplate.includes(token)) {
|
|
runtimeKeys.add(canonicalizeInputKey(key));
|
|
}
|
|
});
|
|
|
|
return Array.from(runtimeKeys);
|
|
}
|
|
|
|
function buildExecutionMeta({ approvedTemplate, eventSlug, requiredInputs = [], slotMap = {}, processedCurlTemplate = '' }) {
|
|
const placeholderTokens = sanitizeString(approvedTemplate).match(DLT_PLACEHOLDER_REGEX) || [];
|
|
const serializedRequiredInputs = serializeExecutionRequiredInputs(requiredInputs);
|
|
const runtimeInputKeys = collectExecutionRuntimeKeys(serializedRequiredInputs, slotMap, processedCurlTemplate);
|
|
const profileInputKeys = serializedRequiredInputs
|
|
.filter((input) => input.source !== 'runtime')
|
|
.map((input) => input.key);
|
|
|
|
return {
|
|
eventSlug: sanitizeString(eventSlug),
|
|
renderStrategy: 'deterministic_sample_payload',
|
|
placeholderCount: placeholderTokens.length,
|
|
placeholderTokens,
|
|
runtimeInputKeys,
|
|
profileInputKeys,
|
|
hasContentToken: sanitizeString(processedCurlTemplate).includes(RESERVED_RUNTIME_TOKENS.content),
|
|
hasToNumberToken: sanitizeString(processedCurlTemplate).includes(RESERVED_RUNTIME_TOKENS.toNumber),
|
|
hasTemplateIdToken: sanitizeString(processedCurlTemplate).includes(RESERVED_RUNTIME_TOKENS.templateId),
|
|
};
|
|
}
|
|
|
|
async function parseBrandContext(scrapedData = {}) {
|
|
const representativePages = Array.isArray(scrapedData.representativePages)
|
|
? scrapedData.representativePages.slice(0, 20)
|
|
: [];
|
|
const representativeTextBlocks = Array.isArray(scrapedData.representativeTextBlocks)
|
|
? scrapedData.representativeTextBlocks.slice(0, 20)
|
|
: [];
|
|
const productPages = Array.isArray(scrapedData.productPages)
|
|
? scrapedData.productPages.slice(0, 5)
|
|
: [];
|
|
const contentDigest = representativeTextBlocks
|
|
.map((block) => {
|
|
const title = String(block?.title || '').trim();
|
|
const pageType = String(block?.pageType || '').trim();
|
|
const text = String(block?.text || '').trim();
|
|
return [title, pageType, text].filter(Boolean).join(' | ');
|
|
})
|
|
.filter(Boolean)
|
|
.join('\n\n')
|
|
.slice(0, 14000);
|
|
|
|
const result = await requestStructuredJson({
|
|
model: BRAND_LLM_MODEL,
|
|
taskName: 'Brand context extraction',
|
|
temperature: 0.2,
|
|
systemPrompt: 'You are a brand analyst for ecommerce storefronts. Infer brand identity from crawl evidence and return only valid JSON that matches the requested schema exactly.',
|
|
userPrompt: [
|
|
'Analyze the storefront evidence below and infer brand context.',
|
|
'',
|
|
'Return only valid JSON with exactly these keys:',
|
|
'{',
|
|
' "brandName": "string",',
|
|
` "tone": "one of ${BRAND_CONTEXT_TONE_OPTIONS.join(', ')}",`,
|
|
' "taglines": ["up to 3 strings"],',
|
|
' "colors": ["hex colors only"],',
|
|
' "logoUrl": "absolute http(s) URL for the best company logo or empty string",',
|
|
' "relevantImageUrls": ["3-5 absolute http(s) image URLs only"],',
|
|
' "aboutSummary": "2-4 concise customer-facing sentences"',
|
|
'}',
|
|
'',
|
|
'Constraints:',
|
|
'- No markdown.',
|
|
'- No explanatory prose.',
|
|
'- Do not copy the About page verbatim.',
|
|
'- `logoUrl` must be a real brand logo or wordmark when one is available. Prefer storefront/header logos from branding_json.logos.',
|
|
'- If no credible logo is present, return an empty string for `logoUrl`.',
|
|
'- Exclude icons, tracking pixels, and data URLs from images.',
|
|
'',
|
|
`start_url: ${String(scrapedData.startUrl || '')}`,
|
|
`domain: ${String(scrapedData.domain || '')}`,
|
|
`site_stats_json: ${JSON.stringify(scrapedData.siteStats || {})}`,
|
|
`homepage_json: ${JSON.stringify(scrapedData.homepage || {})}`,
|
|
`about_page_json: ${JSON.stringify(scrapedData.aboutPage || {})}`,
|
|
`product_pages_json: ${JSON.stringify(productPages)}`,
|
|
`contact_page_json: ${JSON.stringify(scrapedData.contactPage || {})}`,
|
|
`representative_pages_json: ${JSON.stringify(representativePages)}`,
|
|
`representative_text_blocks_json: ${JSON.stringify(representativeTextBlocks)}`,
|
|
`navigation_json: ${JSON.stringify(scrapedData.navigation || [])}`,
|
|
`policy_pages_json: ${JSON.stringify(scrapedData.policyPages || [])}`,
|
|
`links_json: ${JSON.stringify(scrapedData.links || [])}`,
|
|
`top_images_json: ${JSON.stringify(scrapedData.topImages || [])}`,
|
|
`screenshots_json: ${JSON.stringify(scrapedData.screenshots || [])}`,
|
|
`branding_json: ${JSON.stringify(scrapedData.branding || {})}`,
|
|
`crawl_summary_json: ${JSON.stringify(scrapedData || {})}`,
|
|
`content_digest: ${contentDigest}`,
|
|
].join('\n'),
|
|
});
|
|
|
|
const normalizedTone = normalizeText(String(result.tone || '')).toLowerCase();
|
|
const normalizedLogoUrl = normalizeText(String(result.logoUrl || ''));
|
|
|
|
return {
|
|
brandName: normalizeText(String(result.brandName || '')) || 'Unknown Brand',
|
|
tone: BRAND_CONTEXT_TONE_OPTIONS.includes(normalizedTone) ? normalizedTone : 'professional',
|
|
taglines: sanitizeStringArray(result.taglines, { maxItems: 3 }),
|
|
colors: sanitizeStringArray(result.colors),
|
|
logoUrl: isAbsoluteHttpUrl(normalizedLogoUrl) ? normalizedLogoUrl : '',
|
|
relevantImageUrls: sanitizeStringArray(result.relevantImageUrls, { maxItems: 5, allowUrlsOnly: true }),
|
|
aboutSummary: normalizeText(String(result.aboutSummary || '')),
|
|
};
|
|
}
|
|
|
|
async function getTemplateApprovalOutcome(templateText, options = {}) {
|
|
const validation = await validateEditedTemplate(templateText, options);
|
|
return {
|
|
approved: validation.approved,
|
|
reason: validation.why || 'Template did not pass compliance validation.',
|
|
issues: Array.isArray(validation.issues) ? validation.issues : [],
|
|
source: 'llm',
|
|
};
|
|
}
|
|
|
|
async function repairRejectedTemplate(rejectedTemplate, rejectionReason, options = {}) {
|
|
const validationContext = buildTemplateValidationContext(options);
|
|
|
|
try {
|
|
const result = await requestStructuredJson({
|
|
model: TEMPLATE_LLM_MODEL,
|
|
taskName: 'Rejected template repair',
|
|
temperature: 0.15,
|
|
systemPrompt: [
|
|
'You repair one Indian transactional SMS template so it becomes compliant and clear.',
|
|
'Keep the same event meaning.',
|
|
'Fix only what is necessary.',
|
|
'Return only valid JSON.',
|
|
].join(' '),
|
|
userPrompt: [
|
|
'Rewrite this rejected SMS template so it passes compliance validation.',
|
|
'',
|
|
`Rejected template:\n${sanitizeString(rejectedTemplate)}`,
|
|
'',
|
|
`Why it was rejected: ${sanitizeString(rejectionReason) || 'It did not pass compliance validation.'}`,
|
|
'',
|
|
validationContext.eventSlug ? `Event slug: ${validationContext.eventSlug}` : '',
|
|
validationContext.eventLabel ? `Event label: ${validationContext.eventLabel}` : '',
|
|
`Event description: ${validationContext.eventDescription}`,
|
|
`Registered sender ID: ${validationContext.registeredSenderId || 'Not provided. Do not append any sender signature.'}`,
|
|
'',
|
|
`Hard rules:\n${TEMPLATE_HARD_RULES_TEXT}`,
|
|
'',
|
|
`Guidance:\n${TEMPLATE_GUIDANCE_RULES_TEXT}`,
|
|
'',
|
|
'Approved placeholder types:',
|
|
describeDltVariableTypes(),
|
|
`- ${LEGACY_DLT_VAR_TOKEN}: Use this when the exact typed placeholder is uncertain or the value is free-form text.`,
|
|
'',
|
|
'Repair rules:',
|
|
'- keep the same event meaning',
|
|
'- stay under 160 characters',
|
|
'- start with clear order or event context',
|
|
'- describe only the provided lifecycle stage; do not drift into a later or different order status',
|
|
'- do not mention the brand name, tagline, sender ID, or any brand-style signoff in the body',
|
|
'- do not use raw URLs unless the event truly requires a link',
|
|
'- if the correct placeholder type is uncertain, prefer {#var#}',
|
|
'- do not add explanations or notes',
|
|
validationContext.blockedBrandPhrases.length > 0
|
|
? `- specifically do not include these literal phrases: ${validationContext.blockedBrandPhrases.join(', ')}`
|
|
: '',
|
|
'',
|
|
'Return only valid JSON with exactly this shape:',
|
|
'{ "template": "rewritten compliant SMS" }',
|
|
].filter(Boolean).join('\n'),
|
|
});
|
|
|
|
return sanitizeString(result.template);
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
async function generateTemplates(brandContext = {}, eventSlug, eventLabel, options = {}) {
|
|
const validationContext = buildTemplateValidationContext({
|
|
...options,
|
|
eventSlug,
|
|
eventLabel,
|
|
brandName: brandContext?.brandName,
|
|
brandTaglines: brandContext?.taglines,
|
|
});
|
|
const approvedTemplates = [];
|
|
const seenTemplates = new Set();
|
|
const rejectionReasons = [];
|
|
const validationOptions = {
|
|
senderId: validationContext.registeredSenderId,
|
|
eventSlug,
|
|
eventLabel,
|
|
brandName: brandContext?.brandName,
|
|
brandTaglines: brandContext?.taglines,
|
|
};
|
|
const attemptTemplateCounts = [8, 10, 12];
|
|
let repairAttempts = 0;
|
|
const maxRepairAttempts = 6;
|
|
|
|
for (let attempt = 0; attempt < attemptTemplateCounts.length && approvedTemplates.length < 3; attempt += 1) {
|
|
const templateCount = attemptTemplateCounts[attempt];
|
|
const recentRejectionReasons = summarizeRejectionReasons(rejectionReasons);
|
|
const result = await requestStructuredJson({
|
|
model: TEMPLATE_LLM_MODEL,
|
|
taskName: 'SMS template generation',
|
|
temperature: 0.35,
|
|
systemPrompt: [
|
|
'You are an expert in Indian transactional SMS templates.',
|
|
'Follow the rules exactly, self-check every candidate before returning it, and return only valid JSON.',
|
|
'Never include a brand name, tagline, sender signature, or signoff in the message body.',
|
|
'If the correct typed placeholder is uncertain, use {#var#}.',
|
|
].join(' '),
|
|
userPrompt: [
|
|
`Generate exactly ${templateCount} distinct transactional SMS templates.`,
|
|
'',
|
|
`Brand: ${String(brandContext.brandName || '')}`,
|
|
`Tone: ${String(brandContext.tone || '')}`,
|
|
`Taglines: ${JSON.stringify(Array.isArray(brandContext.taglines) ? brandContext.taglines : [])}`,
|
|
`Event slug: ${String(eventSlug || '')}`,
|
|
`Event label: ${String(eventLabel || '')}`,
|
|
`Event description: ${validationContext.eventDescription}`,
|
|
`Registered sender ID: ${validationContext.registeredSenderId || 'Not provided. Do not append any brand or sender signature.'}`,
|
|
'',
|
|
`Hard rules:\n${TEMPLATE_HARD_RULES_TEXT}`,
|
|
'',
|
|
`Guidance:\n${TEMPLATE_GUIDANCE_RULES_TEXT}`,
|
|
'',
|
|
'Approved placeholder types:',
|
|
describeDltVariableTypes(),
|
|
`- ${LEGACY_DLT_VAR_TOKEN}: Generic fallback for free-form values such as customer names, product names, or addresses when a stricter typed token does not fit or the exact type is uncertain.`,
|
|
'',
|
|
'Each template must:',
|
|
'- be under 160 characters',
|
|
'- use only the approved placeholder tokens',
|
|
'- do not include blocked literal brand/tagline phrases',
|
|
'- follow the guidance when possible without breaking readability',
|
|
'- self-check that each draft already satisfies every rule before returning it',
|
|
validationContext.blockedBrandPhrases.length > 0
|
|
? `- specifically do not include these literal phrases: ${validationContext.blockedBrandPhrases.join(', ')}`
|
|
: '',
|
|
'',
|
|
recentRejectionReasons.length > 0
|
|
? `Avoid these issues seen in rejected drafts: ${recentRejectionReasons.join(' | ')}`
|
|
: '',
|
|
'',
|
|
'Return only valid JSON with exactly this shape:',
|
|
`{ "templates": ["template 1", "template 2", "... up to ${templateCount} templates"] }`,
|
|
].filter(Boolean).join('\n'),
|
|
});
|
|
|
|
const candidateTemplates = sanitizeStringArray(result.templates, { maxItems: templateCount });
|
|
|
|
for (const candidate of candidateTemplates) {
|
|
if (approvedTemplates.length >= 3) break;
|
|
const normalizedCandidate = sanitizeString(candidate);
|
|
if (!normalizedCandidate || seenTemplates.has(normalizedCandidate)) continue;
|
|
seenTemplates.add(normalizedCandidate);
|
|
|
|
const validation = await getTemplateApprovalOutcome(normalizedCandidate, validationOptions);
|
|
if (validation.approved) {
|
|
approvedTemplates.push(normalizedCandidate);
|
|
continue;
|
|
}
|
|
|
|
const issueSummary = summarizeValidationIssues(validation.issues, 2);
|
|
if (issueSummary.length > 0) {
|
|
rejectionReasons.push(...issueSummary);
|
|
} else if (validation.reason) {
|
|
rejectionReasons.push(validation.reason);
|
|
}
|
|
|
|
if (repairAttempts >= maxRepairAttempts) continue;
|
|
|
|
const repairedCandidate = await repairRejectedTemplate(
|
|
normalizedCandidate,
|
|
issueSummary.join(' | ') || validation.reason,
|
|
validationOptions,
|
|
);
|
|
const normalizedRepairedCandidate = sanitizeString(repairedCandidate);
|
|
if (!normalizedRepairedCandidate || seenTemplates.has(normalizedRepairedCandidate)) continue;
|
|
|
|
repairAttempts += 1;
|
|
seenTemplates.add(normalizedRepairedCandidate);
|
|
|
|
const repairedValidation = await getTemplateApprovalOutcome(normalizedRepairedCandidate, validationOptions);
|
|
if (repairedValidation.approved) {
|
|
approvedTemplates.push(normalizedRepairedCandidate);
|
|
continue;
|
|
}
|
|
|
|
const repairedIssueSummary = summarizeValidationIssues(repairedValidation.issues, 2);
|
|
if (repairedIssueSummary.length > 0) {
|
|
rejectionReasons.push(...repairedIssueSummary);
|
|
} else if (repairedValidation.reason) {
|
|
rejectionReasons.push(repairedValidation.reason);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (approvedTemplates.length < 3) {
|
|
const recentRejectionReasons = summarizeRejectionReasons(rejectionReasons, 3);
|
|
const reasonSuffix = recentRejectionReasons.length > 0
|
|
? ` Last blockers: ${recentRejectionReasons.join(' | ')}`
|
|
: ' Please try again.';
|
|
throw new Error(`Could not generate 3 compliant templates.${reasonSuffix}`);
|
|
}
|
|
|
|
return approvedTemplates.slice(0, 3);
|
|
}
|
|
|
|
async function processCurl(rawCurl, approvedTemplate, eventSlug, options = {}) {
|
|
const normalizedApprovedTemplate = sanitizeString(approvedTemplate);
|
|
const normalizedCurlTemplate = applyRequiredInputTokensToCurlTemplate(
|
|
sanitizeString(options.normalizedCurlTemplate) || sanitizeString(rawCurl),
|
|
options.requiredInputs,
|
|
) || sanitizeString(options.normalizedCurlTemplate) || sanitizeString(rawCurl);
|
|
const requiredInputs = serializeExecutionRequiredInputs(options.requiredInputs);
|
|
const slotMap = sanitizeSlotMap(options.slotMap);
|
|
const executionMeta = buildExecutionMeta({
|
|
approvedTemplate: normalizedApprovedTemplate,
|
|
eventSlug,
|
|
requiredInputs,
|
|
slotMap,
|
|
processedCurlTemplate: normalizedCurlTemplate,
|
|
});
|
|
|
|
if (!normalizedApprovedTemplate) {
|
|
return {
|
|
processedCurl: normalizedCurlTemplate,
|
|
processedCurlTemplate: normalizedCurlTemplate,
|
|
variableMap: {},
|
|
requiredInputs,
|
|
slotMap,
|
|
executionMeta,
|
|
};
|
|
}
|
|
|
|
const placeholderMatches = normalizedApprovedTemplate.match(DLT_PLACEHOLDER_REGEX) || [];
|
|
if (placeholderMatches.length === 0) {
|
|
return {
|
|
processedCurl: normalizedCurlTemplate,
|
|
processedCurlTemplate: normalizedCurlTemplate,
|
|
variableMap: {},
|
|
requiredInputs,
|
|
slotMap,
|
|
executionMeta,
|
|
};
|
|
}
|
|
|
|
const placeholderKeys = placeholderMatches.map((token, index) => `${token}[${index}]`);
|
|
const result = await requestStructuredJson({
|
|
model: CURL_LLM_MODEL,
|
|
taskName: 'Template execution mapping',
|
|
temperature: 0.1,
|
|
systemPrompt: [
|
|
'You infer semantic shipment field mappings for DLT SMS placeholders.',
|
|
'Return only valid JSON.',
|
|
'Do not modify the cURL template or invent provider fields.',
|
|
'Mapped values must be concise camelCase shipment field names such as customerName, orderId, trackingUrl, otp, refundAmount, or callbackNumber.',
|
|
].join(' '),
|
|
userPrompt: [
|
|
'Map each DLT placeholder in the approved SMS template to the shipment field name that should populate it.',
|
|
'',
|
|
`Approved SMS template:\n${normalizedApprovedTemplate}`,
|
|
'',
|
|
`Event slug: ${sanitizeString(eventSlug)}`,
|
|
'',
|
|
`Normalized provider cURL template:\n${normalizedCurlTemplate}`,
|
|
'',
|
|
`Provider slot map:\n${JSON.stringify(slotMap, null, 2)}`,
|
|
'',
|
|
`Execution required inputs:\n${JSON.stringify(requiredInputs, null, 2)}`,
|
|
'',
|
|
'Placeholder appearance order:',
|
|
...placeholderKeys.map((key) => `- ${key}`),
|
|
'',
|
|
'Rules:',
|
|
`- supported DLT token types are ${SUPPORTED_DLT_TOKENS.join(', ')}`,
|
|
'- build variableMap using the exact positional placeholder keys shown above',
|
|
'- map to shipment semantics, not provider parameter names',
|
|
'- do not include explanations or extra keys',
|
|
'',
|
|
'Return only valid JSON with exactly this shape:',
|
|
'{',
|
|
' "variableMap": { "{#var#}[0]": "customerName", "{#numeric#}[1]": "otp" }',
|
|
'}',
|
|
].join('\n'),
|
|
});
|
|
|
|
return {
|
|
processedCurl: normalizedCurlTemplate,
|
|
processedCurlTemplate: normalizedCurlTemplate,
|
|
variableMap: sanitizeVariableMap(result.variableMap),
|
|
requiredInputs,
|
|
slotMap,
|
|
executionMeta,
|
|
};
|
|
}
|
|
|
|
async function validateEditedTemplate(editedTemplate, options = {}) {
|
|
const normalizedTemplate = sanitizeString(editedTemplate);
|
|
const validationContext = buildTemplateValidationContext(options);
|
|
const literalIssues = getLiteralTemplateIssues(normalizedTemplate, validationContext);
|
|
const result = await requestStructuredJson({
|
|
model: EDIT_CHECK_LLM_MODEL,
|
|
taskName: 'Edited template validation',
|
|
temperature: 0,
|
|
systemPrompt: [
|
|
'You validate one Indian transactional SMS template using a fixed rule set.',
|
|
'Return only valid JSON.',
|
|
'Reject only when a hard rule is actually broken.',
|
|
'Guidance items must never cause rejection.',
|
|
'Do not invent violations that are not visible in the template text.',
|
|
'If you use BLOCKED_LITERAL_PHRASE, the evidence must be an exact blocked phrase from the template.',
|
|
'Prefer the smallest set of hard-rule issues necessary to explain the rejection.',
|
|
].join(' '),
|
|
userPrompt: [
|
|
'Review this edited SMS template and decide whether it should be approved.',
|
|
'',
|
|
`Template:\n${normalizedTemplate}`,
|
|
'',
|
|
validationContext.eventSlug ? `Event slug: ${validationContext.eventSlug}` : '',
|
|
validationContext.eventLabel ? `Event label: ${validationContext.eventLabel}` : '',
|
|
`Event description: ${validationContext.eventDescription}`,
|
|
`Registered sender ID: ${validationContext.registeredSenderId || 'Not provided. Reject appended brand or sender signatures.'}`,
|
|
validationContext.blockedBrandPhrases.length > 0
|
|
? `Blocked literal phrases: ${validationContext.blockedBrandPhrases.join(', ')}`
|
|
: 'Blocked literal phrases: none provided',
|
|
'',
|
|
`Hard rules:\n${TEMPLATE_HARD_RULES_TEXT}`,
|
|
'',
|
|
`Guidance (do not reject on guidance alone):\n${TEMPLATE_GUIDANCE_RULES_TEXT}`,
|
|
'',
|
|
'Approved placeholder types:',
|
|
describeDltVariableTypes(),
|
|
`- ${LEGACY_DLT_VAR_TOKEN}: Generic fallback for free-form values such as names, product names, or addresses when a stricter typed token does not fit or the exact type is uncertain.`,
|
|
'',
|
|
'Issue codes you may use:',
|
|
'- EMPTY_TEMPLATE: the template is empty',
|
|
'- LENGTH_EXCEEDED: the template is longer than 160 characters',
|
|
'- UNSUPPORTED_DLT_TOKEN: a placeholder token is not one of the approved DLT tokens',
|
|
'- MALFORMED_DLT_TOKEN: placeholder text is incomplete or malformed',
|
|
'- BLOCKED_LITERAL_PHRASE: an exact blocked literal phrase appears in the template',
|
|
'',
|
|
'Return approved=true only when there are no issues.',
|
|
'Return approved=false when any issue exists.',
|
|
'Each issue must contain a concise user-facing message.',
|
|
'Use evidence only when you can quote the exact text from the template.',
|
|
'',
|
|
'Return only valid JSON with exactly this shape:',
|
|
'{',
|
|
' "approved": true,',
|
|
' "issues": [',
|
|
' {',
|
|
' "code": "BLOCKED_LITERAL_PHRASE",',
|
|
' "message": "Remove blocked literal phrase \\"Rare Beauty\\" from the message body.",',
|
|
' "evidence": "Rare Beauty"',
|
|
' }',
|
|
' ]',
|
|
'}',
|
|
].join('\n'),
|
|
});
|
|
|
|
const llmIssues = normalizeValidationIssues(result.issues, normalizedTemplate, validationContext);
|
|
const issues = [];
|
|
literalIssues.forEach((issue) => appendValidationIssue(issues, issue));
|
|
llmIssues.forEach((issue) => appendValidationIssue(issues, issue));
|
|
|
|
return {
|
|
approved: issues.length === 0,
|
|
issues,
|
|
why: issues[0]?.message || '',
|
|
workflowResult: result,
|
|
};
|
|
}
|
|
|
|
async function validateCurlFields(rawCurl) {
|
|
const normalizedCurl = sanitizeString(rawCurl);
|
|
const deterministicFailure = detectUnsafeCurl(normalizedCurl);
|
|
if (deterministicFailure) {
|
|
return {
|
|
isValidCurl: false,
|
|
reason: deterministicFailure,
|
|
provider: sanitizeProvider(),
|
|
requiredInputs: [],
|
|
slotMap: {},
|
|
authMode: '',
|
|
warnings: [],
|
|
normalizedCurlTemplate: '',
|
|
};
|
|
}
|
|
|
|
const result = await requestStructuredJson({
|
|
model: CURL_LLM_MODEL,
|
|
taskName: 'Provider cURL validation',
|
|
temperature: 0,
|
|
systemPrompt: [
|
|
'You analyze SMS provider curl commands and return only valid JSON.',
|
|
'Do not invent missing provider details.',
|
|
'Preserve the provider request structure in normalizedCurlTemplate.',
|
|
'Profile-level values that should be stored separately must use __PROFILE_<KEY>__ tokens.',
|
|
'Do not rename a request field literally named "password" to authKey.',
|
|
'Only use provider.authKey when the request explicitly contains an auth-key or api-key field/header.',
|
|
'Do not duplicate one credential as both password and authKey.',
|
|
'Runtime SMS values must use only these reserved tokens when applicable:',
|
|
`- toNumber => ${RESERVED_RUNTIME_TOKENS.toNumber}`,
|
|
`- content => ${RESERVED_RUNTIME_TOKENS.content}`,
|
|
`- templateId => ${RESERVED_RUNTIME_TOKENS.templateId}`,
|
|
`- senderId => ${RESERVED_RUNTIME_TOKENS.senderId}`,
|
|
`- dltEntityId => ${RESERVED_RUNTIME_TOKENS.dltEntityId}`,
|
|
].join('\n'),
|
|
userPrompt: [
|
|
'Analyze this provider cURL for SMS sending setup.',
|
|
'',
|
|
`cURL:\n${normalizedCurl}`,
|
|
'',
|
|
'Classify fields as follows:',
|
|
'- source "embedded" when the value already appears literally in the cURL and can be extracted now',
|
|
'- source "profile" when the value must be filled and stored on the profile',
|
|
'- source "runtime" when the value changes per send or per whitelisting flow',
|
|
'- if the request field is literally named "password", keep it as key "password"',
|
|
'- only use key "authKey" when the request explicitly has an auth-key or api-key field/header',
|
|
'- never return the same secret twice as both "password" and "authKey"',
|
|
'',
|
|
'Provider summary fields should be extracted when available:',
|
|
'- providerName',
|
|
'- senderId',
|
|
'- dltEntityId',
|
|
'- authKey',
|
|
'',
|
|
'Return only valid JSON with exactly this shape:',
|
|
'{',
|
|
' "isValidCurl": true,',
|
|
' "reason": "",',
|
|
' "provider": {',
|
|
' "providerName": "string",',
|
|
' "senderId": "string",',
|
|
' "dltEntityId": "string",',
|
|
' "authKey": "string"',
|
|
' },',
|
|
' "authMode": "string",',
|
|
' "requiredInputs": [',
|
|
' {',
|
|
' "key": "string",',
|
|
' "label": "string",',
|
|
' "required": true,',
|
|
' "secret": false,',
|
|
' "source": "embedded|profile|runtime",',
|
|
' "token": "string",',
|
|
' "currentValue": "string"',
|
|
' }',
|
|
' ],',
|
|
' "slotMap": {',
|
|
' "toNumber": "string",',
|
|
' "content": "string",',
|
|
' "templateId": "string"',
|
|
' },',
|
|
' "warnings": ["string"],',
|
|
' "normalizedCurlTemplate": "string"',
|
|
'}',
|
|
].join('\n'),
|
|
});
|
|
|
|
const rawSenderId = extractDeterministicSenderId(normalizedCurl);
|
|
const reconciled = reconcileCredentialInputs(result.requiredInputs, result.provider, normalizedCurl);
|
|
const provider = sanitizeProvider({
|
|
...reconciled.provider,
|
|
...(rawSenderId ? { senderId: rawSenderId } : {}),
|
|
});
|
|
const requiredInputs = injectMissingProviderInputs(reconciled.requiredInputs, provider, normalizedCurl);
|
|
const normalizedCurlTemplate = applyRequiredInputTokensToCurlTemplate(
|
|
sanitizeString(result.normalizedCurlTemplate) || normalizedCurl,
|
|
requiredInputs,
|
|
) || normalizedCurl;
|
|
const isValidCurl = result.isValidCurl !== false;
|
|
|
|
return {
|
|
isValidCurl,
|
|
reason: sanitizeString(result.reason),
|
|
provider,
|
|
requiredInputs,
|
|
slotMap: sanitizeSlotMap(result.slotMap),
|
|
authMode: sanitizeString(result.authMode),
|
|
warnings: sanitizeWarnings(result.warnings),
|
|
normalizedCurlTemplate,
|
|
url: getCurlUrl(normalizedCurl),
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
parseBrandContext,
|
|
generateTemplates,
|
|
processCurl,
|
|
validateEditedTemplate,
|
|
validateCurlFields,
|
|
};
|