2025年4月28日 星期一

OpenAI API 學習筆記 : 用 Function calling 讓模型開外掛 (二)

在前一篇測試中, 我們成功地利用 Function calling 功能實作了一個能讓 gpt-3.5-turbo 模型在不知答案時呼叫外部工具函式求解的聊天機器人, 但那個機器人沒有記憶功能, 本篇旨在前一篇基礎上添加記憶功能, 參考 :


本系列全部文章索引參考 : 



1. 無限制記憶長度的聊天機器人 : 

這是單純地將訊息串列 messages 拿到外面做為全域變數來記憶對話歷史, 包含提示詞, 可能的搜尋結果, 以及模型的回應等, 都依序丟進串列中儲存, 程式碼如下 : 

# cli_chatbot_function_calling_2.py
from openai import OpenAI, APIError
import requests
import json
from dotenv import load_dotenv
import os

def ask_gpt(messages, tools=[], model='gpt-3.5-turbo'):
    try:
        reply=client.chat.completions.create(
            model=model, 
            messages=messages,
            tools=tools
            )
        return reply
    except APIError as e:
        return e.message

def search_google(query, cx, api_key, num=3, gl='tw', lr='lang_zh_TW'):
    params={
        'q': query,
        'key': api_key,
        'cx': cx,
        'gl': gl,  
        'lr': lr,          
        'num': num          
        }
    url='https://www.googleapis.com/customsearch/v1'
    r=requests.get(url, params=params)
    data=r.json()
    return data.get('items', [])

# 載入環境變數
load_dotenv()
openai_api_key=os.environ.get('OPENAI_API')
custom_search_key=os.environ.get('GOOGLE_CUSTOM_SEARCH_API')
cx=os.environ.get('SEARCH_ENGINE_ID')
# 設定外部工具函式串列
tools=[{
    "type": "function",
    "function": {
        "name": "search_google",
        "description": "用 Google Custom Search API 取得搜尋結果",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "要搜尋的關鍵字"
                    },
                "cx": {
                    "type": "string",
                    "description": "Google Custom Search 搜尋引擎 ID"
                    },
                "api_key": {
                    "type": "string",
                    "description": "Google Custom Search API 金鑰"
                    },
                "num": {
                    "type": "integer",
                    "description": "回傳的搜尋結果筆數(最多 10 筆)",
                    "default": 3
                    },
                "gl": {
                    "type": "string",
                    "description": "地理區域 (預設 'tw' 台灣)",
                    "default": "tw"
                    },
                "lr": {
                    "type": "string",
                    "description": "語言 (預設 'lang_zh_TW' 繁體中文)",
                    "default": "lang_zh_TW"
                    }
                },
            "required": ["query", "cx", "api_key"]
            }
        }
    }]
# 建立 OpenAI 物件
client=OpenAI(api_key=openai_api_key)
# 設定 GPT 系統角色
sys_role=input('請設定 GPT 角色 : ')
if sys_role.strip() == '': 
    sys_role='你是繁體中文AI助理'
print(sys_role)
# 初始化聊天訊息 (用來記憶聊天歷史) 
messages=[{'role': 'system', 'content': sys_role}]
# 聊天機器人 (無窮迴圈)
while True: 
    prompt=input('You : ')  # 輸入提示詞
    if prompt.strip() == '':  # 按 Enter 跳出迴圈結束聊天
        print('GPT : 再見。')
        del messages   # 結束對話時主動刪除對話歷史
        break
    messages.append({'role': 'user', 'content': prompt})  # 加入提示詞
    # 第一次查詢 : 確定是否需要搜尋網路
    reply=ask_gpt(messages, tools=tools)
    reply_message=reply.choices[0].message  # 取出回應訊息
    if reply_message.content == None:  # 內容為空表示需要搜尋網路
        messages.append(reply_message)  # 回應放入 messages 串列
        tool_calls=reply_message.tool_calls  # 取得外部工具函式物件
        function_name=tool_calls[0].function.name  # 取得函式名稱
        args=json.loads(tool_calls[0].function.arguments) # 取得參數
        args['api_key']=custom_search_key  # 設定搜尋引擎金鑰
        args['cx']=cx  # 設定搜尋引擎ID
        func=globals().get(function_name)  # 取得函式參考
        results=func(**args)  # 呼叫外部函式
        web_msg=[]  # 用來儲存搜尋結果
        for result in results: 
            web_msg.append(f'標題: {result["title"]}')
            web_msg.append(f'描述: {result["snippet"]}') 
        content='\n'.join(web_msg)  # 搜尋結果串接為字串
        results_dict={  # 製作 JSON 格式的搜尋結果字典
            "tool_call_id": tool_calls[0].id,
            "role": "tool",
            "name": function_name,
            "content": content
            }
        messages.append(results_dict)  # 將搜尋結果字典放進 messages         
        # 第二次查詢 : 根據所提供的網路搜尋結果回答
        reply2=ask_gpt(messages, tools=tools)
        response='搜尋網路...' + reply2.choices[0].message.content
    else:  # 不需搜尋網路, 直接回應答案
        response=reply_message.content
    messages.append({'role': 'assistant', 'content': response})
    print(f'GPT : {response}') 

此例在取得外部函式傳回的結果前冠上 '搜尋網路...' 表示模型有呼叫外掛工具 search_google() 協助 (雖然模型回應通常會以 '根據搜尋結果' 開頭), 第一次執行發現即使是它知道的也呼叫外掛 : 

>>> %Run cli_chatbot_function_calling_2.py   
請設定 GPT 角色 : 
你是繁體中文AI助理
You : 2024台灣總統大選是誰當選?
GPT : 搜尋網路...根據搜尋結果,2024年台灣總統大選中,賴清德當選為中華民國第16任總統,蕭美琴當選為副總統。賴清德總統當選,將預計於2024年5月20日宣誓就職。
You : 那 2018 年呢?
GPT : 搜尋網路...根據搜尋結果,2018年台灣總統大選並未找到明確的結果,但顯示在2018年的「九合一」選舉中,民進黨遭遇大敗,國民黨拿下逾三分之二的地方席位。
You : 那 2016 年呢?
GPT : 搜尋網路...根據搜尋結果,2016年台灣總統大選中,蔡英文當選為中華民國第14任總統,成為台灣史上首位女性最高統治者,也是中華民國首位女性、未婚、擁有獲選總統的人。
You : 那 2012 年呢?
GPT : 搜尋網路...根據搜尋結果,2012年台灣總統大選中,馬英九擊敗蔡英文當選為中華民國第13任總統,副總統為吳敦義。馬英九的得票率為51.6%。
You : 2012台灣總統大選是誰當選?
GPT : 搜尋網路...根據搜尋結果,2012年台灣總統大選中,馬英九當選為中華民國第13任總統,副總統為吳敦義。馬英九在該次選舉中獲得51.6%的得票率。
You : 那 2008 年呢?
GPT : 搜尋網路...根據搜尋結果,2008年台灣總統大選中,國民黨提名的馬英九以壓倒性優勢當選為中華民國第12任總統,副總統為蕭萬長。馬英九在該次選舉中獲得58.45%的得票率。
You : 
GPT : 再見。

第二次執行時知道的有時查網路, 有時又自行回答 : 

