Bloom_Ware / core /intent_detector.py
XiaoBai1221's picture
Play
3fe95cf
"""
意圖檢測器
2025 最佳實踐:使用 OpenAI 原生 Function Calling 進行意圖檢測
核心改進:
1. 不再使用巨大的 system_prompt 描述每個工具
2. 直接使用 OpenAI tools 參數傳遞工具定義
3. GPT 原生選擇工具並生成結構化參數
4. 新增工具只需註冊到 Registry,不需更新任何 prompt
"""
import json
import hashlib
import time
import logging
from typing import Dict, Any, Optional, Tuple, List
from core.tool_registry import tool_registry
from core.logging import get_logger
logger = get_logger("core.intent_detector")
class IntentDetector:
"""
意圖檢測器
使用 OpenAI 原生 Function Calling 進行意圖檢測,
不需要自定義 prompt 描述每個工具。
"""
# 情緒列表
EMOTIONS = ["neutral", "happy", "sad", "angry", "fear", "surprise"]
# 快取 TTL(秒)
CACHE_TTL = 300.0
def __init__(self):
self._cache: Dict[str, Tuple[bool, Optional[Dict[str, Any]], float]] = {}
async def detect(
self,
message: str,
user_id: Optional[str] = None,
include_location_tools: bool = True,
) -> Tuple[bool, Optional[Dict[str, Any]]]:
"""
檢測用戶消息中的意圖
Args:
message: 用戶消息
user_id: 用戶 ID(用於日誌)
include_location_tools: 是否包含需要位置的工具
Returns:
(是否檢測到工具調用, 意圖數據)
"""
# 檢查快取
cache_key = hashlib.md5(message.encode()).hexdigest()
if cache_key in self._cache:
has_intent, intent_data, cached_time = self._cache[cache_key]
if time.time() - cached_time < self.CACHE_TTL:
logger.debug(f"💾 意圖快取命中: {message[:50]}...")
return has_intent, intent_data
else:
del self._cache[cache_key]
logger.info(f"🔍 檢測意圖: \"{message[:100]}...\"")
# 檢查特殊命令
special_result = self._check_special_commands(message)
if special_result:
return special_result
# 使用 OpenAI Function Calling 進行意圖檢測
try:
result = await self._detect_with_function_calling(
message,
include_location_tools=include_location_tools,
)
# 寫入快取
self._cache[cache_key] = (*result, time.time())
return result
except Exception as e:
logger.error(f"❌ 意圖檢測失敗: {e}")
# 降級:使用關鍵字匹配
return self._keyword_fallback(message)
def _check_special_commands(self, message: str) -> Optional[Tuple[bool, Dict[str, Any]]]:
"""檢查特殊命令"""
for command in ["功能列表", "有什麼功能", "能做什麼"]:
if command in message:
logger.info(f"檢測到特殊命令: {command}")
return True, {
"type": "special_command",
"command": "feature_list"
}
return None
async def _detect_with_function_calling(
self,
message: str,
include_location_tools: bool = True,
) -> Tuple[bool, Optional[Dict[str, Any]]]:
"""
使用 OpenAI Function Calling 進行意圖檢測
核心邏輯:
1. 將所有工具以 OpenAI tools 格式傳遞
2. GPT 自動選擇最適合的工具
3. 如果 GPT 不選擇任何工具,視為一般聊天
"""
import services.ai_service as ai_service
from core.reasoning_strategy import get_optimal_reasoning_effort
# 取得所有工具定義(OpenAI 格式)
tools = tool_registry.get_openai_tools(
include_location_tools=include_location_tools,
strict=True,
)
if not tools:
logger.warning("⚠️ 沒有可用的工具")
return False, {"emotion": "neutral"}
# 建構精簡的 system prompt(只處理情緒和特殊規則)
system_prompt = self._build_system_prompt()
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": message}
]
# 使用 OpenAI Function Calling
optimal_effort = get_optimal_reasoning_effort("intent_detection")
logger.info(f"🧠 意圖檢測推理強度: {optimal_effort}")
try:
response = await ai_service.generate_response_with_tools(
messages=messages,
tools=tools,
user_id="intent_detection",
model="gpt-5-nano",
reasoning_effort=optimal_effort,
)
return self._parse_function_calling_response(response)
except Exception as e:
logger.error(f"❌ Function Calling 失敗: {e}")
raise
def _build_system_prompt(self) -> str:
"""
建構精簡的 system prompt
注意:不再描述每個工具,工具定義由 tools 參數傳遞
"""
return """你是一個多語言智能助手,根據用戶需求選擇合適的工具。支援中文、英文、日文、印尼文、越南文。
【核心規則】
1. 用戶詢問任何可用工具能解決的需求時,必須選擇對應工具
2. 只有純粹的閒聊、問候、情感表達才不選擇工具
3. 工具參數盡量從用戶消息中提取,無法確定的使用合理預設值
【多語言意圖識別】
無論用戶使用什麼語言,都要識別以下意圖並選擇對應工具:
天氣查詢(weather_query):
- 中文:天氣、氣溫、會下雨嗎、今天熱嗎
- 英文:weather, temperature, rain, hot today, forecast
- 日文:天気、気温、雨、暑い
- 印尼文:cuaca, suhu, hujan
- 越南文:thời tiết, nhiệt độ, mưa
匯率查詢(exchange_rate):
- 中文:匯率、換算、多少錢
- 英文:exchange rate, convert, currency
- 日文:為替、両替
- 印尼文:kurs, tukar
- 越南文:tỷ giá, đổi tiền
新聞查詢(news_search):
- 中文:新聞、頭條、最新消息
- 英文:news, headlines, latest
- 日文:ニュース、最新
- 印尼文:berita, terbaru
- 越南文:tin tức, mới nhất
【參數處理】
- 天氣查詢:城市名稱使用英文(台北→Taipei, 東京→Tokyo, Jakarta, Hanoi)
- 匯率查詢:貨幣使用 ISO 4217 代碼(USD, TWD, JPY, IDR, VND)
- 公車查詢:route_name 必須是路線號碼(如 307、紅30)
- 火車查詢:「往XX」表示 destination_station
- 位置查詢:「我在哪」「where am I」使用 reverse_geocode
- YouBike 查詢:YouBike/Ubike/微笑單車 使用 tdx_youbike
【情緒判斷 - 重要】
根據用戶消息的語氣判斷情緒,並在回應開頭以 [EMOTION:xxx] 格式輸出:
- [EMOTION:neutral] - 平靜、中性、一般詢問
- [EMOTION:happy] - 開心、興奮、正面情緒(如:我很快樂、太棒了、好開心)
- [EMOTION:sad] - 難過、沮喪、失落
- [EMOTION:angry] - 生氣、煩躁、憤怒
- [EMOTION:fear] - 恐懼、擔心、焦慮
- [EMOTION:surprise] - 驚訝、意外
範例:
- 用戶說「我很快樂」→ 回應開頭必須是 [EMOTION:happy]
- 用戶說「今天天氣如何」→ 回應開頭必須是 [EMOTION:neutral]
- 用戶說「我好難過」→ 回應開頭必須是 [EMOTION:sad]"""
def _parse_function_calling_response(
self,
response: Dict[str, Any],
) -> Tuple[bool, Optional[Dict[str, Any]]]:
"""
解析 Function Calling 回應
Args:
response: OpenAI API 回應
Returns:
(是否檢測到工具調用, 意圖數據)
"""
# 檢查是否有 tool_calls
tool_calls = response.get("tool_calls", [])
if tool_calls:
# 取第一個工具調用
tool_call = tool_calls[0]
function = tool_call.get("function", {})
tool_name = function.get("name", "")
arguments_str = function.get("arguments", "{}")
try:
arguments = json.loads(arguments_str)
except json.JSONDecodeError:
arguments = {}
logger.info(f"✅ GPT 選擇工具: {tool_name}")
logger.debug(f"工具參數: {arguments}")
# 提取情緒(從 content 或預設)
emotion = self._extract_emotion_from_response(response)
return True, {
"type": "mcp_tool",
"tool_name": tool_name,
"arguments": arguments,
"emotion": emotion,
}
# 沒有工具調用,視為一般聊天
logger.info("💬 GPT 判斷為一般聊天")
emotion = self._extract_emotion_from_response(response)
return False, {"emotion": emotion}
def _extract_emotion_from_response(self, response: Dict[str, Any]) -> str:
"""從回應中提取情緒
優先使用 [EMOTION:xxx] 格式提取,降級使用關鍵字匹配
"""
import re
content = response.get("content", "")
if not content:
return "neutral"
# 優先:使用正則表達式提取 [EMOTION:xxx] 格式
emotion_match = re.search(r'\[EMOTION:(\w+)\]', content, re.IGNORECASE)
if emotion_match:
extracted = emotion_match.group(1).lower()
if extracted in self.EMOTIONS:
logger.info(f"🎭 從格式化標籤提取情緒: {extracted}")
return extracted
# 降級:使用關鍵字匹配(但需要更精確的匹配)
content_lower = content.lower()
for emotion in self.EMOTIONS:
# 使用單詞邊界匹配,避免誤判(如 "not angry" 被判為 angry)
pattern = rf'\b{emotion}\b'
if re.search(pattern, content_lower):
# 檢查是否有否定詞在前面
negation_pattern = rf'(not|no|isn\'t|aren\'t|wasn\'t|weren\'t|don\'t|doesn\'t|didn\'t|never|neither)\s+{emotion}'
if re.search(negation_pattern, content_lower):
continue # 跳過被否定的情緒
logger.info(f"🎭 從關鍵字提取情緒: {emotion}")
return emotion
return "neutral"
def _keyword_fallback(self, message: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
"""關鍵字匹配降級方案"""
message_lower = message.lower()
# 取得所有工具摘要
summaries = tool_registry.get_summaries()
for summary in summaries:
keywords = summary.get("keywords", [])
for keyword in keywords:
if keyword.lower() in message_lower:
logger.info(f"🔑 關鍵字匹配: {keyword}{summary['name']}")
return True, {
"type": "mcp_tool",
"tool_name": summary["name"],
"arguments": {},
"emotion": "neutral",
}
return False, {"emotion": "neutral"}
def clear_cache(self) -> None:
"""清除快取"""
self._cache.clear()
logger.info("🗑️ 意圖快取已清除")
# 全域單例
intent_detector = IntentDetector()