zhayujie / bot-on-anything

Connect AI models (like ChatGPT-3.5/4.0, Baidu Yiyan, New Bing, Bard) to apps (like Wechat, public account, DingTalk, Telegram, QQ). 将 ChatGPT、必应、文心一言、谷歌Bard 等对话模型连接各类应用,如微信、公众号、QQ、Telegram、Gmail、Slack、Web、企业微信、飞书、钉钉等。
MIT License
3.81k stars 896 forks source link

分享一下小改定制,增加了ChatGPT调用用户自定义函数的功能,可以按需向ChatGPT提交用户的数据。 #430

Closed icejean closed 1 year ago

icejean commented 1 year ago

运行效果: GPTFunctionCall-1 GPTFunctionCall-2 GPTFunctionCall-3

具体可以参阅OpenAI的文档函数调用功能及其它更新,以及我的这篇介绍文章ChatGPT函数调用功能测试

1、修改了config.json,增加了一个参数functions指出出用户自定义函数的入口脚本。

"functions":"/home/jean/scripts/OpenAIFunctionCall-WeChat.py"

2、修改了 /model/openai/chatgpt_model.py,检测该配置,如果没有该配置项,则按原有的程序执行;如果有则做两处操作: 1)在Sesssion.build_session_query(query, user_id)中加载用户自定义函数脚本:

    def build_session_query(query, user_id):
        '''
        build query with conversation history
        e.g.  [
            {"role": "system", "content": "You are a helpful assistant."},
            {"role": "user", "content": "Who won the world series in 2020?"},
            {"role": "assistant", "content": "The Los Angeles Dodgers won the World Series in 2020."},
            {"role": "user", "content": "Where was it played?"}
        ]
        :param query: query content
        :param user_id: from user id
        :return: query content with conversaction
        '''
        session = user_session.get(user_id, [])
        if len(session) == 0:
            system_prompt = model_conf(const.OPEN_AI).get("character_desc", "")
            system_item = {'role': 'system', 'content': system_prompt}
            session.append(system_item)
            user_session[user_id] = session
            # Added by Jean 2023/07/08
            # Load user defined functions to global name space.
            functions = model_conf(const.OPEN_AI).get("functions", None)
            if functions:
              log.info("[CHATGPT] functions={}", functions)
              try:
                exec(open(functions).read(), globals())
              except Exception as e:
                log.exception(e)                

        user_item = {'role': 'user', 'content': query}
        session.append(user_item)
        return session