>>> %Run cli_chatbot_function_calling_2.py   
請設定 GPT 角色 : 
你是繁體中文AI助理
You : 2008台灣總統大選是誰當選?
GPT : 搜尋網路...2008年台灣總統大選中,馬英九當選為台灣的第12任總統。
You : 那2012年呢?
GPT : 搜尋網路...搜尋網路...2012年台灣總統大選中,馬英九連任為台灣的第13任總統。
You : 那2016年呢?
GPT : 2016年台灣總統大選中,蔡英文當選為台灣的第14任總統,也是台灣史上首位女性最高統治者。
You : 那2020年呢?
GPT : 2020年台灣總統大選中,蔡英文當選為台灣的第15任總統。
You : 那2024年呢?
GPT : 搜尋網路...抱歉,我目前沒有2024年台灣總統大選的資訊。您可以稍後再查詢或到網路上尋找最新的資料。如果您有其他問題,歡迎告訴我。我會盡力協助您的。
You : 那2024年呢?
GPT : 2024年台灣總統大選中,賴清德當選為台灣的第16任總統。
You : 
GPT : 再見。

上面 2024 年的去查網路, 但不知為何無法根據搜尋結果做出確定之回覆, 但這些搜尋結果都存入對話歷史中了, 所以第二次問 2024 年大選結果它沒查網路, 而是根據對話歷史中上一次搜尋的紀錄直接做出回應. 

第三次測試結果如下 :

>>> %Run cli_chatbot_function_calling_2.py   
請設定 GPT 角色 : 
你是繁體中文AI助理
You :  我養了兩隻貓, 名叫咪咪與萬萬
GPT : 搜尋網路...那兩隻貓的名字很可愛! 有什麼關於咪咪和萬萬的資訊需要我幫忙查詢嗎?
You : 愛情是甚麼?
GPT : 根據愛情心理學的分析,愛情由親密、激情和承諾三個要素組成。親密表示雙方之間的緊密聯繫,激情則是指對彼此的熱情和慾望,而承諾則是指對關係的堅定承諾。愛情的發展通常是對另一個人的強烈依附和愛的感覺,讓人陷入一種脆弱且讓人無法控制的狀態。結合這些要素,愛情被認為是一種深刻的情感與關係的結合。
You : 2024 台灣金曲歌王是誰?
GPT : 根據2024年台灣金曲獎的得獎名單,金曲歌王是 MC HotDog(熱狗),而歌后則由孫盛希獲得。祝賀他們在音樂界取得了傑出的成就!
You : 你還記得我那兩隻貓的名字嗎?
GPT : 搜尋網路...當然記得!你養的兩隻貓的名字是咪咪和萬萬。有關咪咪和萬萬需要我為你查找更多資訊嗎?
You : 
GPT : 再見。

很奇怪的是, 2024 金曲歌王它應該不知道, 要去查網路才對, 結果居然直接回答! 還有, 我那兩隻貓的名字是啥幹嘛去搜尋網路? 可見即使不傳 tool_choice 參數, 它預設就是 'auto', 對於它知識範圍的詢問要不要查網路是不確定的. 


2. 限制記憶長度的聊天機器人 : 

上面的程式沒有對紀錄聊天歷史的串列 messages 的長度做限制, 它的長度就會隨著對話次數增加而擴增, 這樣會使得耗費的 token 數也會逐漸增加, 扣款數字就會攀升, 因此有必要限制此串列之長度. 我們可以使用一個全域變數來設定對話歷史串列的最大長度 (預設 10), 程式碼如下 : 

# cli_chatbot_function_calling_2.py
from openai import OpenAI, APIError
import requests
import json
from dotenv import load_dotenv
import os

def ask_gpt(messages, tools=[], model='gpt-3.5-turbo'):
    try:
        reply=client.chat.completions.create(
            model=model, 
            messages=messages,
            tools=tools
            )
        return reply
    except APIError as e:
        return e.message

def search_google(query, cx, api_key, num=3, gl='tw', lr='lang_zh_TW'):
    params={
        'q': query,
        'key': api_key,
        'cx': cx,
        'gl': gl,  
        'lr': lr,          
        'num': num          
        }
    url='https://www.googleapis.com/customsearch/v1'
    r=requests.get(url, params=params)
    data=r.json()
    return data.get('items', [])

# 載入環境變數
load_dotenv()
openai_api_key=os.environ.get('OPENAI_API')
custom_search_key=os.environ.get('GOOGLE_CUSTOM_SEARCH_API')
cx=os.environ.get('SEARCH_ENGINE_ID')
# 設定外部工具函式串列
tools=[{
    "type": "function",
    "function": {
        "name": "search_google",
        "description": "用 Google Custom Search API 取得搜尋結果",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "要搜尋的關鍵字"
                    },
                "cx": {
                    "type": "string",
                    "description": "Google Custom Search 搜尋引擎 ID"
                    },
                "api_key": {
                    "type": "string",
                    "description": "Google Custom Search API 金鑰"
                    },
                "num": {
                    "type": "integer",
                    "description": "回傳的搜尋結果筆數(最多 10 筆)",
                    "default": 3
                    },
                "gl": {
                    "type": "string",
                    "description": "地理區域 (預設 'tw' 台灣)",
                    "default": "tw"
                    },
                "lr": {
                    "type": "string",
                    "description": "語言 (預設 'lang_zh_TW' 繁體中文)",
                    "default": "lang_zh_TW"
                    }
                },
            "required": ["query", "cx", "api_key"]
            }
        }
    }]
# 建立 OpenAI 物件
client=OpenAI(api_key=openai_api_key)
# 設定 GPT 系統角色
sys_role=input('請設定 GPT 角色 : ')
if sys_role.strip() == '': 
    sys_role='你是繁體中文AI助理'
print(sys_role)
# 初始化聊天訊息 (用來記憶聊天歷史) 
messages=[{'role': 'system', 'content': sys_role}]
# 全域變數:限制對話歷史長度
MAX_HISTORY_LENGTH=10  
# 聊天機器人 (無窮迴圈)
while True: 
    prompt=input('You : ')  # 輸入提示詞
    if prompt.strip() == '':  # 按 Enter 跳出迴圈結束聊天
        print('GPT : 再見。')
        del messages   # 結束對話時主動刪除對話歷史
        break
    messages.append({'role': 'user', 'content': prompt})  # 加入提示詞
    # 第一次查詢 : 確定是否需要搜尋網路
    reply=ask_gpt(messages, tools=tools)
    reply_message=reply.choices[0].message  # 取出回應訊息
    if reply_message.content == None:  # 內容為空表示需要搜尋網路
        messages.append(reply_message)  # 回應放入 messages 串列
        tool_calls=reply_message.tool_calls  # 取得外部工具函式物件
        function_name=tool_calls[0].function.name  # 取得函式名稱
        args=json.loads(tool_calls[0].function.arguments) # 取得參數
        args['api_key']=custom_search_key  # 設定搜尋引擎金鑰
        args['cx']=cx  # 設定搜尋引擎ID
        func=globals().get(function_name)  # 取得函式參考
        results=func(**args)  # 呼叫外部函式
        web_msg=[]  # 用來儲存搜尋結果
        for result in results: 
            web_msg.append(f'標題: {result["title"]}')
            web_msg.append(f'描述: {result["snippet"]}') 
        content='\n'.join(web_msg)  # 搜尋結果串接為字串
        results_dict={  # 製作 JSON 格式的搜尋結果字典
            "tool_call_id": tool_calls[0].id,
            "role": "tool",
            "name": function_name,
            "content": content
            }
        messages.append(results_dict)  # 將搜尋結果字典放進 messages         
        # 第二次查詢 : 根據所提供的網路搜尋結果回答
        reply2=ask_gpt(messages, tools=tools)
        response=reply2.choices[0].message.content
    else:  # 不需搜尋網路, 直接回應答案
        response='搜尋網路...' + reply_message.content
    messages.append({'role': 'assistant', 'content': response})
    # 限制對話歷史長度,保留第一個元素(系統角色)
    if len(messages) > MAX_HISTORY_LENGTH:
        messages=[messages[0]] + messages[-(MAX_HISTORY_LENGTH - 1):]    
    print(f'GPT : {response}') 

