/**
* cp-ai-service.js
*
* Shared AI access module
* ————————————————————
*
* Principle:
* The database—not the AI—is the source of truth.
*
* The user’s response must already have been saved before this
* service is called. responseId identifies that saved record.
*
* Responsibilities:
* – send requests to the existing AI endpoint
* – apply a timeout
* – retry retryable technical failures once
* – parse text or JSON replies
* – return one consistent success/failure contract
* – create technical diagnostic metadata
*
* Must NOT:
* – save the user’s response
* – make coaching decisions
* – create fallback coaching content
* – advance the framework
* – consume a follow-up
* – log prompts, user responses or AI replies
*/

(function () {
‘use strict’;

const DEFAULT_TIMEOUT_MS = 20000;
const DEFAULT_MAX_ATTEMPTS = 2;
const DEFAULT_RETRY_DELAY_MS = 750;

/**
* Convert a value to trimmed text.
*/
function clean(value) {
return String(value == null ? ” : value).trim();
}

/**
* Pause before a retry.
*/
function sleep(milliseconds) {
return new Promise(function (resolve) {
setTimeout(resolve, milliseconds);
});
}

/**
* Create a unique identifier for one AI service request.
*/
function createRequestId() {
if (
window.crypto &&
typeof window.crypto.randomUUID === ‘function’
) {
return window.crypto.randomUUID();
}

return [
‘cpai’,
Date.now().toString(36),
Math.random().toString(36).slice(2, 10)
].join(‘-‘);
}

/**
* Resolve the AI configuration.
*
* A caller may pass config directly. If it does not, the service
* falls back to window.cpVars.
*/
function resolveConfig(config) {
const globalConfig =
window.cpVars && typeof window.cpVars === ‘object’
? window.cpVars
: {};

const suppliedConfig =
config && typeof config === ‘object’
? config
: {};

return {
…globalConfig,
…suppliedConfig
};
}

/**
* Build request headers for the existing WordPress AI endpoint.
*/
function buildHeaders(config, diagnostic) {
const headers = {
‘Content-Type’: ‘application/json’,
‘X-CP-AI-Request-ID’: diagnostic.requestId,
‘X-CP-AI-Task’: diagnostic.task,
‘X-CP-Response-ID’: diagnostic.responseId
};

if (config.token) {
headers[‘X-CP-Token’] = config.token;
} else if (config.restNonce) {
headers[‘X-WP-Nonce’] = config.restNonce;
}

return headers;
}

/**
* Build the non-confidential diagnostic metadata.
*
* responseId is sufficient because the database record contains
* the associated user and Insight information.
*/
function createDiagnostic(options) {
return {
requestId: createRequestId(),
task: clean(options.task) || ‘unnamed_ai_task’,
responseId: clean(options.responseId),
requestedAt: new Date().toISOString()
};
}

/**
* Turn technical failures into the standard error contract.
*/
function classifyError(error) {
if (error && error.name === ‘AbortError’) {
return {
type: ‘timeout’,
message: ‘The AI request timed out.’,
retryable: true
};
}

if (error && error.code === ‘invalid_json’) {
return {
type: ‘invalid_json’,
message: ‘The AI returned invalid JSON.’,
retryable: true
};
}

if (error && error.code === ‘invalid_endpoint_response’) {
return {
type: ‘invalid_endpoint_response’,
message: ‘The AI endpoint returned an invalid response.’,
retryable: true
};
}

if (error && error.code === ’empty_response’) {
return {
type: ’empty_response’,
message: ‘The AI returned an empty response.’,
retryable: true
};
}

if (error && Number.isInteger(error.httpStatus)) {
const retryable =
error.httpStatus === 408 ||
error.httpStatus === 429 ||
error.httpStatus >= 500;

return {
type: ‘http_error’,
message:
‘The AI endpoint returned HTTP ‘ +
error.httpStatus +
‘.’,
retryable,
httpStatus: error.httpStatus
};
}

return {
type: ‘network_error’,
message: ‘The AI request could not be completed.’,
retryable: true
};
}

/**
* Extract the AI reply text from the current endpoint response.
*
* This supports the commonly used response properties:
* text, reply and content.
*/
function extractReplyText(endpointResponse) {
if (
!endpointResponse ||
typeof endpointResponse !== ‘object’
) {
const error = new Error(
‘The AI endpoint response was not an object.’
);

error.code = ‘invalid_endpoint_response’;
throw error;
}

const replyText = clean(
endpointResponse.text ||
endpointResponse.reply ||
endpointResponse.content
);

if (!replyText) {
const error = new Error(‘The AI returned an empty response.’);
error.code = ’empty_response’;
throw error;
}

return replyText;
}

/**
* Remove Markdown code fences and parse a JSON reply.
*/
function parseJsonReply(rawText) {
const cleanedText = clean(rawText)
.replace(/^“`json\s*/i, ”)
.replace(/^“`\s*/i, ”)
.replace(/\s*“`$/i, ”)
.trim();

if (!cleanedText) {
const error = new Error(‘The AI returned an empty response.’);
error.code = ’empty_response’;
throw error;
}

try {
return JSON.parse(cleanedText);
} catch (parseError) {
const error = new Error(
‘The AI returned invalid JSON.’
);

error.code = ‘invalid_json’;
error.cause = parseError;

throw error;
}
}

/**
* Write technical metadata to the browser console.
*
* Confidential content is deliberately excluded.
*/
function logTechnicalEvent(level, diagnostic, details) {
const logEntry = {
requestId: diagnostic.requestId,
task: diagnostic.task,
responseId: diagnostic.responseId,
requestedAt: diagnostic.requestedAt,
…details
};

const logger =
typeof console[level] === ‘function’
? console[level]
: console.log;

logger.call(console, ‘CP AI SERVICE:’, logEntry);
}

/**
* Perform one HTTP attempt.
*/
async function performAttempt(options, diagnostic, attempt) {
const config = resolveConfig(options.config);

const timeoutMs = Number.isFinite(options.timeoutMs)
? Math.max(1000, options.timeoutMs)
: DEFAULT_TIMEOUT_MS;

const controller = new AbortController();

const timeoutId = setTimeout(function () {
controller.abort();
}, timeoutMs);

const startedAt = Date.now();

try {
const response = await fetch(config.aiUrl, {
method: ‘POST’,
headers: buildHeaders(config, diagnostic),
body: JSON.stringify(options.payload || {}),
signal: controller.signal
});

if (!response.ok) {
const error = new Error(
‘The AI endpoint returned HTTP ‘ +
response.status +
‘.’
);

error.httpStatus = response.status;
throw error;
}

let endpointResponse;

try {
endpointResponse = await response.json();
} catch (parseError) {
const error = new Error(
‘The AI endpoint returned invalid JSON.’
);

error.code = ‘invalid_endpoint_response’;
error.cause = parseError;

throw error;
}

return {
rawText: extractReplyText(endpointResponse),
attempt,
durationMs: Date.now() – startedAt
};
} finally {
clearTimeout(timeoutId);
}
}

/**
* Main AI service call.
*
* Required:
* – task
* – responseId
* – payload
*
* Optional:
* – config
* – responseType: “json” or “text”
* – timeoutMs
* – maxAttempts
* – retryDelayMs
*/
async function call(options) {
const request =
options && typeof options === ‘object’
? options
: {};

const config = resolveConfig(request.config);
const diagnostic = createDiagnostic(request);

const responseType =
request.responseType === ‘text’
? ‘text’
: ‘json’;

const maxAttempts = Number.isInteger(
request.maxAttempts
)
? Math.max(1, request.maxAttempts)
: DEFAULT_MAX_ATTEMPTS;

const retryDelayMs = Number.isFinite(
request.retryDelayMs
)
? Math.max(0, request.retryDelayMs)
: DEFAULT_RETRY_DELAY_MS;

/*
* A responseId must exist because the user’s response should
* already have been saved before AI processing begins.
*/
if (!diagnostic.responseId) {
const error = {
type: ‘missing_response_id’,
message:
‘The saved response ID was not provided.’,
retryable: false
};

logTechnicalEvent(‘error’, diagnostic, {
status: ‘failed’,
attempt: 0,
errorType: error.type
});

return {
ok: false,
status: ‘request_invalid’,
error,
meta: {
…diagnostic,
attempts: 0,
completedAt: new Date().toISOString()
}
};
}

if (!clean(config.aiUrl)) {
const error = {
type: ‘configuration_error’,
message: ‘No AI endpoint is configured.’,
retryable: false
};

logTechnicalEvent(‘error’, diagnostic, {
status: ‘failed’,
attempt: 0,
errorType: error.type
});

return {
ok: false,
status: ‘ai_unavailable’,
error,
meta: {
…diagnostic,
attempts: 0,
completedAt: new Date().toISOString()
}
};
}

let lastError = null;
let attemptsMade = 0;
let totalDurationMs = 0;

for (
let attempt = 1;
attempt <= maxAttempts; attempt += 1 ) { attemptsMade = attempt; try { const result = await performAttempt( request, diagnostic, attempt ); totalDurationMs += result.durationMs; const data = responseType === 'json' ? parseJsonReply(result.rawText) : result.rawText; logTechnicalEvent('info', diagnostic, { status: 'success', attempt, attempts: attempt, durationMs: totalDurationMs }); return { ok: true, status: 'success', data, meta: { ...diagnostic, attempts: attempt, durationMs: totalDurationMs, completedAt: new Date().toISOString() } }; } catch (error) { lastError = classifyError(error); logTechnicalEvent('warn', diagnostic, { status: 'attempt_failed', attempt, errorType: lastError.type, httpStatus: lastError.httpStatus || null, retryable: lastError.retryable }); const shouldRetry = lastError.retryable && attempt < maxAttempts; if (!shouldRetry) { break; } await sleep(retryDelayMs); } } logTechnicalEvent('error', diagnostic, { status: 'failed', attempt: attemptsMade, attempts: attemptsMade, errorType: lastError ? lastError.type : 'unknown_error', httpStatus: lastError && lastError.httpStatus ? lastError.httpStatus : null }); return { ok: false, status: 'ai_unavailable', error: lastError || { type: 'unknown_error', message: 'The AI request failed.', retryable: false }, meta: { ...diagnostic, attempts: attemptsMade, durationMs: totalDurationMs, completedAt: new Date().toISOString() } }; } /** * Request and parse a structured JSON reply. */ function callJson(options) { return call({ ...(options || {}), responseType: 'json' }); } /** * Request a plain-text reply. */ function callText(options) { return call({ ...(options || {}), responseType: 'text' }); } window.cpAiService = { call, callJson, callText }; })();