‘0’], $atts, ‘cp_ai_memory’);
ob_start();
if ($atts[‘use_snippet’] === ‘1’) {
$snippet = plugin_dir_path(__FILE__) . ‘views/cp-ai-memory-snippet.html’;
if (file_exists($snippet)) include $snippet;
else echo ‘‘;
}
return ob_get_clean();
}
add_shortcode(‘cp_ai_memory’, ‘cp_ai_memory_render’);

add_action(‘wp_enqueue_scripts’, function () {
if (!is_page(‘chapter’)) return;

$cp_chapter_path = WPMU_PLUGIN_DIR . ‘/cp-assets/cp-chapter.js’;
$cp_chapter_ver = file_exists($cp_chapter_path) ? (string) filemtime($cp_chapter_path) : ‘1.0.0’;

wp_enqueue_script(
‘cp-chapter’,
content_url(‘/mu-plugins/cp-assets/cp-chapter.js’),
[],
$cp_chapter_ver,
true
);

$cfg = [
‘restBase’ => rest_url(‘cp/v1’),
‘token’ => defined(‘CP_AI_TOKEN’) ? CP_AI_TOKEN : ”,
‘chapter’ => ‘1.1’,
‘insight1Url’ => home_url(‘/insight/?chapter=1.1&insight=1’),
‘contextUrl’ => rest_url(‘cp/v1/chapter-page-context’),
‘aiUrl’ => rest_url(‘cp/v1/ai’),
];

wp_add_inline_script(
‘cp-chapter’,
‘window.cpVars = window.cpVars || {}; window.cpVars.chapterPage = ‘ . wp_json_encode($cfg) . ‘;’,
‘before’
);
});

/** REST routes */
add_action(‘rest_api_init’, function () {

// Simple health check
register_rest_route(‘cp/v1’, ‘/ping’, [
‘methods’ => ‘GET’,
‘permission_callback’ => ‘__return_true’,
‘callback’ => function () {
return new WP_REST_Response([
‘ok’ => true,
‘loaded’ => true,
‘expectTokenLen’ => strlen(CP_AI_TOKEN),
‘fingerprint’ => substr(md5(NONCE_SALT . AUTH_SALT), 0, 8),
], 200);
},
]);

// Main AI save endpoint
register_rest_route(‘cp/v1’, ‘/save-memory’, [
‘methods’ => ‘POST’,
‘permission_callback’ => ‘__return_true’,
‘callback’ => ‘cp_ai_handle_save_memory’,
]);

// Chapter map endpoint
register_rest_route(‘cp/v1’, ‘/chapter-map’, [
‘methods’ => ‘GET’,
‘permission_callback’ => ‘__return_true’,
‘callback’ => function (WP_REST_Request $req) {

$hdr = (string) $req->get_header(‘x-cp-token’);
if (!defined(‘CP_AI_TOKEN’) || CP_AI_TOKEN === ” || !hash_equals(CP_AI_TOKEN, $hdr)) {
return new WP_Error(‘forbidden’, ‘Bad or missing token’, [‘status’ => 403]);
}

if (!function_exists(‘cp_ai_load_chapter_map’)) {
return new WP_REST_Response([
‘ok’ => false,
‘map’ => [],
‘note’ => ‘cp_ai_load_chapter_map not available’,
], 200);
}

$map = cp_ai_load_chapter_map();
if (!is_array($map)) $map = [];

return new WP_REST_Response([
‘ok’ => true,
‘map’ => $map,
], 200);
},
]);

// GTKY report
register_rest_route(‘cp/v1’, ‘/gtky-report’, [
‘methods’ => ‘GET’,
‘permission_callback’ => ‘__return_true’,
‘callback’ => ‘cp_ai_gtky_report_handler’,
]);

// Coach Notes fetcher (Form 342)
register_rest_route(‘cp/v1’, ‘/coach-notes’, [
‘methods’ => ‘GET’,
‘permission_callback’ => ‘__return_true’,
‘callback’ => ‘cp_ai_coach_notes_handler’,
]);

// Chapter page context (Chapter defs + chapter connections + latest coach feedback)
register_rest_route(‘cp/v1’, ‘/chapter-page-context’, [
‘methods’ => ‘GET’,
‘permission_callback’ => ‘__return_true’,
‘callback’ => ‘cp_ai_chapter_page_context_handler’,
]);
// AI proxy (server-side OpenAI call)
register_rest_route(‘cp/v1’, ‘/ai’, [
‘methods’ => ‘POST’,
‘permission_callback’ => ‘__return_true’,
‘callback’ => ‘cp_ai_ai_proxy_handler’,
]);

});

/** —– Helpers —– */
function cp_ai_check_token(WP_REST_Request $req) {
$hdr = (string) $req->get_header(‘x-cp-token’);
if (!defined(‘CP_AI_TOKEN’) || CP_AI_TOKEN === ” || !hash_equals(CP_AI_TOKEN, $hdr)) {
return new WP_Error(‘forbidden’, ‘Bad or missing token’, [‘status’ => 403]);
}
return true;
}
function cp_ai_chapter_page_context_handler(WP_REST_Request $req) {
$auth = cp_ai_check_token($req);
if (is_wp_error($auth)) return $auth;

$userpass = trim((string) $req->get_param(‘userpass’));
$chapter = trim((string) $req->get_param(‘chapter’));

if ($userpass === ”) {
return new WP_Error(‘bad_request’, ‘Missing userpass’, [‘status’ => 400]);
}
if ($chapter === ”) {
$chapter = ‘1.1’;
}

// 1) Chapter definitions (from cp_chapter_map.php if available)
$chapter_def = null;
if (function_exists(‘cp_ai_load_chapter_map’)) {
$map = cp_ai_load_chapter_map();
if (is_array($map) && isset($map[$chapter])) {
$chapter_def = $map[$chapter];
}
}

// 2) Latest coach feedback (Form 343, Aspect = “Coach”)
$feedback = null;
if (isset($GLOBALS[‘wpdb’])) {
global $wpdb;

$form_id = 343;

// Field IDs you gave me:
$fid_userpass = 7183;
$fid_aspect = 7184;
$fid_quality = 7186;
$fid_confidence = 7187;
$fid_rationale = 7189;
$fid_quote = 7192;

$i = $wpdb->prefix . ‘frm_items’;
$m = $wpdb->prefix . ‘frm_item_metas’;

$sql = ”
SELECT it.id,
mq.meta_value AS quality,
mc.meta_value AS confidence,
mr.meta_value AS rationale,
mqt.meta_value AS quote
FROM {$i} it
JOIN {$m} mu ON mu.item_id = it.id AND mu.field_id = %d AND mu.meta_value = %s
JOIN {$m} ma ON ma.item_id = it.id AND ma.field_id = %d AND ma.meta_value = %s
LEFT JOIN {$m} mq ON mq.item_id = it.id AND mq.field_id = %d
LEFT JOIN {$m} mc ON mc.item_id = it.id AND mc.field_id = %d
LEFT JOIN {$m} mr ON mr.item_id = it.id AND mr.field_id = %d
LEFT JOIN {$m} mqt ON mqt.item_id = it.id AND mqt.field_id = %d
WHERE it.form_id = %d
ORDER BY it.created_at DESC
LIMIT 1
“;

$row = $wpdb->get_row($wpdb->prepare(
$sql,
$fid_userpass, $userpass,
$fid_aspect, ‘Coach’,
$fid_quality,
$fid_confidence,
$fid_rationale,
$fid_quote,
$form_id
), ARRAY_A);

if (is_array($row) && !empty($row)) {
$feedback = [
‘quality’ => $row[‘quality’] ?? ”,
‘confidence’ => $row[‘confidence’] ?? ”,
‘rationale’ => $row[‘rationale’] ?? ”,
‘quote’ => $row[‘quote’] ?? ”,
‘sourceForm’ => 343,
‘sourceEntry’ => (int) ($row[‘id’] ?? 0),
];
}
}
// 3) Chapter connections (Form 338)
$connections = [];
if (isset($GLOBALS[‘wpdb’])) {
global $wpdb;

$form_id = 338;

// Field IDs you gave:
$fid_userpass = 7123;
$fid_chapter = 7127;
$fid_quality = 7128;
$fid_confidence = 7129;
$fid_rationale = 7132;
$fid_quote = 7133;
$fid_terms = 7135;
$fid_seq = 7125; // response sequence

$i = $wpdb->prefix . ‘frm_items’;
$m = $wpdb->prefix . ‘frm_item_metas’;

$sql = ”
SELECT it.id,
ms.meta_value AS seq,
mq.meta_value AS quality,
mc.meta_value AS confidence,
mt.meta_value AS matched_terms,
mr.meta_value AS rationale,
mqt.meta_value AS quote,
it.created_at
FROM {$i} it
JOIN {$m} mu ON mu.item_id = it.id AND mu.field_id = %d AND mu.meta_value = %s
JOIN {$m} mch ON mch.item_id = it.id AND mch.field_id = %d AND mch.meta_value = %s
LEFT JOIN {$m} ms ON ms.item_id = it.id AND ms.field_id = %d
LEFT JOIN {$m} mq ON mq.item_id = it.id AND mq.field_id = %d
LEFT JOIN {$m} mc ON mc.item_id = it.id AND mc.field_id = %d
LEFT JOIN {$m} mt ON mt.item_id = it.id AND mt.field_id = %d
LEFT JOIN {$m} mr ON mr.item_id = it.id AND mr.field_id = %d
LEFT JOIN {$m} mqt ON mqt.item_id = it.id AND mqt.field_id = %d
WHERE it.form_id = %d
ORDER BY it.created_at DESC
LIMIT 12
“;

$rows = $wpdb->get_results($wpdb->prepare(
$sql,
$fid_userpass, $userpass,
$fid_chapter, $chapter,
$fid_seq,
$fid_quality,
$fid_confidence,
$fid_terms,
$fid_rationale,
$fid_quote,
$form_id
), ARRAY_A);

if (is_array($rows)) {
foreach ($rows as $r) {
$connections[] = [
‘entry_id’ => (int)($r[‘id’] ?? 0),
‘created_at’ => (string)($r[‘created_at’] ?? ”),
‘sequence’ => (string)($r[‘seq’] ?? ”),
‘quality’ => (string)($r[‘quality’] ?? ”),
‘confidence’ => (string)($r[‘confidence’] ?? ”),
‘matched_terms’ => (string)($r[‘matched_terms’] ?? ”),
‘rationale’ => (string)($r[‘rationale’] ?? ”),
‘quote’ => (string)($r[‘quote’] ?? ”),
];
}
}
}

return new WP_REST_Response([
‘ok’ => true,
‘userpass’ => $userpass,
‘chapter’ => $chapter,
‘chapterDef’ => $chapter_def,
‘connections’ => $connections,
‘feedback’ => $feedback,
], 200);
}

