From 354aadfce018f14b8f28ad378502dad65072960a Mon Sep 17 00:00:00 2001 From: fdc310 <2213070223@qq.com> Date: Sun, 12 Jul 2026 00:31:12 +0800 Subject: [PATCH] feat: Enhance select input handling and validation in Dify API runner and Telegram adapter --- src/langbot/pkg/platform/sources/telegram.py | 2 +- src/langbot/pkg/provider/runners/difysvapi.py | 137 ++++++++++++++---- .../platform/test_telegram_adapter.py | 2 +- .../provider/runners/test_difysvapi_runner.py | 90 ++++++++++++ 4 files changed, 204 insertions(+), 27 deletions(-) diff --git a/src/langbot/pkg/platform/sources/telegram.py b/src/langbot/pkg/platform/sources/telegram.py index 295c3b274..9d58c9287 100644 --- a/src/langbot/pkg/platform/sources/telegram.py +++ b/src/langbot/pkg/platform/sources/telegram.py @@ -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, } diff --git a/src/langbot/pkg/provider/runners/difysvapi.py b/src/langbot/pkg/provider/runners/difysvapi.py index 1807294d0..afee2f68f 100644 --- a/src/langbot/pkg/provider/runners/difysvapi.py +++ b/src/langbot/pkg/provider/runners/difysvapi.py @@ -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 {}): diff --git a/tests/unit_tests/platform/test_telegram_adapter.py b/tests/unit_tests/platform/test_telegram_adapter.py index c45a5c6dc..c97349f51 100644 --- a/tests/unit_tests/platform/test_telegram_adapter.py +++ b/tests/unit_tests/platform/test_telegram_adapter.py @@ -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, } diff --git a/tests/unit_tests/provider/runners/test_difysvapi_runner.py b/tests/unit_tests/provider/runners/test_difysvapi_runner.py index 38bf1d2a5..2263267c1 100644 --- a/tests/unit_tests/provider/runners/test_difysvapi_runner.py +++ b/tests/unit_tests/provider/runners/test_difysvapi_runner.py @@ -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()