前言
這是 Jaba AI 技術分享系列 的第八篇文章。
jaba-ai 的核心功能是讓使用者用自然語言點餐。這篇文章分享如何設計 AI 對話流程,讓它能理解各種點餐表達方式。
點餐場景分析
使用者在群組點餐時,可能會用各種方式表達:
| 表達方式 | 範例 | 意圖 |
|---|---|---|
| 直接點餐 | 「我要雞腿便當」 | 為自己點一份 |
| 多品項 | 「雞腿便當和排骨便當各一」 | 點多個品項 |
| 指定數量 | 「三個雞腿便當」 | 點多份同品項 |
| 加備註 | 「雞腿便當不要辣」 | 點餐加註記 |
| 跟單 | 「+1」「我也要」 | 複製前一人的訂單 |
| 代點 | 「幫小明點雞腿便當」 | 為他人點餐 |
| 修改 | 「把排骨換成雞腿」 | 修改已點品項 |
| 取消 | 「我不要了」 | 取消訂單 |
AI 對話設計
系統提示詞
你是呷爸點餐助手,負責協助群組成員點餐。
【你的能力】
1. 理解使用者的點餐意圖
2. 從菜單中找到對應的品項
3. 處理備註、數量等細節
4. 執行點餐動作
【回應格式】
必須回傳 JSON:
{
"message": "回覆使用者的訊息",
"actions": [
{"type": "group_create_order", "data": {...}}
]
}
【動作類型】
- group_create_order: 建立/新增訂單
- group_update_order: 修改訂單
- group_remove_item: 移除品項
- group_cancel_order: 取消訂單
Context 結構
每次呼叫 AI 時,傳入完整的上下文:
context = {
"mode": "group_ordering",
"user_name": "小明",
"today_stores": [
{"id": "store-uuid", "name": "好吃便當"}
],
"menus": {
"store-uuid": {
"name": "好吃便當",
"categories": [
{
"name": "主餐",
"items": [
{"id": "item-1", "name": "雞腿便當", "price": 85},
{"id": "item-2", "name": "排骨便當", "price": 80},
]
}
]
}
},
"session_orders": [
{
"display_name": "小華",
"items": [
{"name": "雞腿便當", "quantity": 1, "price": 85}
],
"total": 85
}
],
"user_preferences": {
"preferred_name": "小明",
"dietary_restrictions": ["不吃辣"]
}
}
管理後台的訂單管理介面,顯示群組訂單和呷爸助手對話框
對話處理流程
async def _handle_ai_chat(
self,
user: User,
group: Group,
active_session: Optional[OrderSession],
text: str,
reply_token: str,
) -> None:
"""處理 AI 對話"""
# 1. 記錄使用者訊息
chat_msg = ChatMessage(
group_id=group.id,
user_id=user.id,
session_id=active_session.id if active_session else None,
role="user",
content=text,
)
await self.chat_repo.create(chat_msg)
# 2. 取得系統提示詞
system_prompt = await self._get_group_system_prompt()
# 3. 建構上下文
today_stores = await self.today_store_repo.get_today_stores(group.id)
menus_context = await self._build_menus_context(today_stores)
session_orders = await self._build_session_orders(active_session)
# 4. 取得對話歷史
history = await self.chat_repo.get_group_messages(
group.id,
limit=settings.chat_history_limit,
session_id=active_session.id if active_session else None,
)
# 5. 輸入過濾
sanitized_text, trigger_reasons = sanitize_user_input(text)
if trigger_reasons:
await self._log_security_event(...)
return # 可疑內容,不處理
# 6. 呼叫 AI
ai_response = await self.ai_service.chat(
message=sanitized_text,
system_prompt=system_prompt,
context={
"mode": "group_ordering" if active_session else "group_idle",
"user_name": user.display_name or "使用者",
"today_stores": [...],
"menus": menus_context,
"session_orders": session_orders,
"user_preferences": user.preferences,
},
history=[...],
)
# 7. 處理 AI 動作
actions = ai_response.get("actions", [])
if actions and active_session:
action_results = await self._execute_group_actions(
user, group, active_session, today_stores, actions
)
# 8. 回覆使用者
response_text = ai_response.get("message", "")
await self.reply_message(reply_token, response_text)
動作執行
建立訂單
async def _action_create_order(
self,
user: User,
session: OrderSession,
today_stores: list,
data: dict,
) -> dict:
"""建立訂單"""
items = data.get("items", [])
if not items:
return {"success": False, "error": "沒有品項"}
# 取得或建立使用者訂單
order = await self.order_repo.get_by_session_and_user(session.id, user.id)
if not order:
store_id = today_stores[0].store_id if today_stores else None
if not store_id:
return {"success": False, "error": "今日尚未設定店家"}
order = Order(
session_id=session.id,
user_id=user.id,
store_id=store_id,
)
order = await self.order_repo.create(order)
# 新增品項
for item_data in items:
item_name = item_data.get("name", "")
quantity = item_data.get("quantity", 1)
note = item_data.get("note", "")
# 從菜單找價格
price = await self._find_item_price(today_stores, item_name)
if price == 0:
return {"success": False, "error": f"菜單中找不到「{item_name}」"}
order_item = OrderItem(
order_id=order.id,
name=item_name,
quantity=quantity,
unit_price=Decimal(str(price)),
subtotal=Decimal(str(price * quantity)),
note=note,
)
await self.order_item_repo.create(order_item)
# 重新計算總金額
await self.order_repo.calculate_total(order)
return {"success": True, "order_id": str(order.id)}
移除品項
async def _action_remove_item(
self,
user: User,
session: OrderSession,
data: dict,
) -> dict:
"""移除品項"""
item_name = data.get("item_name", "")
quantity = data.get("quantity", 1)
order = await self.order_repo.get_by_session_and_user(session.id, user.id)
if not order:
return {"success": False, "error": "你目前沒有訂單"}
# 找到品項
for item in order.items:
if item.name == item_name or item_name in item.name:
if quantity >= item.quantity:
# 全部移除
await self.order_item_repo.delete(item)
else:
# 減少數量
item.quantity -= quantity
item.subtotal = item.unit_price * item.quantity
await self.order_item_repo.update(item)
await self.order_repo.calculate_total(order)
return {"success": True}
return {"success": False, "error": f"找不到品項「{item_name}」"}
取消訂單
async def _action_cancel_order(
self,
user: User,
session: OrderSession,
) -> dict:
"""取消訂單"""
order = await self.order_repo.get_by_session_and_user(session.id, user.id)
if not order:
return {"success": False, "error": "你目前沒有訂單"}
# 刪除所有品項
for item in order.items:
await self.order_item_repo.delete(item)
# 刪除訂單
await self.order_repo.delete(order)
return {"success": True}
跟單功能
跟單是最常用的功能之一。AI 需要:
- 理解「+1」、「我也要」等表達
- 找到前一個人的訂單
- 複製品項給當前使用者
AI 處理方式
在 system prompt 中說明:
【跟單處理】
當使用者說「+1」、「我也要」、「同上」時:
1. 查看 session_orders 中最後一筆訂單
2. 複製該訂單的品項給當前使用者
3. 如果使用者有額外指定,如「+1 不要辣」,需要加上備註
Context 中的訂單資訊
"session_orders": [
{
"display_name": "小華",
"items": [
{"name": "雞腿便當", "quantity": 1, "price": 85}
],
"total": 85
},
{
"display_name": "小明",
"items": [
{"name": "排骨便當", "quantity": 1, "price": 80}
],
"total": 80
}
]
AI 可以看到所有人的訂單,知道「最後一個人點了什麼」。
代點功能
代點需要處理「目標對象」的識別:
| 表達 | 目標 |
|---|---|
| 「幫小明點」 | 名字 |
| 「幫老闆點」 | 稱呼 |
| 「幫樓上的點」 | 描述 |
AI 處理方式
在 system prompt 中說明:
【代點處理】
當使用者說「幫 XXX 點」時:
1. 建立訂單時,在備註中標記「代點: XXX」
2. 或者如果系統支援,可以指定 target_user_name
動作格式
{
"type": "group_create_order",
"data": {
"items": [
{"name": "雞腿便當", "quantity": 1, "note": "代點: 小明"}
]
}
}
對話歷史的重要性
對話歷史讓 AI 能理解上下文:
小華: 我要雞腿便當
AI: 好的,已為小華點了雞腿便當 $85
小明: 我也要
AI: 好的,已為小明點了雞腿便當 $85(跟小華一樣)
小明: 再加一個排骨
AI: 好的,已新增排骨便當 $80,小明目前共 $165
歷史格式
history = [
{"role": "user", "name": "小華", "content": "我要雞腿便當"},
{"role": "assistant", "name": "呷爸", "content": "好的,已為小華點了雞腿便當 $85"},
{"role": "user", "name": "小明", "content": "我也要"},
]
價格查找
從菜單中找到品項價格:
async def _find_item_price(
self,
today_stores: list,
item_name: str,
category: Optional[str] = None,
) -> float:
"""從菜單找品項價格"""
for ts in today_stores:
store = ts.store
if not store:
continue
result = await self.session.execute(
select(Menu)
.where(Menu.store_id == store.id)
.options(
selectinload(Menu.categories).selectinload(MenuCategory.items)
)
)
menu = result.scalar_one_or_none()
if not menu:
continue
for cat in menu.categories:
# 如果有指定類別,先匹配類別
if category and cat.name != category:
continue
for item in cat.items:
# 精確匹配或模糊匹配
if item.name == item_name or item_name in item.name:
return float(item.price)
return 0 # 找不到
訂單摘要
每次訂單變動後,產生摘要:
async def _get_session_summary_by_id(self, session_id: UUID) -> str:
"""產生點餐摘要"""
session = await self.session_repo.get_with_orders(session_id)
if not session or not session.orders:
return "📋 本次點餐沒有任何訂單"
lines = ["📋 點餐摘要", ""]
grand_total = Decimal(0)
item_counts = {}
for order in session.orders:
user_name = order.user.display_name if order.user else "未知"
user_total = int(order.total_amount)
lines.append(f"👤 {user_name}(${user_total})")
for item in order.items:
item_text = f" • {item.name}"
if item.note:
item_text += f"({item.note})"
if item.quantity > 1:
item_text += f" x{item.quantity}"
item_text += f" ${int(item.subtotal)}"
lines.append(item_text)
# 統計
item_counts[item.name] = item_counts.get(item.name, 0) + item.quantity
lines.append("")
grand_total += order.total_amount
# 品項統計
lines.append("📦 品項統計")
for name, count in sorted(item_counts.items(), key=lambda x: -x[1]):
lines.append(f" • {name} x{count}")
lines.append("")
lines.append(f"💰 總金額:${int(grand_total)}")
lines.append(f"👥 共 {len(session.orders)} 人點餐")
return "\n".join(lines)
輸出範例:
📋 點餐摘要
👤 小華($85)
• 雞腿便當 $85
👤 小明($165)
• 雞腿便當 $85
• 排骨便當 $80
📦 品項統計
• 雞腿便當 x2
• 排骨便當 x1
💰 總金額:$250
👥 共 2 人點餐
邊界情況處理
品項不在菜單中
price = await self._find_item_price(today_stores, item_name)
if price == 0:
return {"success": False, "error": f"菜單中找不到「{item_name}」"}
AI 回覆:「抱歉,菜單中找不到「炒飯」,請確認品項名稱。」
重複點餐
# 檢查是否已有相同品項
for existing in order.items:
if existing.name == item_name:
# 累加數量而非新增
existing.quantity += quantity
existing.subtotal = existing.unit_price * existing.quantity
return {"success": True}
沒有進行中的點餐
在 _should_respond_in_group() 中檢查:
if not is_ordering:
# 非點餐中只回應特定關鍵字
return text in ["開單", "菜單", "jaba", "呷爸"]
總結
自然語言點餐的實作要點:
| 項目 | 說明 |
|---|---|
| 完整上下文 | 傳入店家、菜單、現有訂單、使用者偏好 |
| 對話歷史 | 讓 AI 理解「我也要」的指代 |
| 動作格式 | 定義清晰的 action type 和 data 結構 |
| 價格查找 | 從菜單中匹配品項取得價格 |
| 訂單摘要 | 每次變動後顯示完整摘要 |
| 錯誤處理 | 品項不存在、訂單不存在等情況 |
下一篇
下一篇文章會介紹菜單圖片辨識:菜單圖片 AI 辨識:上傳照片自動建立菜單。