此程式使用一個全域變數 MAX_HISTORY_LENGTH 來限制對話歷史的長度, 在程式最後面儲存完模型的回應後進行 messages 串列裁減, 保留地一個 system 角色訊息, 然後擷取倒數 MAX_HISTORY_LENGTH - 1 個對話歷史組成新的 MAX_HISTORY_LENGTH 個對話歷史訊息. 執行結果如下 : 

>>> %Run cli_chatbot_function_calling_3.py   
請設定 GPT 角色 : 
你是繁體中文AI助理
You : 我養了兩隻貓, 名叫咪咪與萬萬
GPT : 搜尋網路...咪咪與萬萬是很可愛的名字!有什麼關於你的貓咪想讓我知道或幫忙的嗎?
You : 我養的兩隻貓名叫甚麼?
GPT : 搜尋網路...你養的兩隻貓的名字分別是咪咪和萬萬。有什麼其他問題或需要幫忙的嗎?
You : 2016台灣總統大選是誰當選?
GPT : 2016年台灣總統大選中,蔡英文當選為台灣史上首位女性最高統治者。如果有其他問題或需要幫忙,請告訴我!
You : 你還記得我養的兩隻貓嗎?
GPT : 搜尋網路...當然記得!你養的兩隻貓的名字是咪咪和萬萬。有任何其他問題或需要幫忙的,歡迎告訴我!
You : 2024年呢?
GPT : 根據搜尋結果,2024年台灣總統大選中,賴清德當選為中華民國第16任總統。如果有其他問題或需要幫忙,請告訴我!
You : 2024 台灣金曲歌王是誰?
GPT : 根據搜尋結果,2024年台灣金曲歌王是 MC HotDog。如果您有其他問題或需要進一步資訊的話,請告訴我!
You : 考考你的記性, 我那兩隻貓名字是甚麼?
GPT : 搜尋網路...你養的兩隻貓的名字分別是咪咪和萬萬。有其他問題或需要我幫忙查詢資訊嗎?
You : 那 2020 年呢?
GPT : 搜尋網路...很抱歉,我無法追蹤您在2020年養的兩隻貓的名字。如果您需要任何其他幫助或資訊,請隨時告訴我!
You : 2020 年金曲獎歌王啦!
GPT : 根據搜尋結果,2020年台灣金曲獎歌王是周華健。如果您有其他問題或需要進一步資訊的話,請告訴我!
You : 簡單說明何謂量子態?
GPT : 搜尋網路...量子態是描述量子系統在某一特定時刻的狀態。在量子力學中,一個系統的狀態可以用一個稱為波函數的數學函數來描述,波函數包含有關系統可能出現在各種不同狀態的機率分佈。量子態的特點是具有超越經典物理觀念的性質,如超位置性、量子糾纏等特徵。量子態是量子力學中非常重要的概念,用於描述微觀粒子的行為和性質。
You : 請問下一次五星連珠時間?
GPT : 搜尋網路...抱歉,我目前無法提供五星連珠的下一次活動時間。您可以查詢遊戲的官方網站或社群媒體以獲取最新的活動資訊。如果您有其他問題或需要幫忙,請隨時告訴我!
You : 愛情是甚麼?
GPT : 搜尋網路...愛情是一種情感和關係,通常在人與人之間產生。它包含了深厚的情感連結、照顧、尊重、支持和傾聽。愛情可以是各種形式,包括親情、友情和愛情關係。在愛情中,人們分享他們的喜悅、悲傷、希望和夢想,共同建立彼此之間的情感聯繫和相互扶持。愛情是人類最基本和深刻的情感之一,為生活帶來溫暖、意義和幸福感。
You : 你會不會忘記我那兩隻貓了?
GPT : 搜尋網路...我不會忘記您提到的兩隻貓!如果您需要我提醒您有關它們的任何資訊或需要幫助,請隨時告訴我。您可以提供更多關於您的貓咪的資訊,讓我更好地了解它們。
You : 它們的名字叫甚麼?
GPT : 搜尋網路...抱歉,我無法記住先前的對話內容,因此沒有你提到兩隻貓的名字。如果你方便的話,可以提供一下它們的名字,讓我記住以便日後提醒。
You : 
GPT : 再見。

由於限制對話歷史的長度為 10 個, 如果哈拉太久的話, 它就會忘記我那兩隻貓的名字了.  

2025年4月27日 星期日

2025 年第 17 周記事

好快, 又是一周, 正確地說, 人生又少了一周. 本周分心把 ESP32 原來使用的函式庫 xtools.py 改版為 Telegram 版本的 esptools.py, 這是接續月初解決 LINE Notify 終止服務解決方案的收尾工作, 完成這項任務後, 目前 Telegram 只剩機器人尚未搞定了. 而周末這兩天就回頭繼續測試 OpenAI API, 終於弄清楚 Function calling 機制了哈哈. 最近 MCP 火紅, 但我連 LangChain 都還在看書階段哩! AI 時代每天醒來都會發現新花樣, 但上點年紀了學習真的是龜速級, 啊! 反正田僑仔又不靠這些吃飯, 純興趣慢慢學又何妨 (跟深度旅遊一樣, 我是深度學習). 

這個周末原本以為能完成很多工作, 其實屈指可數. 週六早上是學習時間, 下午則是給芭樂套袋到傍晚, 進入四月後氣候明顯變成夏季 (睡袋已經收起來, 晚上電扇也開動了), 溫度回升使芭樂開花結果時間變快, 套一棵芭樂樹又回到耗費兩小時狀態, 一個下午套了超過 80 顆左右 (冬天大約才 10 顆而已). 




今天下午則是給芒果套袋, 今年結果率低, 能採收到 100 顆就要偷笑了 :




傍晚拿梯子到雞舍邊的酪梨樹看看有沒有可套袋的, 跟芒果一樣, 花開得很漂亮, 果子卻沒結成幾個 : 



OpenAI API 學習筆記 : 用 Function calling 讓模型開外掛 (一)

在前面幾篇測試中, 我們利用特製的回應模板規範 OpenAI API 的回應格式, 從初次回應中得知模型是否能回答所詢問之問題, 如果能就直接取出其生成之內容; 否則應用程式就會搜尋網路 (Call-out), 將取得的資訊做為參考資料再次詢問模型, 要求模型依據參考內容生成回應, 這基本上解決了所詢問題超出模型知識範圍的困境. 

但是這種方法會因為模型的隨機本質, 有時並不會按照規定的回應模板格式回答知或不知, 導致應用程式無法搜尋網路取得參考資料給模型做為依據來生成回應, 參考下面這篇的測試 :


本系列全部文章索引參考 : 


OpenAI 於 2023 年發布的 function calling 功能解決了此問題, 此機制是透過在請求訊息中增加一個 tools 參數告訴模型, 應用程式提供了哪些外部工具函式可調用, 如果 tools 參數中的 tool_choice 屬性為 'auto', 則模型會依據所詢問之內容自行判斷是否需要外部工具的協助, 如果需要就會在回應中以 tools_calls 屬性指定要呼叫的函式名稱與參數, 然後應用程式據此呼叫外部函式, 然後將傳回的結果以 tool 角色再次詢問模型來取得答案. OpenAI API 有了 function calling 功能之後, 使得微調過的 GPT 模型能夠整合外部工具 (即開發者撰寫的第三方函式) 來生成回應, 也就是具備了開外掛的能力. 

