iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
AI Engineering

地端 AI 建築學系列 第 8

08 案例一 : Workflow (2) AI 請假助理實作 (上)

  • 分享至 

  • xImage
  •  

上一篇把整個請假機器人的流程:「先過濾、再迴圈追問修改、最後才送單」講完了。這篇我們要直接看程式碼,把整支機器人拆成幾個部分,每個部分先看「程式碼長什麼樣」,再說明「為什麼要這樣寫」。這篇先講 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,不重複列出實作,接自己公司系統時把這幾行換掉即可。


State 變數表 — model/stateModel.py

程式碼

from 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

程式碼

目前流程有四組模板(rewriteLeavePromptFirstTimerewriteLeavePromptcheckFormaskForCreatedAt):

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。


上一篇
07 案例一 :Workflow (1) AI 請假助理架構設計
下一篇
09 案例一:Workflow (3) AI 請假助理實作 (下)
系列文
地端 AI 建築學14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言