mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-01 23:27:14 +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 None
|
||||||
return {
|
return {
|
||||||
'action_id': '',
|
'action_id': '',
|
||||||
'inputs': {'select': str(option_index)},
|
'inputs': {'select': {'index': option_index}},
|
||||||
'_input_progress': True,
|
'_input_progress': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,57 @@ def _select_options(field: dict[str, typing.Any]) -> list[str]:
|
|||||||
return []
|
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:
|
def _default_field_value(field: dict[str, typing.Any]) -> typing.Any:
|
||||||
default = field.get('default')
|
default = field.get('default')
|
||||||
if isinstance(default, dict):
|
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)
|
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(
|
def _normalize_form_action_inputs(
|
||||||
pending_form: dict[str, typing.Any],
|
pending_form: dict[str, typing.Any],
|
||||||
raw_inputs: dict[str, typing.Any],
|
raw_inputs: dict[str, typing.Any],
|
||||||
@@ -543,23 +630,15 @@ def _normalize_form_action_inputs(
|
|||||||
field = fields.get(name)
|
field = fields.get(name)
|
||||||
if not field or _field_type(field) != 'select':
|
if not field or _field_type(field) != 'select':
|
||||||
continue
|
continue
|
||||||
selected = value
|
valid, selected = _normalize_select_value(
|
||||||
if isinstance(value, str):
|
value,
|
||||||
stripped = value.strip()
|
_select_options(field),
|
||||||
try:
|
allow_legacy_zero_based_index=True,
|
||||||
parsed = json.loads(stripped)
|
)
|
||||||
except json.JSONDecodeError:
|
if valid:
|
||||||
parsed = None
|
normalized[name] = selected
|
||||||
if isinstance(parsed, dict):
|
else:
|
||||||
selected = parsed.get('value', selected)
|
normalized.pop(name, None)
|
||||||
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
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
@@ -855,7 +934,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
raw_value = keyed_values.get(name)
|
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()
|
raw_value = user_text.strip()
|
||||||
if raw_value is None and current_field_name == name and typ in {'file', 'file-list'} and uploaded_files:
|
if raw_value is None and current_field_name == name and typ in {'file', 'file-list'} and uploaded_files:
|
||||||
raw_value = ''
|
raw_value = ''
|
||||||
@@ -864,13 +943,13 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
if typ == 'select':
|
if typ == 'select':
|
||||||
options = _select_options(field)
|
options = _select_options(field)
|
||||||
selected = raw_value.strip()
|
valid, selected = _normalize_select_value(
|
||||||
if selected.isdigit() and 1 <= int(selected) <= len(options):
|
raw_value,
|
||||||
selected = options[int(selected) - 1]
|
options,
|
||||||
elif options:
|
allow_one_based_index=True,
|
||||||
lowered = selected.lower()
|
)
|
||||||
selected = next((option for option in options if option.lower() == lowered), selected)
|
if valid:
|
||||||
values[name] = selected
|
values[name] = selected
|
||||||
elif typ == 'file':
|
elif typ == 'file':
|
||||||
urls = _extract_urls(raw_value)
|
urls = _extract_urls(raw_value)
|
||||||
if urls:
|
if urls:
|
||||||
@@ -1367,6 +1446,14 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
|||||||
'user': pending_form.get('user', ''),
|
'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:
|
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)
|
inputs = await self._collect_form_inputs_from_query(query, latest_form, user_text)
|
||||||
if inputs != (latest_form.get('inputs') or {}):
|
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():
|
def test_telegram_select_callback_becomes_input_progress():
|
||||||
assert _telegram_form_action_from_callback({'f': 1, 'x': 1}) == {
|
assert _telegram_form_action_from_callback({'f': 1, 'x': 1}) == {
|
||||||
'action_id': '',
|
'action_id': '',
|
||||||
'inputs': {'select': '1'},
|
'inputs': {'select': {'index': 1}},
|
||||||
'_input_progress': True,
|
'_input_progress': True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -602,6 +602,42 @@ class TestDifyHumanInputForms:
|
|||||||
assert action['inputs'] == {'us_input': 'hello', 'xiala': '2'}
|
assert action['inputs'] == {'us_input': 'hello', 'xiala': '2'}
|
||||||
assert action['_form_data']['_action_select_only'] is True
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_workflow_pause_without_text_yields_form_chunk(self):
|
async def test_workflow_pause_without_text_yields_form_chunk(self):
|
||||||
from langbot.pkg.provider.runners import difysvapi
|
from langbot.pkg.provider.runners import difysvapi
|
||||||
@@ -809,6 +845,60 @@ class TestDifyHumanInputForms:
|
|||||||
assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'}
|
assert second['inputs'] == {'comment': 'looks good', 'choice': 'B'}
|
||||||
assert second['_form_data']['_action_select_only'] is True
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_blocking_resume_uses_chatflow_answer_node_output(self):
|
async def test_blocking_resume_uses_chatflow_answer_node_output(self):
|
||||||
runner = self._create_runner()
|
runner = self._create_runner()
|
||||||
|
|||||||
Reference in New Issue
Block a user