feat: Enhance select input handling and validation in Dify API runner and Telegram adapter

This commit is contained in:
fdc310
2026-07-12 00:31:12 +08:00
parent 4b6931d294
commit 354aadfce0
4 changed files with 204 additions and 27 deletions
+1 -1
View File
@@ -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,
}
+112 -25
View File
@@ -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()