外部工具參數 tools 的值是一個用來描述有哪些可用的外部工具函式的字典串列, 每一個字典代表一個工具函式, 它包含兩個鍵 (屬性) type 與 function :
  • type (工具類型) :
    值為字串, 例如函式 'function', 程式碼解譯器 'code_interpreter', 瀏覽器 'browser' 等, 但目前只對 API 開放 'function' 而已. 
  • function (函式描述) : 
    值為符合 JSON 格式的字典 (即鍵必須使用雙引號括起來), 有三個鍵 :
    • "name" : 函式名稱 (字串)
    • "description" : 功能描述 (字串)
    • "parameters" : 參數格式 (值為 JSON 格式字典), 可有三個鍵 :
      • "type" : 參數型態, 值必須是 "object"
      • "properties" : 參數屬性 (值為 JSON 格式字典), 鍵為參數名稱, 例如 :
        {"para1": {
             "type": "string",
             "description": "參數1"},
          "para2": {
              "type": "number",
              "description": "參數2"}
         }
      • "required" : 必要參數 (串列)
注意, 參數格式 parameters 的結構可以是巢狀的, 亦即參數值本身也可以是一個物件, 這時該參數之 type 值必須是 "object", 也會有 properties 鍵. 

以前面測試中搜尋 Google 的函式 search_google() 為例 : 

>>> def search_google(query, cx, api_key, num=3, gl='tw', lr='lang_zh_TW'):
    params={
        'q': query,
        'key': api_key,
        'cx': cx,
        'gl': gl,  
        'lr': lr,          
        'num': num          
        }
    url='https://www.googleapis.com/customsearch/v1'
    r=requests.get(url, params=params)
    data=r.json()
    return data.get('items', [])

外部工具參數 tools 可以這麼寫 : 

>>> tools=[{
    "type": "function",
    "function": {
        "name": "search_google",
        "description": "用 Google Custom Search API 取得搜尋結果",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "要搜尋的關鍵字"
                    },
                "cx": {
                    "type": "string",
                    "description": "Google Custom Search 搜尋引擎 ID"
                    },
                "api_key": {
                    "type": "string",
                    "description": "Google Custom Search API 金鑰"
                    },
                "num": {
                    "type": "integer",
                    "description": "回傳的搜尋結果筆數(最多 10 筆)",
                    "default": 3
                    },
                "gl": {
                    "type": "string",
                    "description": "地理區域 (預設 'tw' 台灣)",
                    "default": "tw"
                    },
                "lr": {
                    "type": "string",
                    "description": "語言 (預設 'lang_zh_TW' 繁體中文)",
                    "default": "lang_zh_TW"
                    }
                },
            "required": ["query", "cx", "api_key"]
            }
        }
    }]

每一個參數都要在 properties 屬性中描述, 必要參數名稱則須放入 required 屬性的串列中. 

為了做個對比, 接下來我們先重複之前的測試, 就是詢問 gpt-3.5-turbo 模型它所不知道之事件, 由於 gpt-3.5-turbo 模型訓練所用之資料是在 2021 年 9 月之前準備的, 因此若詢問它此日期之後之事件 (例如 2024 年台灣總統大選結果), 它無法給出答案. 

我先將 API 金鑰等機敏資訊放在目前工作目錄下的環境變數檔案 .env 內, 然後用 dotenv 模組載入後用 os.environ.get() 取出來 : 

>>> from dotenv import load_dotenv   
>>> load_dotenv()     
True
>>> import os     
>>> openai_api_key=os.environ.get('OPENAI_API')      
>>> custom_search_key=os.environ.get('GOOGLE_CUSTOM_SEARCH_API')     
>>> cx=os.environ.get('SEARCH_ENGINE_ID')      

作法參考 :


詢問 gpt-3.5-turbo 模型關於 2024 台灣總統大選當選人不會得到答案 :
    
>>> from openai import OpenAI 
>>> client=OpenAI(api_key=openai_api_key)   
>>> messages=[
    {'role': 'system', 'content': '你是一個繁體中文AI助理'},
    {'role': 'user', 'content': '2024台灣總統大選是誰當選?'}
    ]   
>>> model='gpt-3.5-turbo'
>>> reply=client.chat.completions.create(
    model=model, 
    messages=messages)
>>> print(reply.choices[0].message.content)   
對不起,我無法提供即時或未來的真實事件資訊。台灣總統大選的結果取決於選舉當時的情況和選民投票。請隨時關注新聞和相關資訊來獲取最新的選舉結果。

可見超出模型知識範圍時, 它就無法給出所要的答案. 但如果傳入上面所定義的外部工具參數 tools, 並將 tool_choice 參數設為 'auto' 讓模型自動判斷此提問是否需要外部工具函式幫忙, 這時回應就會不一樣了 : 

>>> reply=client.chat.completions.create(
    model=model, 
    messages=messages,
    tools=tools,
    tool_choice='auto' 
    )   

因為傳回值為物件型態, 可匯入 rich.print() 函式來讓輸出格式化為較好閱讀之形式 : 

>>> from rich import print as pprint   
>>> pprint(reply)  
ChatCompletion(
    id='chatcmpl-BQcX8LVJMO5xugwO5bXBrqJVUt5xz',
    choices=[
        Choice(
            finish_reason='tool_calls',
            index=0,
            logprobs=None,
            message=ChatCompletionMessage(
                content=None,
                refusal=None,
                role='assistant',
                audio=None,
                function_call=None,
                tool_calls=[
                    ChatCompletionMessageToolCall(
                        id='call_uR88qhBuY8df7dnasncDI0qI',
                        function=Function(
                            arguments='{"query":"2024 
台灣總統大選當選者","cx":"<搜尋引擎 ID>","api_key":"<搜尋引擎金鑰>","num":3}',
                            name='search_google'
                        ),
                        type='function'
                    )
                ],
                annotations=[]
            )
        )
    ],
    created=1745684350,
    model='gpt-3.5-turbo-0125',
    object='chat.completion',
    service_tier='default',
    system_fingerprint=None,
    usage=CompletionUsage(
        completion_tokens=87,
        prompt_tokens=251,
        total_tokens=338,
        completion_tokens_details=CompletionTokensDetails(
            accepted_prediction_tokens=0,
            audio_tokens=0,
            reasoning_tokens=0,
            rejected_prediction_tokens=0
        ),
        prompt_tokens_details=PromptTokensDetails(
            audio_tokens=0,
            cached_tokens=0
        )
    )
)

回應內容中的 content=None 表示此回應並非實際的回覆 (無內容), 所以 finish_reason 欄不是一般的 'stop' 而是 'tool_calls', 表示模型需要外部工具函式協助, 需要呼叫的函式則是放在 tool_calls 串列中, 每一個串列元素都包裹了一個 Function 物件, 代表一個工具函式 (用串列存放表示可以呼叫多個函式), 函式名稱放在 Function 物件的 name 屬性, 而所需的參數放在 arguments 屬性中. 

我們的應用程式只要將此 tool_calls 回應中的函式名稱與參數取出來後呼叫此函式取得搜尋結果, 再將此函式傳回值與原本的提示詞, 以及上面的 tool_calls 中的回應訊息 message, 用 user 角色一併送回模型做第二次詢問, 它就會參考裡面的搜尋結果做出實際的回應內容.

首先取出 ChatCompletionMessage 物件, 也就是上面 ChatCompletion 物件 reply 的 message 屬性 : 

>>> reply_message=reply.choices[0].message   
>>> type(reply_message)   
<class 'openai.types.chat.chat_completion_message.ChatCompletionMessage'>

然後從回應訊息取出其 tool_calls 屬性, 其值為一個串列, 其元素均為包裹了 Function 物件的 ChatCompletionMessageToolCall 物件 :

>>> tool_calls=reply_message.tool_calls    
>>> tool_calls    
[ChatCompletionMessageToolCall(id='call_LUtiMc54O2NfP1eBmdGahsYt', function=Function(arguments='{"query":"2024 台灣總統大選當選者","cx":"<搜尋引擎 ID>","api_key":"<搜尋引擎金鑰>","num":3}', name='search_google'), type='function')]
>>> type(tool_calls)   
<class 'list'>  
>>> type(tool_calls[0])   
<class 'openai.types.chat.chat_completion_message_tool_call.ChatCompletionMessageToolCall'>
>>> len(tool_calls)   
1

