‘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 () {
/** ————————-
* Chapter page (existing)
* ————————- */
if (is_page(‘chapter’)) {
$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
);
// Allow chapter to be driven by URL (?chapter=sequence OR entry-key)
$chapter_param = isset($_GET[‘chapter’])
? sanitize_text_field(wp_unslash($_GET[‘chapter’]))
: ”;
if ($chapter_param === ”) {
$chapter_param = ‘1.1’; // fallback only if nothing supplied
}
$cfg = [
‘restBase’ => rest_url(‘cp/v1’),
‘token’ => defined(‘CP_AI_TOKEN’) ? CP_AI_TOKEN : ”,
‘chapter’ => $chapter_param,
‘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’
);
}
/** ————————-
* Insight page (NEW)
* ————————- */
if (is_page(‘insight’)) {
$cp_insight_path = WPMU_PLUGIN_DIR . ‘/cp-assets/cp-insight.js’;
$cp_insight_ver = file_exists($cp_insight_path) ? (string) filemtime($cp_insight_path) : ‘1.0.0’;
wp_enqueue_script(
‘cp-insight’,
content_url(‘/mu-plugins/cp-assets/cp-insight.js’),
[],
$cp_insight_ver,
true
);
$cfg = [
‘restBase’ => rest_url(‘cp/v1’),
‘token’ => defined(‘CP_AI_TOKEN’) ? CP_AI_TOKEN : ”,
‘contextUrl’ => rest_url(‘cp/v1/insight-context’),
‘videosUrl’ => rest_url(‘cp/v1/insight-videos’),
‘aiUrl’ => rest_url(‘cp/v1/ai’),
‘saveUrl’ => rest_url(‘cp/v1/save-memory’),
// optional convenience urls for navigation (JS can ignore if you prefer)
‘chapterUrl’ => home_url(‘/chapter/’),
‘insightUrl’ => home_url(‘/insight/’),
];
add_action(‘wp_print_footer_scripts’, function () use ($cfg) {
if (!is_page(‘insight’)) return;
// Guaranteed to emit a real ';
}
}, 1);
}
});
/** 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',
]);
// Insight context (Form 318)
register_rest_route('cp/v1', '/insight-context', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => 'cp_ai_insight_context_handler',
]);
// Insight videos (Form 303)
register_rest_route('cp/v1', '/insight-videos', [
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => 'cp_ai_insight_videos_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;
}
// Form 317: Chapter Sequence field
function cp_ai_chapter_sequence_from_entry_key(string $chapter_entry_key): string {
$chapter_entry_key = trim($chapter_entry_key);
if ($chapter_entry_key === '') return '';
if (!isset($GLOBALS['wpdb'])) return '';
global $wpdb;
$FORM_CHAPTERS = 317;
$FID_CHAPTER_SEQUENCE = 6853;
$FID_CHAPTER_TITLE = 6850;
$items = $wpdb->prefix . 'frm_items';
$meta = $wpdb->prefix . 'frm_item_metas';
// We assume "entry-key" means Frm item_key (Formidable's built-in entry key)
$seq = $wpdb->get_var($wpdb->prepare(
"SELECT ms.meta_value
FROM {$items} it
LEFT JOIN {$meta} ms ON ms.item_id = it.id AND ms.field_id = %d
WHERE it.form_id = %d
AND it.item_key = %s
LIMIT 1",
(int)$FID_CHAPTER_SEQUENCE,
(int)$FORM_CHAPTERS,
(string)$chapter_entry_key
));
return is_string($seq) ? trim($seq) : '';
}
// Fetch Chapter Title [6850] + Chapter Number/Sequence [6853] from Form 317,
// accepting either a chapter entry-key (frm_items.item_key) OR a sequence value stored in [6853].
function cp_ai_chapter_meta_from_param(string $chapter_param): array {
$chapter_param = trim($chapter_param);
if ($chapter_param === '') return ['ok' => false];
if (!isset($GLOBALS['wpdb'])) return ['ok' => false];
global $wpdb;
$FORM_CHAPTERS = 317;
$FID_CHAPTER_TITLE = 6850;
$FID_CHAPTER_SEQUENCE = 6853;
$items = $wpdb->prefix . 'frm_items';
$meta = $wpdb->prefix . 'frm_item_metas';
// 1) Try treat param as Formidable entry-key: frm_items.item_key
$row = $wpdb->get_row($wpdb->prepare(
"SELECT it.item_key AS entry_key,
t.meta_value AS title,
s.meta_value AS chapter_number
FROM {$items} it
LEFT JOIN {$meta} t ON t.item_id = it.id AND t.field_id = %d
LEFT JOIN {$meta} s ON s.item_id = it.id AND s.field_id = %d
WHERE it.form_id = %d
AND it.item_key = %s
LIMIT 1",
(int)$FID_CHAPTER_TITLE,
(int)$FID_CHAPTER_SEQUENCE,
(int)$FORM_CHAPTERS,
(string)$chapter_param
), ARRAY_A);
// 2) If not found, treat param as a sequence stored in field [6853]
if (!$row) {
$row = $wpdb->get_row($wpdb->prepare(
"SELECT it.item_key AS entry_key,
t.meta_value AS title,
s.meta_value AS chapter_number
FROM {$items} it
JOIN {$meta} s ON s.item_id = it.id AND s.field_id = %d AND s.meta_value = %s
LEFT JOIN {$meta} t ON t.item_id = it.id AND t.field_id = %d
WHERE it.form_id = %d
LIMIT 1",
(int)$FID_CHAPTER_SEQUENCE,
(string)$chapter_param,
(int)$FID_CHAPTER_TITLE,
(int)$FORM_CHAPTERS
), ARRAY_A);
}
if (!$row) return ['ok' => false];
return [
'ok' => true,
'entry_key' => (string)($row['entry_key'] ?? ''),
'chapter_number'=> trim((string)($row['chapter_number'] ?? '')),
'title' => trim((string)($row['title'] ?? '')),
];
}
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'));
$chapter_raw = $chapter;
if ($userpass === '') {
return new WP_Error('bad_request', 'Missing userpass', ['status' => 400]);
}
if ($chapter === '') {
$chapter = '1.1';
} else {
// If chapter isn't a sequence like "1.1", treat it as a Chapter entry-key and map to sequence via Form 317 [6853].
if (!preg_match('/^\d+\.\d+$/', $chapter)) {
$seq = cp_ai_chapter_sequence_from_entry_key($chapter);
if ($seq !== '') {
$chapter = $seq;
} else {
return new WP_Error('not_found', 'Chapter not found for entry-key', ['status' => 404]);
}
}
}
// 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'] ?? ''),
];
}
}
}
$chapter_meta = cp_ai_chapter_meta_from_param($chapter_raw);
return new WP_REST_Response([
'ok' => true,
'userpass' => $userpass,
'chapter' => $chapter,
'chapterMeta' => $chapter_meta,
'chapterDef' => $chapter_def,
'connections' => $connections,
'feedback' => $feedback,
], 200);
}
/**
* GET /wp-json/cp/v1/insight-context?userpass=...&insight=...&edition=workplace
*
* We treat `insight=` as the value stored in Form 318 field [6855] (Linked Step Key),
* e.g. "359ze".
*/
function cp_ai_insight_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'));
$insight = trim((string)$req->get_param('insight'));
$edition = strtolower(trim((string)$req->get_param('edition')));
if ($userpass === '') return new WP_Error('bad_request', 'Missing userpass', ['status' => 400]);
if ($insight === '') return new WP_Error('bad_request', 'Missing insight', ['status' => 400]);
if (!in_array($edition, ['workplace','personal','university'], true)) $edition = 'workplace';
// Hard requirement: Insights form must be configured
if (!defined('CP_FORM_INSIGHTS') || (int)CP_FORM_INSIGHTS <= 0) {
return new WP_Error('server_config', 'CP_FORM_INSIGHTS not set', ['status' => 500]);
}
global $wpdb;
$items = $wpdb->prefix . 'frm_items';
$meta = $wpdb->prefix . 'frm_item_metas';
// --- Field IDs (Form 318) ---
$FID_LINKED_STEP_KEY = 6855; // Linked Step Key (this is what `insight=` refers to)
$FID_INSIGHT_SEQUENCE = 6856; // Insight Sequence (e.g. "1.1")
$FID_TITLE = 6857;
$FID_QUOTE = 6898;
$FID_NEXT = 6897; // "next" (entry-key of next Insight)
$FID_END_FLAG = 7146; // "end"
$FID_SECTION_CHAPTER = 7164; // "section.chapter" e.g. "1.1"
$FID_WE_CHAPTER = 6923; // we -chapter (entry key of Chapter record)
// Edition-specific workplace fields
$FID_WP_TEXT = 6924;
$FID_WP_QUESTION = 6925;
$FID_WP_GUIDE = 6926;
// Generic fallback fields
$FID_TEXT = 6858;
$FID_QUESTION = 6860;
$FID_GUIDE = 6861;
// AI scaffolding columns
$FID_INSIGHT_SUMMARY = 6984;
$FID_COACH_STYLE = 6986;
$FID_CHANGE_TRAITS = 6987;
// AI coaching rails (v3)
$FID_INTERVENTION_SCOPE_V3 = 7207;
$FID_AI_INSIGHT_PROMPT_V3 = 7208;
$FID_AI_STEP_PROMPT_V3 = 7209;
$FID_AI_COACH_FRAMEWORK_STEPS = 7202;
// Find the Insight row.
// Prefer frm_items.item_key (Entry Key, e.g. "cg411") because that's what the URL currently passes.
// Fallback to [6855] Linked Step Key (e.g. "359ze") for legacy links.
$item_id = (int)$wpdb->get_var($wpdb->prepare(
"SELECT it.id
FROM {$items} it
WHERE it.form_id = %d
AND it.item_key = %s
ORDER BY it.created_at DESC
LIMIT 1",
(int)CP_FORM_INSIGHTS,
(string)$insight
));
if (!$item_id) {
$item_id = (int)$wpdb->get_var($wpdb->prepare(
"SELECT it.id
FROM {$items} it
JOIN {$meta} mk ON mk.item_id = it.id
WHERE it.form_id = %d
AND mk.field_id = %d
AND mk.meta_value = %s
ORDER BY it.created_at DESC
LIMIT 1",
(int)CP_FORM_INSIGHTS,
(int)$FID_LINKED_STEP_KEY,
(string)$insight
));
}
if (!$item_id) {
return new WP_REST_Response(['ok' => false, 'note' => 'Insight not found'], 200);
}
$get_meta = function(int $fid) use ($wpdb, $meta, $item_id) {
$v = $wpdb->get_var($wpdb->prepare(
"SELECT meta_value FROM {$meta} WHERE item_id=%d AND field_id=%d LIMIT 1",
$item_id, $fid
));
return is_string($v) ? $v : '';
};
// Choose edition fields
// Choose edition fields
$text = ($edition === 'workplace') ? $get_meta($FID_WP_TEXT) : '';
$q = ($edition === 'workplace') ? $get_meta($FID_WP_QUESTION) : '';
$guide = ($edition === 'workplace') ? $get_meta($FID_WP_GUIDE) : '';
if ($text === '') $text = $get_meta($FID_TEXT);
if ($q === '') $q = $get_meta($FID_QUESTION);
if ($guide === '') $guide = $get_meta($FID_GUIDE);
// ---- Safe HTML versions (future-proof formatting) ----
// Allow a tiny subset of markup for admin-authored Insight content.
// Everything else is stripped.
$allowed_html = [
'br' => [],
'p' => [],
'strong' => [],
'em' => [],
'b' => [],
'i' => [],
'ul' => [],
'ol' => [],
'li' => [],
'a' => ['href' => true, 'title' => true, 'target' => true, 'rel' => true],
];
$quote_raw = $get_meta($FID_QUOTE);
$text_html = wp_kses((string)$text, $allowed_html);
$quote_html = wp_kses((string)$quote_raw, $allowed_html);
$guide_html = wp_kses((string)$guide, $allowed_html);
// Parse 7164 "section.chapter"
$sectionChapter = trim($get_meta($FID_SECTION_CHAPTER));
$section = '';
$chapter = '';
if (preg_match('/^\s*(\d+)\.(\d+)\s*$/', $sectionChapter, $mm)) {
$section = $mm[1];
$chapter = $mm[2];
}
// Videos are NOT edition-scoped
$videos = cp_ai_fetch_videos_for_insight($insight);
// Chapter title (Form 317 field [6850]) via chapter meta helper.
// Prefer the explicit chapter entry-key link [6923]; fallback to section.chapter (e.g. "1.1") if blank.
$chapter_raw = trim($get_meta($FID_WE_CHAPTER));
$chapter_param = ($chapter_raw !== '') ? $chapter_raw : $sectionChapter;
$chapter_meta = cp_ai_chapter_meta_from_param($chapter_param);
return new WP_REST_Response([
'ok' => true,
'edition' => $edition,
'chapterMeta'=> $chapter_meta,
'insight' => [
'linked_key' => $insight,
// IMPORTANT: expose the Formidable Insight Sequence field by its numeric field id.
// JS reads this as i['6856'] to display "Insight X.Y" above the title.
'6856' => $get_meta($FID_INSIGHT_SEQUENCE),
'title' => $get_meta($FID_TITLE),
'quote' => $quote_raw,
'quote_html' => $quote_html,
'next' => $get_meta($FID_NEXT),
'new_section_end' => $get_meta($FID_END_FLAG),
'section_chapter' => $sectionChapter,
'section_number' => $section,
'chapter_number' => $chapter,
'text' => $text,
'text_html' => $text_html,
'question' => $q,
'guide' => $guide,
'guide_html' => $guide_html,
'insight_summary' => $get_meta($FID_INSIGHT_SUMMARY),
'coach_style' => $get_meta($FID_COACH_STYLE),
'change_traits' => $get_meta($FID_CHANGE_TRAITS),
// AI coaching rails (v3 only)
'intervention_scope_v3' => $get_meta($FID_INTERVENTION_SCOPE_V3),
'ai_insight_prompt_v3' => $get_meta($FID_AI_INSIGHT_PROMPT_V3),
'ai_step_prompt_v3' => $get_meta($FID_AI_STEP_PROMPT_V3),
'ai_coach_framework_steps' => $get_meta($FID_AI_COACH_FRAMEWORK_STEPS),// β ADD
],
'videos' => $videos,
], 200);
}
function cp_ai_fetch_videos_for_insight(string $insight): array {
global $wpdb;
$items = $wpdb->prefix . 'frm_items';
$meta = $wpdb->prefix . 'frm_item_metas';
$FORM_VIDEOS = 303;
// Field IDs (Form 303)
$FID_LINKED_INSIGHT_KEY = 6891;
$FID_VIDEO_SEQ = 6892;
$FID_VIDEO_ADDRESS = 6822;
$FID_CLIP_START = 6753;
$FID_CLIP_END = 6754;
$FID_CLIP_TITLE = 6757;
$FID_VIDEO_TITLE = 6696;
$FID_HEADING = 6755;
$FID_QUOTE = 6756;
$FID_BOOK_TEXT = 6749;
$FID_TRANSCRIPT = 6894;
$FID_EDITION = 6695;
$FID_TAB_INVITE_TEXT = 7205;
$ids = $wpdb->get_col($wpdb->prepare(
"SELECT it.id
FROM {$items} it
JOIN {$meta} mk ON mk.item_id = it.id
WHERE it.form_id = %d
AND mk.field_id = %d
AND mk.meta_value = %s
ORDER BY it.created_at ASC",
(int)$FORM_VIDEOS,
(int)$FID_LINKED_INSIGHT_KEY,
(string)$insight
));
if (!$ids) return [];
$videos = [];
foreach ($ids as $item_id) {
$item_id = (int)$item_id;
if ($item_id <= 0) continue;
$get_meta = function(int $fid) use ($wpdb, $meta, $item_id) {
$v = $wpdb->get_var($wpdb->prepare(
"SELECT meta_value FROM {$meta} WHERE item_id=%d AND field_id=%d LIMIT 1",
$item_id, $fid
));
return is_string($v) ? $v : '';
};
$videos[] = [
'id' => $item_id,
'sequence' => $get_meta($FID_VIDEO_SEQ),
'address' => $get_meta($FID_VIDEO_ADDRESS),
'clip_start' => $get_meta($FID_CLIP_START),
'clip_end' => $get_meta($FID_CLIP_END),
'clip_title' => $get_meta($FID_CLIP_TITLE),
'video_title' => $get_meta($FID_VIDEO_TITLE),
'heading' => $get_meta($FID_HEADING),
'quote' => $get_meta($FID_QUOTE),
'book_text' => $get_meta($FID_BOOK_TEXT),
'transcript' => $get_meta($FID_TRANSCRIPT),
'edition' => $get_meta($FID_EDITION),
'tab_invite_text' => $get_meta($FID_TAB_INVITE_TEXT), ];
}
return $videos;
}
/**
* GET /wp-json/cp/v1/insight-videos?insight=...
*
* Form 303 rows are linked to an Insight via field [6891] (NEW Linked Insight Key).
* We will assume [6891] stores the same "Linked Step Key" string you pass as `insight=`.
*/
function cp_ai_insight_videos_handler(WP_REST_Request $req) {
$auth = cp_ai_check_token($req);
if (is_wp_error($auth)) return $auth;
$insight = trim((string)$req->get_param('insight'));
if ($insight === '') return new WP_Error('bad_request', 'Missing insight', ['status' => 400]);
global $wpdb;
$items = $wpdb->prefix . 'frm_items';
$meta = $wpdb->prefix . 'frm_item_metas';
$FORM_VIDEOS = 303;
// Field IDs (Form 303)
$FID_LINKED_INSIGHT_KEY = 6891;
$FID_VIDEO_SEQ = 6892;
$FID_VIDEO_ADDRESS = 6822;
$FID_CLIP_START = 6753;
$FID_CLIP_END = 6754;
$FID_CLIP_TITLE = 6757;
$FID_VIDEO_TITLE = 6696;
$FID_HEADING = 6755;
$FID_QUOTE = 6756;
$FID_BOOK_TEXT = 6749;
$FID_TRANSCRIPT = 6894;
$FID_TAB_INVITE_TEXT = 7205;
// Get all matching video items for this Insight key, ordered by numeric sequence
$sql = $wpdb->prepare(
"SELECT it.id
FROM {$items} it
JOIN {$meta} mk ON mk.item_id = it.id AND mk.field_id = %d AND mk.meta_value = %s
LEFT JOIN {$meta} ms ON ms.item_id = it.id AND ms.field_id = %d
WHERE it.form_id = %d
ORDER BY CAST(ms.meta_value AS DECIMAL(10,2)) ASC, it.id ASC",
(int)$FID_LINKED_INSIGHT_KEY,
(string)$insight,
(int)$FID_VIDEO_SEQ,
(int)$FORM_VIDEOS
);
$ids = $wpdb->get_col($sql);
if (!$ids) return new WP_REST_Response(['ok' => true, 'videos' => []], 200);
$videos = [];
foreach ($ids as $item_id) {
$item_id = (int)$item_id;
if ($item_id <= 0) continue;
$get_meta = function(int $fid) use ($wpdb, $meta, $item_id) {
$v = $wpdb->get_var($wpdb->prepare(
"SELECT meta_value FROM {$meta} WHERE item_id=%d AND field_id=%d LIMIT 1",
$item_id, $fid
));
return is_string($v) ? $v : '';
};
$videos[] = [
'id' => $item_id,
'sequence' => $get_meta($FID_VIDEO_SEQ),
'address' => $get_meta($FID_VIDEO_ADDRESS),
'clip_start' => $get_meta($FID_CLIP_START),
'clip_end' => $get_meta($FID_CLIP_END),
'clip_title' => $get_meta($FID_CLIP_TITLE),
'video_title' => $get_meta($FID_VIDEO_TITLE),
'heading' => $get_meta($FID_HEADING),
'quote' => $get_meta($FID_QUOTE),
'book_text' => $get_meta($FID_BOOK_TEXT),
'transcript' => $get_meta($FID_TRANSCRIPT),
];
}
return new WP_REST_Response(['ok' => true, 'videos' => $videos], 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
'coach_decision' => '7206',
// 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);
}