File size: 11,853 Bytes
69fb140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91f3927
69fb140
91f3927
 
 
69fb140
 
91f3927
 
69fb140
91f3927
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3fe95cf
 
 
 
 
 
 
 
 
 
 
 
 
69fb140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3fe95cf
 
 
 
 
 
69fb140
3fe95cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69fb140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
"""
意圖檢測器
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()