2)在reply_text(self, query, user_id, retry_count=0)中判断是否有配置用户自定义函数,用if else分开处理,没有的话就照旧:

    def reply_text(self, query, user_id, retry_count=0):
        try:
            # Added by Jean 2023/7/8 for user defined function call
            functions = model_conf(const.OPEN_AI).get("functions", None)
            if not functions:

              response = openai.ChatCompletion.create(
                model= model_conf(const.OPEN_AI).get("model") or "gpt-3.5-turbo",  # 对话模型的名称
                messages=query,
                temperature=model_conf(const.OPEN_AI).get("temperature", 0.75),  # 熵值,在[0,1]之间,越大表示选取的候选词越随机,回复越具有不确定性,建议和top_p参数二选一使用,创意性任务越大越好,精确性任务越小越好
                max_tokens=model_conf(const.OPEN_AI).get("conversation_max_tokens", 1024), # 回复最大的字符数,为输入和输出的总数
                #top_p=model_conf(const.OPEN_AI).get("top_p", 0.7),,  #候选词列表。0.7 意味着只考虑前70%候选词的标记,建议和temperature参数二选一使用
                frequency_penalty=model_conf(const.OPEN_AI).get("frequency_penalty", 0.0),  # [-2,2]之间,该值越大则越降低模型一行中的重复用词,更倾向于产生不同的内容
                presence_penalty=model_conf(const.OPEN_AI).get("presence_penalty", 1.0)  # [-2,2]之间,该值越大则越不受输入限制,将鼓励模型生成输入中不存在的新词,更倾向于产生不同的内容
                )
              reply_content = response.choices[0]['message']['content']
              used_token = response['usage']['total_tokens']
              log.debug(response)
              log.info("[CHATGPT] reply={}", reply_content)
              if reply_content:
                # save conversation
                Session.save_session(query, reply_content, user_id, used_token)
              return response.choices[0]['message']['content']

            # Added by Jean 2023/07/09 for userdefined function call
            else:
              try:
                # Copy request dictionary for latter retreatment.
                response, used_token = reply_text_function(query.copy())
                for message in response[len(query):]:
                  role = message.get("role")
                  content = message.get("content")
                  function_call = message.get("function_call")
                  # Insert the function call request and result into dialogue cntext
                  # with the correct form of {"role":role, "content": content}
                  if content==None:
                    content = json.dumps({"arguments":json.loads(function_call["arguments"]),"name":function_call["name"]})
                    session = user_session[user_id]
                    session.append({"role":role,"content":content})
                  elif role=="function":
                    role = "user"
                    session = user_session[user_id]
                    session.append({"role":role,"content":content})
                  else:
                    # The whole dialogue context is not referenced in the function yet.
                    Session.save_session(response, content, user_id, used_token)

                log.info("[CHATGPT] reply={}", content)
                log.info("[CHATGPT] tokens={}", used_token)
                return content

              except Exception as e:
                  # Return exception message to client side.
                  log.exception(e)
                  return repr(e)

        except openai.error.RateLimitError as e:
            # rate limit exception
            log.warn(e)
            if retry_count < 1:
                time.sleep(5)
                log.warn("[CHATGPT] RateLimit exceed, 第{}次重试".format(retry_count+1))
                return self.reply_text(query, user_id, retry_count+1)
            else:
                return "提问太快啦,请休息一下再问我吧"
        except openai.error.APIConnectionError as e:
            log.warn(e)
            log.warn("[CHATGPT] APIConnection failed")
            return "我连接不到网络,请稍后重试"
        except openai.error.Timeout as e:
            log.warn(e)
            log.warn("[CHATGPT] Timeout")
            return "我没有收到消息,请稍后重试"
        except Exception as e:
            # unknown exception
            log.exception(e)
            Session.clear_session(user_id)
            return "请再问我一次吧"

3、在用户自定义函数脚本中实现对用户自定义函数的调用,不同的自定义函数可以写不同的脚本,实现不同的功能,只需实现reply_text(self, query, user_id, retry_count=0)函数中调用的response, used_token = reply_text_function(query.copy())接口。具体可以参阅OpenAI的文档函数调用功能及其它更新,以及我的这篇介绍文章ChatGPT函数调用功能测试。以下是本例的实现:

# For access to OpenAI API
import openai
import json
import os
import sys

# For gRPC call to Melbourne outliers as a trusted third party data supplier
# Need to append the path as they're in different directory 
sys.path.append( '/home/jean/scripts' )
import grpc
import helloworld_pb2
import helloworld_pb2_grpc

# Class to add authentication header to the meta data of every gRPC call.
class GrpcAuth(grpc.AuthMetadataPlugin):
    def __init__(self, key):
        self._key = key

    # 'rpc-auth-header' is the authentication header defined by the server.
    def __call__(self, context, callback):
        callback((('rpc-auth-header', self._key),), None)

# Test gRPC call to Melbourne house pricing outliers.
# Need to be loaded with absolute path
with open('/home/jean/scripts/ca.crt', 'rb') as f:
    creds = grpc.ssl_channel_credentials(f.read())

# A composite channel credentials with SSL and password.
# Host name need to be the same as the server certificate. 
channel = grpc.secure_channel(
    'jeanye.cn:50051',
    grpc.composite_channel_credentials(
        creds,
        grpc.metadata_call_credentials(
            GrpcAuth('right_access_key')
            # A worng key will fail then.
            # GrpcAuth('wrong_access_key')
        )
    )
)

stub = helloworld_pb2_grpc.GreeterStub(channel)