串列長度為 1 表示模型要求呼叫一個指定函式 (空串列表示不需要呼叫外部函式), 利用 tool_calls 物件的 function.name 屬性可取得模型回應我們要去呼叫的外部函式名稱 (字串) :

>>> function_name=tool_calls[0].function.name   
>>> function_name     
'search_google'   

參數則是一個 JSON 格式字串 :

>>> tool_calls[0].function.arguments   
'{"query":"2024 台灣總統大選當選者","cx":"<搜尋引擎 ID>","api_key":"<搜尋引擎金鑰>","num":3}'

可以用 json.loads() 將它轉成 Python 字典,  :

>>> import json     
>>> args=json.loads(tool_calls[0].function.arguments)  
>>> args    
{'query': '2024 台灣總統大選當選者', 'cx': '<搜尋引擎 ID>', 'api_key': '<搜尋引擎金鑰>', 'num': 3}

如果參數毋須修改的話, 只要在 args 前面加上 ** 將參數拆開傳給外部函式即可. 但在此處無法這樣做, 因為 args 裡面的 cx 與 api_key 是模型自己幻想編造出來的無法使用, 這兩個參數必須個人去申請才有效, 參考 :


所以 args 字典裡面的 cx 與 api_key 必須用上面我們透過 dotenv 從環境變數中讀取的值 (變數 custom_search_key 與 cx) 去修改才可以用來傳給外部函式 search_google() : 

>>> args['api_key']=custom_search_key   
>>> args['cx']=cx    
>>> args     
{'query': '2024 台灣總統大選當選人', 'cx': '<我的搜尋引擎ID>', 'api_key': '<我的搜尋引擎金鑰>', 'num': 3}

這樣參數字典中的 cx 與 api_key 就是有效可用的了, 只要將 args 字典用 ** 拆開傳給函式 search_google() 即可. 如果要從模型傳回的函式名稱去呼叫, 可以呼叫內建函式 gloabals(), 它會傳回全域變數字典, 呼叫其 get() 方法並傳入函式名稱字串即可取得函式物件的參考 : 

>>> func=globals().get(function_name)    
>>> func     
<function search_google at 0x000002460701F0A0>

這樣就可以呼叫外部函式 func 了, 它會將 :

>>> results=func(**args)    # 呼叫外部函式 

當然直接呼叫 search_google() 也可以 :

>>> results=search_google(**args) 
>>> len(results)  
3

可見搜尋結果有 3 筆, 檢視最後一筆 :

>>> pprint(results[2])   
{
    'kind': 'customsearch#result',
    'title': '第16屆中華民國總統選舉- 維基百科,自由嘅百科全書',
    'htmlTitle': '第16屆中華民國<b>總統選舉</b>- 維基百科,自由嘅百科全書',
    'link': 
'https://zh-yue.wikipedia.org/wiki/%E7%AC%AC16%E5%B1%86%E4%B8%AD%E8%8F%AF%E6%B0
%91%E5%9C%8B%E7%B8%BD%E7%B5%B1%E9%81%B8%E8%88%89',
    'displayLink': 'zh-yue.wikipedia.org',
    'snippet': 
'賴清德在選舉內得取四成票數,侯友宜獲得三成,柯文哲擭兩成。賴清德總統選舉獲得勝
預計會喺2024年5月20號宣誓就職。 中華民國第16任總統、副總統\xa0...',
    'htmlSnippet': 
'賴清德在選舉內得取四成票數,侯友宜獲得三成,柯文哲擭兩成。賴清德<b>總統選舉</b
預計會喺<b>2024</b>年5月20號宣誓就職。 
中華民國第16任總統、副總統&nbsp;...',
    'formattedUrl': 'https://zh-yue.wikipedia.org/wiki/第16屆中華民國總統選舉',
    'htmlFormattedUrl': 
'https://zh-yue.wikipedia.org/wiki/第16屆中華民國<b>總統</b>選舉',
    'pagemap': {
        'metatags': [
            {
                'referrer': 'origin',
                'og:image': 
'https://upload.wikimedia.org/wikipedia/commons/thumb/d/d3/%E6%9F%AF%E6%96%87%E
5%93%B2_IMG_9322-1_%2814300234412%29_%28cropped%29.jpg/1200px-%E6%9F%AF%E6%96%8
7%E5%93%B2_IMG_9322-1_%2814300234412%29_%28cropped%29.jpg',
                'theme-color': '#eaecf0',
                'og:image:width': '1200',
                'og:type': 'website',
                'viewport': 'width=device-width, initial-scale=1.0, 
user-scalable=yes, minimum-scale=0.25, maximum-scale=5.0',
                'og:title': '第16屆中華民國總統選舉 - 
維基百科,自由嘅百科全書',
                'og:image:height': '1501',
                'format-detection': 'telephone=no'
            }
        ]
    }
}

search_google() 的傳回值是字典串列, 其中有參考價值的鍵是 title 與 snippet, 因此利用迴圈將這兩個鍵之值串成字串, 這個搜尋結果字串稍後要放進角色為 tool 的訊息字典中的 content 鍵 :  

>>> web_msg=[]   
>>> for result in results: 
    web_msg.append(f'標題: {result["title"]}')
    web_msg.append(f'描述: {result["snippet"]}') 
>>> content='\n'.join(web_msg)   

接下來要將上面第一次詢問得到的回應訊息 reply_message (是一個 ChatCompletionMessage 物件) 直接放進查詢訊息串列 messages 中 :

>>> messages.append(reply_message)  

然後製作一個包含 tool_code_id (呼叫外部工具 id), role (角色, 固定為 'tool'), name (函式名稱), 以及 content (外部函式的搜尋結果字串) 四個鍵的字典 : 

>>> results_dict={
    "tool_call_id": tool_calls[0].id,
    "role": "tool",
    "name": function_name,
    "content": content} 

同樣將其加入 messages 串列中 : 

>>> messages.append(results_dict)      
>>> messages   
[{'role': 'system', 'content': '你是一個繁體中文AI助理'}, {'role': 'user', 'content': '2024台灣總統大選是誰當選?'}, ChatCompletionMessage(content=None, refusal=None, role='assistant', audio=None, function_call=None, tool_calls=[ChatCompletionMessageToolCall(id='call_RYkrPwyTkaCC92abOT0GXXdh', function=Function(arguments='{"query":"2024台灣總統大選當選人","cx":"3395206c2d90694f9","api_key":"AIzaSyA-J_1DWGJHfDcltPm3U0onRMy8-OnlCU0","num":3}', name='search_google'), type='function')], annotations=[]), {'tool_call_id': 'call_RYkrPwyTkaCC92abOT0GXXdh', 'role': 'tool', 'name': 'search_google', 'content': '標題: 2024年中華民國總統選舉- 維基百科,自由的百科全書\n描述: 本次選舉候選人共有三組,依登記號次分別為台灣民眾黨推薦的黨主席柯文哲及全國不分區立法委員吳欣盈(通稱「柯吳配」或「柯盈配」)、民主進步黨推薦的副總統兼黨主席賴清德\xa0...\n標題: 台灣大選2024:賴清德當選總統民進黨未能控制立法院- BBC News ...\n描述: Jan 13, 2024 ... 1月13日,台灣舉行2024年總統選舉與立法委員選舉,執政黨民進黨候選人賴清德以超過558萬票勝選。\n標題: 第16屆中華民國總統選舉- 維基百科,自由嘅百科全書\n描述: 賴清德在選舉內得取四成票數,侯友宜獲得三成,柯文哲擭兩成。賴清德總統選舉獲得勝利,將預計會喺2024年5月20號宣誓就職。 中華民國第16任總統、副總統\xa0...'}]

