mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-24 05:16:09 +00:00
feat: Update the human-computer interaction template cards, and optimize the prompt information and content display.
This commit is contained in:
@@ -797,22 +797,12 @@ def _wecom_input_hint_lines(form_data: dict) -> list[str]:
|
|||||||
continue
|
continue
|
||||||
if not field_name:
|
if not field_name:
|
||||||
continue
|
continue
|
||||||
if field_type == 'select':
|
if field_type in {'file', 'file-list'}:
|
||||||
source = field.get('option_source') or {}
|
|
||||||
options = source.get('value') if isinstance(source, dict) else []
|
|
||||||
if isinstance(options, list) and options:
|
|
||||||
option_text = ', '.join(f'{idx}. {option}' for idx, option in enumerate(options, start=1))
|
|
||||||
lines.append(f'- {field_label}: {option_text}')
|
|
||||||
else:
|
|
||||||
lines.append(f'- {field_label}: choose one option')
|
|
||||||
elif field_type in {'file', 'file-list'}:
|
|
||||||
limit = field.get('number_limits') if field_type == 'file-list' else 1
|
limit = field.get('number_limits') if field_type == 'file-list' else 1
|
||||||
allowed_types = ', '.join(field.get('allowed_file_types') or [])
|
allowed_types = ', '.join(field.get('allowed_file_types') or [])
|
||||||
suffix = f', up to {limit}' if field_type == 'file-list' and limit else ''
|
suffix = f', up to {limit}' if field_type == 'file-list' and limit else ''
|
||||||
allowed = f' ({allowed_types})' if allowed_types else ''
|
allowed = f' ({allowed_types})' if allowed_types else ''
|
||||||
lines.append(f'- {field_label}: upload file(s){allowed}{suffix} or reply `{field_name}: <url>`')
|
lines.append(f'- {field_label}: upload file(s){allowed}{suffix} or reply `{field_name}: <url>`')
|
||||||
else:
|
|
||||||
lines.append(f'请直接回复:{field_label}')
|
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|
||||||
@@ -861,18 +851,8 @@ def _wecom_field_title(field: dict, fallback: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _wecom_form_desc(form_data: dict) -> str:
|
def _wecom_form_desc(form_data: dict) -> str:
|
||||||
current_field = str(form_data.get('_current_input_field') or '').strip()
|
|
||||||
if current_field:
|
|
||||||
for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []:
|
|
||||||
if str(field.get('output_variable_name') or '').strip() != current_field:
|
|
||||||
continue
|
|
||||||
field_type = str(field.get('type') or 'text').strip().lower()
|
|
||||||
if field_type != 'select':
|
|
||||||
return f'请直接回复:{_wecom_field_display_name(field, current_field)}'
|
|
||||||
form_content = _wecom_clean_form_content(form_data)
|
form_content = _wecom_clean_form_content(form_data)
|
||||||
if form_content:
|
return form_content[:512] if form_content else ''
|
||||||
return form_content[:512]
|
|
||||||
return 'Please choose an option to continue.'
|
|
||||||
|
|
||||||
|
|
||||||
def build_human_input_text_prompt(form_data: dict) -> Optional[str]:
|
def build_human_input_text_prompt(form_data: dict) -> Optional[str]:
|
||||||
@@ -887,8 +867,11 @@ def build_human_input_text_prompt(form_data: dict) -> Optional[str]:
|
|||||||
field_type = str(field.get('type') or 'text').strip().lower()
|
field_type = str(field.get('type') or 'text').strip().lower()
|
||||||
if field_type == 'select':
|
if field_type == 'select':
|
||||||
return None
|
return None
|
||||||
label = _wecom_field_display_name(field, current_field)
|
form_content = _wecom_clean_form_content(form_data)
|
||||||
return f'{str(form_data.get("node_title") or "人工介入").strip()}\n\n请直接回复:{label}'
|
if not form_content:
|
||||||
|
form_content = _wecom_field_display_name(field, current_field)
|
||||||
|
node_title = str(form_data.get('node_title') or '人工介入').strip()
|
||||||
|
return f'{node_title}\n\n{form_content}' if form_content else node_title
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -1010,13 +993,11 @@ def build_select_button_interaction_payload(
|
|||||||
overflow_options = options[6:]
|
overflow_options = options[6:]
|
||||||
|
|
||||||
node_title = (form_data.get('node_title') or '').strip() or 'Human Input'
|
node_title = (form_data.get('node_title') or '').strip() or 'Human Input'
|
||||||
field_title = _wecom_field_title(field, field_name or 'Select') if field else 'Select'
|
|
||||||
form_content = _wecom_clean_form_content(form_data)
|
form_content = _wecom_clean_form_content(form_data)
|
||||||
|
|
||||||
sub_title_parts: list[str] = []
|
sub_title_parts: list[str] = []
|
||||||
if form_content:
|
if form_content:
|
||||||
sub_title_parts.append(form_content)
|
sub_title_parts.append(form_content)
|
||||||
sub_title_parts.append(f'Choose {field_title}.')
|
|
||||||
if overflow_options:
|
if overflow_options:
|
||||||
extra_lines = [f' - {idx + 7}. {option}' for idx, option in enumerate(overflow_options)]
|
extra_lines = [f' - {idx + 7}. {option}' for idx, option in enumerate(overflow_options)]
|
||||||
sub_title_parts.append(
|
sub_title_parts.append(
|
||||||
@@ -1066,21 +1047,57 @@ def build_human_input_template_card_payload(
|
|||||||
|
|
||||||
|
|
||||||
def _wecom_clean_form_content(form_data: dict) -> str:
|
def _wecom_clean_form_content(form_data: dict) -> str:
|
||||||
content = form_data.get('raw_form_content') or form_data.get('form_content') or ''
|
is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only')
|
||||||
field_names = {
|
raw_content = str(form_data.get('raw_form_content') or '')
|
||||||
str(field.get('output_variable_name') or '').strip()
|
content = form_data.get('form_content') or raw_content
|
||||||
for field in form_data.get('input_defs') or []
|
input_defs = list(form_data.get('all_input_defs') or form_data.get('input_defs') or [])
|
||||||
|
fields = {
|
||||||
|
str(field.get('output_variable_name') or '').strip(): field
|
||||||
|
for field in input_defs
|
||||||
if str(field.get('output_variable_name') or '').strip()
|
if str(field.get('output_variable_name') or '').strip()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if form_data.get('_action_select_only') and raw_content:
|
||||||
|
placeholders = [
|
||||||
|
match
|
||||||
|
for match in re.finditer(r'\{\{#\$output\.([^#{}]+)#\}\}', raw_content)
|
||||||
|
if match.group(1).strip() in fields
|
||||||
|
]
|
||||||
|
if placeholders:
|
||||||
|
content = raw_content[placeholders[-1].end() :]
|
||||||
|
|
||||||
|
if is_field_step:
|
||||||
|
inputs = form_data.get('inputs') or {}
|
||||||
|
|
||||||
|
def replace_placeholder(match: re.Match[str]) -> str:
|
||||||
|
field_name = match.group(1).strip()
|
||||||
|
field = fields.get(field_name)
|
||||||
|
value = inputs.get(field_name)
|
||||||
|
if not field or value in (None, '', []):
|
||||||
|
return ''
|
||||||
|
return f'✅ {field_name}:{_wecom_display_input_value(field, value)}'
|
||||||
|
|
||||||
|
content = re.sub(r'\{\{#\$output\.([^#{}]+)#\}\}', replace_placeholder, str(content))
|
||||||
|
|
||||||
kept_lines: list[str] = []
|
kept_lines: list[str] = []
|
||||||
for line in str(content).splitlines():
|
for line in str(content).splitlines():
|
||||||
placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line)
|
placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line)
|
||||||
if placeholder and placeholder.group(1) in field_names:
|
if placeholder and placeholder.group(1).strip() in fields:
|
||||||
continue
|
continue
|
||||||
kept_lines.append(line)
|
kept_lines.append(line)
|
||||||
return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip())
|
return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _wecom_display_input_value(field: dict, value: Any) -> str:
|
||||||
|
field_type = str(field.get('type') or 'text').strip().lower()
|
||||||
|
if field_type == 'file':
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return str(value.get('url') or value.get('upload_file_id') or '1 file')
|
||||||
|
elif field_type == 'file-list' and isinstance(value, list):
|
||||||
|
return f'{len(value)} file(s)'
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
def build_button_interaction_payload(
|
def build_button_interaction_payload(
|
||||||
form_data: dict,
|
form_data: dict,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
@@ -1150,7 +1167,6 @@ def build_button_interaction_payload(
|
|||||||
'card_type': 'button_interaction',
|
'card_type': 'button_interaction',
|
||||||
'main_title': {
|
'main_title': {
|
||||||
'title': node_title,
|
'title': node_title,
|
||||||
'desc': _wecom_form_desc(form_data),
|
|
||||||
},
|
},
|
||||||
'sub_title_text': sub_title_text,
|
'sub_title_text': sub_title_text,
|
||||||
'button_list': button_list,
|
'button_list': button_list,
|
||||||
@@ -1434,7 +1450,7 @@ def build_button_interaction_update_card(
|
|||||||
"""Build the template_card body used to update a clicked form card."""
|
"""Build the template_card body used to update a clicked form card."""
|
||||||
|
|
||||||
node_title = str(form_data.get('node_title') or '').strip() or '人工介入'
|
node_title = str(form_data.get('node_title') or '').strip() or '人工介入'
|
||||||
form_content = str(form_data.get('form_content') or '').strip()
|
form_content = _wecom_clean_form_content(form_data)
|
||||||
action_title = resolve_form_action_title(form_data, action_id)
|
action_title = resolve_form_action_title(form_data, action_id)
|
||||||
clean_action_id = str(action_id or '').strip()
|
clean_action_id = str(action_id or '').strip()
|
||||||
|
|
||||||
@@ -1528,15 +1544,19 @@ def build_multiple_interaction_update_card(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
display_form_data = dict(form_data)
|
||||||
|
display_form_data['inputs'] = selected_values
|
||||||
|
form_content = _wecom_clean_form_content(display_form_data)
|
||||||
|
|
||||||
card: dict[str, Any] = {
|
card: dict[str, Any] = {
|
||||||
'card_type': 'multiple_interaction',
|
'card_type': 'multiple_interaction',
|
||||||
'main_title': {
|
'main_title': {
|
||||||
'title': node_title,
|
'title': node_title,
|
||||||
'desc': 'Submitted',
|
'desc': form_content,
|
||||||
},
|
},
|
||||||
'select_list': select_list,
|
'select_list': select_list,
|
||||||
'submit_button': {
|
'submit_button': {
|
||||||
'text': 'Submitted',
|
'text': '✅',
|
||||||
'key': 'submit_human_input',
|
'key': 'submit_human_input',
|
||||||
},
|
},
|
||||||
'task_id': task_id,
|
'task_id': task_id,
|
||||||
|
|||||||
@@ -169,11 +169,12 @@ def test_build_payload_can_emulate_select_as_buttons_for_wecombot_ws():
|
|||||||
assert parse_select_button_action(card['button_list'][1]['key'], form_data) == {'choice': 'B'}
|
assert parse_select_button_action(card['button_list'][1]['key'], form_data) == {'choice': 'B'}
|
||||||
|
|
||||||
|
|
||||||
def test_text_input_card_shows_direct_reply_prompt():
|
def test_text_input_card_uses_current_stage_content_without_direct_reply_prompt():
|
||||||
payload = build_human_input_template_card_payload(
|
payload = build_human_input_template_card_payload(
|
||||||
{
|
{
|
||||||
'node_title': '人工介入',
|
'node_title': '人工介入',
|
||||||
'form_content': '请输入你的问题\n\n{{#$output.us_input#}}',
|
'form_content': '11\n请输入你的问题\n\n{{#$output.us_input#}}',
|
||||||
|
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
||||||
'input_defs': [
|
'input_defs': [
|
||||||
{
|
{
|
||||||
'output_variable_name': 'us_input',
|
'output_variable_name': 'us_input',
|
||||||
@@ -189,8 +190,8 @@ def test_text_input_card_shows_direct_reply_prompt():
|
|||||||
)
|
)
|
||||||
|
|
||||||
card = payload['template_card']
|
card = payload['template_card']
|
||||||
assert card['main_title']['desc'] == '请直接回复:请输入你的问题'
|
assert 'desc' not in card['main_title']
|
||||||
assert '请直接回复:请输入你的问题' in card['sub_title_text']
|
assert card['sub_title_text'] == '11\n请输入你的问题'
|
||||||
assert card['button_list'] == []
|
assert card['button_list'] == []
|
||||||
|
|
||||||
|
|
||||||
@@ -198,6 +199,8 @@ def test_build_human_input_text_prompt_for_current_text_field():
|
|||||||
prompt = build_human_input_text_prompt(
|
prompt = build_human_input_text_prompt(
|
||||||
{
|
{
|
||||||
'node_title': '人工介入',
|
'node_title': '人工介入',
|
||||||
|
'form_content': '11\n请输入你的问题\n{{#$output.us_input#}}',
|
||||||
|
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
||||||
'input_defs': [
|
'input_defs': [
|
||||||
{
|
{
|
||||||
'output_variable_name': 'us_input',
|
'output_variable_name': 'us_input',
|
||||||
@@ -209,7 +212,7 @@ def test_build_human_input_text_prompt_for_current_text_field():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
assert prompt == '人工介入\n\n请直接回复:请输入你的问题'
|
assert prompt == '人工介入\n\n11\n请输入你的问题'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -229,6 +232,8 @@ async def test_ws_push_form_pause_sends_text_prompt_without_empty_card():
|
|||||||
'msg-1',
|
'msg-1',
|
||||||
{
|
{
|
||||||
'node_title': '人工介入',
|
'node_title': '人工介入',
|
||||||
|
'form_content': '11\n请输入你的问题\n{{#$output.us_input#}}',
|
||||||
|
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
||||||
'input_defs': [
|
'input_defs': [
|
||||||
{
|
{
|
||||||
'output_variable_name': 'us_input',
|
'output_variable_name': 'us_input',
|
||||||
@@ -243,7 +248,7 @@ async def test_ws_push_form_pause_sends_text_prompt_without_empty_card():
|
|||||||
assert ok is True
|
assert ok is True
|
||||||
assert stream_id == 'stream-1'
|
assert stream_id == 'stream-1'
|
||||||
assert task_id is None
|
assert task_id is None
|
||||||
assert sent == [('req-1', '人工介入\n\n请直接回复:请输入你的问题')]
|
assert sent == [('req-1', '人工介入\n\n11\n请输入你的问题')]
|
||||||
assert client._pending_forms_by_task == {}
|
assert client._pending_forms_by_task == {}
|
||||||
assert 'msg-1' not in client._stream_ids
|
assert 'msg-1' not in client._stream_ids
|
||||||
|
|
||||||
@@ -363,10 +368,12 @@ def test_extract_template_card_selections_reads_response_data_direct_mapping():
|
|||||||
assert selections == {'choice': 'B'}
|
assert selections == {'choice': 'B'}
|
||||||
|
|
||||||
|
|
||||||
def test_build_multiple_interaction_update_card_disables_submitted_select():
|
def test_build_multiple_interaction_update_card_disables_selected_value_without_submitted_text():
|
||||||
card = build_multiple_interaction_update_card(
|
card = build_multiple_interaction_update_card(
|
||||||
{
|
{
|
||||||
'node_title': 'Manual Review',
|
'node_title': 'Manual Review',
|
||||||
|
'form_content': 'Choose a label\n{{#$output.choice#}}',
|
||||||
|
'raw_form_content': 'Choose a label\n{{#$output.choice#}}',
|
||||||
'input_defs': [
|
'input_defs': [
|
||||||
{
|
{
|
||||||
'output_variable_name': 'choice',
|
'output_variable_name': 'choice',
|
||||||
@@ -374,12 +381,73 @@ def test_build_multiple_interaction_update_card_disables_submitted_select():
|
|||||||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
'_current_input_field': 'choice',
|
||||||
},
|
},
|
||||||
task_id='task-1',
|
task_id='task-1',
|
||||||
selections={'choice': 'B'},
|
selections={'choice': 'B'},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert card['card_type'] == 'multiple_interaction'
|
assert card['card_type'] == 'multiple_interaction'
|
||||||
assert card['main_title']['desc'] == 'Submitted'
|
assert card['main_title']['desc'] == 'Choose a label\n✅ choice:B'
|
||||||
|
assert card['submit_button']['text'] == '✅'
|
||||||
assert card['select_list'][0]['disable'] is True
|
assert card['select_list'][0]['disable'] is True
|
||||||
assert card['select_list'][0]['selected_id'] == 'opt_2'
|
assert card['select_list'][0]['selected_id'] == 'opt_2'
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_stage_only_shows_current_prompt_in_a_separate_message():
|
||||||
|
raw_content = '11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'
|
||||||
|
payload = build_human_input_template_card_payload(
|
||||||
|
{
|
||||||
|
'node_title': '人工介入',
|
||||||
|
'form_content': '请选择你的答案\n{{#$output.xiala#}}',
|
||||||
|
'raw_form_content': raw_content,
|
||||||
|
'input_defs': [
|
||||||
|
{
|
||||||
|
'output_variable_name': 'xiala',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['1', '2']},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'all_input_defs': [
|
||||||
|
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||||||
|
{
|
||||||
|
'output_variable_name': 'xiala',
|
||||||
|
'type': 'select',
|
||||||
|
'option_source': {'type': 'constant', 'value': ['1', '2']},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'inputs': {'us_input': '你叫啥'},
|
||||||
|
'_current_input_field': 'xiala',
|
||||||
|
},
|
||||||
|
task_id='task-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload['template_card']['main_title']['desc'] == '请选择你的答案'
|
||||||
|
|
||||||
|
|
||||||
|
def test_action_stage_only_shows_content_after_fields_without_placeholders():
|
||||||
|
raw_content = '11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}\n请选择操作'
|
||||||
|
payload = build_human_input_template_card_payload(
|
||||||
|
{
|
||||||
|
'node_title': '人工介入',
|
||||||
|
'form_content': raw_content,
|
||||||
|
'raw_form_content': raw_content,
|
||||||
|
'input_defs': [],
|
||||||
|
'all_input_defs': [
|
||||||
|
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||||||
|
{'output_variable_name': 'xiala', 'type': 'select'},
|
||||||
|
],
|
||||||
|
'inputs': {'us_input': '你叫啥', 'xiala': '2'},
|
||||||
|
'actions': [
|
||||||
|
{'id': 'yes', 'title': 'yes'},
|
||||||
|
{'id': 'no', 'title': 'no'},
|
||||||
|
],
|
||||||
|
'_action_select_only': True,
|
||||||
|
},
|
||||||
|
task_id='task-1',
|
||||||
|
)
|
||||||
|
|
||||||
|
card = payload['template_card']
|
||||||
|
assert card['sub_title_text'] == '请选择操作'
|
||||||
|
assert '{{#$output.' not in card['sub_title_text']
|
||||||
|
assert [button['text'] for button in card['button_list']] == ['yes', 'no']
|
||||||
|
|||||||
Reference in New Issue
Block a user