上一篇把整個請假機器人的流程:「先過濾、再迴圈追問修改、最後才送單」講完了。這篇我們要直接看程式碼,把整支機器人拆成幾個部分,每個部分先看「程式碼長什麼樣」,再說明「為什麼要這樣寫」。這篇先講 State 資料模型、提問腳本,還有提問初始化跟提問改寫的部分。
實作總共會用到四支檔案,各自的角色分工如下:
project/
├── run-gradio-edgeLLM.py # 執行入口:組 LangGraph 的 Graph
├── model/
│ ├── stateModel.py # State 變數表,整場對話的「記憶體」
│ └── promptModel.py # 提問腳本,教 LLM 把口語轉成結構化資料
└── module/
└── leaveModule.py # 請假模組核心邏輯,真正在做事的地方
依賴關係是由上到下:run-gradio-edgeLLM.py 匯入 leaveModule.py 的節點函式來組 Graph;leaveModule.py 匯入 stateModel.py 的 State 定義資料長什麼樣、匯入 promptModel.py 的模板來跟 LLM 對話,所以接下來會照「資料模型 → 提問腳本 → 核心邏輯 → 執行入口」的順序介紹。
外部串接的部分(EIP 送單 API、log 紀錄系統)跟本篇的 workflow 設計無關,程式碼裡會直接沿用 import,不重複列出實作,接自己公司系統時把這幾行換掉即可。
model/stateModel.pyfrom typing import TypedDict, Annotated
# 資料疊加
def concat(original: list, new: list) -> list:
return original + new
class State(TypedDict):
messages: Annotated[list, concat] #對話記錄
chatType: str #對話類型
category: Annotated[list, concat] #假別
startTime: Annotated[list, concat] #起始時間
endTime: Annotated[list, concat] #結束時間
memo: Annotated[list, concat] #請假事由
agent: Annotated[list, concat] #代理人
staffData: list #員工基本資料
canSubmit: Annotated[list, concat] #是否可送出
attachPath: Annotated[list, concat] #假單附件連結
createdAt: Annotated[list, concat] #事件發生日
isCreatedAtAsk: Annotated[list, concat] #事件發生日是否觸發追問
isCreatedAtAsked: Annotated[list, concat] #事件發生日是否已追問
previewTmp: Annotated[list, concat] #假單預覽暫存
isFirstTimePromptPassed: Annotated[list, concat] #首次提問是否已通過
State 就是整個對話能夠「越講越完整」的資料結構,先看關鍵的 reducer:concat 函式把新值接到舊值的後面,而不是覆蓋掉。搭配 Annotated[list, concat] 這個型別標註,LangGraph 的更新規則就變成:每次節點回傳某個欄位的新值,是「用 concat 接到原本的 list 後面」。也就是說 category 從頭到尾都不是一個字串,而是一份「這場對話裡所有出現過的假別」的紀錄清單,程式裡要用的時候永遠是拿最後一筆 state['category'][-1]。
這個設計乍看有點囉唆(一個假別為什麼要存成 list),但其實正是「反覆追問、局部修改」迴圈能運作的關鍵:因為舊的值不會被沖掉,所以每次只有「有新資訊」的欄位才會被 append 新值,沒被提到的欄位自然維持原樣,[-1] 永遠拿到目前最新、最正確的狀態。
幾個比較特別的欄位,順便對照它們在流程裡的角色:
isFirstTimePromptPassed:這場對話是不是已經通過了第一階段的「這句話到底是不是在講請假」的檢查,對應前一篇說的「首次提問過濾檢查」
isCreatedAtAsk / isCreatedAtAsked:婚假、喪假這類特殊假別需要額外問「事件發生日」,這兩個欄位是專門控制「有沒有觸發追問」跟「有沒有問完」的狀態旗標
previewTmp:每次產生的假單預覽文字會暫存在這裡,用來跟上一次的預覽比對,這是用來偵測「LLM 是不是卡住、講來講去都改寫成同樣結果」的土法煉鋼技巧,等一下在 preview() 會看到
model/promptModel.py目前流程有四組模板(rewriteLeavePromptFirstTime、rewriteLeavePrompt、checkForm、askForCreatedAt):
promptTemplate = {
'llama3': {
# 請假提問引導式改寫 (首次提問)
'rewriteLeavePromptFirstTime': """<|begin_of_text|><|start_header_id|>system<|end_header_id|>
今年是{year}年。
如果提問中僅提及"MM-dd"或"MM/dd",請自動補充當前日期,轉換為"yyyy-MM-dd HH:mm"格式輸出。
已知請假腳本範例:
「我要請假 | 時間是{開始時間}~{結束時間} | 請假類別是{假別} | 請假事由是{原因} | 代理人是{代理人}」
{開始時間}預設為{start_date}。如果提問中有提到時間或日期禁止使用預設值。
{結束時間}預設為{end_date}。如果提問中有提到時間或日期禁止使用預設值。
{假別}預設為{category}。如果提問中沒提到假別則限制只使用預設值且禁止修改預設值。
{原因}預設為{memo}。如果提問中沒提到事由則禁止修改只使用預設值。輸出內容開頭禁止自動加上「因為」等解釋性字眼。
{代理人}預設為{agent}。
假別有:{leaveTypes}。
# 以下為同義詞轉換規則,格式都是「A 和 B 是同義詞,都改寫為 B」,依實際需求增減:
特休和特休假為同義詞,都改寫為特休假。
改、改為、改成是同義詞,都改寫為改為。
喪假三日與三日喪假為同義詞,都改寫為三日喪假。
...(其餘同義詞規則略)
請假需求必須至少與以下其中一個關鍵字相關:「請假、假別、原因、代理人、事件發生日、時間、請假事由、因為、開始時間、結束時間、日期」。
<|eot_id|><|start_header_id|>user<|end_header_id|>
判斷以下內容中如果有請假需求,請將以下內容用上述請假腳本範例改寫,如果判斷提問中沒有提到請假需求則直接輸出[WrongFirstTimePrompt],除結果外禁止輸出多餘文字:
「{messages}」<|eot_id|><|start_header_id|>assistant<|end_header_id|>""",
# 請假提問引導式改寫 (後續每一輪)
'rewriteLeavePrompt': """<|begin_of_text|><|start_header_id|>system<|end_header_id|>
已知請假腳本範例:
「我要請假 | 時間是{開始時間}~{結束時間} | 請假類別是{假別} | 請假事由是{原因} | 代理人是{代理人} | 事件發生日是{事件發生日}」
{開始時間}預設為{start_date}。提問中有事件發生日則只使用預設值且禁止修改預設值。
{結束時間}預設為{end_date}。提問中有事件發生日則只使用預設值且禁止修改預設值。
{假別}預設為{category}。如果提問中沒提到假別禁止修改只使用預設值。
{原因}預設為{memo}。如果提問中沒提到事由禁止修改只使用預設值。
{代理人}預設為{agent}。
{事件發生日}預設為{created_at}。如果提問中沒有提到事件發生日則只使用預設值且禁止修改預設值。
# 同義詞轉換規則同上,依實際需求增減
...(略)
<|eot_id|><|start_header_id|>user<|end_header_id|>
請將以下內容用上述請假腳本範例改寫,除結果外禁止輸出多餘文字:
「{messages}」<|eot_id|><|start_header_id|>assistant<|end_header_id|>""",
# 檢查假單內容 (產生 JSON)
'checkForm': """<|begin_of_text|><|start_header_id|>system<|end_header_id|>
今天日期為{today}。現在時間為{today} {now}。
所有時間請以台北時區(UTC+8)為準,回答只用 ISO 8601 格式: yyyy-MM-dd HH:mm。
起始時間的 HH:mm 預設為 09:00,結束時間的 HH:mm 預設為 18:00,若已存在則不使用預設值。
回答時只輸出台灣繁體中文,不輸出任何說明文字。
category為請假類別,禁止根據請假事由修改。
memo為請假事由,預設為空值,禁止根據請假類別修改。
createdAt為事件發生日,預設為空值。
回答只允許出現JSON格式的答案,禁止出現任何額外的說明與解釋。
<|eot_id|><|start_header_id|>user<|end_header_id|>
從內容解析出假單內容:「{messages}」。
Respond using the following format:
{
"category": string, #請假類別 ({leaveTypes}、無假別)
"startTime": string, #起始時間 (格式:yyyy-MM-dd HH:mm)
"endTime": string, #結束時間 (格式:yyyy-MM-dd HH:mm)
"agent": string, #工作職務代理人
"memo": string, #請假事由
"createdAt": string, #事件發生日 (格式:yyyy/MM/dd)
}<|eot_id|><|start_header_id|>assistant<|end_header_id|>""",
# 取得事件發生日(觸發追問處理)
'askForCreatedAt': """<|begin_of_text|><|start_header_id|>system<|end_header_id|>
Generate a JSON object, without any additional text or comments.
createdAt為事件發生日。
<|eot_id|><|start_header_id|>user<|end_header_id|>
從內容解析出假單內容:「{messages}」。
Respond using the following format:
{
"createdAt": string, #事件發生日 (格式:yyyy/MM/dd)
}<|eot_id|><|start_header_id|>assistant<|end_header_id|>"""
}
}
這支檔案自己手刻 Llama 3 的對話 template(<|begin_of_text|>、<|start_header_id|>system<|end_header_id|>……),把 System Prompt 跟 User Prompt 兩段內容直接組好成一個字串再丟進去。
再來看內容本身,會發現這套 Prompt 幾乎像一本「使用者亂講話對照表」,因為 8B 這種規模的地端模型,語意理解能力比不上雲端的大模型,所以選擇用「窮舉規則」硬把常見的口語變體都列出來,等於是把一部分「理解」的工作,用人工規則的方式先幫模型做掉,模型只需要照著規則抄一遍,而不是真的要「理解」使用者在講什麼。這是地端小模型很實際的工程取捨:「模型能力不夠,就用 Prompt Engineering 去補」。
四個 Prompt 模板各自負責流程裡不同的關卡:
| 模板 | 用途 | 對應前一篇的哪個階段 |
|---|---|---|
rewriteLeavePromptFirstTime |
第一次講話時用,如果判斷跟請假無關會直接輸出 [WrongFirstTimePrompt] |
首次提問過濾檢查 |
rewriteLeavePrompt |
後續每一輪對話都用這個改寫,把新輸入疊加到既有的預設值上 | 修改提問迴圈 - 提問內容正規化 |
checkForm |
把改寫過的句子轉成正式 JSON(category、startTime、endTime、agent、memo、createdAt) | 修改提問迴圈 - JSON 產生器 |
askForCreatedAt |
只解析「事件發生日」一個欄位,用在婚喪產假的專門追問 | 修改提問迴圈裡的特殊子迴圈 |
拿範例對話代進去看一次:第一輪「我要請病假因為要去看醫生」會先過 rewriteLeavePromptFirstTime(因為 isFirstTimePromptPassed 還是 False),通過後 isFirstTimePromptPassed 被設成 True,後面所有「代理人改成 XXX」這種局部修改,就都走 rewriteLeavePrompt 這個模板了。
module/leaveModule.py這支檔案是整支機器人真正在做事的地方,拆成幾個小節分別看「程式碼」跟「說明」。這篇先看初始化跟 rewriteLeavePrompt(),checkForm() 之後的驗證邏輯留到下篇繼續。
import datetime, re, json
from model.stateModel import State
from model.promptModel import promptTemplate
# 這三行請接上你自己的系統
# from event_log.logging import log
# from ad.api import submitLeave
# import configparser; config = configparser.ConfigParser(); config.read('config.ini')
def setLLM(newLLM, setPromptIndex, setToday, setNow, setLeaveTypes):
global llm, promptIndex, today, now, leaveTypes
llm, promptIndex, today, now, leaveTypes = newLLM, setPromptIndex, setToday, setNow, setLeaveTypes
## 追問類節點(缺什麼就丟哪種提示,都是固定文字,照需要調整措辭即可)##
def askForTime(state: State):
return {"messages": ["請問要請什麼時候(開始時間 ~ 結束時間,時間格式:YYYY/MM/DD HH:MM)?"]}
def noAgent(state: State):
return {"messages": ["很抱歉,您目前沒有可用代理人,目前無法請假喔,請詢問人資單位提供協助"]}
def notAvailableAgent(state: State):
availableAgents = [d['name'] + ' (' + d['name_en'] + ')' for d in state['staffData'][-1]['agents']]
if len(availableAgents) > 0:
return {"messages": ["該名字不在您的代理設定中。\n目前可指定代理人:\n{}".format(
'\n'.join(str(a) for a in availableAgents)
)]}
return {"messages": ["該名字不在您的代理設定中,您沒有其他代理人可更換喔"]}
def notAvailableLeaveType(state: State):
return {"messages": ["您的假別無法申請,可能是輸入錯誤或不適用。請參考可申請的假別進行修正:\n\n「{}」".format(leaveTypes)]}
def noneLeaveHour(state: State):
return {"messages": ["很抱歉,此假別您的可用時數已用完(剩餘 0 小時),建議改成其他假別喔"]}
def askForMemo(state: State):
return {"messages": ["請假事由?\n建議回答範例:「家裡有事」、「醫院回診」、「要去參加婚禮」"]}
def askForCreatedAt(state: State):
return {
"messages": ["事件發生日?\n建議回答範例:「{}」".format(str(today)[:-2] + "01").replace('-','/')],
"isCreatedAtAsk": [True]
}
def askForClearCreatedAt(state: State):
return {
"messages": ["很抱歉,無法理解您目前的輸入\n請輸入正確的事件發生日"],
"isCreatedAtAsk": [True]
}
def askForClearPrompt(state: State):
return {"messages": ["很抱歉,無法理解您目前的輸入\n請再給多點提示讓我更了解您的假單修改需求\n\n修改假單可參考以下範例:\n「假別改為有薪病假」\n「代理人改為OOO」\n「事由改為身體不適」"]}
def askForClearPromptFirstTime(state: State):
return {"messages": ["很抱歉,無法理解您目前的輸入\n請再給多點提示讓我更了解您的請假需求\n\n申請假單可參考以下範例:\n「我因爲臨時有事,今天要請事假」\n「我因為出國,下週一到下週三要請特休」"]}
setLLM() 是外部呼叫進來初始化的入口,把 LLM 物件、目前用哪套 Prompt 腳本(llama3)、今天日期時間、可用假別清單都存成 module 層級的全域變數,後面所有節點函式才不用每次都重新傳一次這些參數。
其餘這一串都是「缺什麼就回什麼提示」的節點:假別不在清單、代理人找不到、忘記填事由、還沒問到事件發生日……每一種缺漏情況都有自己專屬的訊息函式,回傳的內容也盡量附上範例句型(例如 askForClearPrompt 直接給三個修改假單的例句),這呼應前一篇提到的設計精神 — 用範例把使用者帶回正軌,而不是丟一句冷冰冰的「我聽不懂」。這些函式之後會被 checkForm() 依情況呼叫,是整條流程「反覆追問」體驗的最終出口。
rewriteLeavePrompt():選對 Prompt、補上預設值def rewriteLeavePrompt(state: State):
# 使用者按下確認送出
if state["messages"][-2].content == "[LeaveFormSubmit]":
state['canSubmit'].append(True)
return {"messages": ["[LeaveFormSubmit]"]}
rewritePrompt = str(promptTemplate[promptIndex]['rewriteLeavePrompt'])
if True not in state["isFirstTimePromptPassed"]:
rewritePrompt = str(promptTemplate[promptIndex]['rewriteLeavePromptFirstTime'])
rewritePrompt = rewritePrompt.replace('{messages}', state["messages"][-2].content) \
.replace('{leaveTypes}', leaveTypes) \
.replace('{year}', str(datetime.datetime.strptime(today, "%Y-%m-%d").year))
# 沒提到的欄位一律用上一輪的值當預設,這是「局部修改」的關鍵
rewritePrompt = rewritePrompt.replace('{start_date}', state['startTime'][-1] if len(state['startTime']) > 0 else str(today) + ' 09:00')
rewritePrompt = rewritePrompt.replace('{end_date}', state['endTime'][-1] if len(state['endTime']) > 0 else str(today) + ' 18:00')
rewritePrompt = rewritePrompt.replace('{category}', state['category'][-1] if len(state['category']) > 0 else '特休假')
rewritePrompt = rewritePrompt.replace('{memo}', state['memo'][-1] if len(state['memo']) > 0 else '「休假」')
if len(state['agent']) > 0:
rewritePrompt = rewritePrompt.replace('{agent}', state['agent'][-1])
else:
state['agent'].append(state['staffData'][-1]['agents'][0]['name'] if len(state['staffData'][-1]['agents']) > 0 else '')
rewritePrompt = rewritePrompt.replace('{agent}', state['agent'][-1])
rewritePrompt = rewritePrompt.replace('{created_at}', state['createdAt'][-1] if len(state['createdAt']) > 0 else '')
answer = llm.invoke(rewritePrompt)
return {"messages": [answer.content.replace('\n','')]}
這裡就是回答「為什麼使用者可以只講半句話」的核心程式碼:{start_date}、{end_date}、{category}、{memo}、{agent} 這幾個佔位符,一律先檢查 State 裡有沒有存過上一輪的值,有就拿上一輪的值當預設,沒有才用系統預設(例如今天 09:00 ~ 18:00、特休假)。所以「代理人改成 XXX,時間改成 12/30 到 12/31」這句話丟進 LLM 前,Prompt 裡其實已經先把假別、事由這些沒提到的欄位用上一輪的答案墊好了,LLM 只需要處理「有講到的部分」就好。
最開頭那段 [LeaveFormSubmit] 的判斷,對應前端送出按鈕:一旦偵測到這個標記,直接把 canSubmit 設成 True,跳過整段改寫邏輯,讓流程往下直接進 submit()。
isFirstTimePromptPassed 這個判斷決定要用哪一套模板:還沒通過首次過濾就用 rewriteLeavePromptFirstTime(會擋掉「怪怪的」這種無關句子),通過之後才切到 rewriteLeavePrompt 做局部修改。
下一篇實作接著看 checkForm() — 整支機器人最忙的節點,還有 preview()、submit()、輔助函式,最後看怎麼把這些節點組裝成一套真的能運行的 workflow。