最後用此包含搜尋結果的訊息字典串列 messages 向模型提出第二次詢問 :

>>> second_reply=client.chat.completions.create(
    model=model, 
    messages=messages)

就會得到正確的答案了 : 

>>> print(second_reply.choices[0].message.content)   
根據搜尋結果,2024年台灣總統大選賴清德當選,成功連任總統。

不用再用那不十分確定的回應模板真是太好了! 

不過有一個問題, 如果問 GPT 模型它確知的問題, 但攜帶 tools 與 tool_choice='auto' 參數時它會怎麼做? 例如問它一定知道的 2016 年台灣總統大選結果 : 

>>> messages=[
    {'role': 'system', 'content': '你是一個繁體中文AI助理'},
    {'role': 'user', 'content': '2016台灣總統大選是誰當選?'}
    ]
>>> reply=client.chat.completions.create(
    model=model, 
    messages=messages)   
>>> print(reply.choices[0].message.content)    
2016年台灣總統大選當選的是蔡英文,她是台灣歷史上第一位女性總統。

因為這是遠早於訓練資料截止日的事件, 所以模型回應了正確答案. 

如果改成攜帶 tools 與 tool_choice='auto' 參數去問 gpt-3.5-turbo 模型 : 

>>> reply=client.chat.completions.create(
    model=model, 
    messages=messages,
    tools=tools,  
    tool_choice='auto'   
    ) 
>>> print(reply.choices[0].message.content)   
None  

奇怪, 它居然沒直接回應它確知的答案?  tool_choice='auto' 不是叫它自動判斷是否知道答案嗎? 明明知道為何沒回答? 檢視完整的 reply 內容發現它居然要求 call-out 協助 : 

>>> pprint(reply)   
ChatCompletion(
    id='chatcmpl-BQuZvSWqz21zYXzf0mJP7XEJiCfDA',
    choices=[
        Choice(
            finish_reason='tool_calls',
            index=0,
            logprobs=None,
            message=ChatCompletionMessage(
                content=None,
                refusal=None,
                role='assistant',
                audio=None,
                function_call=None,
                tool_calls=[
                    ChatCompletionMessageToolCall(
                        id='call_MHkxV5UqX2YdyIbHPivHfvkm',
                        function=Function(
                            arguments='{"query":"2016台灣總統大選結果","cx":"24
91495a0","api_key":"AIzaSyA2woHvIIooLIlLWVd3zphLptQ_8WBdgSk","num":3}',
                            name='search_google'
                        ),
                        type='function'
                    )
                ],
                annotations=[]
            )
        )
    ],
    created=1745753715,
    model='gpt-3.5-turbo-0125',
    object='chat.completion',
    service_tier='default',
    system_fingerprint=None,
    usage=CompletionUsage(
        completion_tokens=80,
        prompt_tokens=251,
        total_tokens=331,
        completion_tokens_details=CompletionTokensDetails(
            accepted_prediction_tokens=0,
            audio_tokens=0,
            reasoning_tokens=0,
            rejected_prediction_tokens=0
        ),
        prompt_tokens_details=PromptTokensDetails(
            audio_tokens=0,
            cached_tokens=0
        )
    )
)

我問 ChatGPT 為何會這樣, 原來是 OpenAI 在設計 Function calling 功能時採取了較保守的思維, 如果啟用 tools 參數且 tool_choice='auto' 的話, 即使所問的問題是它知道的知識, 也會讓模型優先考慮呼叫外掛工具, 這樣可以取得即時或更準確的資料. 

在開啟 tools 功能情況下, 如果要讓模型在它確知答案時直接回答不要去 call out 外掛, 那麼就不要指定 tool_choice 參數, 但我測試發現這並不一定, 大多數時候會直接回答, 但偶而還是會要求 call out, 例如 : 

>>> messages=[
    {'role': 'system', 'content': '你是一個繁體中文AI助理'},
    {'role': 'user', 'content': '2016台灣總統大選是誰當選?'}
    ]
>>> reply=client.chat.completions.create(
    model=model, 
    messages=messages,
    tools=tools
    )    
>>> print(reply.choices[0].message.content)   
2016年台灣總統大選中,蔡英文當選為台灣第14任總統。
>>> reply=client.chat.completions.create(
    model=model, 
    messages=messages,
    tools=tools)  
>>> print(reply.choices[0].message.content)   
None   
>>> reply=client.chat.completions.create(
    model=model, 
    messages=messages,
    tools=tools)   
>>> print(reply.choices[0].message.content)   
2016年台灣總統大選中,蔡英文當選為台灣第14任總統。
>>> reply=client.chat.completions.create(
    model=model, 
    messages=messages,
    tools=tools)
>>> print(reply.choices[0].message.content)   
2016年台灣總統大選中,蔡英文當選為台灣第14任總統。

連續詢問四次, 有三次直接回應答案, 有一次回答 None, 也就是尋求外部函式協助. 

綜合以上測試可知, 我們可以在每次詢問時都開啟 tools 功能 (但不傳送 tool_choice 參數), 然後利用回應訊息 reply.choices[0].message.content 的值是否為 None 來判斷是否需要呼叫外部函式協助.

根據上面測試結果, 我改寫了如下之 Function calling 版命令列聊天機器人 (無記憶) :

# cli_chatbot_function_calling_1.py
from openai import OpenAI, APIError
import requests
import json
from dotenv import load_dotenv
import os

def ask_gpt(messages, tools=[], model='gpt-3.5-turbo'):
    try:
        reply=client.chat.completions.create(
            model=model, 
            messages=messages,
            tools=tools
            )
        return reply
    except APIError as e:
        return e.message

def search_google(query, cx, api_key, num=3, gl='tw', lr='lang_zh_TW'):
    params={
        'q': query,
        'key': api_key,
        'cx': cx,
        'gl': gl,  
        'lr': lr,          
        'num': num          
        }
    url='https://www.googleapis.com/customsearch/v1'
    r=requests.get(url, params=params)
    data=r.json()
    return data.get('items', [])

# 載入環境變數
load_dotenv()
openai_api_key=os.environ.get('OPENAI_API')
custom_search_key=os.environ.get('GOOGLE_CUSTOM_SEARCH_API')
cx=os.environ.get('SEARCH_ENGINE_ID')
# 設定外部工具函式串列
tools=[{
    "type": "function",
    "function": {
        "name": "search_google",
        "description": "用 Google Custom Search API 取得搜尋結果",
        "parameters": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "要搜尋的關鍵字"
                    },
                "cx": {
                    "type": "string",
                    "description": "Google Custom Search 搜尋引擎 ID"
                    },
                "api_key": {
                    "type": "string",
                    "description": "Google Custom Search API 金鑰"
                    },
                "num": {
                    "type": "integer",
                    "description": "回傳的搜尋結果筆數(最多 10 筆)",
                    "default": 3
                    },
                "gl": {
                    "type": "string",
                    "description": "地理區域 (預設 'tw' 台灣)",
                    "default": "tw"
                    },
                "lr": {
                    "type": "string",
                    "description": "語言 (預設 'lang_zh_TW' 繁體中文)",
                    "default": "lang_zh_TW"
                    }
                },
            "required": ["query", "cx", "api_key"]
            }
        }
    }]
# 建立 OpenAI 物件
client=OpenAI(api_key=openai_api_key)
# 設定 GPT 系統角色
sys_role=input('請設定 GPT 角色 : ')
if sys_role.strip() == '': 
    sys_role='你是繁體中文AI助理'
print(sys_role)

