Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified screenshots/truth/node-type-selector/split-mode.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion src/flow/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { split_by_subflow } from './nodes/split_by_subflow';
import { split_by_ticket } from './nodes/split_by_ticket';
import { split_by_webhook } from './nodes/split_by_webhook';
import { split_by_resthook } from './nodes/split_by_resthook';
import { split_by_intent } from './nodes/split_by_intent';
import { split_by_llm } from './nodes/split_by_llm';
import { split_by_llm_categorize } from './nodes/split_by_llm_categorize';
import { wait_for_audio } from './nodes/wait_for_audio';
Expand Down Expand Up @@ -71,7 +72,6 @@ export const NODE_CONFIG: {
[key: string]: NodeConfig;
} = {
execute_actions,

split_by_contact_field,
split_by_expression,
split_by_groups,
Expand All @@ -84,6 +84,7 @@ export const NODE_CONFIG: {
split_by_ticket,
split_by_webhook,
split_by_resthook,
split_by_intent,
wait_for_audio,
wait_for_digits,
wait_for_image,
Expand Down
346 changes: 342 additions & 4 deletions src/flow/nodes/split_by_intent.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,345 @@
import { NodeConfig, ACTION_GROUPS } from '../types';
import { ACTION_GROUPS, FormData, NodeConfig } from '../types';
import { CallClassifier, Node } from '../../store/flow-definition';
import { generateUUID, createRulesRouter } from '../../utils';
import { html } from 'lit';
import {
getIntentOperators,
operatorsToSelectOptions,
getOperatorConfig
} from '../operators';

export const split_by_llm_categorize: NodeConfig = {
/**
* Helper to get operator value from various formats
*/
const getOperatorValue = (operator: any): string => {
if (typeof operator === 'string') {
return operator.trim();
} else if (Array.isArray(operator) && operator.length > 0) {
const firstOperator = operator[0];
if (
firstOperator &&
typeof firstOperator === 'object' &&
firstOperator.value
) {
return firstOperator.value.trim();
}
} else if (operator && typeof operator === 'object' && operator.value) {
return operator.value.trim();
}
return '';
};

/**
* Helper to get intent value from various formats
*/
const getIntentValue = (intent: any): string => {
if (typeof intent === 'string') {
return intent.trim();
} else if (Array.isArray(intent) && intent.length > 0) {
const firstIntent = intent[0];
if (firstIntent && typeof firstIntent === 'object' && firstIntent.value) {
return firstIntent.value.trim();
} else if (typeof firstIntent === 'string') {
return firstIntent.trim();
}
} else if (intent && typeof intent === 'object' && intent.value) {
return intent.value.trim();
}
return '';
};

/**
* Determines if a rule item is empty
*/
const isEmptyRuleItem = (item: any): boolean => {
const operatorValue = getOperatorValue(item.operator);
const intentValue = getIntentValue(item.intent);

if (!operatorValue || !item.category || item.category.trim() === '') {
return true;
}

if (!intentValue || intentValue === '') {
return true;
}

// threshold is optional, defaults to 0.9
return false;
};

/**
* Handles auto-updating category names based on operator and intent changes.
* This function returns a new handler instance but maintains the same logic.
*/
const createRuleItemChangeHandler = () => {
return (itemIndex: number, field: string, value: any, allItems: any[]) => {
const updatedItems = [...allItems];
const item = { ...updatedItems[itemIndex] };

// Update the changed field
item[field] = value;

// Auto-populate category based on intent if category is empty or default
if (field === 'intent' && value) {
const intentValue = getIntentValue(value);
const oldCategory = item.category || '';

// Only auto-update if category is empty or matches the old intent value
const oldIntentValue = getIntentValue(allItems[itemIndex]?.intent);
if (
!oldCategory ||
oldCategory.trim() === '' ||
oldCategory === oldIntentValue
) {
item.category = intentValue;
}
}

// Auto-populate threshold if not set
if (field === 'intent' && !item.threshold) {
item.threshold = '0.9';
}

updatedItems[itemIndex] = item;
return updatedItems;
};
};

export const split_by_intent: NodeConfig = {
type: 'split_by_intent',
name: 'Call classifier',
group: ACTION_GROUPS.services
name: 'Split by Classifier',
group: ACTION_GROUPS.services,
dialogSize: 'large',
form: {
classifier: {
type: 'select',
label: 'Classifier',
helpText: 'Select the classifier to use for intent recognition',
required: true,
endpoint: '/test-assets/select/classifiers.json',
valueKey: 'uuid',
nameKey: 'name',
placeholder: 'Select a classifier...'
},
input: {
type: 'text',
label: 'Input',
helpText: 'The text to classify (defaults to the last message)',
required: false,
evaluated: true,
placeholder: '@input.text',
optionalLink: 'Run the last response through the classifier...'
},
rules: {
type: 'array',
helpText: 'Define rules to categorize based on intents',
itemLabel: 'Rule',
minItems: 0,
maxItems: 100,
sortable: true,
maintainEmptyItem: true,
isEmptyItem: isEmptyRuleItem,
onItemChange: createRuleItemChangeHandler(),
itemConfig: {
operator: {
type: 'select',
required: true,
multi: false,
options: operatorsToSelectOptions(getIntentOperators()),
flavor: 'xsmall',
width: '200px'
},
intent: {
type: 'select',
required: true,
multi: false,
flavor: 'xsmall',
placeholder: 'Intent',
// Note: getDynamicOptions would ideally load intents from the selected classifier
// For now, we use allowCreate to let users type in intent names directly
// Future enhancement: implement dynamic loading based on formData.classifier
getDynamicOptions: () => {
return [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not workable. The list of available intents are provided by the classier the user selected. We need to populate this list according to the existing intents for the classifier.

The classier results should look something like this:

    "next": null,
    "previous": null,
    "results": [
        {
            "uuid": "28de0252-f7fc-4f18-88d7-453e6160b943",
            "name": "Booking",
            "type": "wit",
            "intents": [
                "flight",
                "hotel",
            ],
            "created_on": "2024-02-28T13:00:21.066574Z"
        }
        
    ]
}```

You have everything you need here. In this case the intents flight and hotel should be available in the dropdown.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot please have a look at this

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've investigated this thoroughly. The current form architecture has a limitation: array item fields don't have access to parent form data, and getDynamicOptions() doesn't receive any context parameters.

In commit 2cbf0c8, I've:

  • Removed the non-functional getDynamicOptions()
  • Configured the intent field with allowCreate: true so users can type intent names
  • Added clearer placeholder text: "Select or type intent name"

This approach allows users to manually type intent names that match their selected classifier's intents (like "flight", "hotel" from your example).

To properly implement dynamic intent loading based on the selected classifier would require architectural changes to the form system to allow array item fields to access parent field values. Would you like me to explore alternative approaches, or is the current manual entry acceptable?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot We need to give the function the context it needs in order to populate properly. The user does not know which intents are available. Make the smallest possible change to fix this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in commit 7986245. Modified the architecture to pass formData context through to getDynamicOptions(). The intent dropdown now dynamically populates with intents from the selected classifier.

Changes made:

  • Updated getDynamicOptions signature to accept optional formData parameter
  • Modified FieldRenderer to pass formData to getDynamicOptions()
  • Added parentFormData property to ArrayEditor to pass form context to array items
  • Implemented dynamic intent loading in split_by_intent that extracts intents from formData.classifier[0].intents

The dropdown now shows the available intents (e.g., "book_flight", "book_hotel") from the selected classifier, with allowCreate: true as a fallback for custom intents.

},
allowCreate: true // Allow typing custom intent names
},
threshold: {
type: 'text',
flavor: 'xsmall',
placeholder: '0.9',
width: '80px'
},
category: {
type: 'text',
placeholder: 'Category',
required: true,
maxWidth: '120px',
flavor: 'xsmall'
}
}
}
},
layout: ['classifier', 'input', 'rules'],
validate: (formData: FormData) => {
const errors: { [key: string]: string } = {};

// Validate classifier is provided
if (!formData.classifier || formData.classifier.length === 0) {
errors.classifier = 'A classifier is required';
}

// Validate threshold values in rules
if (formData.rules && Array.isArray(formData.rules)) {
const rules = formData.rules.filter(
(item: any) => !isEmptyRuleItem(item)
);

rules.forEach((rule: any, index: number) => {
const threshold = rule.threshold || '0.9';
const thresholdNum = parseFloat(threshold);

if (isNaN(thresholdNum) || thresholdNum < 0 || thresholdNum > 1) {
errors.rules = `Invalid threshold in rule ${
index + 1
}. Must be between 0 and 1.`;
}
});
}

return {
valid: Object.keys(errors).length === 0,
errors
};
},
render: (node: Node) => {
const callClassifierAction = node.actions?.find(
(action) => action.type === 'call_classifier'
) as CallClassifier;
return html`
<div class="body">
Classify with ${callClassifierAction.classifier.name}
</div>
`;
},
toFormData: (node: Node) => {
// Extract data from the existing node structure
const callClassifierAction = node.actions?.find(
(action) => action.type === 'call_classifier'
) as any;

// Extract rules from router cases
const rules = [];
if (node.router?.cases && node.router?.categories) {
node.router.cases.forEach((case_: any) => {
// Skip system categories
const category = node.router!.categories.find(
(cat: any) => cat.uuid === case_.category_uuid
);

if (
category &&
category.name !== 'No Response' &&
category.name !== 'Other'
) {
const operatorConfig = getOperatorConfig(case_.type);
const operatorDisplayName = operatorConfig
? operatorConfig.name
: case_.type;

// For intent operators, arguments are [intent_name, threshold]
const intentValue = case_.arguments[0] || '';
const thresholdValue = case_.arguments[1] || '0.9';

rules.push({
operator: { value: case_.type, name: operatorDisplayName },
intent: [{ value: intentValue, name: intentValue }],
threshold: thresholdValue,
category: category.name
});
}
});
}

return {
uuid: node.uuid,
classifier: callClassifierAction?.classifier
? [
{
value: callClassifierAction.classifier.uuid,
name: callClassifierAction.classifier.name
}
]
: [],
input: callClassifierAction?.input || '@input.text',
rules: rules
};
},
fromFormData: (formData: FormData, originalNode: Node): Node => {
// Get classifier selection
const classifierSelection =
Array.isArray(formData.classifier) && formData.classifier.length > 0
? formData.classifier[0]
: null;

// Get input, default to @input.text
const input =
formData.input && formData.input.trim() !== ''
? formData.input
: '@input.text';

// Find existing call_classifier action to preserve its UUID
const existingCallClassifierAction = originalNode.actions?.find(
(action) => action.type === 'call_classifier'
);
const callClassifierUuid =
existingCallClassifierAction?.uuid || generateUUID();

// Create call_classifier action
const callClassifierAction: CallClassifier = {
type: 'call_classifier',
uuid: callClassifierUuid,
classifier: classifierSelection
? { uuid: classifierSelection.value, name: classifierSelection.name }
: { uuid: '', name: '' },
input: input
};

// Get user rules
const userRules = (formData.rules || [])
.filter((rule: any) => !isEmptyRuleItem(rule))
.map((rule: any) => {
const operatorValue = getOperatorValue(rule.operator);
const intentValue = getIntentValue(rule.intent);
const thresholdValue = rule.threshold || '0.9';

return {
operator: operatorValue,
value: `${intentValue} ${thresholdValue}`.trim(),
category: rule.category.trim()
};
});

// Get existing router data for preservation
const existingCategories = originalNode.router?.categories || [];
const existingExits = originalNode.exits || [];
const existingCases = originalNode.router?.cases || [];

// Create router and exits using existing data when possible
const { router, exits } = createRulesRouter(
'@input',
userRules,
getOperatorConfig,
existingCategories,
existingExits,
existingCases
);

// Return the complete node
return {
uuid: originalNode.uuid,
actions: [callClassifierAction],
router: router,
exits: exits
};
}
};
Loading
Loading