mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-15 00:46:07 +00:00
feat: Enhance select input handling and validation in Dify API runner and Telegram adapter
This commit is contained in:
@@ -72,7 +72,7 @@ def _telegram_form_action_from_callback(data: dict) -> dict | None:
|
||||
return None
|
||||
return {
|
||||
'action_id': '',
|
||||
'inputs': {'select': str(option_index)},
|
||||
'inputs': {'select': {'index': option_index}},
|
||||
'_input_progress': True,
|
||||
}
|
||||
|
||||
|
||||
@@ -223,6 +223,57 @@ def _select_options(field: dict[str, typing.Any]) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_select_value(
|
||||
value: typing.Any,
|
||||
options: list[str],
|
||||
*,
|
||||
allow_legacy_zero_based_index: bool = False,
|
||||
allow_one_based_index: bool = False,
|
||||
) -> tuple[bool, typing.Any]:
|
||||
"""Resolve a select input without confusing numeric values with indexes."""
|
||||
if not options:
|
||||
return True, value
|
||||
|
||||
parsed = value
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
try:
|
||||
json_value = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
json_value = None
|
||||
if isinstance(json_value, dict):
|
||||
parsed = json_value
|
||||
else:
|
||||
parsed = stripped
|
||||
|
||||
if isinstance(parsed, dict):
|
||||
explicit_value = parsed.get('value')
|
||||
if explicit_value not in (None, ''):
|
||||
candidate = str(explicit_value).strip()
|
||||
match = next((option for option in options if option.casefold() == candidate.casefold()), None)
|
||||
return (True, match) if match is not None else (False, value)
|
||||
explicit_index = parsed.get('index')
|
||||
if isinstance(explicit_index, int) and not isinstance(explicit_index, bool):
|
||||
if 0 <= explicit_index < len(options):
|
||||
return True, options[explicit_index]
|
||||
return False, value
|
||||
return False, value
|
||||
|
||||
candidate = str(parsed).strip()
|
||||
match = next((option for option in options if option.casefold() == candidate.casefold()), None)
|
||||
if match is not None:
|
||||
return True, match
|
||||
|
||||
if candidate.isdigit():
|
||||
index = int(candidate)
|
||||
if allow_one_based_index and 1 <= index <= len(options):
|
||||
return True, options[index - 1]
|
||||
if allow_legacy_zero_based_index and 0 <= index < len(options):
|
||||
return True, options[index]
|
||||
|
||||
return False, value
|
||||
|
||||
|
||||
def _default_field_value(field: dict[str, typing.Any]) -> typing.Any:
|
||||
default = field.get('default')
|
||||
if isinstance(default, dict):
|
||||
@@ -531,6 +582,42 @@ def _format_missing_form_inputs_notice(form: dict[str, typing.Any], missing: lis
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _invalid_select_inputs(form: dict[str, typing.Any], user_text: str) -> list[str]:
|
||||
keyed_values = _extract_key_value_inputs(user_text)
|
||||
current_field_name = str(form.get('current_input_field') or '').strip()
|
||||
invalid: list[str] = []
|
||||
for field in form.get('input_defs') or []:
|
||||
if _field_type(field) != 'select':
|
||||
continue
|
||||
name = _field_name(field)
|
||||
raw_value = keyed_values.get(name)
|
||||
if raw_value is None and not keyed_values and current_field_name == name and user_text.strip():
|
||||
raw_value = user_text.strip()
|
||||
if raw_value is None:
|
||||
continue
|
||||
valid, _ = _normalize_select_value(
|
||||
raw_value,
|
||||
_select_options(field),
|
||||
allow_one_based_index=True,
|
||||
)
|
||||
if not valid:
|
||||
invalid.append(name)
|
||||
return invalid
|
||||
|
||||
|
||||
def _format_invalid_select_notice(form: dict[str, typing.Any], invalid: list[str]) -> str:
|
||||
lines = ['Invalid select value.']
|
||||
invalid_names = set(invalid)
|
||||
for field in form.get('input_defs') or []:
|
||||
name = _field_name(field)
|
||||
if name not in invalid_names:
|
||||
continue
|
||||
options = _select_options(field)
|
||||
choices = ', '.join(f'{index}. {option}' for index, option in enumerate(options, start=1))
|
||||
lines.append(f'{name}: {choices or "choose one of the available options"}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _normalize_form_action_inputs(
|
||||
pending_form: dict[str, typing.Any],
|
||||
raw_inputs: dict[str, typing.Any],
|
||||
@@ -543,23 +630,15 @@ def _normalize_form_action_inputs(
|
||||
field = fields.get(name)
|
||||
if not field or _field_type(field) != 'select':
|
||||
continue
|
||||
selected = value
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
if isinstance(parsed, dict):
|
||||
selected = parsed.get('value', selected)
|
||||
elif stripped.isdigit():
|
||||
options = _select_options(field)
|
||||
index = int(stripped)
|
||||
if 0 <= index < len(options):
|
||||
selected = options[index]
|
||||
elif 1 <= index <= len(options):
|
||||
selected = options[index - 1]
|
||||
normalized[name] = selected
|
||||
valid, selected = _normalize_select_value(
|
||||
value,
|
||||
_select_options(field),
|
||||
allow_legacy_zero_based_index=True,
|
||||
)
|
||||
if valid:
|
||||
normalized[name] = selected
|
||||
else:
|
||||
normalized.pop(name, None)
|
||||
return normalized
|
||||
|
||||
|
||||
@@ -855,7 +934,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
continue
|
||||
|
||||
raw_value = keyed_values.get(name)
|
||||
if raw_value is None and current_field_name == name and user_text.strip():
|
||||
if raw_value is None and not keyed_values and current_field_name == name and user_text.strip():
|
||||
raw_value = user_text.strip()
|
||||
if raw_value is None and current_field_name == name and typ in {'file', 'file-list'} and uploaded_files:
|
||||
raw_value = ''
|
||||
@@ -864,13 +943,13 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
|
||||
if typ == 'select':
|
||||
options = _select_options(field)
|
||||
selected = raw_value.strip()
|
||||
if selected.isdigit() and 1 <= int(selected) <= len(options):
|
||||
selected = options[int(selected) - 1]
|
||||
elif options:
|
||||
lowered = selected.lower()
|
||||
selected = next((option for option in options if option.lower() == lowered), selected)
|
||||
values[name] = selected
|
||||
valid, selected = _normalize_select_value(
|
||||
raw_value,
|
||||
options,
|
||||
allow_one_based_index=True,
|
||||
)
|
||||
if valid:
|
||||
values[name] = selected
|
||||
elif typ == 'file':
|
||||
urls = _extract_urls(raw_value)
|
||||
if urls:
|
||||
@@ -1367,6 +1446,14 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
'user': pending_form.get('user', ''),
|
||||
}
|
||||
|
||||
if latest_form:
|
||||
invalid_selects = _invalid_select_inputs(latest_form, user_text)
|
||||
if invalid_selects:
|
||||
inputs = await self._collect_form_inputs_from_query(query, latest_form, user_text)
|
||||
form_action = _build_input_progress_action(latest_form, inputs, force_partial=True)
|
||||
form_action['notice'] = _format_invalid_select_notice(latest_form, invalid_selects)
|
||||
return form_action
|
||||
|
||||
if latest_form and latest_form.get('current_input_field') and not normalized_action:
|
||||
inputs = await self._collect_form_inputs_from_query(query, latest_form, user_text)
|
||||
if inputs != (latest_form.get('inputs') or {}):
|
||||
|
||||
@@ -36,7 +36,7 @@ def test_telegram_select_field_options_are_extracted():
|
||||
def test_telegram_select_callback_becomes_input_progress():
|
||||
assert _telegram_form_action_from_callback({'f': 1, 'x': 1}) == {
|
||||
'action_id': '',
|
||||
'inputs': {'select': '1'},
|
||||
'inputs': {'select': {'index': 1}},
|
||||
'_input_progress': True,
|
||||
}
|
||||
|
||||
|
||||
@@ -602,6 +602,42 @@ class TestDifyHumanInputForms:
|
||||
assert action['inputs'] == {'us_input': 'hello', 'xiala': '2'}
|
||||
assert action['_form_data']['_action_select_only'] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_select_reply_keeps_the_current_form_field(self):
|
||||
from langbot.pkg.provider.runners import difysvapi
|
||||
|
||||
runner = self._create_runner()
|
||||
session_key = 'person_user-1'
|
||||
difysvapi._PENDING_FORMS.clear()
|
||||
difysvapi._set_pending_form(
|
||||
session_key,
|
||||
{
|
||||
'form_token': 'token-1',
|
||||
'workflow_run_id': 'run-1',
|
||||
'actions': [{'id': 'yes', 'title': 'Yes'}],
|
||||
'input_defs': [
|
||||
{
|
||||
'output_variable_name': 'choice',
|
||||
'type': 'select',
|
||||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||||
}
|
||||
],
|
||||
'inputs': {},
|
||||
'current_input_field': 'choice',
|
||||
'user': session_key,
|
||||
},
|
||||
)
|
||||
query = MagicMock()
|
||||
query.message_chain = platform_message.MessageChain([platform_message.Plain(text='')])
|
||||
|
||||
action = await runner._match_pending_form_action(query, session_key, 'C')
|
||||
|
||||
assert action['_partial'] is True
|
||||
assert action['inputs'] == {}
|
||||
assert action['_form_data']['_current_input_field'] == 'choice'
|
||||
assert 'choice: 1. A, 2. B' in action['notice']
|
||||
assert difysvapi._get_latest_pending_form(session_key)['inputs'] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_pause_without_text_yields_form_chunk(self):
|
||||
from langbot.pkg.provider.runners import difysvapi
|
||||
@@ -809,6 +845,60 @@ class TestDifyHumanInputForms:
|
||||
assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'}
|
||||
assert second['_form_data']['_action_select_only'] is True
|
||||
|
||||
def test_card_select_preserves_numeric_option_values(self):
|
||||
from langbot.pkg.provider.runners import difysvapi
|
||||
|
||||
runner = self._create_runner()
|
||||
session_key = 'person_user-1'
|
||||
difysvapi._PENDING_FORMS.clear()
|
||||
difysvapi._set_pending_form(
|
||||
session_key,
|
||||
{
|
||||
'form_token': 'token-1',
|
||||
'workflow_run_id': 'run-1',
|
||||
'actions': [{'id': 'yes', 'title': 'Yes'}],
|
||||
'input_defs': [
|
||||
{
|
||||
'output_variable_name': 'choice',
|
||||
'type': 'select',
|
||||
'option_source': {'type': 'constant', 'value': ['1', '2']},
|
||||
}
|
||||
],
|
||||
'inputs': {},
|
||||
'current_input_field': 'choice',
|
||||
'user': session_key,
|
||||
},
|
||||
)
|
||||
|
||||
action = runner._merge_pending_form_action(
|
||||
session_key,
|
||||
{
|
||||
'form_token': 'token-1',
|
||||
'workflow_run_id': 'run-1',
|
||||
'inputs': {'select': '1'},
|
||||
'_current_input_field': 'choice',
|
||||
'_input_progress': True,
|
||||
},
|
||||
)
|
||||
|
||||
assert action['_partial'] is True
|
||||
assert action['inputs'] == {'choice': '1'}
|
||||
|
||||
def test_invalid_card_select_value_is_not_saved(self):
|
||||
from langbot.pkg.provider.runners import difysvapi
|
||||
|
||||
form = {
|
||||
'input_defs': [
|
||||
{
|
||||
'output_variable_name': 'choice',
|
||||
'type': 'select',
|
||||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert difysvapi._normalize_form_action_inputs(form, {'choice': 'C'}) == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocking_resume_uses_chatflow_answer_node_output(self):
|
||||
runner = self._create_runner()
|
||||
|
||||
Reference in New Issue
Block a user