_fieldDescriptor = new AIFieldDescriptor(); } /** * Generate a condition from a user message. */ public function Generate($message = '', $_history = null, $formOptions = null, $targetFieldId = '', $conditionContext = null) { $this->FormOptions = $formOptions; $this->History = new HistoryManager($_history); $this->ConditionContext = $conditionContext; $prompt = $this->BuildPromptMessages($message, $targetFieldId); $response = $this->GetCaller()->Chat($prompt); return $this->ProcessResponse($response, $message); } /** * Build the prompt text only (for external chat mode). */ public function BuildPromptOnly($message = '', $_history = null, $formOptions = null, $targetFieldId = '', $conditionContext = null) { $this->FormOptions = $formOptions; $this->History = new HistoryManager($_history); $this->ConditionContext = $conditionContext; $prompt = $this->BuildPromptMessages($message, $targetFieldId); $promptText = ''; foreach ($prompt as $msg) { if (isset($msg->role) && $msg->role === 'system') { $promptText .= $msg->content . "\n\n"; } else if (isset($msg->role) && $msg->role === 'user') { $promptText .= "User request: " . $msg->content; } } $promptText = "IMPORTANT: Return ONLY the raw JSON object. Do not add any additional text, explanations, markdown formatting, or code blocks before or after the JSON. Your entire response must be valid JSON and nothing else.\n\n" . $promptText; $this->History->RecordHistoryLog($message, 'user'); return [ "history" => $this->History->GetHistory(), "data" => [ "promptText" => $promptText ] ]; } /** * Process an external AI response (pasted JSON). */ public function ProcessExternalResponse($responseJson, $message = '', $_history = null, $formOptions = null) { $this->FormOptions = $formOptions; $this->History = new HistoryManager($_history); return $this->ProcessResponse($responseJson, $message); } /** * Build the prompt messages array. */ private function BuildPromptMessages($message, $targetFieldId) { // 1. Load prompt template $promptFile = AllInOneForms()->GetLoader()->DIR . '/Managers/AIManagers/AIGenerators/AIConditionGenerator/ConditionGeneratorPrompt.txt'; $prompt = file_get_contents($promptFile); // 1b. Inject shared formula syntax $formulaSyntaxFile = AllInOneForms()->GetLoader()->DIR . '/Managers/AIManagers/AIGenerators/prompts/formula_syntax.txt'; $formulaSyntax = file_get_contents($formulaSyntaxFile); $prompt = str_replace('{{formulaSyntax}}', $formulaSyntax, $prompt); // 2. Build target field info $targetFieldText = $this->BuildTargetFieldText($targetFieldId); $prompt = str_replace('{{targetField}}', $targetFieldText, $prompt); // 3. Extract referenced field IDs from the message and build field list $referencedFieldIds = $this->ExtractFieldIds($message); $fieldList = $this->BuildFieldList($targetFieldId, $referencedFieldIds); $prompt = str_replace('{{fieldList}}', $fieldList, $prompt); // 4. Build condition behavior context $conditionBehavior = $this->BuildConditionBehaviorText(); $prompt = str_replace('{{conditionBehavior}}', $conditionBehavior, $prompt); // 5. Build conversation history $conversationHistory = $this->BuildConversationHistory(); return [ $this->CreateMessage('system', $prompt), $this->CreateMessage('user', $conversationHistory . "\n\nCurrent request: " . $message) ]; } /** * Process the AI response into the format the frontend expects. */ private function ProcessResponse($response, $message) { $data = json_decode($response); if ($data == null) { throw new FriendlyException('The AI could not generate a valid condition. Please rephrase your request and try again.'); } $type = isset($data->type) ? $data->type : null; if ($type == null) { throw new FriendlyException('The AI response is missing the condition type. Please try again.'); } $this->History->RecordHistoryLog($message, 'user'); $result = [ "history" => $this->History->GetHistory(), "data" => [ "message" => isset($data->explanation) ? $data->explanation : "Condition generated successfully.", "type" => $type ] ]; if ($type === 'formula') { $result['data']['formula'] = isset($data->formula) ? $data->formula : ''; } else { // Simple condition — sanitize and transform the condition data $result['data']['condition'] = $this->SanitizeCondition(isset($data->condition) ? $data->condition : []); } return $result; } /** * Build text describing the target field. */ private function BuildTargetFieldText($targetFieldId) { if (empty($targetFieldId)) { return 'No specific target field'; } $fieldParts = $this->GetAIFieldUtilities()->GetIdParts($targetFieldId); $field = $this->GetForm()->GetFieldById($fieldParts[0], true, false, 'FieldTemplate'); if ($field == null) { return 'Field ID: ' . $targetFieldId . ' (field not found)'; } $text = 'Field ID: ' . $targetFieldId . "\n"; $text .= 'Field Type: ' . $field->Options->Type . "\n"; $text .= 'Label: ' . (isset($field->Options->Label) ? $field->Options->Label : 'No label') . "\n"; // Check if the target field is inside a repeater $repeaterId = $this->FindParentRepeaterId($field); if ($repeaterId !== null) { $repeaterField = $this->GetForm()->GetFieldById($repeaterId); $repeaterLabel = ($repeaterField != null && isset($repeaterField->Options->Label)) ? $repeaterField->Options->Label : 'Repeater'; $text .= 'INSIDE REPEATER: This field is inside [Field ' . $repeaterId . '] ("' . $repeaterLabel . '"). '; $text .= 'When referring to values across all repeater rows, use the repeater\'s GetNumericalTotal() (for values) or GetTotal() (for prices) in a formula condition.' . "\n"; } return $text; } /** * Build the field list for all relevant fields, with detailed comparison info for referenced ones. */ private function BuildFieldList($targetFieldId, $referencedFieldIds) { // Build set of field IDs that need detailed info (target + referenced) $detailedFieldIds = $referencedFieldIds; if (!empty($targetFieldId) && !in_array($targetFieldId, $detailedFieldIds)) { array_unshift($detailedFieldIds, $targetFieldId); } // List ALL form fields so the AI knows what's available // Note: GetFields(true) may NOT return repeater children when there's no entry data, // so we also iterate TemplateRows of repeater fields below. $allFields = $this->GetForm()->GetFields(true); $text = ''; $processedFieldIds = []; // Helper to format a single field entry $formatField = function ($field, $repeaterId = null) use ($targetFieldId, $detailedFieldIds, &$text, &$processedFieldIds) { $fieldId = $field->Options->Id; // Skip already processed fields (avoid duplicates) if (in_array((string)$fieldId, $processedFieldIds)) { return; } $processedFieldIds[] = (string)$fieldId; $fieldType = $field->Options->Type; $fieldLabel = isset($field->Options->Label) ? $field->Options->Label : 'No label'; // Skip repeater_item containers if ($fieldType === 'repeater_item' || $fieldType === 'repeateritem') { return; } $isTarget = ($fieldId == $targetFieldId); $targetTag = $isTarget ? ' ⭐ TARGET FIELD' : ''; $showDetails = in_array((string)$fieldId, array_map('strval', $detailedFieldIds)); $fieldDescription = $this->_fieldDescriptor->GetField($fieldType); $dataType = $fieldDescription != null && isset($fieldDescription['datatype']) ? $fieldDescription['datatype'] : 'text'; // Check if the field is inside a repeater if ($repeaterId === null) { $repeaterId = $this->FindParentRepeaterId($field); } $text .= "\n### [Field " . $fieldId . '] - "' . $fieldLabel . '" (Type: ' . $fieldType . ', DataType: ' . $dataType . ')' . $targetTag . "\n"; // If the field is inside a repeater, show the parent if ($repeaterId !== null) { $text .= "RepeaterParent: " . $repeaterId . "\n"; } // For repeater fields, show the available aggregation methods if ($fieldType === 'repeater') { $text .= "Repeater methods (use in formula mode): [Field " . $fieldId . "].GetNumericalTotal(childFieldId), [Field " . $fieldId . "].GetTotal(childFieldId), [Field " . $fieldId . "].GetCount()\n"; } // Only show detailed comparison info for the target field and fields mentioned in the message if ($showDetails) { $supportedComparisons = $this->GetSupportedComparisons($dataType); if (!empty($supportedComparisons)) { $text .= "Supported comparison types: " . implode(', ', $supportedComparisons) . "\n"; } } }; foreach ($allFields as $field) { $formatField($field); // For repeater fields, also add their template children (which GetFields misses without entry data) if ($field->Options->Type === 'repeater' && isset($field->TemplateRows) && is_array($field->TemplateRows)) { $repeaterId = (string)$field->Options->Id; foreach ($field->TemplateRows as $row) { if (!isset($row->Columns)) continue; foreach ($row->Columns as $column) { if (!isset($column->Field) || $column->Field == null) continue; $formatField($column->Field, $repeaterId); } } } } if (empty($text)) { $text = "No fields found in the form.\n"; } return $text; } /** * Map of child field IDs to their parent repeater field ID. * Built once and cached for the lifetime of this generator instance. */ private $_repeaterChildMap = null; /** * Find the parent repeater field ID for a given field, if it is inside a repeater. * Returns the repeater field ID, or null if the field is not inside a repeater. */ private function FindParentRepeaterId($field) { if ($this->_repeaterChildMap === null) { $this->_repeaterChildMap = $this->BuildRepeaterChildMap(); } $fieldId = (string)$field->Options->Id; return isset($this->_repeaterChildMap[$fieldId]) ? $this->_repeaterChildMap[$fieldId] : null; } /** * Build a map of child field ID => parent repeater field ID. * Iterates all top-level fields, and for each repeater, collects its child field IDs. */ private function BuildRepeaterChildMap() { $map = []; // Get top-level fields (including group panel children but not repeater children) $topLevelFields = $this->GetForm()->GetFields(false, true); foreach ($topLevelFields as $field) { if ($field->Options->Type !== 'repeater') { continue; } $repeaterId = (string)$field->Options->Id; // Use TemplateRows to get child fields (ContainerManager->Rows is empty since there's no entry data) if (isset($field->TemplateRows) && is_array($field->TemplateRows)) { foreach ($field->TemplateRows as $row) { if (!isset($row->Columns)) continue; foreach ($row->Columns as $column) { if (!isset($column->Field) || $column->Field == null) continue; $childField = $column->Field; // Skip the repeater_item container itself if ($childField->Options->Type === 'repeater_item' || $childField->Options->Type === 'repeateritem') { continue; } $map[(string)$childField->Options->Id] = $repeaterId; } } } } return $map; } /** * Get supported comparison types for a given data type. */ private function GetSupportedComparisons($dataType) { $map = [ 'text' => ['EqualTo', 'NotEqualTo', 'Contains', 'NotContains', 'IsEmpty', 'IsNotEmpty'], 'composed' => ['EqualTo', 'NotEqualTo', 'Contains', 'NotContains', 'IsEmpty', 'IsNotEmpty'], 'number' => ['EqualTo', 'NotEqualTo', 'GreaterThan', 'LessThan', 'GreaterOrEqualThan', 'LessOrEqualThan', 'IsEmpty', 'IsNotEmpty'], 'date' => ['EqualTo', 'NotEqualTo', 'GreaterThan', 'LessThan', 'GreaterOrEqualThan', 'LessOrEqualThan', 'IsEmpty', 'IsNotEmpty'], 'multiple_options' => ['ContainsOption', 'NotContainsOption', 'IsEmpty', 'IsNotEmpty'], 'boolean' => ['IsChecked', 'IsNotChecked'], ]; return isset($map[$dataType]) ? $map[$dataType] : ['EqualTo', 'NotEqualTo', 'IsEmpty', 'IsNotEmpty']; } /** * Sanitize condition groups from the AI response. */ private function SanitizeCondition($conditionGroups) { $sanitized = []; if (!is_array($conditionGroups)) { return $sanitized; } foreach ($conditionGroups as $group) { if (!is_array($group) || count($group) == 0) continue; $sanitizedGroup = []; foreach ($group as $line) { $fieldId = Sanitizer::GetStringValueFromPath($line, ['Field']); $comparisonType = Sanitizer::GetStringValueFromPath($line, ['ComparisonType']); $value = Sanitizer::GetValueFromPath($line, ['Value']); $formula = isset($line->Formula) ? trim($line->Formula) : ''; if (empty($fieldId) || empty($comparisonType)) continue; $sanitizedLine = [ 'Field' => $fieldId, 'ComparisonType' => $comparisonType, 'Value' => $value !== null ? $value : '' ]; if (!empty($formula)) { $sanitizedLine['Formula'] = $formula; $sanitizedLine['Value'] = ''; } $sanitizedGroup[] = (object) $sanitizedLine; } if (!empty($sanitizedGroup)) { $sanitized[] = $sanitizedGroup; } } return $sanitized; } /** * Extract field IDs from the message (matches [Field X] patterns). */ private function ExtractFieldIds($message) { $fieldIds = []; if (preg_match_all('/\[Field\s+(\d+(?:_[a-zA-Z0-9]+)?)\]/i', $message, $matches)) { foreach ($matches[1] as $fieldId) { if (!in_array($fieldId, $fieldIds)) { $fieldIds[] = $fieldId; } } } return $fieldIds; } /** * Build conversation history from previous user messages. */ private function BuildConversationHistory() { $userMessages = $this->History->GetAllMessagesOfRole('user'); $messageHistory = ""; foreach ($userMessages as $userMessage) { if ($messageHistory != "") { $messageHistory .= "\n\n"; } $messageHistory .= $userMessage->content; } if (trim($messageHistory) == "") { return ""; } return "--Previous conversation--\n" . $messageHistory . "\n\n"; } public function GetForm() { if ($this->_form == null) { $this->_form = new FormBuilder(AllInOneForms()->GetLoader(), (new BuilderOptionsDTO())->Merge($this->FormOptions)); $this->_form->Initialize(); } return $this->_form; } public function GetAIFieldUtilities() { if ($this->_aiFieldUtilities == null) $this->_aiFieldUtilities = new AIFieldUtilities($this->GetForm()); return $this->_aiFieldUtilities; } /** * Build a text explanation of what the current condition type does. */ private function BuildConditionBehaviorText() { if ($this->ConditionContext == null) { return "No specific condition context provided. Generate the condition so that when it evaluates to TRUE, the desired behavior occurs."; } $type = isset($this->ConditionContext->type) ? $this->ConditionContext->type : ''; $showWhenTrue = isset($this->ConditionContext->showWhenTrue) ? $this->ConditionContext->showWhenTrue : true; switch ($type) { case 'ShowHide': if ($showWhenTrue) { return "CONDITION TYPE: Show/Hide — Currently set to \"Show\" mode.\n" . "BEHAVIOR: When your condition evaluates to TRUE → the field is VISIBLE (shown).\n" . "When your condition evaluates to FALSE → the field is HIDDEN.\n\n" . "CRITICAL: If the user says \"hide when X is empty\", you need to create a condition for \"X is NOT empty\" because the dropdown is set to SHOW. " . "The field will be SHOWN when the condition is true (X is not empty), and HIDDEN when the condition is false (X is empty). " . "Think of it as: \"Show this field when [opposite of what user wants to hide on]\".\n\n" . "Examples:\n" . "- User says \"hide when field 1 is empty\" → condition should be: field 1 IsNotEmpty (show when NOT empty = hide when empty)\n" . "- User says \"show when field 1 equals hello\" → condition should be: field 1 EqualTo \"hello\" (direct match)\n" . "- User says \"hide when field 1 is checked\" → condition should be: field 1 IsNotChecked (show when NOT checked = hide when checked)\n"; } else { return "CONDITION TYPE: Show/Hide — Currently set to \"Hide\" mode.\n" . "BEHAVIOR: When your condition evaluates to TRUE → the field is HIDDEN.\n" . "When your condition evaluates to FALSE → the field is VISIBLE (shown).\n\n" . "CRITICAL: If the user says \"hide when X is empty\", you should create a condition for \"X is empty\" directly because the dropdown is set to HIDE. " . "The field will be HIDDEN when the condition is true (X is empty), and SHOWN when the condition is false (X is not empty).\n\n" . "Examples:\n" . "- User says \"hide when field 1 is empty\" → condition should be: field 1 IsEmpty (direct match)\n" . "- User says \"show when field 1 equals hello\" → condition should be: field 1 NotEqualTo \"hello\" (hide when NOT hello = show when hello)\n" . "- User says \"hide when field 1 is checked\" → condition should be: field 1 IsChecked (direct match)\n"; } case 'Required': return "CONDITION TYPE: Required Condition.\n" . "BEHAVIOR: When your condition evaluates to TRUE → the field becomes REQUIRED (user must fill it in).\n" . "When your condition evaluates to FALSE → the field is OPTIONAL.\n\n" . "If the user says \"make required when field 1 is checked\" → condition should be: field 1 IsChecked.\n" . "If the user says \"make optional when field 1 is empty\" → condition should be: field 1 IsNotEmpty (required when NOT empty = optional when empty).\n"; case 'Validation': $invalidMessage = isset($this->ConditionContext->invalidMessage) ? $this->ConditionContext->invalidMessage : ''; $msgInfo = !empty($invalidMessage) ? " The current error message is: \"$invalidMessage\"." : ""; return "CONDITION TYPE: Validation Condition.\n" . "BEHAVIOR: When your condition evaluates to TRUE → the field is INVALID (shows error).\n" . "When your condition evaluates to FALSE → the field is VALID (passes validation).$msgInfo\n\n" . "IMPORTANT: The formula should directly express the INVALID condition. If the user says \"invalid when X\", return true when X.\n\n" . "Examples:\n" . "- User says \"invalid when field 1 is empty\" → condition should be: field 1 IsEmpty\n" . "- User says \"invalid when sum is less than 10\" → formula should return true when sum < 10\n"; case 'SkipRepeaterItem': return "CONDITION TYPE: Skip Repeater Item Condition.\n" . "BEHAVIOR: When your condition evaluates to TRUE → the repeater item is SKIPPED (excluded from calculations/submissions).\n" . "When your condition evaluates to FALSE → the repeater item is INCLUDED normally.\n\n" . "If the user says \"skip when field 1 is empty\" → condition should be: field 1 IsEmpty (direct match).\n"; default: return "CONDITION TYPE: $type.\n" . "BEHAVIOR: When the condition evaluates to TRUE, the associated behavior is activated.\n" . "Generate the condition so TRUE triggers the desired behavior.\n"; } } }