# 聊天機器人 (無窮迴圈)
while True: 
    prompt=input('You : ')  # 輸入提示詞
    if prompt.strip() == '':  # 按 Enter 跳出迴圈結束聊天
        print('GPT : 再見。')
        break
    messages=[{'role': 'system', 'content': sys_role},
              {'role': 'user', 'content': prompt}]  # 初始化訊息
    # 第一次查詢 : 確定是否需要搜尋網路
    reply=ask_gpt(messages, tools=tools)
    reply_message=reply.choices[0].message  # 取出回應訊息
    if reply_message.content == None:  # 內容為空表示需要搜尋網路
        messages.append(reply_message)  # 回應放入 messages 串列
        tool_calls=reply_message.tool_calls  # 取得外部工具函式物件
        function_name=tool_calls[0].function.name  # 取得函式名稱
        args=json.loads(tool_calls[0].function.arguments) # 取得參數
        args['api_key']=custom_search_key  # 設定搜尋引擎金鑰
        args['cx']=cx  # 設定搜尋引擎ID
        func=globals().get(function_name)  # 取得函式參考
        results=func(**args)  # 呼叫外部函式, 也可以用 search_google(**args)
        web_msg=[]  # 用來儲存搜尋結果
        for result in results: 
            web_msg.append(f'標題: {result["title"]}')
            web_msg.append(f'描述: {result["snippet"]}') 
        content='\n'.join(web_msg)  # 搜尋結果串接為字串
        results_dict={  # 製作 JSON 格式的搜尋結果字典
            "tool_call_id": tool_calls[0].id,
            "role": "tool",
            "name": function_name,
            "content": content
            }
        messages.append(results_dict)  # 將搜尋結果字典放進 messages         
        # 第二次查詢 : 根據所提供的網路搜尋結果回答
        reply2=ask_gpt(messages, tools=tools)
        response=reply2.choices[0].message.content
    else:  # 不需搜尋網路, 直接回應答案
        response=reply_message.content
    print(f'GPT : {response}') 

注意, 此處的 ask_gpt() 直接將呼叫 client.chat.completions.create() 的結果傳回以便分析模型是否需要呼叫外部工具函式. 

結果如下 : 

>>> %Run cli_chatbot_function_calling_1.py   
請設定 GPT 角色 : 
你是繁體中文AI助理
You : 2024台灣總統大選是誰當選?
GPT : 根據搜尋結果,2024年台灣總統大選中,民進黨候選人賴清德以超過558萬票勝選,成為當選總統。
You : 2016台灣總統大選是誰當選?
GPT : 2016年台灣總統大選的當選者是蔡英文。她在此次選舉中成為台灣史上首位女性最高統治者,也是中華民國首位女性、未婚、擁有這一榮銜的領導人。
You : 2024 台灣金曲歌王是誰?
GPT : 根據搜索結果,2024年台灣金曲歌王是 MC HotDog。
You : 
GPT : 再見。

嗯, 看起來只要有呼叫外部函式的, 回應都會以 "根據搜索結果" 開頭, 否則就直接回應. 

2025年4月26日 星期六

好站 : 線上轉檔工具 online-convert.com

自從我的 iPad 2 儲存容量爆滿後就很少用它來讀電子書了, 而且現在有了 NotebookLM 輔助, 用筆電就可以快速摘要讀完一本電子書精髓, 我用平板閱讀的機會越來越少 (iPad 2 真的非常耐用, 但那時為了省錢只買最便宜的 64GB 版, 哭哭). 

不過, 利用 NotebookLM 輔助學習有個問題 : 它無法上傳 epub 電子書, 目前只支援 .pdf, .txt, .docx 等文件, 所以我之前在 iPad 上的 epub 電子書都必須轉成 pdf 才行. 過去我都使用免費軟體 Calibre 轉檔, 但轉換後的字型格式不管怎麼調整都與 epub 無法相近 (但無礙閱讀啦, 只是完美主義), 於是我就去問 ChatGPT 是否有線上轉檔服務? 它推薦了下面這款 : 


首先需按右上角的 SIGN UP 註冊帳號, 每個新帳戶會贈送 65 點使用點數 (credits) 試用, 用完可以購買隨用扣點數方案 (儲值 14 美元 480 點) 或月付方案 : 




可以使用 Google 或 Apple 帳號快速註冊, 但我偏好用 email 註冊, 填寫姓名後提交, 它會寄一封確認信到郵箱, 按裡面的確認鈕後會自動登入帳戶 : 




在首頁的 Document 就是檔案轉換的功能, 我是要將 epub 轉成 pdf, 所以第一個下拉式選單勾選 epub, 第二個勾選 pdf : 





選好格式後按底下的 Choose File 點選 epub 檔案上傳, 可以從本機, Dropbox, OneDrive, 或 Google 雲端硬碟等來源上傳 : 





按 START 鈕即開始轉換 (也可以先按 Settings 鈕去設定字型等轉換參數, 但我用預設值就轉得很漂亮), 每 30 秒會扣一點 : 




轉換完成按 Download 下載 pdf 檔 : 




按右上角的三條槓點選 Credits 會顯示目前的點數使用狀態 : 





可見因為上面轉 epub 為 pdf 在 30 秒內轉完, 所以扣點數 1 點, 試用點數剩 15 點 : 




新帳戶除了贈送 16 個 credits 試用外, 還會額外提供 50 點的 welcome credits, 因此每個新註冊用戶總共有 66 點的試用點數 (16 + 50 = 66 點). 免費點數用完可以按 Upgrade Now 升級為付費帳戶繼續使用 : 




我覺得它的轉換品質非常好, 比 Calibre 的輸出效果好很多. 如果是要求 pdf 品質的話可以考慮付費使用, 但如果只是要用 NotebookLM 輔助學習的話, 用免費的 Calibre 軟體就好. 

Mappleboard 更改 RealVNC Connect 登入帳號 (Yahoo)

上週發現 Mapleboard 無法用 RealVNC 遠端登入, 改用 Anydesk 也是一樣, 網域也無法連線, 研判是網路連線有問題. 昨晚回到鄉下立馬重開機, 但 RealVNC Connect 還是無法連線, 檢查發現是 VNC Server 未登入, 參考下面這篇 Pi 3A+ 的筆記的程序, 卻一時不察登入到 Yahoo 信箱帳戶 (以前是用 Hinet 帳戶), 乾脆將錯就錯, 跟 Pi 3A+ 一起歸入 Yahoo 帳戶好了 : 


按 Mapleboard 右上方狀態列的 RealVNC 按鈕 : 




在彈出的 VNC Server 視窗中, 按右上角的三條槓, 點選 Licencing :




按 Next :




輸入 RealVNC Connect 帳戶的 email (就是這裡沒注意, 輸入了 Yahoo 信箱) 與密碼 (akf) : 




按 Next : 




認證選 VNC password 按 Next : 




按 Next : 




用預設名稱, 按 Apply : 




這時會彈出一個終端機視窗, 輸入登入密碼按 Enter : 




按 Done 即可 :



這樣 Mappleboard 便改登錄到 Yahoo 信箱帳戶內了 : 




其實隨時都可以更改 Licencing 設定, 將此設備改掛到別的 RealVNC Connect 帳戶 (免費的 Lite 帳戶至多只能掛三台主機). 

2025年4月24日 星期四

momo 買書 3 本加購 64GB TF 卡

最近在學 Canva, 從母校借來的 "剛剛好的 Canva 設計教本" 被預約還回去了, 這本寫得很好值得買下來, 剛好 momo 有滿額再打 86 折活動, 所以又多買了兩本, 並加購一張 64GB TF 卡 (momo 加購商品價格都比較優惠) :





全部原價 2748 元, 優惠後 2030 元, 用掉 136 元 momo 幣, 實付 1894 元 (69 折).

MicroPython 學習筆記 : 用於 ESP8266 開發板的 esptools

