Files
baiyue-logistics-chat/tests/test_model_tool_loop.py
T

384 lines
21 KiB
Python

"""Exercise the actual Flask tool loop with synthetic provider messages and data."""
from copy import deepcopy
import json
import unittest
from unittest.mock import patch
import test_model_chat as fixtures
from test_quote_timing import timing_snapshot
from test_quotes import frames
from logistics_chat import db
from logistics_chat.chat_tools import execute_tool as actual_execute_tool
from logistics_chat.order_listing import read_orders as actual_read_orders
from logistics_chat.quote_data import import_snapshot, load_book
def tool_plan(name, arguments, call_id):
return fixtures.Completion({'role': 'assistant', 'content': '', 'tool_calls': [
{'id': call_id, 'type': 'function', 'function': {
'name': name, 'arguments': json.dumps(arguments, ensure_ascii=False)}}]}, 'tool_calls')
def search_arguments(question='美国海派外箱标签有什么要求'):
return {'question': question, 'country': 'US', 'services': ['普船海派'], 'topic': ''}
def quote_arguments(**updates):
result = {'question': '请核对当前货物报价', 'request': 'quote', 'selection': 'current',
'services': [], 'policy_topic': 'auto'}
result.update(updates)
return result
def final_facts(*ids):
return fixtures.reply(json.dumps({'facts': list(ids)}, ensure_ascii=False))
def evidence(body):
return [json.loads(item['content']) for item in body['messages'] if item['role'] == 'tool']
class ModelToolLoopTests(unittest.TestCase):
def setUp(self):
fixtures.ModelChatTests.setUp(self)
self.snapshot = timing_snapshot()
self.snapshot['source_sha256'] = '2' * 64
self.snapshot['documents'] = [{'sheet': '海派', 'cell': 'A30',
'text': '外箱必须贴入仓条码和唛头;旧标须移除,但产品原产地标识不得遮挡。'}]
with self.app.app_context():
connection = db.connect()
self.book_id = import_snapshot(connection, self.snapshot, activate=True)['book_id']
connection.commit()
connection.close()
self.initial_rows = self.saved()[0]
def login(self):
fixtures.ModelChatTests.login(self)
def ask(self, question):
events = frames(self.client.post('/stream_chat', json={'msg': question, 'conv_id': self.cid}))
self.assertTrue(events and events[-1].get('d'), events)
self.assertNotIn('SECRET', str(events))
return events
def saved(self):
with self.app.app_context():
connection = db.connect()
rows = connection.execute('SELECT role,content FROM messages WHERE conv_id=? ORDER BY id',
(self.cid,)).fetchall()
state = connection.execute('SELECT conversation_state FROM chat_history WHERE id=?',
(self.cid,)).fetchone()
connection.close()
return rows, json.loads(state[0]) if state and state[0] else None
def test_search_read_final_preserves_tool_ids_and_only_renders_selected_facts(self):
bodies = []
def provider(body, **kwargs):
bodies.append(deepcopy(body))
if len(bodies) == 1:
return tool_plan('search_knowledge', search_arguments(), 'search_a')
if len(bodies) == 2:
facts = evidence(body)
self.assertEqual(facts[0]['status'], 'ok')
ref = facts[0]['data']['matches'][0]['reference']
return tool_plan('read_knowledge', {'question': '查看完整贴标条款', 'reference': ref}, 'read_b')
self.assertEqual(len(bodies), 3)
facts = evidence(body)
self.assertEqual([item['fact_id'] for item in facts], ['fact_0', 'fact_1'])
self.assertEqual(facts[1]['data_version'], self.book_id)
return final_facts('fact_1')
with patch('logistics_chat.ai_client.post_completion', side_effect=provider):
events = self.ask('美国海派标签怎么贴,需要看完整要求')
text = events[0]['t']
self.assertIn('不得遮挡', text)
self.assertEqual(text.count('不得遮挡'), 1)
self.assertNotIn('fact_1', text)
tool_messages = [item for item in bodies[2]['messages'] if item['role'] == 'tool']
self.assertEqual([item['tool_call_id'] for item in tool_messages], ['search_a', 'read_b'])
self.assertEqual([role for role, _ in self.saved()[0][len(self.initial_rows):]], ['user', 'assistant'])
def test_activating_new_book_between_search_and_read_keeps_the_turn_snapshot(self):
bodies = []
def provider(body, **kwargs):
bodies.append(deepcopy(body))
if len(bodies) == 1:
return tool_plan('search_knowledge', search_arguments(), 'search_old')
if len(bodies) == 2:
ref = evidence(body)[0]['data']['matches'][0]['reference']
changed = deepcopy(self.snapshot)
changed['source_sha256'] = '3' * 64
changed['documents'][0]['text'] = '新版外箱贴标必须加贴红色标识。'
connection = db.connect()
self.new_book_id = import_snapshot(connection, changed, activate=True)['book_id']
connection.commit()
connection.close()
return tool_plan('read_knowledge', {'question': '继续读取刚才的原文', 'reference': ref}, 'read_old')
facts = evidence(body)
self.assertEqual([item['data_version'] for item in facts], [self.book_id, self.book_id])
return final_facts('fact_1')
with patch('logistics_chat.ai_client.post_completion', side_effect=provider):
text = self.ask('美国海派外箱标签怎么贴')[0]['t']
self.assertIn('不得遮挡', text)
self.assertNotIn('红色', text)
with self.app.app_context():
self.assertEqual(load_book()['id'], self.new_book_id)
def test_forged_reference_cannot_read_a_document_without_this_turn_search(self):
responses = [tool_plan('read_knowledge', {'question': '读取资料',
'reference': f'kb1:b:{self.book_id}:' + 'a' * 24}, 'forged'), final_facts('fact_0')]
with patch('logistics_chat.ai_client.post_completion', side_effect=responses) as model:
events = self.ask('给我这份资料的原文')
fact = evidence(model.call_args_list[1].args[0])[0]
self.assertEqual(fact['status'], 'not_found')
self.assertEqual(fact['sources'], [])
self.assertNotIn('不得遮挡', events[0]['t'])
def test_multi_region_faq_read_keeps_the_requested_country_scope(self):
calls = 0
def provider(body, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
return tool_plan('search_knowledge', {'question': '英国各渠道时效',
'country': 'GB', 'services': [], 'topic': ''}, 'regional_search')
if calls == 2:
matches = evidence(body)[0]['data']['matches']
shared = next(match for match in matches if match['country'] == 'EU,GB')
return tool_plan('read_knowledge', {'question': '读取这份共同条款',
'reference': shared['reference']}, 'regional_read')
self.assertEqual(evidence(body)[-1]['status'], 'ok')
return final_facts('fact_1')
with patch('logistics_chat.ai_client.post_completion', side_effect=provider):
text = self.ask('英国各渠道时效多少')[0]['t']
self.assertIn('英国', text)
self.assertNotIn('范围已变化', text)
def test_query_inferred_country_survives_search_reference_registration(self):
calls = 0
def provider(body, **kwargs):
nonlocal calls
calls += 1
if calls == 1:
return tool_plan('search_knowledge', {'question': '美国海派外箱标签要求',
'country': '', 'services': ['普船海派'], 'topic': ''}, 'inferred_search')
if calls == 2:
result = evidence(body)[0]
self.assertEqual(result['data']['resolved_country'], 'US')
ref = result['data']['matches'][0]['reference']
return tool_plan('read_knowledge', {'question': '读取完整条款',
'reference': ref}, 'inferred_read')
self.assertEqual(evidence(body)[-1]['status'], 'ok')
return final_facts('fact_1')
with patch('logistics_chat.ai_client.post_completion', side_effect=provider):
text = self.ask('美国海派外箱标签有什么要求')[0]['t']
self.assertIn('不得遮挡', text)
def test_repeated_arguments_reuse_evidence_but_return_each_distinct_tool_id(self):
arguments = search_arguments()
responses = [tool_plan('search_knowledge', arguments, 'first'),
tool_plan('search_knowledge', arguments, 'repeat'), final_facts('fact_0')]
with patch('logistics_chat.ai_client.post_completion', side_effect=responses) as model, \
patch('logistics_chat.model_chat_service.execute_tool', wraps=actual_execute_tool) as execute:
events = self.ask('再核对美国海派贴标要求')
self.assertEqual(execute.call_count, 1)
facts = evidence(model.call_args_list[2].args[0])
self.assertEqual([fact['fact_id'] for fact in facts], ['fact_0', 'fact_0'])
self.assertIn('不得遮挡', events[0]['t'])
def test_duplicate_call_id_and_tool_budget_stop_further_execution(self):
for duplicate in (True, False):
with self.subTest(duplicate=duplicate):
self.cid = self.client.post('/api/new_chat').json['id']
self.app.config['MODEL_MAX_TOOL_CALLS'] = 6 if duplicate else 1
second = 'first' if duplicate else 'second'
responses = [tool_plan('search_knowledge', search_arguments(), 'first'),
tool_plan('search_knowledge', search_arguments('美国海派标签原文'), second)]
with patch('logistics_chat.ai_client.post_completion', side_effect=responses) as model, \
patch('logistics_chat.model_chat_service.execute_tool', wraps=actual_execute_tool) as execute:
events = self.ask('美国海派贴标要求')
self.assertEqual(model.call_count, 2)
self.assertEqual(execute.call_count, 1)
self.assertIn('不得遮挡', events[0]['t'])
def test_nested_invalid_parameters_reject_the_entire_batch_before_tools_run(self):
valid = tool_plan('search_knowledge', search_arguments(), 'good').message['tool_calls'][0]
malformed = quote_arguments(shipment={'packages': [
{'count': 1, 'weight_kg': '20', 'tenant_id': 'b'}]})
invalid = tool_plan('quote_shipping', malformed, 'bad').message['tool_calls'][0]
response = fixtures.Completion({'role': 'assistant', 'content': '',
'tool_calls': [valid, invalid]}, 'tool_calls')
with patch('logistics_chat.ai_client.post_completion', return_value=response) as model, \
patch('logistics_chat.model_chat_service.execute_tool', wraps=actual_execute_tool) as execute:
events = self.ask('请协助了解一下')
self.assertEqual(model.call_count, 1)
execute.assert_not_called()
self.assertIn('e', events[0])
def test_revoked_login_stops_before_private_tool_facts_reach_the_model(self):
self.login()
order_plan = fixtures.plan(('query_orders', '我的异常订单有多少个'))
def execute(name, arguments, conv_id, **kwargs):
result = actual_execute_tool(name, arguments, conv_id, **kwargs)
self.assertTrue(result.get('_customer_data'))
connection = db.connect()
connection.execute('DELETE FROM customer_sessions')
connection.commit()
connection.close()
return result
with patch('logistics_chat.ai_client.post_completion', return_value=order_plan) as model, \
patch('logistics_chat.model_chat_service.execute_tool', side_effect=execute):
events = self.ask('盘一下异常订单')
self.assertEqual(model.call_count, 1)
self.assertNotIn('等待资料', json.dumps(model.call_args.args[0], ensure_ascii=False))
self.assertIn('e', events[0])
self.assertNotIn('等待资料', str(events))
self.assertEqual(self.saved()[0], self.initial_rows)
def test_planner_timeout_after_session_revocation_cannot_fallback_to_private_orders(self):
self.login()
def provider(body, **kwargs):
connection = db.connect()
connection.execute('DELETE FROM customer_sessions')
connection.commit()
connection.close()
raise fixtures.requests.exceptions.Timeout('synthetic planner timeout')
with patch('logistics_chat.ai_client.post_completion', side_effect=provider) as model, \
patch('logistics_chat.order_listing.read_orders', wraps=actual_read_orders) as read:
events = self.ask('我的异常订单有多少个')
self.assertEqual(model.call_count, 1)
read.assert_not_called()
self.assertTrue(events[0].get('e') or events[0].get('login_required'), events)
serialized = json.dumps(events, ensure_ascii=False)
self.assertNotIn('VISIBLE20260001', serialized)
self.assertNotIn('等待资料', serialized)
def test_order_card_does_not_end_a_turn_before_the_policy_task(self):
self.login()
responses = [fixtures.plan(('query_orders', '查入仓码INBOUND20260001')),
tool_plan('search_knowledge', search_arguments(), 'policy_after_order'),
final_facts('fact_0', 'fact_1')]
with patch('logistics_chat.ai_client.post_completion', side_effect=responses) as model:
events = self.ask('查INBOUND20260001这票,另外美国海派的外箱标签有什么要求')
self.assertEqual(model.call_count, 3)
second_request_facts = evidence(model.call_args_list[1].args[0])
self.assertIn('VISIBLE20260001', second_request_facts[0]['result'])
text = json.dumps(events, ensure_ascii=False)
self.assertIn('VISIBLE20260001', text)
self.assertIn('不得遮挡', text)
def test_two_structured_quote_calls_apply_addition_once_and_commit_one_state(self):
start = quote_arguments(shipment={'country': 'US', 'total_weight_kg': '1000',
'zone': '美东', 'origin': '义乌', 'kinds': ['parcel'], 'tax': 'included'}, new_shipment=True)
with patch('logistics_chat.ai_client.post_completion', side_effect=[
tool_plan('quote_shipping', start, 'start'), final_facts('fact_0')]):
self.ask('这批一吨货去美国美东,从义乌发')
self.assertEqual(self.saved()[1]['intent']['weight'], '1000')
changes = [{'field': 'total_weight_kg', 'operation': 'add', 'value': '100'}]
responses = [tool_plan('quote_shipping', quote_arguments(changes=changes, selection='fastest'), 'fast'),
tool_plan('quote_shipping', quote_arguments(changes=changes, selection='cheapest'), 'cheap'),
final_facts('fact_0', 'fact_1')]
with patch('logistics_chat.ai_client.post_completion', side_effect=responses) as model:
text = self.ask('再添一百公斤,分别看最快和最便宜')[0]['t']
facts = evidence(model.call_args_list[2].args[0])
self.assertEqual([item['confirmed_inputs']['total_weight_kg'] for item in facts], ['1100', '1100'])
self.assertIn('1100', text)
self.assertNotIn('1200KG', text)
rows, state = self.saved()
self.assertEqual(state['intent']['weight'], '1100')
self.assertEqual(state['version'], 2)
self.assertEqual(len(rows) - len(self.initial_rows), 4)
def test_later_dimensions_supersede_old_quote_facts_and_do_not_revive_cached_prices(self):
initial = quote_arguments(services=['普船海派'], shipment={
'country': 'US', 'total_weight_kg': '10', 'boxes': 1, 'zone': '美西',
'origin': '义乌', 'kinds': ['parcel']})
dimensions = quote_arguments(services=['普船海派'], shipment={'dimensions_cm': ['100', '100', '100']})
bodies = []
def provider(body, **kwargs):
bodies.append(deepcopy(body))
if len(bodies) == 1:
return tool_plan('quote_shipping', initial, 'without_dimensions')
if len(bodies) == 2:
self.assertIn('48元', evidence(body)[0]['result'])
return tool_plan('quote_shipping', dimensions, 'with_dimensions')
if len(bodies) == 3:
first, second = evidence(body)
self.assertEqual(first['status'], 'superseded')
self.assertFalse(first['selectable'])
self.assertNotIn('48元', first['result'])
self.assertIn('666.67元', second['result'])
return tool_plan('quote_shipping', initial, 'repeat_original_arguments')
self.assertEqual(len(bodies), 4)
facts = evidence(body)
self.assertEqual([item['fact_id'] for item in facts], ['fact_0', 'fact_1', 'fact_2'])
self.assertIn('666.67元', facts[2]['result'])
self.assertNotIn('48元', facts[2]['result'])
# Even a model that selects a superseded ID cannot render its price.
return final_facts('fact_0', 'fact_1', 'fact_2')
with patch('logistics_chat.ai_client.post_completion', side_effect=provider):
text = self.ask('这票10KG一箱,箱规100乘100乘100厘米,核算普船美西价')[0]['t']
self.assertNotIn('48元', text)
self.assertIn('666.67元', text)
self.assertEqual(len(self.saved()[1]['options']), 1)
def test_conflicting_quote_amendment_removes_prior_prices_and_candidate(self):
initial = quote_arguments(services=['普船海派'], shipment={
'country': 'US', 'total_weight_kg': '10', 'boxes': 1, 'zone': '美西',
'origin': '义乌', 'kinds': ['parcel']})
conflicting = quote_arguments(changes=[{'field': 'total_weight_kg', 'operation': 'add', 'value': '100'}])
responses = [tool_plan('quote_shipping', initial, 'quote_first'),
tool_plan('quote_shipping', conflicting, 'conflict_second'),
final_facts('fact_0', 'fact_1')]
with patch('logistics_chat.ai_client.post_completion', side_effect=responses) as model:
text = self.ask('核对这票重量和报价')[0]['t']
facts = evidence(model.call_args_list[2].args[0])
self.assertEqual(facts[0]['status'], 'superseded')
self.assertEqual(facts[1]['status'], 'needs_clarification')
self.assertIn('冲突', text)
self.assertNotIn('48元', text)
self.assertIsNone(self.saved()[1])
def test_deleted_conversation_does_not_save_quote_candidate_or_reply(self):
count = 0
def provider(body, **kwargs):
nonlocal count
count += 1
if count == 1:
return tool_plan('quote_shipping', quote_arguments(shipment={
'country': 'US', 'total_weight_kg': '1000', 'zone': '美东',
'origin': '义乌', 'kinds': ['parcel']}), 'quote_before_delete')
connection = db.connect()
connection.execute('DELETE FROM messages WHERE conv_id=?', (self.cid,))
connection.execute('DELETE FROM chat_history WHERE id=?', (self.cid,))
connection.commit()
connection.close()
return final_facts('fact_0')
with patch('logistics_chat.ai_client.post_completion', side_effect=provider):
events = self.ask('请核算一吨货到美国美东')
self.assertIn('e', events[0])
self.assertEqual(self.saved(), ([], None))
def test_late_model_reply_rolls_back_its_messages_and_candidate_state(self):
count = 0
def provider(body, **kwargs):
nonlocal count
count += 1
if count == 1:
return tool_plan('quote_shipping', quote_arguments(shipment={
'country': 'US', 'total_weight_kg': '1000', 'zone': '美东',
'origin': '义乌', 'kinds': ['parcel']}), 'slow_quote')
db.save_message(self.cid, 'user', '较新的问题')
db.save_message(self.cid, 'assistant', '较新的回答')
return final_facts('fact_0')
with patch('logistics_chat.ai_client.post_completion', side_effect=provider):
events = self.ask('这是一条迟到的报价请求')
self.assertIn('e', events[0])
rows, state = self.saved()
self.assertEqual(rows, self.initial_rows + [('user', '较新的问题'), ('assistant', '较新的回答')])
self.assertIsNone(state)
if __name__ == '__main__':
unittest.main()