function cp_ai_ai_proxy_handler(WP_REST_Request $req) {
$auth = cp_ai_check_token($req);
if (is_wp_error($auth)) return $auth;

if (!defined(‘CP_OPENAI_API_KEY’) || CP_OPENAI_API_KEY === ”) {
return new WP_Error(‘config’, ‘Missing CP_OPENAI_API_KEY’, [‘status’ => 500]);
}

$body = json_decode($req->get_body(), true);
if (!is_array($body)) $body = [];

$payload = [
‘model’ => $body[‘model’] ?? ‘gpt-4o-mini’,
‘messages’ => $body[‘messages’] ?? [],
‘temperature’ => $body[‘temperature’] ?? 0.6,
];

$resp = wp_remote_post(‘https://api.openai.com/v1/chat/completions’, [
‘headers’ => [
‘Authorization’ => ‘Bearer ‘ . CP_OPENAI_API_KEY,
‘Content-Type’ => ‘application/json’,
],
‘timeout’ => 45,
‘body’ => wp_json_encode($payload),
]);

if (is_wp_error($resp)) {
return new WP_Error(‘upstream’, $resp->get_error_message(), [‘status’ => 502]);
}

$code = (int) wp_remote_retrieve_response_code($resp);
$raw = (string) wp_remote_retrieve_body($resp);
$json = json_decode($raw, true);

if ($code < 200 || $code >= 300) {
return new WP_Error(‘upstream’, ‘OpenAI error’, [
‘status’ => 502,
‘body’ => $raw,
]);
}

return new WP_REST_Response([
‘ok’ => true,
‘text’ => $json[‘choices’][0][‘message’][‘content’] ?? ”,
], 200);
}

function cp_ai_field_id_by_key($form_id, $field_key_or_id) {

// ———————————————————
// 1. If it is an actual integer β†’ definitely a field ID
// ———————————————————
if (is_int($field_key_or_id)) {
return $field_key_or_id;
}

// ———————————————————
// 2. If it is a numeric string (e.g. “7009”, “7127”)
// β†’ treat it as a FIELD ID (this is how your site works)
// ———————————————————
if (is_string($field_key_or_id) && ctype_digit($field_key_or_id)) {
return (int) $field_key_or_id;
}

// ———————————————————
// 3. Otherwise treat it as a field_key and resolve via cache
// ———————————————————
if (!class_exists(‘FrmField’)) {
return 0;
}

static $cache = [];
$form_id = (int) $form_id;
$field_key = (string) $field_key_or_id;

if (!isset($cache[$form_id])) {
$cache[$form_id] = [];

$fields = FrmField::get_all_for_form($form_id);
foreach ((array) $fields as $f) {
if (!empty($f->field_key)) {
$cache[$form_id][$f->field_key] = (int) $f->id;
}
}
}

// Return the resolved field ID or 0 if not found
return $cache[$form_id][$field_key] ?? 0;
}

function cp_ai_insert_json($form_id, $field_key_json_blob, $json_string, $payload_array = [], $map_json = ‘[]’, $force_blob_fallback = true) {
if (!class_exists(‘FrmEntry’)) {
return new WP_Error(‘formidable_missing’, ‘Formidable Forms Pro not active’, [‘status’ => 500]);
}

// Build item_meta from the field map + always include the JSON blob
$item_meta = cp_ai_build_item_meta_from_map(
(int)$form_id,
(string)$map_json,
(array)$payload_array,
(string)$field_key_json_blob,
(string)$json_string
);

// Fallback: if map produced nothing, optionally write just the blob
if (empty($item_meta)) {
if ($force_blob_fallback) {
$blob_id = cp_ai_field_id_by_key($form_id, $field_key_json_blob);
if ($blob_id) {
$item_meta[$blob_id] = $json_string;
}
} else {
// Nothing meaningful to save for this form – skip quietly
return new WP_Error(’empty_meta’, ‘Nothing to save for this form’, [‘status’ => 200]);
}
}

$entry = [
‘form_id’ => (int)$form_id,
‘item_key’ => ‘cp_’ . wp_generate_password(8, false, false),
‘item_meta’ => $item_meta,
];

$entry_id = FrmEntry::create($entry);
if (is_wp_error($entry_id) || empty($entry_id)) {
return new WP_Error(‘insert_failed’, ‘Failed to insert entry’, [‘status’ => 500]);
}
return (int)$entry_id;
}

