- Fix AI Assistant timeout issue (frontend: 35s → 50s) - Fix AJAX action name mismatch for categories (categorys → categories) - Fix nonce mismatch (hvac_general_nonce → hvac_ajax_nonce) - Add modal forms for creating new organizers, categories, and venues - Add comprehensive AJAX endpoints with security validation - Implement role-based permissions for category creation - Fix searchable selectors action mapping 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
880 lines
No EOL
31 KiB
PHP
880 lines
No EOL
31 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* HVAC AI Event Populator
|
|
*
|
|
* Handles AI-powered event form population using Anthropic Claude API
|
|
* Integrates with existing form builder and TEC data structures
|
|
*
|
|
* @package HVAC_Community_Events
|
|
* @subpackage Includes
|
|
* @since 3.2.0 (AI Feature Implementation)
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Class HVAC_AI_Event_Populator
|
|
*
|
|
* Main service class for AI-assisted event population
|
|
*/
|
|
class HVAC_AI_Event_Populator {
|
|
|
|
use HVAC_Singleton_Trait;
|
|
|
|
/**
|
|
* API endpoint for Anthropic Claude
|
|
*
|
|
* @var string
|
|
*/
|
|
private const API_ENDPOINT = 'https://api.anthropic.com/v1/messages';
|
|
|
|
/**
|
|
* API model to use
|
|
*
|
|
* @var string
|
|
*/
|
|
private const API_MODEL = 'claude-sonnet-4-20250514';
|
|
|
|
/**
|
|
* Maximum request timeout in seconds
|
|
*
|
|
* @var int
|
|
*/
|
|
private const REQUEST_TIMEOUT = 45;
|
|
|
|
/**
|
|
* Cache prefix for transients
|
|
*
|
|
* @var string
|
|
*/
|
|
private const CACHE_PREFIX = 'hvac_ai_cache_';
|
|
|
|
/**
|
|
* Cache TTL in seconds (24 hours)
|
|
*
|
|
* @var int
|
|
*/
|
|
private const CACHE_TTL = 86400;
|
|
|
|
/**
|
|
* Field mapping from AI output to form fields
|
|
*
|
|
* @var array
|
|
*/
|
|
private array $field_mapping = [
|
|
'title' => 'event_title',
|
|
'description' => 'event_description',
|
|
'start_date' => 'event_start_datetime',
|
|
'end_date' => 'event_end_datetime',
|
|
'venue' => 'venue_data',
|
|
'organizer' => 'organizer_data',
|
|
'cost' => 'event_cost',
|
|
'capacity' => 'event_capacity',
|
|
'url' => 'event_url'
|
|
];
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
private function __construct() {
|
|
// Validate API key availability
|
|
if (!defined('ANTHROPIC_API_KEY') || empty(ANTHROPIC_API_KEY)) {
|
|
error_log('HVAC AI Event Populator: ANTHROPIC_API_KEY not defined in wp-config.php');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main method to populate event data from input
|
|
*
|
|
* @param string $input User input (URL, text, or description)
|
|
* @param string $input_type Type of input: 'url', 'text', or 'description'
|
|
* @return array|WP_Error Parsed event data or error
|
|
*/
|
|
public function populate_from_input(string $input, string $input_type = 'auto'): array|WP_Error {
|
|
// Validate inputs
|
|
$validation = $this->validate_input($input, $input_type);
|
|
if (is_wp_error($validation)) {
|
|
return $validation;
|
|
}
|
|
|
|
// Auto-detect input type if not specified
|
|
if ($input_type === 'auto') {
|
|
$input_type = $this->detect_input_type($input);
|
|
}
|
|
|
|
// Check cache first
|
|
$cache_key = $this->generate_cache_key($input);
|
|
$cached_response = $this->get_cached_response($cache_key);
|
|
if ($cached_response !== false) {
|
|
error_log('HVAC AI: Using cached response for input');
|
|
return $cached_response;
|
|
}
|
|
|
|
// Build context for prompt
|
|
$context = $this->build_context();
|
|
|
|
// Create structured prompt
|
|
$prompt = $this->build_prompt($input, $input_type, $context);
|
|
|
|
// Make API request
|
|
$api_response = $this->make_api_request($prompt);
|
|
if (is_wp_error($api_response)) {
|
|
return $api_response;
|
|
}
|
|
|
|
// Parse and validate response
|
|
$parsed_data = $this->parse_api_response($api_response);
|
|
if (is_wp_error($parsed_data)) {
|
|
return $parsed_data;
|
|
}
|
|
|
|
// Post-process data (venue/organizer matching, etc.)
|
|
$processed_data = $this->post_process_data($parsed_data);
|
|
|
|
// Cache successful response
|
|
$this->cache_response($cache_key, $processed_data);
|
|
|
|
return $processed_data;
|
|
}
|
|
|
|
/**
|
|
* Validate user input
|
|
*
|
|
* @param string $input User input
|
|
* @param string $input_type Input type
|
|
* @return true|WP_Error
|
|
*/
|
|
private function validate_input(string $input, string $input_type): bool|WP_Error {
|
|
$input = trim($input);
|
|
|
|
// Check minimum length
|
|
if (strlen($input) < 10) {
|
|
return new WP_Error(
|
|
'input_too_short',
|
|
'Input must be at least 10 characters long.',
|
|
['status' => 400]
|
|
);
|
|
}
|
|
|
|
// Check maximum length (prevent token overflow)
|
|
if (strlen($input) > 50000) {
|
|
return new WP_Error(
|
|
'input_too_long',
|
|
'Input is too large. Please provide a shorter description or URL.',
|
|
['status' => 400]
|
|
);
|
|
}
|
|
|
|
// URL-specific validation
|
|
if ($input_type === 'url') {
|
|
if (!filter_var($input, FILTER_VALIDATE_URL)) {
|
|
return new WP_Error(
|
|
'invalid_url',
|
|
'Please provide a valid URL.',
|
|
['status' => 400]
|
|
);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Auto-detect input type
|
|
*
|
|
* @param string $input User input
|
|
* @return string Detected type: 'url', 'text', or 'description'
|
|
*/
|
|
private function detect_input_type(string $input): string {
|
|
$input = trim($input);
|
|
|
|
// Check if it's a URL
|
|
if (filter_var($input, FILTER_VALIDATE_URL)) {
|
|
return 'url';
|
|
}
|
|
|
|
// Check for common text patterns (emails, structured content)
|
|
if (preg_match('/\b(from|to|subject|date):\s/i', $input) ||
|
|
preg_match('/\n.*\n.*\n/s', $input) ||
|
|
strlen($input) > 500) {
|
|
return 'text';
|
|
}
|
|
|
|
// Default to description for short, unstructured input
|
|
return 'description';
|
|
}
|
|
|
|
/**
|
|
* Build context for the AI prompt
|
|
*
|
|
* @return array Context data
|
|
*/
|
|
private function build_context(): array {
|
|
$context = [
|
|
'current_date' => current_time('Y-m-d'),
|
|
'current_datetime' => current_time('c'),
|
|
'venues' => $this->get_existing_venues(),
|
|
'organizers' => $this->get_existing_organizers(),
|
|
];
|
|
|
|
return $context;
|
|
}
|
|
|
|
/**
|
|
* Get existing venues for context
|
|
*
|
|
* @return array List of venue names and addresses
|
|
*/
|
|
private function get_existing_venues(): array {
|
|
$venues = get_posts([
|
|
'post_type' => 'tribe_venue',
|
|
'posts_per_page' => 50,
|
|
'post_status' => 'publish',
|
|
'orderby' => 'post_title',
|
|
'order' => 'ASC'
|
|
]);
|
|
|
|
$venue_list = [];
|
|
foreach ($venues as $venue) {
|
|
$address = get_post_meta($venue->ID, '_VenueAddress', true);
|
|
$city = get_post_meta($venue->ID, '_VenueCity', true);
|
|
|
|
$venue_list[] = [
|
|
'name' => $venue->post_title,
|
|
'address' => trim($address . ', ' . $city, ', '),
|
|
'id' => $venue->ID
|
|
];
|
|
}
|
|
|
|
return $venue_list;
|
|
}
|
|
|
|
/**
|
|
* Get existing organizers for context
|
|
*
|
|
* @return array List of organizer names and details
|
|
*/
|
|
private function get_existing_organizers(): array {
|
|
$organizers = get_posts([
|
|
'post_type' => 'tribe_organizer',
|
|
'posts_per_page' => 50,
|
|
'post_status' => 'publish',
|
|
'orderby' => 'post_title',
|
|
'order' => 'ASC'
|
|
]);
|
|
|
|
$organizer_list = [];
|
|
foreach ($organizers as $organizer) {
|
|
$email = get_post_meta($organizer->ID, '_OrganizerEmail', true);
|
|
$phone = get_post_meta($organizer->ID, '_OrganizerPhone', true);
|
|
|
|
$organizer_list[] = [
|
|
'name' => $organizer->post_title,
|
|
'email' => $email,
|
|
'phone' => $phone,
|
|
'id' => $organizer->ID
|
|
];
|
|
}
|
|
|
|
return $organizer_list;
|
|
}
|
|
|
|
/**
|
|
* Build structured prompt for Claude API
|
|
*
|
|
* @param string $input User input
|
|
* @param string $input_type Type of input
|
|
* @param array $context Context data
|
|
* @return string Formatted prompt
|
|
*/
|
|
private function build_prompt(string $input, string $input_type, array $context): string {
|
|
$venue_context = '';
|
|
if (!empty($context['venues'])) {
|
|
$venue_names = array_slice(array_column($context['venues'], 'name'), 0, 20);
|
|
$venue_context = "Existing venues: " . implode(', ', $venue_names);
|
|
}
|
|
|
|
$organizer_context = '';
|
|
if (!empty($context['organizers'])) {
|
|
$organizer_names = array_slice(array_column($context['organizers'], 'name'), 0, 20);
|
|
$organizer_context = "Existing organizers: " . implode(', ', $organizer_names);
|
|
}
|
|
|
|
// For URLs, fetch content using Jina.ai reader
|
|
$actual_content = $input;
|
|
$source_note = '';
|
|
if ($input_type === 'url' && filter_var($input, FILTER_VALIDATE_URL)) {
|
|
$fetched_content = $this->fetch_url_with_jina($input);
|
|
if (!is_wp_error($fetched_content)) {
|
|
$actual_content = $fetched_content;
|
|
$source_note = "\n\nSOURCE: Content extracted from {$input}";
|
|
} else {
|
|
$source_note = "\n\nNOTE: Could not fetch URL content ({$fetched_content->get_error_message()}). Please extract what you can from the URL itself.";
|
|
}
|
|
}
|
|
|
|
$input_instruction = match($input_type) {
|
|
'url' => "Please extract event information from this webpage content:",
|
|
'text' => "Please extract event information from this text content (likely from an email or document):",
|
|
'description' => "Please extract event information from this brief description:",
|
|
default => "Please extract event information from the following content:"
|
|
};
|
|
|
|
return <<<PROMPT
|
|
You are an HVAC event extraction specialist for a professional training calendar. Your task is to extract structured event data from various sources.
|
|
|
|
CONTEXT:
|
|
- These are professional HVAC training events for technicians
|
|
- Current date: {$context['current_date']}
|
|
- {$venue_context}
|
|
- {$organizer_context}
|
|
|
|
EXAMPLE:
|
|
Here's an example of how to extract and creatively enhance an HVAC training event:
|
|
|
|
INPUT: "Manual J LiDAR Training - March 15th, 2025 from 8:00 AM to 12:00 PM at HVAC Institute. Learn iPad-based load calculations. \$99 per person. Contact: training@hvacpro.com. Max 20 students."
|
|
|
|
OUTPUT:
|
|
{
|
|
"title": "Transform Your HVAC Business with Manual J LiDAR",
|
|
"description": "## Training Overview\n\n### Who Should Attend?\n\n* **HVAC company owners wanting to modernize their operations**\n* **Sales professionals tired of spending hours on Manual Js**\n* **Service managers looking to reduce callbacks**\n* **Technicians ready to leverage cutting-edge diagnostics**\n\n### Why This Matters\n\n**The days of pencil-whipping load calcs and guessing at system performance are over. Modern HVAC equipment demands precision - and this session gives you the tools to deliver it consistently.**\n\n### What You'll Learn\n\n#### Part 1: LiDAR Load Calculations (2 hours)\n\nMaster the magic of iPad-based Manual J calculations:\n\n* Turn a 15-minute iPad scan into a complete ACCA load calculation\n* Generate professional 3D models that wow customers\n* Create winning proposals with scientific backing\n* Stop losing jobs to low-ballers by demonstrating value\n\n#### Part 2: measureQuick Fundamentals (2 hours)\n\nGet hands-on with diagnostic technology that pays for itself:\n\n* Connect smart tools for bulletproof diagnostics\n* Leverage remote support to help junior techs\n* Generate professional reports that drive sales\n* Access just-in-time education for tricky situations\n\n### Key Takeaways\n\n* **Complete Manual J's in minutes instead of hours**\n* **Win more premium jobs with professional documentation**\n* **Reduce callbacks through data-driven commissioning**\n* **Support your team remotely when they need backup**\n* **Generate reports that justify higher ticket prices**\n\n**Training Requirements:** No special heating/cooling equipment needed, good Internet connection required.",
|
|
"start_date": "2025-03-15",
|
|
"start_time": "08:00",
|
|
"end_date": "2025-03-15",
|
|
"end_time": "12:00",
|
|
"venue_name": "HVAC Institute",
|
|
"venue_address": "123 Training Way",
|
|
"venue_city": "Dallas",
|
|
"venue_state": "TX",
|
|
"venue_zip": "75201",
|
|
"organizer_name": "HVAC Pro Education",
|
|
"organizer_email": "training@hvacpro.com",
|
|
"organizer_phone": "(555) 123-4567",
|
|
"website": "www.hvacpro.com/events",
|
|
"cost": 99,
|
|
"capacity": 20,
|
|
"event_url": "www.hvacpro.com/events",
|
|
"event_image_url": null,
|
|
"price": 99,
|
|
"confidence": {
|
|
"overall": 0.95,
|
|
"per_field": {
|
|
"title": 1.0,
|
|
"dates": 1.0,
|
|
"venue": 0.9,
|
|
"organizer": 0.9,
|
|
"cost": 1.0
|
|
}
|
|
}
|
|
}
|
|
|
|
TASK:
|
|
{$input_instruction}
|
|
|
|
INPUT:
|
|
{$actual_content}{$source_note}
|
|
|
|
EXTRACTION & ENHANCEMENT RULES:
|
|
CRITICAL: You MUST extract ALL available event details, not just the title. Search the content carefully for:
|
|
|
|
**REQUIRED FIELD EXTRACTION:**
|
|
- DATES: Look for any date patterns (MM/DD/YYYY, Month DD, DD-DD, "November 11-12", etc.)
|
|
- TIMES: Extract start/end times even if approximate (8:00 AM, 9-5, "morning session")
|
|
- COST/PRICING: Find any dollar amounts, fee structures, early bird pricing
|
|
- VENUE: Extract location names, addresses, cities, states (unless virtual/online)
|
|
- ORGANIZER: Find contact info, company names, training organizations
|
|
- CAPACITY: Look for "max students", "limited to X", registration limits
|
|
|
|
**EXTRACTION PROCESS:**
|
|
1. **Scan the ENTIRE content** - dates/pricing may appear anywhere in the text
|
|
2. **Extract explicitly stated information first**, then CREATIVELY ENHANCE the description
|
|
3. **Look for patterns**: "$495/$470" = pricing, "Nov 11-12" = multi-day dates, "8 hours" = duration
|
|
4. **Don't assume missing** - if content mentions "two days" or "workshop fee", extract those details
|
|
|
|
**DESCRIPTION ENHANCEMENT (MANDATORY):**
|
|
CRITICAL: You MUST ALWAYS generate a description - NEVER return null for description field.
|
|
Transform basic info into professional training content with these sections:
|
|
- **Who Should Attend?** (target audience with specific roles/pain points)
|
|
- **Why This Matters** (compelling business case and industry context)
|
|
- **What You'll Learn** (detailed curriculum with practical applications)
|
|
- **Key Takeaways** (specific benefits and outcomes)
|
|
- **Training Requirements** (equipment/setup needed)
|
|
|
|
If the source content is minimal, use your HVAC expertise to create relevant training content based on the event title/topic.
|
|
|
|
**ADDITIONAL RULES:**
|
|
5. Use HVAC industry terminology and focus on business value, efficiency, profitability
|
|
6. If basic input, expand into professional training format matching HVAC education standards
|
|
7. Match venues/organizers to existing ones when similarity > 80%
|
|
8. Convert relative dates to absolute dates (e.g., "next Tuesday" to actual date)
|
|
9. Handle both in-person and virtual events appropriately
|
|
10. For event_image_url: Only include images that are at least 200x200 pixels - ignore favicons, icons, and small logos
|
|
11. If multiple events are found, extract only the first/primary one
|
|
12. CRITICAL: For virtual/online events (webinars, online training, virtual conferences), set ALL venue fields to null - do not use "Virtual", "Online", or any venue name for virtual events
|
|
13. Set confidence scores based on how explicitly the information is stated:
|
|
- 1.0 = Explicitly stated with exact details
|
|
- 0.8 = Clearly stated but some interpretation needed
|
|
- 0.6 = Somewhat implied or requires inference
|
|
- 0.4 = Vague reference that might be correct
|
|
- 0.2 = Highly uncertain, mostly guessing
|
|
- 0.0 = Information not present
|
|
|
|
OUTPUT FORMAT:
|
|
Return ONLY a valid JSON object with this exact structure (use null for missing fields):
|
|
|
|
{
|
|
"title": "string or null",
|
|
"description": "string (NEVER null - always generate professional training description)",
|
|
"start_date": "YYYY-MM-DD or null",
|
|
"start_time": "HH:MM or null",
|
|
"end_date": "YYYY-MM-DD or null",
|
|
"end_time": "HH:MM or null",
|
|
"venue_name": "string or null",
|
|
"venue_address": "string or null",
|
|
"venue_city": "string or null",
|
|
"venue_state": "string or null",
|
|
"venue_zip": "string or null",
|
|
"organizer_name": "string or null",
|
|
"organizer_email": "string or null",
|
|
"organizer_phone": "string or null",
|
|
"website": "string or null",
|
|
"cost": "number or null",
|
|
"capacity": "number or null",
|
|
"event_url": "string or null",
|
|
"event_image_url": "string or null",
|
|
"price": "number or null",
|
|
"confidence": {
|
|
"overall": 0.0-1.0,
|
|
"per_field": {
|
|
"title": 0.0-1.0,
|
|
"dates": 0.0-1.0,
|
|
"venue": 0.0-1.0,
|
|
"organizer": 0.0-1.0,
|
|
"cost": 0.0-1.0
|
|
}
|
|
}
|
|
}
|
|
|
|
IMPORTANT: Return ONLY the JSON object, no explanatory text before or after.
|
|
PROMPT;
|
|
}
|
|
|
|
/**
|
|
* Fetch URL content using Jina.ai reader
|
|
*
|
|
* @param string $url URL to fetch
|
|
* @return string|WP_Error Fetched content or error
|
|
*/
|
|
private function fetch_url_with_jina(string $url): string|WP_Error {
|
|
$jina_url = "https://r.jina.ai/";
|
|
$token = "jina_73c8ff38ef724602829cf3ff8b2dc5b5jkzgvbaEZhFKXzyXgQ1_o1U9oE2b";
|
|
|
|
$data = wp_json_encode([
|
|
'url' => $url,
|
|
'injectPageScript' => [
|
|
"// Remove headers, footers, navigation elements\ndocument.querySelectorAll('header, footer, nav, .header, .footer, .navigation, .sidebar').forEach(el => el.remove());\n\n// Remove ads and promotional content\ndocument.querySelectorAll('.ad, .ads, .advertisement, .promo, .banner').forEach(el => el.remove());"
|
|
]
|
|
]);
|
|
|
|
$args = [
|
|
'timeout' => 45, // Jina can take 5-40 seconds
|
|
'headers' => [
|
|
'Accept' => 'application/json',
|
|
'Authorization' => 'Bearer ' . $token,
|
|
'Content-Type' => 'application/json'
|
|
],
|
|
'body' => $data,
|
|
'method' => 'POST'
|
|
];
|
|
|
|
$response = wp_remote_post($jina_url, $args);
|
|
|
|
if (is_wp_error($response)) {
|
|
error_log('HVAC AI: Jina.ai request failed: ' . $response->get_error_message());
|
|
return new WP_Error(
|
|
'jina_request_failed',
|
|
'Failed to fetch webpage content: ' . $response->get_error_message(),
|
|
['status' => 500]
|
|
);
|
|
}
|
|
|
|
$response_code = wp_remote_retrieve_response_code($response);
|
|
if ($response_code !== 200) {
|
|
error_log("HVAC AI: Jina.ai returned HTTP {$response_code}");
|
|
return new WP_Error(
|
|
'jina_http_error',
|
|
"Webpage content service returned error: HTTP {$response_code}",
|
|
['status' => $response_code]
|
|
);
|
|
}
|
|
|
|
$response_body = wp_remote_retrieve_body($response);
|
|
if (empty($response_body)) {
|
|
return new WP_Error(
|
|
'jina_empty_response',
|
|
'No content received from webpage',
|
|
['status' => 500]
|
|
);
|
|
}
|
|
|
|
// Jina returns the cleaned text content directly
|
|
error_log('HVAC AI: Jina.ai extracted content (' . strlen($response_body) . ' characters)');
|
|
return $response_body;
|
|
}
|
|
|
|
/**
|
|
* Make API request to Claude
|
|
*
|
|
* @param string $prompt Structured prompt
|
|
* @return array|WP_Error API response or error
|
|
*/
|
|
private function make_api_request(string $prompt): array|WP_Error {
|
|
if (!defined('ANTHROPIC_API_KEY') || empty(ANTHROPIC_API_KEY)) {
|
|
return new WP_Error(
|
|
'api_key_missing',
|
|
'Anthropic API key not configured.',
|
|
['status' => 500]
|
|
);
|
|
}
|
|
|
|
$headers = [
|
|
'Content-Type' => 'application/json',
|
|
'x-api-key' => ANTHROPIC_API_KEY,
|
|
'anthropic-version' => '2023-06-01'
|
|
];
|
|
|
|
$body = [
|
|
'model' => self::API_MODEL,
|
|
'max_tokens' => 4000,
|
|
'temperature' => 0.4,
|
|
'messages' => [
|
|
[
|
|
'role' => 'user',
|
|
'content' => $prompt
|
|
]
|
|
]
|
|
];
|
|
|
|
$args = [
|
|
'timeout' => self::REQUEST_TIMEOUT,
|
|
'headers' => $headers,
|
|
'body' => wp_json_encode($body),
|
|
'method' => 'POST',
|
|
'sslverify' => true
|
|
];
|
|
|
|
$start_time = microtime(true);
|
|
error_log('HVAC AI: Making API request to Claude (timeout: ' . self::REQUEST_TIMEOUT . 's)');
|
|
$response = wp_remote_request(self::API_ENDPOINT, $args);
|
|
$duration = round(microtime(true) - $start_time, 2);
|
|
error_log("HVAC AI: Claude API request completed in {$duration}s");
|
|
|
|
if (is_wp_error($response)) {
|
|
error_log('HVAC AI: API request failed: ' . $response->get_error_message());
|
|
return $response;
|
|
}
|
|
|
|
$response_code = wp_remote_retrieve_response_code($response);
|
|
$response_body = wp_remote_retrieve_body($response);
|
|
|
|
if ($response_code !== 200) {
|
|
error_log("HVAC AI: API returned error code {$response_code}: {$response_body}");
|
|
return new WP_Error(
|
|
'api_request_failed',
|
|
'AI service temporarily unavailable. Please try again later.',
|
|
['status' => $response_code]
|
|
);
|
|
}
|
|
|
|
$decoded_response = json_decode($response_body, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
error_log('HVAC AI: Failed to decode API response JSON');
|
|
return new WP_Error(
|
|
'api_response_invalid',
|
|
'Invalid response from AI service.',
|
|
['status' => 500]
|
|
);
|
|
}
|
|
|
|
return $decoded_response;
|
|
}
|
|
|
|
/**
|
|
* Parse API response and extract event data
|
|
*
|
|
* @param array $api_response Raw API response
|
|
* @return array|WP_Error Parsed event data or error
|
|
*/
|
|
private function parse_api_response(array $api_response): array|WP_Error {
|
|
// Extract content from Claude's response structure
|
|
if (!isset($api_response['content'][0]['text'])) {
|
|
error_log('HVAC AI: Unexpected API response structure');
|
|
return new WP_Error(
|
|
'api_response_structure',
|
|
'Unexpected response structure from AI service.',
|
|
['status' => 500]
|
|
);
|
|
}
|
|
|
|
$content = trim($api_response['content'][0]['text']);
|
|
|
|
// Debug: Log raw Claude response
|
|
error_log('HVAC AI: Raw Claude response: ' . substr($content, 0, 1000) . (strlen($content) > 1000 ? '...' : ''));
|
|
|
|
// Try to extract JSON from response
|
|
$json_match = [];
|
|
if (preg_match('/\{.*\}/s', $content, $json_match)) {
|
|
$content = $json_match[0];
|
|
}
|
|
|
|
// Parse JSON
|
|
$event_data = json_decode($content, true);
|
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
|
error_log('HVAC AI: Failed to parse event data JSON: ' . json_last_error_msg());
|
|
return new WP_Error(
|
|
'event_data_invalid',
|
|
'AI service returned invalid event data format.',
|
|
['status' => 500]
|
|
);
|
|
}
|
|
|
|
// Debug: Log the parsed event data structure
|
|
error_log('HVAC AI: Parsed event data: ' . json_encode($event_data, JSON_PRETTY_PRINT));
|
|
|
|
// Validate required fields
|
|
$required_fields = ['title', 'description', 'confidence'];
|
|
foreach ($required_fields as $field) {
|
|
if (empty($event_data[$field])) {
|
|
error_log("HVAC AI: Missing required field: {$field}");
|
|
return new WP_Error(
|
|
'missing_required_field',
|
|
"Missing required event information: {$field}",
|
|
['status' => 422]
|
|
);
|
|
}
|
|
}
|
|
|
|
return $event_data;
|
|
}
|
|
|
|
/**
|
|
* Post-process extracted data (venue/organizer matching, etc.)
|
|
*
|
|
* @param array $event_data Raw event data
|
|
* @return array Processed event data
|
|
*/
|
|
private function post_process_data(array $event_data): array {
|
|
// Process venue matching (handle both flat and nested structures)
|
|
$venue_name = $event_data['venue_name'] ?? $event_data['venue']['name'] ?? null;
|
|
if (!empty($venue_name)) {
|
|
$venue_data = [
|
|
'name' => $venue_name,
|
|
'address' => $event_data['venue_address'] ?? $event_data['venue']['address'] ?? null,
|
|
'city' => $event_data['venue_city'] ?? $event_data['venue']['city'] ?? null,
|
|
'state' => $event_data['venue_state'] ?? $event_data['venue']['state'] ?? null,
|
|
'zip' => $event_data['venue_zip'] ?? $event_data['venue']['zip'] ?? null
|
|
];
|
|
|
|
$matched_venue = $this->find_matching_venue($venue_data);
|
|
if ($matched_venue) {
|
|
$event_data['venue_matched_id'] = $matched_venue['id'];
|
|
$event_data['venue_is_existing'] = true;
|
|
}
|
|
}
|
|
|
|
// Process organizer matching (handle both flat and nested structures)
|
|
$organizer_name = $event_data['organizer_name'] ?? $event_data['organizer']['name'] ?? null;
|
|
if (!empty($organizer_name)) {
|
|
$organizer_data = [
|
|
'name' => $organizer_name,
|
|
'email' => $event_data['organizer_email'] ?? $event_data['organizer']['email'] ?? null,
|
|
'phone' => $event_data['organizer_phone'] ?? $event_data['organizer']['phone'] ?? null
|
|
];
|
|
|
|
$matched_organizer = $this->find_matching_organizer($organizer_data);
|
|
if ($matched_organizer) {
|
|
$event_data['organizer_matched_id'] = $matched_organizer['id'];
|
|
$event_data['organizer_is_existing'] = true;
|
|
}
|
|
}
|
|
|
|
// Combine date and time fields
|
|
if (!empty($event_data['start_date']) && !empty($event_data['start_time'])) {
|
|
$event_data['start_datetime'] = $event_data['start_date'] . 'T' . $event_data['start_time'];
|
|
}
|
|
|
|
if (!empty($event_data['end_date']) && !empty($event_data['end_time'])) {
|
|
$event_data['end_datetime'] = $event_data['end_date'] . 'T' . $event_data['end_time'];
|
|
}
|
|
|
|
// Sanitize data
|
|
$event_data = $this->sanitize_event_data($event_data);
|
|
|
|
return $event_data;
|
|
}
|
|
|
|
/**
|
|
* Find matching venue from existing venues
|
|
*
|
|
* @param array $extracted_venue Venue data from AI
|
|
* @return array|null Matched venue or null
|
|
*/
|
|
private function find_matching_venue(array $extracted_venue): ?array {
|
|
$existing_venues = $this->get_existing_venues();
|
|
$venue_name = strtolower($extracted_venue['name'] ?? '');
|
|
|
|
foreach ($existing_venues as $venue) {
|
|
$existing_name = strtolower($venue['name']);
|
|
|
|
// Calculate similarity
|
|
similar_text($venue_name, $existing_name, $percent);
|
|
|
|
// Match if similarity is above 80%
|
|
if ($percent >= 80) {
|
|
return $venue;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Find matching organizer from existing organizers
|
|
*
|
|
* @param array $extracted_organizer Organizer data from AI
|
|
* @return array|null Matched organizer or null
|
|
*/
|
|
private function find_matching_organizer(array $extracted_organizer): ?array {
|
|
$existing_organizers = $this->get_existing_organizers();
|
|
$organizer_name = strtolower($extracted_organizer['name'] ?? '');
|
|
|
|
foreach ($existing_organizers as $organizer) {
|
|
$existing_name = strtolower($organizer['name']);
|
|
|
|
// Calculate similarity
|
|
similar_text($organizer_name, $existing_name, $percent);
|
|
|
|
// Match if similarity is above 80%
|
|
if ($percent >= 80) {
|
|
return $organizer;
|
|
}
|
|
|
|
// Also check email match if available
|
|
if (!empty($extracted_organizer['email']) && !empty($organizer['email'])) {
|
|
if (strtolower($extracted_organizer['email']) === strtolower($organizer['email'])) {
|
|
return $organizer;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Sanitize event data for security
|
|
*
|
|
* @param array $event_data Raw event data
|
|
* @return array Sanitized event data
|
|
*/
|
|
private function sanitize_event_data(array $event_data): array {
|
|
// Sanitize text fields
|
|
$text_fields = ['title', 'description'];
|
|
foreach ($text_fields as $field) {
|
|
if (isset($event_data[$field])) {
|
|
$event_data[$field] = sanitize_textarea_field($event_data[$field]);
|
|
}
|
|
}
|
|
|
|
// Sanitize URL fields
|
|
if (isset($event_data['url'])) {
|
|
$event_data['url'] = esc_url_raw($event_data['url']);
|
|
}
|
|
|
|
// Sanitize venue data
|
|
if (isset($event_data['venue']) && is_array($event_data['venue'])) {
|
|
foreach ($event_data['venue'] as $key => $value) {
|
|
if (is_string($value)) {
|
|
$event_data['venue'][$key] = sanitize_text_field($value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sanitize organizer data
|
|
if (isset($event_data['organizer']) && is_array($event_data['organizer'])) {
|
|
foreach ($event_data['organizer'] as $key => $value) {
|
|
if ($key === 'email' && is_string($value)) {
|
|
$event_data['organizer'][$key] = sanitize_email($value);
|
|
} elseif (is_string($value)) {
|
|
$event_data['organizer'][$key] = sanitize_text_field($value);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sanitize numeric fields
|
|
if (isset($event_data['cost'])) {
|
|
$event_data['cost'] = (float) $event_data['cost'];
|
|
}
|
|
if (isset($event_data['capacity'])) {
|
|
$event_data['capacity'] = (int) $event_data['capacity'];
|
|
}
|
|
|
|
return $event_data;
|
|
}
|
|
|
|
/**
|
|
* Generate cache key for input
|
|
*
|
|
* @param string $input User input
|
|
* @return string Cache key
|
|
*/
|
|
private function generate_cache_key(string $input): string {
|
|
return self::CACHE_PREFIX . md5($input);
|
|
}
|
|
|
|
/**
|
|
* Get cached response
|
|
*
|
|
* @param string $cache_key Cache key
|
|
* @return array|false Cached data or false
|
|
*/
|
|
private function get_cached_response(string $cache_key): array|false {
|
|
return get_transient($cache_key) ?: false;
|
|
}
|
|
|
|
/**
|
|
* Cache API response
|
|
*
|
|
* @param string $cache_key Cache key
|
|
* @param array $data Data to cache
|
|
* @return bool Success
|
|
*/
|
|
private function cache_response(string $cache_key, array $data): bool {
|
|
return set_transient($cache_key, $data, self::CACHE_TTL);
|
|
}
|
|
|
|
/**
|
|
* Clear all cached responses (for admin use)
|
|
*
|
|
* @return void
|
|
*/
|
|
public function clear_cache(): void {
|
|
global $wpdb;
|
|
|
|
$wpdb->query($wpdb->prepare(
|
|
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
|
|
'_transient_' . self::CACHE_PREFIX . '%'
|
|
));
|
|
|
|
$wpdb->query($wpdb->prepare(
|
|
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
|
|
'_transient_timeout_' . self::CACHE_PREFIX . '%'
|
|
));
|
|
|
|
error_log('HVAC AI: Cache cleared');
|
|
}
|
|
} |