這幾天完成手上所有 ESP32 開發板的 esptools.py 測試, 結論是所有 ESP32 開發板用 esptools.py 傳送文字與網路圖片到 Telegram, 以及詢問 GPT 都沒問題, 但傳送本機圖片則必須要有 SPI RAM (PSRAM) 才行, 例如 ESP32 WROVER 或 ESP32 S3 開發板. 

剩下 ESP8266 開發板還沒測, 今天找了一塊 D1 Mini 板子測試, 結果如下 : 

MPY: soft reboot
MicroPython v1.25.0 on 2025-04-15; ESP module with ESP8266
Type "help()" for more information.

冷開機後上傳 config.py 與 esptools.py (從 ESP32 那邊複製過來), 先查初始記憶體 :

>>> import gc  
>>> print(gc.mem_free())    
33712

ESP8266 開發板配備 160KB SRAM, 其中一半給 MicroPython 用, 剩下一半約 80KB 給程式使用, 但實際上目前剩下 33KB 而已. 

>>> import config  
>>> print(gc.mem_free())      
32800

匯入 config 後剩下 32.8KB.

>>> import esptools   
>>> print(gc.mem_free())   
28544   

匯入 esptools 後剩下 28.5KB.

>>> ssid=config.SSID   
>>> password=config.PASSWORD   
>>> token=config.TELEGRAM_TOKEN    
>>> chat_id=config.TELEGRAM_CHAT_ID 
>>> ip=esptools.connect_wifi(ssid, password)   
network config: ('192.168.77.84', '255.255.255.0', '192.168.77.150', '192.168.77.150')
>>> print(gc.mem_free())   
26144

連線 WiFi 後只剩 26KB 左右. 試試看傳訊息到 Telegram : 

>>> text='Hello! 你好!'    
>>> esptools.telegram_text(token, chat_id, text)   

Exception occurred : -40
False

結果拋出 -40 例外, 查詢 ChatGPT 原因為記憶體不足所致. ESP8266 因為 SRAM 記憶體有限, 無法進行需要耗費較大記憶體的作業, 所以我將 esptools.py 模組刪減為如下的 ESP8266 版 :

# esptools.py for ESP8266, 2025-04-24 updated (adapted from xtools.py)
from machine import Pin, RTC, unique_id
import urandom, time, network, urequests, ubinascii
import ntptime, ujson
import uos

def get_id():
    return ubinascii.hexlify(unique_id()).decode('utf8')

def get_mac():
    sta=network.WLAN(network.STA_IF)
    mac=sta.config('mac')
    return ubinascii.hexlify(mac, ':').decode('utf8')

def get_num(x):
    return float(''.join(filter(lambda c: c.isdigit() or c == ".", x)))

def random_in_range(low=0, high=1000):
    return urandom.getrandbits(32) % (high - low) + low

def map_range(x, in_min, in_max, out_min, out_max):
    return int((x-in_min) * (out_max-out_min) / (in_max-in_min) + out_min)
   
def connect_wifi(ssid, password, led=2, timeout=20):
    wifi_led=Pin(led, Pin.OUT, value=1)
    sta=network.WLAN(network.STA_IF)
    if not sta.active():
        sta.active(True)   # 確保已啟動 WiFi
    start_time=time.time() # 記錄時間判斷是否超時
    if not sta.isconnected():
        print("Connecting to network...")
        sta.connect(ssid, password)
        while not sta.isconnected() and time.time() - start_time <= timeout:
            wifi_led.value(0)
            time.sleep_ms(300)
            wifi_led.value(1)
            time.sleep_ms(300)
        if not sta.isconnected():
            print("Wifi connecting timeout!")
            return None
    if sta.isconnected():
        for _ in range(25):  # 連線成功 : 快閃 5 秒
            wifi_led.value(0)
            time.sleep_ms(100)
            wifi_led.value(1)
            time.sleep_ms(100)
        print("network config:", sta.ifconfig())
        return sta.ifconfig()[0] 

def scan_ssid():
    sta=network.WLAN(network.STA_IF)
    sta.active(True)
    aps=sta.scan()
    for ap in aps:
        ssid=ap[0].decode()
        mac=ubinascii.hexlify(ap[1], ':').decode()
        rssi=str(ap[3]) + 'dBm'
        print(f'{ssid} {mac} {rssi}')

def show_error(led=2, final_state=0):
    led=Pin(led, Pin.OUT)   # D1 mini built-in D4=LED 2
    for i in range(3):
        led.value(1)
        time.sleep(0.5)
        led.value(0)
        time.sleep(0.5)
    led.value(final_state)    

def webhook_post(url, value, led=2):
    try:
        r=urequests.post(url, data=value)
        if r.status_code == 200:
            print("Webhook invoked")
        else:
            print("Webhook failed")
            show_error(led)
    finally:
        r.close()  # 釋放資源
    return r

def webhook_get(url, led=2):
    try:
        r=urequests.get(url)
        if r.status_code == 200:
            print("Webhook invoked")
        else:
            print("Webhook failed")
            show_error(led)
    finally:
        r.close()  # 釋放資源
    return r

def urlencode(params):
    # 將字典的鍵值對轉換為 URL 編碼的字串 (k=v) 並以 & 連接多個鍵值對
    kv=['{}={}'.format(k, v) for k, v in params.items()]
    return '&'.join(kv)

def tw_now():
    try: # 從 NTP 取得 UTC 時戳加 8 為台灣時間, 若成功設定 RTC
        print('Querying NTP server and set RTC time ... ', end='')
        utc=ntptime.time() # 取得 UTC 時戳
        print('OK.')
        t=time.localtime(utc + 28800) # 傳回台灣時間的元組
        RTC().datetime(t[0:3] + (0,) + t[3:6] + (0,))
    except Exception as e:  # 加入例外處理
        print(f'Failed. {e}')
    return strftime()  # 傳回目前之日期時間字串 YYYY-mm-dd HH:MM:SS 

def strptime(dt_str):
    t=time.mktime((
        int(dt_str[0:4]), int(dt_str[5:7]), int(dt_str[8:10]),
        int(dt_str[11:13]), int(dt_str[14:16]), int(dt_str[17:19]),
        0, 0))
    return time.localtime(t)
    
def strftime(dt=None, format_str="%Y-%m-%d %H:%M:%S"):
    if dt is None:
        dt=time.localtime()
    return format_str.replace("%Y", str(dt[0])) \
                     .replace("%m", "{:02d}".format(dt[1])) \
                     .replace("%d", "{:02d}".format(dt[2])) \
                     .replace("%H", "{:02d}".format(dt[3])) \
                     .replace("%M", "{:02d}".format(dt[4])) \
                     .replace("%S", "{:02d}".format(dt[5]))


將 Telegram 與 GPT 等函式刪除後, esptools.py 瘦身為 5KB 左右. 注意, 此處 strptime() 已改為較精簡省記憶體的寫法.  上傳精簡後的 esptools.py 重開機測試 : 

MPY: soft reboot
MicroPython v1.25.0 on 2025-04-15; ESP module with ESP8266
Type "help()" for more information.

>>> import gc   
>>> print(gc.mem_free())    
33712
>>> import config   
>>> print(gc.mem_free())    
32800
>>> import esptools   
>>> print(gc.mem_free())    
28912 
>>> ssid=config.SSID    
password=config.PASSWORD 
>>> ip=esptools.connect_wifi(ssid, password)   
network config: ('192.168.77.84', '255.255.255.0', '192.168.77.150', '192.168.77.150')
>>> print(gc.mem_free())    
27808
>>> esptools.tw_now()   
Querying NTP server and set RTC time ... OK.
'2025-04-24 11:01:44'

可見連線 WiFi 後記憶體多出了 1.7KB 左右. 總之, ESP8266 屬於低階物聯網控制器, 較高階之專案要用 ESP32.