# Test access to gRPC call
# print("Outlier client received: \n")
# for outlier in stub.GetOutliers(helloworld_pb2.MelbourneRequest(algo='cat', threshold=65)):
#     print(outlier)

# Function call to get Melbourne house pricing outliers with specific algorithm and threshold.
def get_Melbourne_Outliers(algo='cat', threshold=65):
    """Get the outliers of house pricing in Melbourne city"""
    outliers = []
    # Turn the returned object into a Python dictionary so that it can be dumped into a JSON string.
    for outlier in stub.GetOutliers(helloworld_pb2.MelbourneRequest(algo=algo, threshold=threshold)):
        outlierDic = {}
        outlierDic["row"] = outlier.row
        outlierDic["origin"] = outlier.origin
        outlierDic["predict"] = outlier.predict
        outlierDic["se"] = outlier.se
        outliers.append(outlierDic)
    return json.dumps(outliers)

# Description of the function for being called by GPT, it's important to set a default value.
functions = [
    {
        "name": "get_Melbourne_Outliers",
        "description": "Get the outliers of house pricing in Melbourne city",
        "parameters": {
            "type": "object",
            "properties": {
                "algo": {
                    "type": "string",
                    "description": "The algorithm name to find outliers, default is cat"
                },
                "threshold": {
                    "type": "integer",
                     "description": "The threshold of percentage to filter outliers, default is 65, means 65%"
                },
            },
            "required": ["algo","threshold"],
        },
    }
]

# only one function in this example, but you can have multiple
available_functions = {
    "get_Melbourne_Outliers": get_Melbourne_Outliers,
}  

# Function to call GPT through GPT API with function call
def run_conversation(messages, functions):
    response = openai.ChatCompletion.create(
        # I can only access this model now.
        # model="gpt-3.5-turbo-0613",
        # model="gpt-4-0613",
        model= model_conf(const.OPEN_AI).get("model") or "gpt-3.5-turbo",
        messages=messages,
        max_tokens=model_conf(const.OPEN_AI).get("conversation_max_tokens", 1024),
        temperature=model_conf(const.OPEN_AI).get("temperature", 0.75), 
        functions=functions,
        function_call="auto",  # auto is default, but we'll be explicit
    )
    return response    

def reply_text_function(messages):
    # messages is a dictionary of message, which is a dictionary in nesting.
    # Step 1: send the conversation and available functions to GPT 
    response = run_conversation(messages, functions)
    # Get the response message.
    response_message = response["choices"][0]["message"]
    # print(response_message)

    # Step 2: check if GPT wanted to call a function
    if response_message.get("function_call"):
       # Step 3: call the function
       # Note: the JSON response may not always be valid; be sure to handle errors
       function_name = response_message["function_call"]["name"]
       fuction_to_call = available_functions[function_name]
       function_args = json.loads(response_message["function_call"]["arguments"])

       # Set default for function args if it is missing.
       if (function_args.get("algo")==None):
          function_args["algo"]="cat"
       if (function_args.get("threshold")==None):
          function_args["threshold"]=65       
       # Call the function by yourself
       function_response = fuction_to_call(
          # With the parameters provided by GPT.
          algo=function_args.get("algo"),
          threshold=function_args.get("threshold"),
       )

       # Step 4: send the info on the function call and function response to GPT
       messages.append(response_message)  # extend conversation with assistant's reply
       messages.append(
          {
            "role": "function",
            "name": function_name,
            "content": function_response,
          }
       )  # extend conversation with function response
       second_response = run_conversation(messages, functions)

    else:  # No need to call a user function
       second_response = response

    # Need to keep all conversation messages for the future.
    messages.append(second_response['choices'][0]['message'])
    # And the token of total dialogue too.
    used_token = second_response['usage']['total_tokens']
    return messages, used_token

# Will be comment out in production environment

# # Test the conversation fucntion with function call
# messages = [{"role": "user", "content": "What're the top 3 outliers of house pricing in Melbourne city?"}]
# response, used_token = reply_text_function(messages)
# print(response)
icejean commented 1 year ago

已整理成知乎文章《微信+ChatGPT+用户自定义函数简介》