function cp_ai_build_item_meta_from_map($form_id, $map_json, $payload_array, $json_blob_field_key, $json_blob_string) {
$item_meta = [];
$map = json_decode($map_json, true);
if (!is_array($map)) $map = [];

foreach ($map as $payloadKey => $field_key_or_id) {
if ($payloadKey === ‘_payload_json’) continue; // handle blob after loop
if (!array_key_exists($payloadKey, $payload_array)) continue;

$field_id = cp_ai_field_id_by_key($form_id, $field_key_or_id);
if ($field_id) {
$val = $payload_array[$payloadKey];
if (is_array($val) || is_object($val)) {
$val = wp_json_encode($val, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$item_meta[$field_id] = $val;
}
}

// Always include the full JSON blob
if (!empty($json_blob_field_key)) {
$blob_field_id = cp_ai_field_id_by_key($form_id, $json_blob_field_key);
if ($blob_field_id) $item_meta[$blob_field_id] = (string)$json_blob_string;
}

return $item_meta;
}

/**
* Find an existing Responses entry for this user & step so we UPDATE (upsert)
* instead of inserting duplicates.
* Keys:
* – userpass [6882]
* – kind [gtky|insight] via field [7144]
* – gtky [7079] (when kind = gtky)
* – insight [6875] (when kind = insight)
*/
function cp_ai_find_existing_step(
$form_id,
$fid_userpass,
$userpass_val,
$fid_kind,
$kind_val,
$fid_gtky,
$gtky_val,
$fid_insight,
$insight_val
) {
global $wpdb;
$i = $wpdb->prefix . ‘frm_items’;
$m = $wpdb->prefix . ‘frm_item_metas’;

$form_id = (int) $form_id;
$userpass_val = (string) $userpass_val;
$kind_val = (string) $kind_val;

// If we don’t even know the user, bail.
if (!$fid_userpass || $userpass_val === ”) {
return 0;
}

$has_kind = (bool) $fid_kind && $kind_val !== ”;
$use_gtky = ($kind_val === ‘gtky’ && (string) $gtky_val !== ”);
$use_insight = ($kind_val === ‘insight’ && (string) $insight_val !== ”);

// — Fallback: no gtky/insight key in the payload.
// In that case, treat this as “update the latest step for this user (+kind if known)”.
if (!$use_gtky && !$use_insight) {
$joins = ” JOIN $m mu ON mu.item_id = it.id AND mu.field_id = %d AND mu.meta_value = %s “;
$params = [$fid_userpass, $userpass_val];

if ($has_kind) {
$joins .= ” JOIN $m mk ON mk.item_id = it.id AND mk.field_id = %d AND mk.meta_value = %s “;
$params[] = $fid_kind;
$params[] = $kind_val;
}

$sql = ”
SELECT it.id
FROM $i it
$joins
WHERE it.form_id = %d
ORDER BY it.created_at DESC
LIMIT 1
“;
$params[] = $form_id;

$id = $wpdb->get_var($wpdb->prepare($sql, …$params));
return $id ? (int) $id : 0;
}

// — Normal path: we DO have a gtky/insight step key.
$joins = ” JOIN $m mu ON mu.item_id = it.id AND mu.field_id = %d AND mu.meta_value = %s “; // userpass
$joins .= ” JOIN $m mk ON mk.item_id = it.id AND mk.field_id = %d AND mk.meta_value = %s “; // kind
$params = [$fid_userpass, $userpass_val, $fid_kind, $kind_val];

if ($use_gtky) {
$joins .= ” JOIN $m mg ON mg.item_id = it.id AND mg.field_id = %d AND mg.meta_value = %s “;
$params[] = $fid_gtky;
$params[] = (string) $gtky_val;
} else {
$joins .= ” JOIN $m mi ON mi.item_id = it.id AND mi.field_id = %d AND mi.meta_value = %s “;
$params[] = $fid_insight;
$params[] = (string) $insight_val;
}

$sql = ”
SELECT it.id
FROM $i it
$joins
WHERE it.form_id = %d
ORDER BY it.created_at DESC
LIMIT 1
“;
$params[] = $form_id;

$id = $wpdb->get_var($wpdb->prepare($sql, …$params));
return $id ? (int) $id : 0;
}

function cp_ai_load_chapter_map() {
static $cache = null;
if ($cache !== null) {
return $cache;
}

$cache = [];

// We rely on Formidable being active
if (!class_exists(‘FrmEntry’) || !class_exists(‘FrmEntryMeta’)) {
return $cache;
}

// Field IDs in Form 341 (chapter definitions)
// 7164 = section# (chapter key, e.g. “1.1”)
// 7165 = title
// 7166 = keywords (comma-separated)
// 7170 = ch_summary
//
// IMPORTANT:
// These are REAL Formidable field IDs, not field_keys.
// cp_ai_field_id_by_key() now only treats TRUE integers as IDs,
// so we must pass them as ints, not as “7164” strings.
$fid_section = cp_ai_field_id_by_key(CP_FORM_CHAPTER_DEFS, 7164);
$fid_title = cp_ai_field_id_by_key(CP_FORM_CHAPTER_DEFS, 7165);
$fid_keywords= cp_ai_field_id_by_key(CP_FORM_CHAPTER_DEFS, 7166);
$fid_summary = cp_ai_field_id_by_key(CP_FORM_CHAPTER_DEFS, 7170);

// We at least need section# and keywords to do any matching
if (!$fid_section || !$fid_keywords) {
return $cache;
}

global $wpdb;
$items_table = $wpdb->prefix . ‘frm_items’;
$meta_table = $wpdb->prefix . ‘frm_item_metas’;

$field_ids = array_filter([
(int)$fid_section,
(int)$fid_title,
(int)$fid_keywords,
(int)$fid_summary,
]);

if (empty($field_ids)) {
return $cache;
}

$field_ids_in = implode(‘,’, array_map(‘intval’, $field_ids));

// Pull all metas for those fields on Form 341
$rows = $wpdb->get_results($wpdb->prepare(
“SELECT it.id AS item_id, im.field_id, im.meta_value
FROM {$items_table} it
JOIN {$meta_table} im ON im.item_id = it.id
WHERE it.form_id = %d
AND im.field_id IN ($field_ids_in)”,
CP_FORM_CHAPTER_DEFS
));

if (empty($rows)) {
// Fallback to legacy PHP map if present (safety net)
$file = WP_CONTENT_DIR . ‘/mu-plugins/cp_chapter_map.php’;
if (file_exists($file)) {
$legacy = require $file;
if (is_array($legacy)) {
$cache = $legacy;
}
}
return $cache;
}

// Pivot metas by entry (item)
$by_item = [];
foreach ($rows as $row) {
$id = (int)$row->item_id;
if (!isset($by_item[$id])) {
$by_item[$id] = [];
}
$by_item[$id][(int)$row->field_id] = (string)$row->meta_value;
}

foreach ($by_item as $meta) {
$section = isset($meta[$fid_section]) ? trim((string)$meta[$fid_section]) : ”;
$keywords_raw = isset($meta[$fid_keywords]) ? (string)$meta[$fid_keywords] : ”;

if ($section === ” || $keywords_raw === ”) {
continue; // must have both a key and keywords
}

$title = isset($meta[$fid_title]) ? (string)$meta[$fid_title] : ”;
$summary = isset($meta[$fid_summary]) ? (string)$meta[$fid_summary] : ”;

// Split comma-separated keywords β†’ [‘passion’,’energy’,…]
$keywords = preg_split(‘/\s*,\s*/’, $keywords_raw);
$keywords = array_filter(array_map(‘trim’, (array)$keywords));

if (empty($keywords)) {
continue;
}

// Use section# (e.g. “1.1”) as the canonical chapter key
$cache[$section] = [
‘title’ => $title,
‘keywords’ => $keywords,
‘summary’ => $summary,
];
}

// If DB ended up empty for some reason, soft-fallback to legacy file
if (empty($cache)) {
$file = WP_CONTENT_DIR . ‘/mu-plugins/cp_chapter_map.php’;
if (file_exists($file)) {
$legacy = require $file;
if (is_array($legacy)) {
$cache = $legacy;
}
}
}

return $cache;
}

/**
* Build a simple GTKY trait profile for the Start Report page.
* – Reads Form 329 (Traits) for a given userpass
* – Aggregates by trait name: avg confidence, dominant quality, example quote, count
*
* GET /wp-json/cp/v1/gtky-report?userpass=XXXX
* Header: X-CP-Token: CP_AI_TOKEN
*/
function cp_ai_gtky_report_handler( WP_REST_Request $req ) {
// Re-use the same token guard as /save-memory
$auth = cp_ai_check_token( $req );
if ( is_wp_error( $auth ) ) {
return $auth;
}

$userpass = trim( (string) $req->get_param( ‘userpass’ ) );
if ( $userpass === ” ) {
return new WP_REST_Response( [
‘ok’ => false,
‘error’ => ‘Missing userpass’,
], 400 );
}

if ( ! class_exists( ‘FrmEntry’ ) || ! class_exists( ‘FrmField’ ) ) {
return new WP_REST_Response( [
‘ok’ => false,
‘error’ => ‘Formidable Forms not available’,
], 500 );
}

global $wpdb;
$items = $wpdb->prefix . ‘frm_items’;
$meta = $wpdb->prefix . ‘frm_item_metas’;

// Form 329 field IDs (we resolve via cp_ai_field_id_by_key for safety)
$fid_userpass = cp_ai_field_id_by_key( CP_FORM_TRAITS, 7118 ); // userpass
$fid_trait = cp_ai_field_id_by_key( CP_FORM_TRAITS, 7009 ); // trait name
$fid_conf = cp_ai_field_id_by_key( CP_FORM_TRAITS, 7019 ); // confidence
$fid_qual = cp_ai_field_id_by_key( CP_FORM_TRAITS, 7117 ); // quality
$fid_quote = cp_ai_field_id_by_key( CP_FORM_TRAITS, 7119 ); // quote

if ( ! $fid_userpass || ! $fid_trait ) {
return new WP_REST_Response( [
‘ok’ => false,
‘error’ => ‘Traits field mapping incomplete’,
], 500 );
}

$trait_field_ids = array_filter( [
(int) $fid_trait,
(int) $fid_conf,
(int) $fid_qual,
(int) $fid_quote,
] );

if ( empty( $trait_field_ids ) ) {
return new WP_REST_Response( [
‘ok’ => false,
‘error’ => ‘No trait fields configured’,
], 500 );
}

$ids_in = implode( ‘,’, array_map( ‘intval’, $trait_field_ids ) );

// Pull all trait metas for this userpass on Form 329
$rows = $wpdb->get_results( $wpdb->prepare(
“SELECT it.id AS item_id, im.field_id, im.meta_value
FROM {$items} it
JOIN {$meta} mu ON mu.item_id = it.id
AND mu.field_id = %d
AND mu.meta_value = %s
JOIN {$meta} im ON im.item_id = it.id
WHERE it.form_id = %d
AND im.field_id IN ( {$ids_in} )”,
$fid_userpass,
$userpass,
CP_FORM_TRAITS
) );

if ( empty( $rows ) ) {
return new WP_REST_Response( [
‘ok’ => true,
‘userpass’ => $userpass,
‘traits’ => [],
], 200 );
}

// Pivot metas by entry
$by_item = [];
foreach ( $rows as $row ) {
$id = (int) $row->item_id;
if ( ! isset( $by_item[ $id ] ) ) {
$by_item[ $id ] = [];
}
$by_item[ $id ][ (int) $row->field_id ] = (string) $row->meta_value;
}

// Aggregate by trait name
$agg = [];
foreach ( $by_item as $entry ) {
$trait = isset( $entry[ $fid_trait ] ) ? trim( (string) $entry[ $fid_trait ] ) : ”;
if ( $trait === ” ) {
continue;
}

$conf = isset( $entry[ $fid_conf ] ) ? (int) $entry[ $fid_conf ] : 0;
if ( $conf < 1 || $conf > 10 ) {
$conf = max( 1, min( 10, $conf ) );
}

$quality = isset( $entry[ $fid_qual ] ) ? trim( (string) $entry[ $fid_qual ] ) : ”;
if ( $quality === ” ) {
$quality = ‘Neutral’;
}

$quote = isset( $entry[ $fid_quote ] ) ? trim( (string) $entry[ $fid_quote ] ) : ”;

if ( ! isset( $agg[ $trait ] ) ) {
$agg[ $trait ] = [
‘trait’ => $trait,
‘count’ => 0,
‘conf_sum’ => 0,
‘qualities’ => [],
‘example_quote’ => ”,
];
}

$agg[ $trait ][‘count’]++;
$agg[ $trait ][‘conf_sum’] += $conf;
$agg[ $trait ][‘qualities’][] = $quality;

// Choose the first non-empty quote as example; later you can get fancier
if ( $quote !== ” && $agg[ $trait ][‘example_quote’] === ” ) {
$agg[ $trait ][‘example_quote’] = $quote;
}
}

// Collapse into a neat list for the FE
$traits_out = [];
foreach ( $agg as $t ) {
$count = max( 1, (int) $t[‘count’] );
$avg = round( $t[‘conf_sum’] / $count );

// pick most frequent quality
$qCounts = array_count_values( $t[‘qualities’] );
arsort( $qCounts );
$dominant = key( $qCounts );

$traits_out[] = [
‘trait’ => $t[‘trait’],
‘avg_confidence’ => (int) $avg,
‘count’ => (int) $count,
‘quality’ => $dominant ?: ‘Neutral’,
‘example_quote’ => $t[‘example_quote’],
];
}

// Sort traits by avg_confidence desc, then count desc
usort( $traits_out, function( $a, $b ) {
if ( $b[‘avg_confidence’] === $a[‘avg_confidence’] ) {
return $b[‘count’] <=> $a[‘count’];
}
return $b[‘avg_confidence’] <=> $a[‘avg_confidence’];
} );

return new WP_REST_Response( [
‘ok’ => true,
‘userpass’ => $userpass,
‘traits’ => $traits_out,
], 200 );
}

/* ⬇⬇⬇ PASTE cp_ai_coach_notes_handler() HERE ⬇⬇⬇ */

// Does a 342 row already exist for (userpass + chapter_key + sequence)?
function cp_ai_coach_notes_triplet_exists($userpass_val, $chapter_key_val, $sequence_val) {
global $wpdb;
$i = $wpdb->prefix . ‘frm_items’;
$m = $wpdb->prefix . ‘frm_item_metas’;

$fid_user = cp_ai_field_id_by_key(CP_FORM_COACH_NOTES, 7172); // userpass
$fid_chap = cp_ai_field_id_by_key(CP_FORM_COACH_NOTES, 7173); // chapter_key
$fid_seq = cp_ai_field_id_by_key(CP_FORM_COACH_NOTES, 7174); // sequence
if (!$fid_user || !$fid_chap || !$fid_seq) return false;

$sql = ”
SELECT it.id
FROM {$i} it
JOIN {$m} mu ON mu.item_id = it.id AND mu.field_id = %d AND mu.meta_value = %s
JOIN {$m} mc ON mc.item_id = it.id AND mc.field_id = %d AND mc.meta_value = %s
JOIN {$m} ms ON ms.item_id = it.id AND ms.field_id = %d AND ms.meta_value = %s
WHERE it.form_id = %d
LIMIT 1
“;
$id = $wpdb->get_var($wpdb->prepare(
$sql,
$fid_user, (string)$userpass_val,
$fid_chap, (string)$chapter_key_val,
$fid_seq, (string)$sequence_val,
(int)CP_FORM_COACH_NOTES
));
return !empty($id);
}

/**
* Fetch the latest Coach Notes snapshot for a user + chapter_key.
*
* GET /wp-json/cp/v1/coach-notes?userpass=XXXX&chapter_key=0
* Header: X-CP-Token: CP_AI_TOKEN
*/
function cp_ai_coach_notes_handler( WP_REST_Request $req ) {
$auth = cp_ai_check_token( $req );
if ( is_wp_error( $auth ) ) return $auth;

$userpass = trim( (string) $req->get_param(‘userpass’) );
$chapter_key = trim( (string) $req->get_param(‘chapter_key’) );
if ( $chapter_key === ” ) $chapter_key = ‘0’;

if ( $userpass === ” ) {
return new WP_REST_Response([ ‘ok’ => false, ‘error’ => ‘Missing userpass’ ], 400);
}

if ( !class_exists(‘FrmEntry’) || !class_exists(‘FrmField’) ) {
return new WP_REST_Response([ ‘ok’ => false, ‘error’ => ‘Formidable Forms not available’ ], 500);
}

// Form 342 field IDs (real numeric IDs)
$fid_userpass = cp_ai_field_id_by_key( CP_FORM_COACH_NOTES, 7172 );
$fid_chapter = cp_ai_field_id_by_key( CP_FORM_COACH_NOTES, 7173 );
$fid_sequence = cp_ai_field_id_by_key( CP_FORM_COACH_NOTES, 7174 );
$fid_notes = cp_ai_field_id_by_key( CP_FORM_COACH_NOTES, 7175 );
$fid_summary = cp_ai_field_id_by_key( CP_FORM_COACH_NOTES, 7176 );
$fid_evidence = cp_ai_field_id_by_key( CP_FORM_COACH_NOTES, 7177 );

if ( !$fid_userpass || !$fid_chapter || !$fid_notes || !$fid_summary ) {
return new WP_REST_Response([
‘ok’ => false,
‘error’ => ‘Coach Notes field mapping incomplete (342)’,
], 500);
}

global $wpdb;
$items = $wpdb->prefix . ‘frm_items’;
$meta = $wpdb->prefix . ‘frm_item_metas’;

// Latest row for (userpass + chapter_key)
$item_id = $wpdb->get_var( $wpdb->prepare(
“SELECT it.id
FROM {$items} it
JOIN {$meta} mu ON mu.item_id = it.id AND mu.field_id = %d AND mu.meta_value = %s
JOIN {$meta} mc ON mc.item_id = it.id AND mc.field_id = %d AND mc.meta_value = %s
WHERE it.form_id = %d
ORDER BY it.created_at DESC
LIMIT 1”,
(int)$fid_userpass, $userpass,
(int)$fid_chapter, $chapter_key,
(int)CP_FORM_COACH_NOTES
) );

if ( !$item_id ) {
return new WP_REST_Response([
‘ok’ => true,
‘found’ => false,
‘userpass’ => $userpass,
‘chapter_key’ => $chapter_key,
], 200);
}

$wanted = array_filter([
(int)$fid_userpass,
(int)$fid_chapter,
(int)$fid_sequence,
(int)$fid_notes,
(int)$fid_summary,
(int)$fid_evidence,
]);
$in = implode(‘,’, array_map(‘intval’, $wanted));

$rows = $wpdb->get_results( $wpdb->prepare(
“SELECT field_id, meta_value
FROM {$meta}
WHERE item_id = %d
AND field_id IN ({$in})”,
(int)$item_id
) );

$by = [];
foreach ((array)$rows as $r) {
$by[(int)$r->field_id] = (string)$r->meta_value;
}

return new WP_REST_Response([
‘ok’ => true,
‘found’ => true,
‘entry_id’ => (int)$item_id,
‘userpass’ => $by[$fid_userpass] ?? $userpass,
‘chapter_key’ => $by[$fid_chapter] ?? $chapter_key,
‘sequence’ => isset($by[$fid_sequence]) ? (int)$by[$fid_sequence] : 1,
‘trait_notes_json’ => $by[$fid_notes] ?? ”,
‘traits_summary_json’=> $by[$fid_summary] ?? ”,
‘evidence_json’ => $by[$fid_evidence] ?? ”,
], 200);
}

/** Build a slim, this-response-only JSON blob for Form 338 (Chapters). */
function cp_ai_chapter_blob_json(array $data) {
// Keep only the linkage + per-turn fields we care about
$keep_keys = [
// identity / linkage
‘userpass’,’user’,’user_id’,’session’,’kind’,’framework’,
‘response_id’,’response_sequence’,
‘gtky’,’gtky_id’,’insight’,’insight_id’,
// chapter fields (either human or numeric aliases)
‘chapter’,’7127′,’matched_terms’,’7135′,’quality’,’7128′,’confidence’,’7129′,
‘rationale’,’7132′,’quote’,’7133′,
// minimal meta
‘_received_at’,’status’,
];

$mini = [];
foreach ($keep_keys as $k) {
if (array_key_exists($k, $data)) {
$mini[$k] = $data[$k];
}
}

// Keep only the *tail* of the dialogue so this row refers to THIS response
$dlg = (string)($data[‘dialogue’] ?? $data[‘user_input’] ?? ”);
if ($dlg !== ”) {
// last ~1200 chars is enough for context without bloating the row
$mini[‘dialogue_tail’] = (strlen($dlg) > 1200) ? substr($dlg, -1200) : $dlg;
}

return wp_json_encode($mini, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}

function cp_ai_find_existing_chapter($userpass_val, $response_id_val) {
global $wpdb;
$i = $wpdb->prefix . ‘frm_items’;
$m = $wpdb->prefix . ‘frm_item_metas’;

// Field IDs in Form 338:
// 7123 = userpass, 7124 = response_id
$sql = ”
SELECT it.id
FROM {$i} it
JOIN {$m} mu ON mu.item_id = it.id AND mu.field_id = %d AND mu.meta_value = %s
JOIN {$m} mr ON mr.item_id = it.id AND mr.field_id = %d AND mr.meta_value = %s
WHERE it.form_id = %d
ORDER BY it.created_at DESC
LIMIT 1
“;
$params = [
cp_ai_field_id_by_key(CP_FORM_CHAPTERS, ‘7123’),
(string)$userpass_val,
cp_ai_field_id_by_key(CP_FORM_CHAPTERS, ‘7124’),
(string)$response_id_val,
(int)CP_FORM_CHAPTERS,
];
$id = $wpdb->get_var($wpdb->prepare($sql, …$params));
return $id ? (int)$id : 0;
}

/* ========================== NEW HELPER ========================== */
// Return the max response_sequence already stored in 338 for this response_id
function cp_ai_max_chapter_seq_for_response($response_id_val) {
global $wpdb;
$i = $wpdb->prefix . ‘frm_items’;
$m = $wpdb->prefix . ‘frm_item_metas’;

$fid_resp = cp_ai_field_id_by_key(CP_FORM_CHAPTERS, ‘7124’); // response_id
$fid_seq = cp_ai_field_id_by_key(CP_FORM_CHAPTERS, ‘7125’); // response_sequence
if (!$fid_resp || !$fid_seq) return -1;

$sql = ”
SELECT MAX(CAST(ms.meta_value AS UNSIGNED))
FROM {$i} it
JOIN {$m} mr ON mr.item_id = it.id AND mr.field_id = %d AND mr.meta_value = %s
JOIN {$m} ms ON ms.item_id = it.id AND ms.field_id = %d
WHERE it.form_id = %d
“;
$max = $wpdb->get_var($wpdb->prepare($sql, $fid_resp, (string)$response_id_val, $fid_seq, (int)CP_FORM_CHAPTERS));
return ($max === null) ? -1 : (int)$max;
}
/* ================================================================ */

/* =================== NEW HELPER: triplet exists? =================== */
// Does a 338 row already exist for (userpass + response_id + response_sequence)?
function cp_ai_chapter_triplet_exists($userpass_val, $response_id_val, $response_seq_val) {
global $wpdb;
$i = $wpdb->prefix . ‘frm_items’;
$m = $wpdb->prefix . ‘frm_item_metas’;

$fid_user = cp_ai_field_id_by_key(CP_FORM_CHAPTERS, ‘7123’); // userpass
$fid_resp = cp_ai_field_id_by_key(CP_FORM_CHAPTERS, ‘7124’); // response_id
$fid_seq = cp_ai_field_id_by_key(CP_FORM_CHAPTERS, ‘7125’); // response_sequence
if (!$fid_user || !$fid_resp || !$fid_seq) return false;

$sql = ”
SELECT it.id
FROM {$i} it
JOIN {$m} mu ON mu.item_id = it.id AND mu.field_id = %d AND mu.meta_value = %s
JOIN {$m} mr ON mr.item_id = it.id AND mr.field_id = %d AND mr.meta_value = %s
JOIN {$m} ms ON ms.item_id = it.id AND ms.field_id = %d AND ms.meta_value = %s
WHERE it.form_id = %d
LIMIT 1
“;
$id = $wpdb->get_var($wpdb->prepare(
$sql,
$fid_user, (string)$userpass_val,
$fid_resp, (string)$response_id_val,
$fid_seq, (string)$response_seq_val,
(int)CP_FORM_CHAPTERS
));
return !empty($id);
}

/**
* Extract a chapter-specific quote from the user’s own words,
* using the chapter map keywords for the chosen chapter.
*
* – Uses cp_chapter_map.php
* – Prefers user_input; falls back to the last non-AI line from dialogue
* – Picks the sentence with the highest keyword hit count
*/
function cp_ai_extract_chapter_quote($dialogue, $user_input, $chapter_key) {
$chapter_key = trim((string)$chapter_key);
if ($chapter_key === ”) return ”;

$map = cp_ai_load_chapter_map();
if (!isset($map[$chapter_key]) || empty($map[$chapter_key][‘keywords’])) {
return ”;
}

$keywords = array_filter(array_map(‘strtolower’, (array)$map[$chapter_key][‘keywords’]));
if (empty($keywords)) return ”;

$source = trim((string)$user_input);

// If no explicit user_input, try to recover the last “user” line from dialogue
if ($source === ” && $dialogue !== ”) {
$lines = preg_split(‘/\r\n|\r|\n/u’, (string)$dialogue);
$lines = is_array($lines) ? $lines : [];

$candidate = ”;
for ($i = count($lines) – 1; $i >= 0; $i–) {
$t = trim($lines[$i]);
if ($t === ”) continue;
// Skip AI lines
if (preg_match(‘/^\[?\s*ai coach\s*\]?\s*:/i’, $t)) continue;
$candidate = $t;
break;
}
$source = $candidate;
}

$source = trim($source);
if ($source === ”) return ”;

// Split into sentences
$sentences = preg_split(‘/(?<=[.!?])\s+|\r\n|\r|\n/u', $source); if (!is_array($sentences)) $sentences = [$source]; $bestSentence = ''; $bestScore = 0; $bestLenDiff = PHP_INT_MAX; foreach ($sentences as $s) { $s = trim($s); if ($s === '') continue; $s_lc = mb_strtolower($s); $score = 0; foreach ($keywords as $kw) { if ($kw === '') continue; if (preg_match('/\b' . preg_quote($kw, '/') . '\b/u', $s_lc)) { $score++; } } if ($score === 0) continue; // Prefer sentences roughly 40–200 chars $len = mb_strlen($s); $target = 120; $lenDiff = abs($len - $target); if ( $score > $bestScore ||
($score === $bestScore && $lenDiff < $bestLenDiff) ) { $bestScore = $score; $bestLenDiff = $lenDiff; $bestSentence = $s; } } if ($bestSentence === '') { return ''; } // Gentle cap for UI – do NOT trim aggressively unless very long if (mb_strlen($bestSentence) > 220) {
$bestSentence = mb_substr($bestSentence, 0, 220);
}

return trim($bestSentence);
}
/**
* Extract the latest single-turn user answer from the payload.
* Priority:
* – explicit ‘user_input’ (sent by FE summary/traits/chapter POSTs)
* – else, last non-AI line from ‘dialogue’
*/
function cp_ai_latest_user_answer_from_payload(array $data) {
// 1) Prefer explicit user_input if provided
if (!empty($data[‘user_input’])) {
return (string) $data[‘user_input’];
}

// 2) Fallback: last non-AI line from dialogue
if (!empty($data[‘dialogue’])) {
$lines = preg_split(‘/\r\n|\r|\n/u’, (string) $data[‘dialogue’]);
if (is_array($lines)) {
for ($i = count($lines) – 1; $i >= 0; $i–) {
$t = trim($lines[$i]);
if ($t === ”) continue;

// Strip simple speaker labels like “You:” / “AI Coach:”
$t = preg_replace(‘/^\s*(you|user|ai coach)\s*:\s*/i’, ”, $t);
if ($t !== ”) {
return $t;
}
}
}
}

return ”;
}

/** —– Main handler —– */
function cp_ai_handle_save_memory(WP_REST_Request $req) {
error_log(“==== RAW SERVER PAYLOAD ====”);
error_log(print_r(json_decode(file_get_contents(‘php://input’), true), true));

// 1) Auth
$auth = cp_ai_check_token($req);
if (is_wp_error($auth)) return $auth;

// 2) Payload in, normalize
$data = $req->get_json_params();
if (empty($data)) {
$raw = (string) $req->get_body();
$tmp = json_decode($raw, true);
if (is_array($tmp)) $data = $tmp;
}
if (!is_array($data)) $data = [];
$data[‘_received_at’] = current_time(‘mysql’, true);

// —- Canonicalise to ‘userpass’ (accept legacy aliases) —-
if (empty($data[‘userpass’])) {
if (!empty($data[‘user_pass’])) $data[‘userpass’] = (string)$data[‘user_pass’];
elseif (!empty($data[‘user-pass’])) $data[‘userpass’] = (string)$data[‘user-pass’];
elseif (!empty($data[‘user’])) $data[‘userpass’] = (string)$data[‘user’];
}
// Keep ‘user’ as a shadow for older code paths until fully removed
if (empty($data[‘user’]) && !empty($data[‘userpass’])) {
$data[‘user’] = (string)$data[‘userpass’];
}

/** —- ai_reply is deprecated: do not touch dialogue with it —- */
if (isset($data[‘ai_reply’])) {
// Just drop it so old front-ends can’t pollute 6.Response
unset($data[‘ai_reply’]);
}

// —- Defaults & normalisers for all forms —-
// Default conversation kind (prefer explicit values from client)
if (empty($data[‘kind’])) {
$data[‘kind’] = ‘gtky’; // expected: ‘gtky’ or ‘insight’
}

// Default framework label for Traits/Chapters (future-proof for white-label)
if (empty($data[‘framework’])) {
$data[‘framework’] = ‘Change Pathway’;
}

// Normalise polarity synonyms β†’ ‘quality’ expected by maps
// Coerce quality to exactly one of Positive/Neutral/Negative/Caution
$allowed_qual = [‘Positive’,’Neutral’,’Negative’,’Caution’];

if (!empty($data[‘match_polarity’]) && empty($data[‘quality’])) {
$qp = ucfirst(strtolower(trim($data[‘match_polarity’])));
if (in_array($qp, $allowed_qual, true)) {
$data[‘quality’] = $qp;
}
}

if (!empty($data[‘quality’])) {
$q = ucfirst(strtolower(trim((string)$data[‘quality’])));
if (!in_array($q, $allowed_qual, true)) {
$data[‘quality’] = ‘Neutral’; // safe default
} else {
$data[‘quality’] = $q;
}
}

// Normalise confidence to 1–10 integer for Traits/Chapters
if (isset($data[‘confidence’])) {
$c = $data[‘confidence’];
if (is_numeric($c)) {
$c = (float)$c;
if ($c <= 1.0) { $c = round($c * 10.0); } // 0..1 β†’ 0..10 elseif ($c > 10.0) { $c = round($c / 10.0); } // 0..100 β†’ 0..10
$c = (int)max(1, min(10, $c)); // clamp to 1..10
} else {
$c = 5; // safe default
}
$data[‘confidence’] = $c;
}

// Ensure Matched Terms serialises cleanly
if (isset($data[‘matched_terms’]) && is_array($data[‘matched_terms’])) {
// leave as array; cp_ai_build_item_meta_from_map handles encoding
}

// — Back-compat shim —
// If FE still sends ‘user_input’ for the first turn (older AI code),
// copy it into ‘dialogue’ so it’s included in the Formidable record.
if (empty($data[‘dialogue’]) && !empty($data[‘user_input’])) {
$data[‘dialogue’] = (string)$data[‘user_input’];
}

// Allow updates that only change status/kind/etc; only skip truly empty *inserts*
$dialogue_present = (
(isset($data[‘dialogue’]) && trim((string)$data[‘dialogue’]) !== ”) ||
(isset($data[‘user_input’]) && trim((string)$data[‘user_input’]) !== ”)
);
$summary_present = isset($data[‘summary’]) && trim((string)$data[‘summary’]) !== ”;

// If there is no text, but we have linkage/meta, we will still proceed (so upserts work)
$has_linkage_or_meta = (
!empty($data[‘user’]) || !empty($data[‘userpass’]) ||
!empty($data[‘kind’]) || !empty($data[‘status’]) ||
!empty($data[‘gtky’]) || !empty($data[‘linked_insight_key’])
);

if (!$dialogue_present && !$summary_present && !$has_linkage_or_meta) {
return new WP_REST_Response([‘ok’ => true, ‘skipped’ => ‘no_text’], 200);
}

// —- Sanity filters to prevent junk from polluting fields —-

// 1) Fallback: populate user_id from current WP user if missing/empty
if (empty($data[‘user_id’])) {
$uid = get_current_user_id();
if ($uid) $data[‘user_id’] = (string) $uid;
}

// 2) Drop placeholder/debug replies so we don’t overwrite good text on upserts
if (!empty($data[‘ai_reply’])) {
$ai_reply_trim = trim((string)$data[‘ai_reply’]);
// Ignore common debug/placeholder strings
if ($ai_reply_trim === ‘βœ… Hook fired.’ || $ai_reply_trim === ‘Hook fired’ || $ai_reply_trim === ‘OK’) {
unset($data[‘ai_reply’]);
}
}

// 4) Only store “traits” on Responses if it looks like real scoring, not UI prefs
if (isset($data[‘traits’]) && is_array($data[‘traits’])) {
$keys = array_keys($data[‘traits’]);
// if it’s only tone/nickname/lang, don’t map it to the Responses form
$ui_only = !array_diff($keys, [‘tone’,’nickname’,’lang’]);
if ($ui_only) {
unset($data[‘traits’]); // prevents writing to field 7114 on Responses
}
}

// — Force a valid kind; allow feedback too —
$k = strtolower((string)($data[‘kind’] ?? ”));

// Normalise aliases coming from FE
if ($k === ‘feedback’) $k = ‘user_feedback’;

// Allow-list (add more here if you introduce new kinds later)
$allowed_kinds = [‘gtky’, ‘insight’, ‘coach_notes’, ‘user_feedback’];

if (!in_array($k, $allowed_kinds, true)) {
if (!empty($data[‘gtky’])) $k = ‘gtky’;
elseif (!empty($data[‘insight’])) $k = ‘insight’;
else $k = ‘gtky’; // conservative default for GTKY flow
}
$data[‘kind’] = $k;

// — Drop placeholder/debug replies more broadly (emoji, variants, case-insensitive)
if (!empty($data[‘ai_reply’])) {
$t = trim((string)$data[‘ai_reply’]);
if (preg_match(‘/hook\s*fired/i’, $t) || preg_match(‘/^βœ…\s*hook/i’, $t) || preg_match(‘/^ok$/i’, $t)) {
unset($data[‘ai_reply’]);
}
}

$json = wp_json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);

// Map: payload keys -> Formidable field IDs/keys for Form 320 (Responses)
if (!defined(‘CP_FORM_RESPONSES_MAP’)) {
define(‘CP_FORM_RESPONSES_MAP’, wp_json_encode([
// Core linkage / identity
‘session’ => ‘7143’, // session
‘userpass’ => ‘6882’, // canonical
‘user’ => ‘6882’, // legacy alias (safe to drop later)
‘edition’ => ‘6874’, // edition (Book)
‘status’ => ‘7062’, // status
‘kind’ => ‘7144’, // kind

// Content
‘dialogue’ => ‘6878’, // dialogue (was: Your Response)
‘summary’ => ‘6970’, // summary (single summary field)

// Metadata keys (either may be empty depending on flow)
‘linked_insight_key’ => ‘6875’, // Linked Insight Key
‘gtky’ => ‘7079’, // Linked GTKY Key

// Optional user id (numeric field id 6921)
‘user_id’ => ‘6921’, // User ID

// Always include the full JSON blob
‘_payload_json’ => CP_FIELD_KEY_RESPONSES, // payload [7140]
]));
}

// Map: payload keys -> Formidable field IDs/keys for Form 343 (User Feedback)
if (!defined(‘CP_FORM_FEEDBACK_MAP’)) {
define(‘CP_FORM_FEEDBACK_MAP’, wp_json_encode([
// Your Form 343 fields
‘payload’ => ‘7181’, // payload
‘user_id’ => ‘7182’, // User ID
‘userpass’ => ‘7183’, // userpass
‘response_sequence’ => ‘7184’, // response sequence
‘aspect’ => ‘7186’, // Aspect
‘quality’ => ‘7187’, // Quality of Match
‘confidence’ => ‘7188’, // Confidence
‘insight_id’ => ‘7189’, // Insight-ID (0 for this survey)
‘rationale’ => ‘7191’, // Rationale
‘quote’ => ‘7192’, // Quote

// Always include full JSON blob into payload [7181]
‘_payload_json’ => CP_FIELD_KEY_FEEDBACK,
]));
}

// Map: payload keys -> Formidable field IDs for Form 329 (Traits)
if (!defined(‘CP_FORM_TRAITS_MAP’)) {
define(‘CP_FORM_TRAITS_MAP’, wp_json_encode([
// Identity & linkage
‘user_id’ => ‘7007’,
‘userpass’ => ‘7118’, // <-- was 'user'; FE sends userpass 'response_sequence' => ‘7121’,
‘framework’ => ‘7008’,

// Human keys from FE
‘trait’ => ‘7009’,
‘rationale’ => ‘7021’,
‘quality’ => ‘7117’,
‘confidence’ => ‘7019’,
‘insight_id’ => ‘7020’,
‘gtky_id’ => ‘7116’,
‘quote’ => ‘7119’,

// Numeric aliases (if FE later sends both)
‘7009’ => ‘7009’,
‘7021’ => ‘7021’,
‘7020’ => ‘7020’,
‘7116’ => ‘7116’,
‘7117’ => ‘7117’,
‘7019’ => ‘7019’,
‘7119’ => ‘7119’,

‘_payload_json’ => CP_FIELD_KEY_TRAITS,
]));
}

// Map: payload keys -> Formidable field IDs for Form 338 (Chapters)
if (!defined(‘CP_FORM_CHAPTERS_MAP’)) {
define(‘CP_FORM_CHAPTERS_MAP’, wp_json_encode([
// Identity & linkage
‘user_id’ => ‘7122’,
‘userpass’ => ‘7123’,
‘response_id’ => ‘7124’,
‘response_sequence’ => ‘7125’,

// Chapter match
‘chapter’ => ‘7127’,
‘matched_terms’ => ‘7135’,
‘quality’ => ‘7128’,
‘confidence’ => ‘7129’,

// Context keys
‘insight_id’ => ‘7130’,
‘gtky_id’ => ‘7131’,
‘rationale’ => ‘7132’,

// IMPORTANT: Chapters use their own quote key, separate from Traits
‘chapter_quote’ => ‘7133’, // 338-specific quote
‘quote’ => ‘7133’, // legacy/FE alias, if ever sent

// Numeric aliases (future-proof)
‘7122’ => ‘7122’,
‘7123’ => ‘7123’,
‘7124’ => ‘7124’,
‘7125’ => ‘7125’,
‘7127’ => ‘7127’,
‘7135’ => ‘7135’,
‘7128’ => ‘7128’,
‘7129’ => ‘7129’,
‘7130’ => ‘7130’,
‘7131’ => ‘7131’,
‘7132’ => ‘7132’,
‘7133’ => ‘7133’,

// Use the slim, per-response blob for 338
‘_payload_json’ => CP_FIELD_KEY_CHAPTERS,
]));
}

// Map: payload keys -> Formidable field IDs for Form 342 (Coach Notes)
if (!defined(‘CP_FORM_COACH_NOTES_MAP’)) {
define(‘CP_FORM_COACH_NOTES_MAP’, wp_json_encode([
‘userpass’ => ‘7172’,
‘chapter_key’ => ‘7173’,
‘sequence’ => ‘7174’,
‘trait_notes_json’ => ‘7175’,
‘traits_summary_json’=> ‘7176’,
‘evidence_json’ => ‘7177’,

// numeric aliases (future-proof)
‘7172’ => ‘7172’,
‘7173’ => ‘7173’,
‘7174’ => ‘7174’,
‘7175’ => ‘7175’,
‘7176’ => ‘7176’,
‘7177’ => ‘7177’,
]));
}

// —- One-active-conversation safeguard (Responses form) —-
$__user_for_safeguard = (string)($data[‘userpass’] ?? $data[‘user’] ?? ”);
if ($__user_for_safeguard !== ” && !empty($data[‘status’]) && strtolower($data[‘status’]) === ‘live’) {
global $wpdb;
$status_field_id = cp_ai_field_id_by_key(CP_FORM_RESPONSES, ‘7062’); // Conversation Status
$user_field_id = cp_ai_field_id_by_key(CP_FORM_RESPONSES, ‘6882’); // Linked User Key

if ($status_field_id && $user_field_id) {
$wpdb->query($wpdb->prepare(
“UPDATE {$wpdb->prefix}frm_item_metas AS s
JOIN {$wpdb->prefix}frm_item_metas AS u ON u.item_id = s.item_id AND u.field_id = %d
SET s.meta_value = ‘Finished’
WHERE s.field_id = %d
AND u.meta_value = %s
AND s.meta_value IN (‘Live’,’Ended’)”,
$user_field_id, $status_field_id, $__user_for_safeguard
));
}
}
// —- End safeguard —-

// Determine if we are finalising (sometimes FE sets this on auto-advance)
$finalising = in_array(strtolower((string)($data[‘status’] ?? ”)), [‘ended’,’finished’], true);

// — Chapter-related helpers (no MU guessing) ————————

// FE-SUPPLIED CHAPTER / RATIONALE / QUOTE ALWAYS WIN
$fe_provided_chapter = !empty($data[‘chapter’]) || !empty($data[‘7127’]) || !empty($data[‘chapter_key’]);
$fe_provided_rationale = !empty($data[‘rationale’]) || !empty($data[‘7132’]);
$fe_provided_quote = !empty($data[‘quote’]) || !empty($data[‘7133’]) || !empty($data[‘chapter_quote’]);

// Always base rationale/quote fallbacks on the *current* user answer only
$answer_text = cp_ai_latest_user_answer_from_payload($data);

// — RATIONALE FALLBACK (only if FE did NOT send one) —
if (!$fe_provided_rationale) {
$sum = trim((string)($data[‘summary’] ?? ”));
if ($sum !== ”) {
$data[‘rationale’] = $sum;
} else {
// Use the single-turn answer as the cleanest rationale
if ($answer_text !== ”) {
$data[‘rationale’] = mb_substr($answer_text, 0, 420);
} else {
$dlg = trim((string)($data[‘dialogue’] ?? ”));
$slice = mb_substr($dlg, 0, 420);
if (mb_strlen($dlg) > 420) {
$slice = preg_replace(‘/\s+\S*$/u’, ”, $slice);
}
$data[‘rationale’] = $slice;
}
}
}

// — QUOTE FALLBACK (only if FE did NOT send one) —
if (!$fe_provided_quote && $answer_text !== ”) {
$data[‘quote’] = mb_substr($answer_text, 0, 140);
}

// We do NOT invent chapters on the server any more.
// Chapters only exist when the front-end has supplied a chapter key/ID.
// Normalise FE alias: if the front-end sends `chapter_key`, treat it as
// the canonical chapter field (7127) unless an explicit chapter is present.
if (!empty($data[‘chapter_key’]) && empty($data[‘chapter’]) && empty($data[‘7127’])) {
$data[‘7127’] = (string) $data[‘chapter_key’];
}

// —– Chapter-specific quote for Form 338 (independent of Traits) —–
$chapter_key = ”;

if (!empty($data[‘7127’])) {
$chapter_key = (string)$data[‘7127’];
} elseif (!empty($data[‘chapter’])) {
$chapter_key = (string)$data[‘chapter’];
}

if ($chapter_key !== ”) {
$chapter_quote = cp_ai_extract_chapter_quote(
(string)($data[‘dialogue’] ?? ”),
(string)($data[‘user_input’] ?? ”),
$chapter_key
);

// Only override if we actually found a good sentence
if ($chapter_quote !== ”) {
$data[‘chapter_quote’] = $chapter_quote;
}
}

// Normalise FE alias: if the front-end sends `chapter_key`, treat it as the
// canonical chapter field (7127) unless a chapter is already present.
if (!empty($data[‘chapter_key’]) && empty($data[‘chapter’]) && empty($data[‘7127’])) {
$data[‘7127’] = (string) $data[‘chapter_key’];
}

// Detect if payload explicitly carries *real* Chapters info (write 338).
// We now require an actual chapter key or explicit matched_terms;
// having only a summary/rationale/quote is NOT enough.
$has_chapters =
(!empty($data[‘chapter’]) ||
!empty($data[‘7127’]) ||
!empty($data[‘matched_terms’]) ||
!empty($data[‘7135’]));

$has_traits =
((isset($data[‘trait’]) && trim((string)$data[‘trait’]) !== ”) || isset($data[‘7009’])) &&
// …and at least one of the useful extras:
(isset($data[‘rationale’]) || isset($data[‘7021’]) ||
isset($data[‘quality’]) || isset($data[‘7117’]) ||
isset($data[‘confidence’])|| isset($data[‘7019’]));

// 3) Map forms β†’ field_keys + maps
// If ‘append_only’ is set (used by the FE for Chapter-only or trait-only emits),
// we do NOT touch the Responses form at all – only Traits/Chapters.
$append_only = !empty($data[‘append_only’]);

// EXTRA GUARD: trait-only payloads (no dialogue/summary, but have a trait)
// should also be treated as append_only so they never create 6.Response rows.
$looks_like_trait_only = (
(isset($data[‘trait’]) || isset($data[‘7009’])) && // human or numeric trait key
empty($data[‘dialogue’]) &&
empty($data[‘summary’])
);
if ($looks_like_trait_only) {
$append_only = true;
}

$targets = [];

// Feedback survey writes ONLY to Form 343
if (($data[‘kind’] ?? ”) === ‘user_feedback’) {
$targets[‘feedback’] = [
‘id’ => CP_FORM_FEEDBACK,
‘key’ => CP_FIELD_KEY_FEEDBACK, // numeric field id is fine
‘map’ => CP_FORM_FEEDBACK_MAP,
];
} else {

if (!$append_only) {
$targets[‘responses’] = [
‘id’ => CP_FORM_RESPONSES,
‘key’ => CP_FIELD_KEY_RESPONSES,
‘map’ => CP_FORM_RESPONSES_MAP,
];
}

// Write traits ONLY if explicitly present (avoid blank 329 rows on finalising)
if ($has_traits) {
$targets[‘traits’] = [
‘id’ => CP_FORM_TRAITS,
‘key’ => CP_FIELD_KEY_TRAITS,
‘map’ => CP_FORM_TRAITS_MAP,
];
}

// Only write to Chapters when we actually have a chapter match.
if ($has_chapters) {
$targets[‘chapters’] = [
‘id’ => CP_FORM_CHAPTERS,
‘key’ => CP_FIELD_KEY_CHAPTERS,
‘map’ => CP_FORM_CHAPTERS_MAP,
];
}

// — Coach Notes snapshot (Form 342) —
// Written exactly once at end of GTKY; append-only by design
if (($data[‘kind’] ?? ”) === ‘coach_notes’) {
$targets[‘coach_notes’] = [
‘id’ => CP_FORM_COACH_NOTES,
‘key’ => ”,
// IMPORTANT: do not write a JSON blob into any coach_notes field
‘map’ => CP_FORM_COACH_NOTES_MAP,
];
}
}

// NEW: if we somehow ended up with no target forms at all,
// treat this as a no-op, not a hard failure.
if (empty($targets)) {
return new WP_REST_Response([
‘ok’ => true,
‘saved’ => false,
‘forms’ => [],
‘_debug’ => [
‘handler_version’ => ‘0.5.0f’,
‘reason’ => ‘no_target_forms’,
],
], 200);
}

$results = [];
$ok_any = false;
$last_response_id = 0;

foreach ($targets as $label => $t) {
// — Upsert only for Responses (Form 320) —
if ($label === ‘responses’) {
$fid_user = cp_ai_field_id_by_key(CP_FORM_RESPONSES, ‘6882’); // userpass (Linked User Key)
$fid_kind = cp_ai_field_id_by_key(CP_FORM_RESPONSES, ‘7144’); // kind
$fid_gtky = cp_ai_field_id_by_key(CP_FORM_RESPONSES, ‘7079’); // gtky
$fid_ins = cp_ai_field_id_by_key(CP_FORM_RESPONSES, ‘6875’); // linked_insight_key

$__user_for_upsert = (string)($data[‘userpass’] ?? $data[‘user’] ?? ”);
$maybe_update_id = cp_ai_find_existing_step(
CP_FORM_RESPONSES,
$fid_user, $__user_for_upsert,
$fid_kind, (string)($data[‘kind’] ?? ”),
$fid_gtky, (string)($data[‘gtky’] ?? ”),
$fid_ins, (string)($data[‘linked_insight_key’] ?? ”)
);

// Build a filtered payload for RESPONSES so we NEVER overwrite with blanks.
$map = json_decode((string)$t[‘map’], true) ?: [];
$data_resp = [];

foreach ($map as $k => $_) {
if ($k === ‘_payload_json’) continue;
if (!array_key_exists($k, $data)) continue;
$v = $data[$k];

// Skip empty values for sensitive fields (prevents β€œzapping”)
$is_sensitive = in_array($k, [‘dialogue’,’summary’,’edition’,’linked_insight_key’,’gtky’], true);
$sv = is_string($v) ? trim($v) : $v;
if ($is_sensitive && ($sv === ” || $sv === null)) continue;

$data_resp[$k] = $v;
}

// DEBUG: log what we think we have for Responses
if (defined(‘WP_DEBUG’) && WP_DEBUG) {
error_log(
‘CP_AI_RESP_DEBUG: len(dialogue)=’ . strlen((string)($data_resp[‘dialogue’] ?? ”)) .
‘ len(summary)=’ . strlen((string)($data_resp[‘summary’] ?? ”)) .
‘ status=’ . ($data[‘status’] ?? ”) .
‘ userpass=’ . ($data[‘userpass’] ?? ”) .
‘ gtky=’ . ($data[‘gtky’] ?? ”) .
‘ kind=’ . ($data[‘kind’] ?? ”)
);
}

if ($maybe_update_id && class_exists(‘FrmEntryMeta’)) {
$item_meta = cp_ai_build_item_meta_from_map(
(int) $t[‘id’],
(string) $t[‘map’],
(array) $data_resp, // filtered payload for Responses
(string) $t[‘key’],
(string) $json
);

if (!empty($item_meta)) {
foreach ($item_meta as $field_id => $value) {
// This will insert or update meta for this field only,
// leaving all other existing fields on the entry untouched.
FrmEntryMeta::update_entry_meta(
(int) $maybe_update_id,
(int) $field_id,
null,
$value
);
}

// NEW: manually bump the Frm entry’s updated_at so the
// Formidable “Updated” column reflects the latest AI save.
try {
global $wpdb;
$items_table = $wpdb->prefix . ‘frm_items’;
$wpdb->update(
$items_table,
[ ‘updated_at’ => current_time(‘mysql’, true) ], // UTC; use false for site-local
[ ‘id’ => (int) $maybe_update_id ],
[ ‘%s’ ],
[ ‘%d’ ]
);
} catch ( Exception $e ) {
if ( defined( ‘WP_DEBUG’ ) && WP_DEBUG ) {
error_log( ‘cp_ai_memory: failed to bump updated_at for entry ‘
. $maybe_update_id . ‘ – ‘ . $e->getMessage() );
}
}

$results[$label] = [
‘ok’ => true,
‘entry_id’ => (int) $maybe_update_id,
‘mode’ => ‘updated_meta’,
];
$ok_any = true;
$last_response_id = (int) $maybe_update_id;
continue; // next target
}
// If somehow we had no meta to write, fall through to “insert” path below.
}

// Insert new Responses entry (keep blob fallback ON)
$r = cp_ai_insert_json(
(int)$t[‘id’],
(string)$t[‘key’],
(string)$json, // full JSON string
(array)$data, // decoded payload array
(string)$t[‘map’],
true // force_blob_fallback
);

if (is_wp_error($r)) {
$results[$label] = [‘ok’ => false, ‘error’ => $r->get_error_message()];
} else {
$results[$label] = [‘ok’ => true, ‘entry_id’ => $r, ‘mode’ => ‘inserted’];
$ok_any = true;
$last_response_id = (int)$r;
}
continue; // done with responses
}

// — Coach Notes (Form 342) append-only with triplet de-dupe —
if ($label === ‘coach_notes’) {

$who = (string)($data[‘userpass’] ?? $data[‘user’] ?? ”);
$chap = (string)($data[‘chapter_key’] ?? ‘0’);
$seq = (string)($data[‘sequence’] ?? ‘1’);

if ($who === ”) {
continue;
}

// De-dupe: if same triplet already exists, skip quietly
if (cp_ai_coach_notes_triplet_exists($who, $chap, $seq)) {
$results[$label] = [‘ok’ => true, ‘skipped’ => ‘duplicate_triplet’];
$ok_any = true;
continue;
}

// Insert mapped fields only (NO blob fallback; key is ” by design)
$r = cp_ai_insert_json(
(int)$t[‘id’],
(string)$t[‘key’], // ” (no blob)
(string)$json, // still passed, but won’t be written as blob
(array)$data,
(string)$t[‘map’],
false // force_blob_fallback OFF
);

if (is_wp_error($r)) {
$results[$label] = [‘ok’ => false, ‘error’ => $r->get_error_message()];
} else {
$results[$label] = [‘ok’ => true, ‘entry_id’ => $r, ‘mode’ => ‘inserted’];
$ok_any = true;
}
continue;
}

// — Append-only Chapters (Form 338) with triplet de-dupe —
if ($label === ‘chapters’) {
// Ensure we have a response_id to bind to; prefer FE β†’ fallback to last Responses write
if (empty($data[‘response_id’]) && $last_response_id) {
$data[‘response_id’] = (string)$last_response_id;
}

$who = (string)($data[‘userpass’] ?? $data[‘user’] ?? ”);
$resp = (string)($data[‘response_id’] ?? ”);

// If no binding keys, don’t write a Chapters row
if ($who === ” || $resp === ”) {
continue;
}

// Ensure a response_sequence for this turn:
// – If FE provided one, keep it.
// – Else infer as (max stored + 1) for this response_id.
$incoming_seq = null;
if (isset($data[‘response_sequence’]) && trim((string)$data[‘response_sequence’]) !== ”) {
$incoming_seq = (int)$data[‘response_sequence’];
} else {
$max_seq = cp_ai_max_chapter_seq_for_response($resp); // -1 if none
$incoming_seq = ($max_seq >= 0) ? ($max_seq + 1) : 1;
$data[‘response_sequence’] = $incoming_seq;
}

// Triplet de-dupe: if the same (userpass, response_id, response_sequence)
// is trying to insert again (e.g., double POST), skip the second insert
// BUT still treat it as a successful operation overall (row already exists).
if (cp_ai_chapter_triplet_exists($who, $resp, (string)$incoming_seq)) {
$results[$label] = [‘ok’ => true, ‘skipped’ => ‘duplicate_triplet’];
$ok_any = true; // ensures HTTP 200 + ok:true at the top level
continue;
}

// Build a *slim* blob that only keeps this-turn context
$blob_json = cp_ai_chapter_blob_json($data);

// Append-only: always INSERT (never UPDATE) one row per user turn.
// No blob-only fallback here – if there’s nothing mapped, skip.
$r = cp_ai_insert_json(
(int)$t[‘id’],
(string)$t[‘key’],
(string)$blob_json, // SLIM JSON for 338
(array)$data,
(string)$t[‘map’],
false // force_blob_fallback OFF for Chapters
);

if (is_wp_error($r)) {
$results[$label] = [‘ok’ => false, ‘error’ => $r->get_error_message()];
} else {
$results[$label] = [‘ok’ => true, ‘entry_id’ => $r, ‘mode’ => ‘inserted’];
$ok_any = true;
}
continue;
}

// — Traits (329) and any other targets β†’ normal full-blob insert.
// No blob-only fallback for these – skip if nothing is mapped.
$r = cp_ai_insert_json(
(int)$t[‘id’],
(string)$t[‘key’],
(string)$json, // full JSON
(array)$data,
(string)$t[‘map’],
false // force_blob_fallback OFF for Traits/others
);

if (is_wp_error($r)) {
$results[$label] = [‘ok’ => false, ‘error’ => $r->get_error_message()];
} else {
$results[$label] = [‘ok’ => true, ‘entry_id’ => $r, ‘mode’ => ‘inserted’];
$ok_any = true;
}
}

// — TEMP DEBUG (remove later)
$results[‘_debug’] = [
‘handler_version’ => ‘0.5.0f’,
‘resolved_kind’ => $data[‘kind’] ?? ”,
‘finalising’ => in_array(strtolower((string)($data[‘status’] ?? ”)), [‘ended’,’finished’], true) ? ‘yes’ : ‘no’,
‘has_dialogue’ => (isset($data[‘dialogue’]) && trim((string)$data[‘dialogue’]) !== ”) ? ‘yes’ : ‘no’,
‘has_summary’ => (isset($data[‘summary’]) && trim((string)$data[‘summary’]) !== ”) ? ‘yes’ : ‘no’,
];

return new WP_REST_Response([
‘ok’ => $ok_any,
‘saved’ => $ok_any,
‘forms’ => $results,
], $ok_any ? 200 : 500);

}