XiaoBai1221 commited on
Commit
79df050
·
1 Parent(s): caa9280

feat: overhaul MCP architecture with structured tool schemas, comprehensive care-mode skill definitions, and enhanced test coverage for pipelines and service integration.

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +83 -0
  2. .gitignore +4 -1
  3. AGENTS.md +0 -74
  4. DEPLOY.md +0 -173
  5. app.py +518 -205
  6. bloom-ware-login/components/login-form.tsx +54 -23
  7. bloom-ware-login/public/audio/pcm-recorder-worklet.js +17 -0
  8. core/ai_client.py +21 -5
  9. core/config.py +104 -7
  10. core/environment/__init__.py +7 -1
  11. core/environment/context_builder.py +92 -0
  12. core/intent_detector.py +18 -3
  13. core/logging.py +79 -63
  14. core/memory_system.py +180 -23
  15. core/pipeline.py +221 -215
  16. core/prompts/care_mode.py +18 -2
  17. core/prompts/care_mode_skills.py +41 -0
  18. core/prompts/intent_detection.py +21 -4
  19. core/prompts/tool_calling_policy.py +18 -0
  20. core/reasoning_strategy.py +3 -3
  21. core/responses_runtime.py +212 -0
  22. core/tool_registry.py +36 -24
  23. core/tool_router.py +19 -11
  24. core/tool_schema.py +66 -19
  25. core/voice_care_gate.py +82 -0
  26. features/care_mode/skills/ACTIVE_LISTENING.md +24 -0
  27. features/care_mode/skills/ANGER_HANDLING.md +15 -0
  28. features/care_mode/skills/CARE_CORE_STRATEGY.md +26 -0
  29. features/care_mode/skills/EMOTIONAL_VALIDATION.md +24 -0
  30. features/care_mode/skills/FEAR_HANDLING.md +15 -0
  31. features/care_mode/skills/FIRST_CONTACT_CARE.md +21 -0
  32. features/care_mode/skills/SADNESS_HANDLING.md +15 -0
  33. features/care_mode/skills/SUPPORTIVE_PRESENCE.md +24 -0
  34. features/mcp/agent_bridge.py +170 -285
  35. features/mcp/auto_registry.py +25 -9
  36. features/mcp/coordinator.py +70 -2
  37. features/mcp/mcp_client.py +19 -3
  38. features/mcp/openai_tools.py +81 -0
  39. features/mcp/server.py +91 -15
  40. features/mcp/skills.py +103 -0
  41. features/mcp/skills/directions/SKILL.md +24 -0
  42. features/mcp/skills/environment_context/SKILL.md +24 -0
  43. features/mcp/skills/exchange_query/SKILL.md +24 -0
  44. features/mcp/skills/forward_geocode/SKILL.md +24 -0
  45. features/mcp/skills/healthkit_query/SKILL.md +24 -0
  46. features/mcp/skills/news_query/SKILL.md +22 -0
  47. features/mcp/skills/reverse_geocode/SKILL.md +24 -0
  48. features/mcp/skills/tdx_bus_arrival/SKILL.md +25 -0
  49. features/mcp/skills/tdx_metro/SKILL.md +25 -0
  50. features/mcp/skills/tdx_parking/SKILL.md +25 -0
.env.example ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bloom Ware environment variables
2
+ # Copy to .env and fill real values locally. Do not commit real secrets.
3
+
4
+ # ===== Application Runtime =====
5
+ ENVIRONMENT=development
6
+ HOST=0.0.0.0
7
+ PORT=7860
8
+ CORS_ORIGINS=*
9
+
10
+ # ===== Authentication =====
11
+ JWT_SECRET_KEY=replace-with-random-secret
12
+ ACCESS_TOKEN_EXPIRE_MINUTES=30
13
+
14
+ # ===== Firebase / Firestore =====
15
+ FIREBASE_PROJECT_ID=your-firebase-project-id
16
+ FIREBASE_CREDENTIALS_JSON=
17
+ FIREBASE_SERVICE_ACCOUNT_JSON_BASE64=
18
+ FIREBASE_SERVICE_ACCOUNT_PATH=
19
+
20
+ # ===== Google OAuth =====
21
+ GOOGLE_CLIENT_ID=your-google-oauth-client-id.apps.googleusercontent.com
22
+ GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret
23
+ GOOGLE_REDIRECT_URI=http://localhost:8080/auth/google/callback
24
+
25
+ # ===== Google Cloud 語音(STT/TTS)— 與 Firebase、OAuth 登入「不同專案/不同憑證」=====
26
+ #
27
+ # 三種 Google 身分請分開設定,勿混用:
28
+ # (1) Firebase / Firestore → FIREBASE_PROJECT_ID + FIREBASE_* 服務帳戶
29
+ # (2) Google OAuth(網站登入)→ GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
30
+ # (3) 語音 GCP(Speech + TTS)→ 專案 ID 字串例:supervisor-project;控制台「專案編號」僅供對照,API 請用專案 ID
31
+ #
32
+ # 已啟用 API:Cloud Speech-to-Text、Cloud Text-to-Speech。
33
+ # - TTS(REST):使用 GOOGLE_TTS_API_KEY 或 GOOGLE_SPEECH_API_KEY(與 GOOGLE_API_KEY 可同一支)
34
+ # - STT v2「串流」:官方為 gRPC,必須使用「語音專案」的服務帳戶 OAuth(GOOGLE_SPEECH_*);僅 API Key 無法走現有串流實作
35
+ #
36
+ # 語音專案 STT 必備(擇一):GOOGLE_SPEECH_CREDENTIALS_JSON / GOOGLE_SPEECH_SERVICE_ACCOUNT_JSON_BASE64 / GOOGLE_SPEECH_SERVICE_ACCOUNT_PATH
37
+ GOOGLE_SPEECH_PROJECT_ID=supervisor-project
38
+ GOOGLE_SPEECH_CREDENTIALS_JSON=
39
+ GOOGLE_SPEECH_SERVICE_ACCOUNT_JSON_BASE64=
40
+ GOOGLE_SPEECH_SERVICE_ACCOUNT_PATH=
41
+ # 語音專案預設 ID(未單獨設 GOOGLE_SPEECH_PROJECT_ID 時亦會參考);勿只填專案「編號」
42
+ GOOGLE_CLOUD_PROJECT_ID=supervisor-project
43
+ GOOGLE_STT_ACCESS_TOKEN=
44
+ GOOGLE_API_KEY=your-google-api-key-for-tts-and-rest
45
+ GOOGLE_SPEECH_API_KEY=
46
+ GOOGLE_TTS_API_KEY=
47
+ GOOGLE_STT_LOCATION=global
48
+ GOOGLE_STT_RECOGNIZER_ID=_
49
+ GOOGLE_STT_AUTO_LANGUAGE_CODES=cmn-Hant-TW,en-US,ja-JP
50
+ GOOGLE_TTS_LANGUAGE_CODE=cmn-TW
51
+ GOOGLE_TTS_DEFAULT_VOICE=cmn-TW-Wavenet-A
52
+
53
+ # ===== OpenAI Agent =====
54
+ OPENAI_API_KEY=your-openai-api-key
55
+ OPENAI_BASE_URL=https://sub2api.flowatelier.com
56
+ OPENAI_MODEL=gpt-5.4
57
+ OPENAI_TIMEOUT=30
58
+ OPENAI_RESPONSES_TIMEOUT=90
59
+ OPENAI_USE_RESPONSES=true
60
+ OPENAI_MODEL_CONTEXT_WINDOW=1000000
61
+ OPENAI_MODEL_AUTO_COMPACT_TOKEN_LIMIT=900000
62
+ OPENAI_ENABLE_WEB_SEARCH=true
63
+ OPENAI_ENABLE_REMOTE_MCP=true
64
+ OPENAI_REMOTE_MCP_SERVERS_JSON=[]
65
+ OPENAI_ENABLE_SKILLS=true
66
+ USE_GPT_INTENT=true
67
+ GPT_INTENT_MODEL=gpt-5.4
68
+
69
+ # ===== External Data APIs =====
70
+ WEATHER_API_KEY=your-weather-api-key
71
+ TAVILY_API_KEY=your-newsdata-api-key
72
+ EXCHANGE_API_KEY=your-exchange-api-key
73
+ OPENROUTESERVICE_API_KEY=your-openrouteservice-api-key
74
+ TDX_CLIENT_ID=your-tdx-client-id
75
+ TDX_CLIENT_SECRET=your-tdx-client-secret
76
+
77
+ # ===== Background Jobs =====
78
+ ENABLE_BACKGROUND_JOBS=true
79
+
80
+ # ===== Environment Context =====
81
+ ENV_CONTEXT_DISTANCE_THRESHOLD=100
82
+ ENV_CONTEXT_HEADING_THRESHOLD=25
83
+ ENV_CONTEXT_TTL_SECONDS=300
.gitignore CHANGED
@@ -1,5 +1,7 @@
1
  # Python
2
  __pycache__/
 
 
3
  *.py[cod]
4
  *$py.class
5
  *.so
@@ -33,6 +35,7 @@ MANIFEST
33
  # Firebase credentials (JSON files)
34
  *-firebase-adminsdk-*.json
35
  bloomware-*.json
 
36
 
37
  # IDE
38
  .vscode/
@@ -174,4 +177,4 @@ celerybeat.pid
174
  dmypy.json
175
 
176
  # Pyre type checker
177
- .pyre/
 
1
  # Python
2
  __pycache__/
3
+ .pytest_cache/
4
+ .benchmarks/
5
  *.py[cod]
6
  *$py.class
7
  *.so
 
35
  # Firebase credentials (JSON files)
36
  *-firebase-adminsdk-*.json
37
  bloomware-*.json
38
+ supervisor-project*.json
39
 
40
  # IDE
41
  .vscode/
 
177
  dmypy.json
178
 
179
  # Pyre type checker
180
+ .pyre/
AGENTS.md DELETED
@@ -1,74 +0,0 @@
1
- # AGENTS.md
2
-
3
- ## 介紹
4
-
5
- 你是 白東衢的狗。主要語言 Python。可用 MCP:context7、feedback-enhanced、filesystem、huggingface、playwright、sequential-thinking。所有思考與回覆皆用繁體中文。先思考再行動。
6
-
7
- ## 全域原則
8
-
9
- - 所有 MCP 工具逾時一律 60 分鐘。
10
- - 非框架情境:以最少檔案完成任務,避免過度模組化。
11
- - 框架情境:依框架慣例放置檔案。
12
- - 禁止產生非目的文件:說明文件、依賴清單、README、requirements.txt 等。
13
- - 禁止要求以命令列參數輸入業務值;業務參數以程式內常數或互動式輸入處理。
14
- - 需新知時主動檢索:優先從網際網路獲取資訊並使用 huggingface(paper_search、model_search、dataset_search、hf_doc_search)與 context7進行輔佐。
15
- - playwright 僅用於前端樣式檢查、UI 互動測試、E2E 視覺驗證與截圖,禁止做爬蟲或搜尋。
16
- - 僅在規定節點使用 feedback-enhanced,禁止重複寒暄。
17
-
18
- ## 環境與執行限制
19
-
20
- - 不得假設環境缺失而自動安裝任何套件或修改系統環境變數。
21
- - 不得建立或使用任何虛擬環境(venv、conda、poetry 等)。
22
- - 一律使用當前系統 Python 版本執行與相容(可於程式內讀取 sys.version 僅作紀錄,不觸碰安裝行為)。
23
- - 不輸出或修改 requirements.txt、pyproject.toml、環境設定檔。
24
-
25
- ## 框架情境的工作區確認
26
-
27
- 偵測到整合型框架或既有專案結構時:
28
-
29
- - 先用 sequential-thinking 規劃「要改哪些模組、檔名、路徑與測試位置」。
30
- - 接著必須以一次 feedback-enhanced.interactive_feedback 與使用者確認「基準工作區路徑、允許寫入子資料夾與檔名慣例」。
31
- - 未獲確認前不得寫入專案根目錄。確認後依框架慣例生成或修改檔案。
32
- - 非框架情境可寫根目錄,但仍以最小檔案集為原則。
33
-
34
- ## 思考與行動流程
35
-
36
- - sequential-thinking:輸出「目標 → 步驟 → 決策準則 → 風險與驗證」。
37
- - 取證:context7、huggingface。
38
- - 產出:filesystem 建立或修改檔案;必要時用 playwright 做 UI 驗證。
39
- - 回覆內容:只含思考計畫、行動步驟、關鍵程式碼、測試摘要、後續建議。
40
-
41
- ## 測試與驗證政策
42
-
43
- - 為每個新增或修改模組撰寫單元測試與關鍵整合測試。
44
- - 測試檔命名 test_*.py;框架專案依其慣例放置。
45
- - 執行方式以終端 Python3 或 Pytest:python3 -m pytest -q 或 pytest -q。
46
- - 回報測試摘要:通過數、失敗數、失敗案例與原因、可能回歸點、下一步。
47
-
48
- ## 錯誤處理與互動節奏
49
-
50
- 發生錯誤或接獲失敗回報時:
51
-
52
- - 先以 sequential-thinking 分析根因、解法選項與取捨與影響。
53
- - 再以一次 feedback-enhanced.interactive_feedback 與使用者對齊解法與影響面。
54
- - 然後修改程式與測試並重跑驗證。除上述節點外避免重複呼叫 feedback。
55
-
56
- ## 產出規範
57
-
58
- - 預設單檔或少量檔案即可完成任務;框架專案依其結構放置。
59
- - 程式需具明確進入點:
60
- ```
61
- if __name__ == "__main__":
62
- main()
63
- ```
64
- - 檔案一律透過 filesystem 操作並回報路徑與成功訊息。
65
- - 不硬編 API 金鑰或密碼;輸出時遮罩敏感資訊。
66
- - 外部資源不可用時,提出替代方案與自我修正步驟,仍不得觸發安裝或改環境行為。
67
-
68
- ## 禁止事項總表
69
-
70
- - 自動安裝或升降版本、修改環境變數、建立/使用虛擬環境。
71
- - 產出說明文件、依賴清單或其他非目的文件。
72
- - 使用命令列參數傳遞業務值。
73
- - 用 playwright 做爬蟲或搜尋。
74
- - 無限制地反覆呼叫 feedback-enhanced。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
DEPLOY.md DELETED
@@ -1,173 +0,0 @@
1
- # 🚀 Bloom Ware Render 部署指南
2
-
3
- ## 📋 前置準備
4
-
5
- ### 1. 生成新的 JWT Secret(生產環境專用)
6
- ```bash
7
- python3 -c "import secrets; print(secrets.token_urlsafe(32))"
8
- ```
9
- 複製輸出的字串,稍後會用到。
10
-
11
- ### 2. 將 Firebase JSON 轉為單行字串
12
- ```bash
13
- cat your-firebase-credentials.json | python3 -m json.tool --compact | pbcopy
14
- ```
15
- (macOS 會自動複製到剪貼簿)
16
-
17
- ---
18
-
19
- ## 🔧 Render 部署步驟
20
-
21
- ### 步驟 1:推送程式碼到 GitHub
22
- ```bash
23
- git add .
24
- git commit -m "準備 Render 部署:統一配置管理 + Firebase 環境變數化"
25
- git push origin main
26
- ```
27
-
28
- ### 步驟 2:在 Render 建立 Web Service
29
- 1. 登入 [Render](https://render.com/)
30
- 2. 點擊 **New** → **Web Service**
31
- 3. 連接 GitHub 倉庫:選擇 `bloom-ware`
32
- 4. 設定:
33
- - **Name**: `bloom-ware`(或自訂名稱)
34
- - **Region**: `Singapore` 或 `Oregon`
35
- - **Branch**: `main`
36
- - **Runtime**: `Python 3`
37
- - **Build Command**: `pip install -r requirements.txt`
38
- - **Start Command**: `python app.py`
39
-
40
- ### 步驟 3:設定環境變數
41
- 在 Render Dashboard → Environment 頁面,新增以下環境變數:
42
-
43
- #### 必要環境變數(16 項)
44
-
45
- | 變數名 | 值 | 說明 |
46
- |--------|-----|------|
47
- | `ENVIRONMENT` | `production` | 環境識別 |
48
- | `FIREBASE_PROJECT_ID` | `your-firebase-project-id` | Firebase 專案 ID |
49
- | `FIREBASE_CREDENTIALS_JSON` | `{"type":"service_account",...}` | **完整 JSON 字串(單行)** |
50
- | `OPENAI_API_KEY` | `sk-proj-...` | OpenAI API Key |
51
- | `OPENAI_MODEL` | `gpt-5-nano` | 模型名稱 |
52
- | `OPENAI_TIMEOUT` | `30` | 超時秒數 |
53
- | `GOOGLE_CLIENT_ID` | `your-google-client-id.apps.googleusercontent.com` | Google OAuth Client ID |
54
- | `GOOGLE_CLIENT_SECRET` | `GOCSPX-...` | Google OAuth Secret |
55
- | `GOOGLE_REDIRECT_URI` | `https://your-app.onrender.com/auth/google/callback` | **OAuth 回調 URI** |
56
- | `WEATHER_API_KEY` | `your-weather-api-key` | OpenWeatherMap Key |
57
- | `NEWSDATA_API_KEY` | `pub_xxxxx` | NewsData.io Key |
58
- | `EXCHANGE_API_KEY` | `your-exchange-api-key` | ExchangeRate Key |
59
- | `JWT_SECRET_KEY` | `YOUR_NEW_SECRET` | **新生成的 Secret** |
60
- | `ACCESS_TOKEN_EXPIRE_MINUTES` | `30` | Token 有效期 |
61
- | `HOST` | `0.0.0.0` | 監聽主機 |
62
- | `PORT` | `10000` | Render 固定端口 |
63
-
64
- ### 步驟 4:部署
65
- 點擊 **Create Web Service**,Render 會自動:
66
- 1. 執行 `pip install -r requirements.txt`
67
- 2. 啟動 `python app.py`
68
- 3. 提供 HTTPS URL(例如:`https://bloom-ware-xxxx.onrender.com`)
69
-
70
- ---
71
-
72
- ## 🔗 Google OAuth 回調 URI 更新
73
-
74
- ### 1. 前往 Google Cloud Console
75
- https://console.cloud.google.com/apis/credentials
76
-
77
- ### 2. 選擇你的 OAuth 2.0 客戶端
78
-
79
- ### 3. 新增「已授權的重新導向 URI」
80
- ```
81
- https://bloom-ware-xxxx.onrender.com/auth/google/callback
82
- ```
83
- (替換為你的實際 Render 網址)
84
-
85
- ### 4. 儲存變更
86
-
87
- ### 5. 更新 Render 環境變數
88
- 回到 Render Dashboard → Environment,更新:
89
- ```
90
- GOOGLE_REDIRECT_URI=https://bloom-ware-xxxx.onrender.com/auth/google/callback
91
- ```
92
-
93
- ---
94
-
95
- ## ✅ 驗證部署
96
-
97
- ### 1. 檢查 Logs
98
- 在 Render Dashboard → Logs 查看:
99
- ```
100
- ✅ Firebase Firestore連接成功!專案ID:your-project-id
101
- ✅ OpenAI 客戶端初始化完成
102
- 🚀 Bloom Ware 後端服務器啟動中...
103
- ```
104
-
105
- ### 2. 測試連接
106
- 訪問:`https://your-app.onrender.com`
107
- 應該看到前端登入頁面
108
-
109
- ### 3. 測試 Google 登入
110
- 1. 點擊「使用 Google 登入」
111
- 2. 授權後應該成功跳轉並登入
112
-
113
- ---
114
-
115
- ## 🐛 常見問題
116
-
117
- ### 問題 1:Firebase 憑證錯誤
118
- **錯誤訊息**:`Firebase 憑證載入失敗`
119
-
120
- **解決方式**:
121
- - 確認 `FIREBASE_CREDENTIALS_JSON` 是**單行字串**(無換行符)
122
- - 檢查 JSON 格式是否正確(使用 `python3 -m json.tool` 驗證)
123
-
124
- ### 問題 2:Google OAuth 回調失敗
125
- **錯誤訊息**:`redirect_uri_mismatch`
126
-
127
- **解決方式**:
128
- - 確認 Google Cloud Console 已新增 Render 回調 URI
129
- - 確認 `GOOGLE_REDIRECT_URI` 環境變數正確
130
-
131
- ### 問題 3:應用休眠(免費方案)
132
- **現象**:閒置 15 分鐘後,首次訪問需等待 30 秒
133
-
134
- **解決方式**:
135
- - 升級到付費方案($7/月)
136
- - 或使用 UptimeRobot 定期 ping(每 14 分鐘)
137
-
138
- ---
139
-
140
- ## 📝 部署後清單
141
-
142
- - [ ] 測試 Google 登入流程
143
- - [ ] 測試 WebSocket 連接
144
- - [ ] 測試語音功能(錄音 + TTS)
145
- - [ ] 測試 MCP 工具(天氣、新聞、匯率)
146
- - [ ] 檢查 Firebase Firestore 資料寫入
147
- - [ ] 監控 Render Logs 是否有錯誤
148
-
149
- ---
150
-
151
- ## 🔄 更新部署
152
-
153
- 每次程式碼更新後:
154
- ```bash
155
- git add .
156
- git commit -m "更新功能"
157
- git push origin main
158
- ```
159
-
160
- Render 會自動檢測並重新部署(約 2-3 分鐘)。
161
-
162
- ---
163
-
164
- ## 📞 支援
165
-
166
- 遇到問題?檢查:
167
- 1. Render Dashboard → Logs
168
- 2. Render Dashboard → Events
169
- 3. GitHub Actions(如有設定 CI/CD)
170
-
171
- ---
172
-
173
- **🎉 恭喜!Bloom Ware 已成功部署到 Render!**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import os
2
  import json
3
  import time
@@ -6,6 +7,7 @@ import mimetypes
6
  import logging
7
  import secrets
8
  import jwt
 
9
  from datetime import datetime
10
  from typing import List, Dict, Optional, Any
11
 
@@ -76,6 +78,7 @@ from core.memory_system import memory_manager
76
  # 環境 Context 寫入 API
77
  from core.database import set_user_env_current, add_user_env_snapshot
78
  from core.environment import EnvironmentContextService
 
79
 
80
 
81
  # -----------------------------
@@ -109,6 +112,77 @@ def serialize_for_json(obj: Any) -> Any:
109
  except Exception:
110
  return None
111
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  # -----------------------------
113
  # Pydantic 模型(從統一模組導入)
114
  # -----------------------------
@@ -322,33 +396,6 @@ app.add_middleware(
322
  )
323
 
324
  # CSP Middleware(允許內嵌 script 用於語音沉浸式前端)
325
- from starlette.middleware.base import BaseHTTPMiddleware
326
- from starlette.requests import Request as StarletteRequest
327
- from starlette.responses import Response
328
-
329
- class CSPMiddleware(BaseHTTPMiddleware):
330
- async def dispatch(self, request: StarletteRequest, call_next):
331
- response = await call_next(request)
332
- # 對所有靜態檔案路徑添加寬鬆的 CSP header(用於語音沉浸式前端)
333
- if request.url.path.startswith("/static/"):
334
- # 移除可能存在的嚴格 CSP
335
- if "Content-Security-Policy" in response.headers:
336
- del response.headers["Content-Security-Policy"]
337
-
338
- # 設定寬鬆的 CSP 以允許內嵌 script
339
- response.headers["Content-Security-Policy"] = (
340
- "default-src 'self'; "
341
- "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://accounts.google.com https://www.gstatic.com; "
342
- "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
343
- "font-src 'self' https://fonts.gstatic.com data:; "
344
- "connect-src 'self' ws: wss: https://accounts.google.com; "
345
- "img-src 'self' data: https: blob:; "
346
- "media-src 'self' blob: data:; "
347
- "frame-src https://accounts.google.com; "
348
- "base-uri 'self';"
349
- )
350
- return response
351
-
352
  app.add_middleware(CSPMiddleware)
353
 
354
  # 掛載靜態檔案目錄(語音沉浸式前端)
@@ -495,6 +542,18 @@ async def websocket_endpoint_with_jwt(
495
 
496
  td = app.state.feature_router.get_current_time_data()
497
  # 使用語音登入傳遞的情緒(如果有)
 
 
 
 
 
 
 
 
 
 
 
 
498
  welcome_msg = compose_welcome(
499
  user_name=user_info.get('name'),
500
  time_data=td,
@@ -506,19 +565,35 @@ async def websocket_endpoint_with_jwt(
506
  welcome_msg = f"歡迎回來,{user_info['name']}!"
507
 
508
  # 發送歡迎訊息,並附帶 chat_id
 
 
 
 
 
 
 
509
  await websocket.send_json({
510
  "type": "system",
511
  "message": welcome_msg,
512
- "chat_id": current_chat_id
 
513
  })
514
 
515
  while True:
516
- data = await websocket.receive_text()
 
 
 
 
 
 
 
 
 
517
  try:
518
  message_data = json.loads(data)
519
  message_type_raw = message_data.get("type", "")
520
  message_type = (message_type_raw or "").strip().lower()
521
-
522
  # 更新最後活動時間
523
  manager.user_sessions[user_id]["last_activity"] = datetime.now()
524
 
@@ -527,6 +602,7 @@ async def websocket_endpoint_with_jwt(
527
  if not user_message:
528
  await manager.send_message("收到空消息", user_id, "error")
529
  continue
 
530
 
531
  chat_id = message_data.get("chat_id", None)
532
 
@@ -573,9 +649,8 @@ async def websocket_endpoint_with_jwt(
573
  "content": (
574
  "你是一個友善、有禮且能夠提供幫助的AI助手。\n\n"
575
  "【重要】語言使用規範:\n"
576
- "- 回覆用戶時:必須使用繁體中文,保持簡潔清晰的表達\n"
577
  "- 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)\n\n"
578
- "另外,請勿自稱為 GPT-4 或其他版本。若需要自我介紹,請表述為 '基於 gpt-5-nano 模型'。"
579
  ),
580
  },
581
  {"role": "user", "content": user_message},
@@ -588,7 +663,28 @@ async def websocket_endpoint_with_jwt(
588
  try:
589
  logger.info(f"🚀 開始處理訊息: user_id={user_id}, chat_id={chat_id}")
590
 
591
- async def _on_text_emotion(em: str, cm: bool):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
592
  logger.info(f"📤 [即時回調] 發送 text emotion_detected: {em}, care_mode={cm}")
593
  await websocket.send_json({
594
  "type": "emotion_detected",
@@ -596,7 +692,7 @@ async def websocket_endpoint_with_jwt(
596
  "care_mode": cm
597
  })
598
 
599
- response = await handle_message(user_message, user_id, chat_id, messages_for_handler, request_id=request_id, emotion_callback=_on_text_emotion)
600
  logger.info(f"📥 handle_message 返回: type={type(response)}, response={response}")
601
 
602
  # 【優化】處理空回應:轉換為帶情緒的 dict 格式
@@ -673,8 +769,7 @@ async def websocket_endpoint_with_jwt(
673
  except Exception as e:
674
  logger.exception(f"❌ _do_process_and_send 發生異常: {e}")
675
 
676
- import asyncio as _asyncio
677
- _asyncio.create_task(_do_process_and_send())
678
 
679
  elif message_type == "env_snapshot":
680
  try:
@@ -752,7 +847,16 @@ async def websocket_endpoint_with_jwt(
752
  sr = 16000
753
 
754
  if mode == "realtime_chat":
755
- # === 即時轉錄模式(使用 OpenAI Realtime API)===
 
 
 
 
 
 
 
 
 
756
  try:
757
  from services.realtime_stt_service import RealtimeSTTService
758
 
@@ -785,21 +889,32 @@ async def websocket_endpoint_with_jwt(
785
  client_info["realtime_transcript"] = full_text
786
  manager.set_client_info(user_id, client_info)
787
 
788
- async def on_vad_committed(item_id: str):
789
- """VAD 偵測到語音段結束"""
790
- logger.debug(f"🎤 VAD Committed: {item_id}")
 
 
 
 
 
 
 
 
 
 
 
791
 
792
  # 從前端獲取語言設定(支援:zh, en, id, ja, vi,或 auto 自動檢測)
793
  language = message_data.get("language", "auto")
794
  logger.info(f"🌐 語言設定: {language}")
795
 
796
- # 連線到 OpenAI Realtime API
797
  success = await realtime_stt.connect(
798
  on_transcript_delta=on_transcript_delta,
799
  on_transcript_done=on_transcript_done,
800
  on_vad_committed=on_vad_committed,
801
- model="gpt-4o-mini-transcribe",
802
- language=language
803
  )
804
 
805
  if success:
@@ -812,11 +927,12 @@ async def websocket_endpoint_with_jwt(
812
  await websocket.send_json({
813
  "type": "realtime_stt_status",
814
  "status": "connected",
815
- "message": "即時轉錄已啟動"
 
816
  })
817
  logger.info(f"✅ 用戶 {user_id} 即時轉錄已啟動")
818
  else:
819
- raise Exception("無法連接到 OpenAI Realtime API")
820
 
821
  except Exception as e:
822
  logger.error(f"❌ 啟動即時轉錄失敗: {e}")
@@ -845,16 +961,22 @@ async def websocket_endpoint_with_jwt(
845
  realtime_stt = client_info.get("realtime_stt")
846
 
847
  if realtime_stt and b64:
848
- # === 即時轉錄模式:轉發到 OpenAI Realtime API ===
849
  try:
850
  import base64
851
  audio_bytes = base64.b64decode(b64)
852
  await realtime_stt.send_audio_chunk(audio_bytes)
853
- logger.debug(f"🎤 轉發音頻到 OpenAI: {len(audio_bytes)} bytes")
854
 
855
  # 同時儲存到本地緩衝(用於音頻情緒辨識)
 
 
 
856
  audio_buffer = client_info.get("audio_buffer", b"")
857
  audio_buffer += audio_bytes
 
 
 
858
  client_info["audio_buffer"] = audio_buffer
859
  manager.set_client_info(user_id, client_info)
860
 
@@ -1000,7 +1122,10 @@ async def websocket_endpoint_with_jwt(
1000
  td = app.state.feature_router.get_current_time_data()
1001
  name = user.get("name") or "用戶"
1002
  emo = result.get("emotion") or {}
1003
- emo_label = str(emo.get("label") or "")
 
 
 
1004
  tz_hint = None
1005
  try:
1006
  env_res = await get_user_env_current(user_id)
@@ -1011,7 +1136,7 @@ async def websocket_endpoint_with_jwt(
1011
  welcome = compose_welcome(
1012
  user_name=name,
1013
  time_data=td,
1014
- emotion_label=emo_label,
1015
  timezone=tz_hint,
1016
  )
1017
  except Exception:
@@ -1030,6 +1155,13 @@ async def websocket_endpoint_with_jwt(
1030
  logger.error(f"生成 JWT token 失敗: {e}")
1031
  access_token = None
1032
 
 
 
 
 
 
 
 
1033
  await websocket.send_json({
1034
  "type": "voice_login_result",
1035
  "success": True,
@@ -1067,7 +1199,7 @@ async def websocket_endpoint_with_jwt(
1067
  })
1068
 
1069
  elif mode == "realtime_chat":
1070
- # === 即時轉���模式:關閉 OpenAI Realtime 連線並處理轉錄結果 ===
1071
  try:
1072
  client_info = manager.get_client_info(user_id) or {}
1073
  realtime_stt = client_info.get("realtime_stt")
@@ -1075,6 +1207,24 @@ async def websocket_endpoint_with_jwt(
1075
  audio_buffer = client_info.get("audio_buffer", b"")
1076
 
1077
  if realtime_stt:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1078
  logger.info(f"🔌 關閉即時轉錄連線,用戶 {user_id}")
1079
  await realtime_stt.disconnect()
1080
 
@@ -1099,150 +1249,128 @@ async def websocket_endpoint_with_jwt(
1099
  # 立即通知前端開始思考,提升即時響應感
1100
  await websocket.send_json({"type": "typing", "message": "thinking"})
1101
 
1102
- # === 方案 B:語音情緒辨識(情緒分佈驗證 + 智能回退)===
1103
- audio_emotion = None
1104
- if audio_buffer and len(audio_buffer) >= 16000 * 2: # 至少 1 秒
1105
- try:
1106
- logger.info(f"🎭 開始語音情緒辨識,音訊長度: {len(audio_buffer)} bytes")
1107
- emotion_result = await predict_emotion_from_audio(audio_buffer, sample_rate=16000)
1108
-
1109
- if emotion_result.get("success"):
1110
- emotion_label = emotion_result.get("emotion", "neutral")
1111
- confidence = emotion_result.get("confidence", 0.0)
1112
- all_emotions = emotion_result.get("all_emotions", {})
1113
-
1114
- # 計算 top-1 與 top-2 的 margin
1115
- sorted_emotions = sorted(all_emotions.items(), key=lambda x: x[1], reverse=True)
1116
- margin = sorted_emotions[0][1] - sorted_emotions[1][1] if len(sorted_emotions) >= 2 else confidence
1117
-
1118
- # 方案 B 判斷邏輯
1119
- use_audio_emotion = False
1120
- reason = ""
1121
-
1122
- if emotion_label == "neutral":
1123
- # neutral 需要更高置信度,但 margin 可較寬鬆
1124
- if confidence >= 0.55 and margin >= 0.12:
1125
- use_audio_emotion = True
1126
- reason = f"neutral 高信心 (conf={confidence:.3f}, margin={margin:.3f})"
1127
- else:
1128
- reason = f"neutral 信心不足 (conf={confidence:.3f}, margin={margin:.3f}) → 回退文字"
1129
- else:
1130
- # 非 neutral 需要足夠 confidence 與 margin
1131
- if confidence >= 0.48 and margin >= 0.18:
1132
- use_audio_emotion = True
1133
- reason = f"{emotion_label} 高信心 (conf={confidence:.3f}, margin={margin:.3f})"
1134
- else:
1135
- reason = f"{emotion_label} 信心不足 (conf={confidence:.3f}, margin={margin:.3f}) → 回退文字"
1136
-
1137
- if use_audio_emotion:
1138
- audio_emotion = emotion_result
1139
- logger.info(f"✅ 使用語音情緒: {emotion_label}, {reason}")
1140
- else:
1141
- audio_emotion = None
1142
- logger.info(f"📝 {reason}")
1143
- else:
1144
- logger.warning(f"⚠️ 語音情緒辨識失敗: {emotion_result.get('error')}")
1145
- except Exception as e:
1146
- logger.error(f"❌ 語音情緒辨識異常: {e}")
1147
- audio_emotion = None
1148
-
1149
- # 清理音頻緩衝
1150
- if audio_buffer:
1151
- client_info.pop("audio_buffer", None)
1152
- manager.set_client_info(user_id, client_info)
1153
-
1154
- # 異步處理對話邏輯
1155
- async def _process_realtime_chat():
1156
  chat_id = message_data.get("chat_id")
1157
-
1158
- # 如果沒有 chat_id,創建新對話
1159
  if not chat_id:
1160
  try:
1161
  user_chats_result = await get_user_chats(user_id)
1162
  if user_chats_result["success"] and user_chats_result["chats"]:
1163
- latest_chat = user_chats_result["chats"][0]
1164
- chat_id = latest_chat["chat_id"]
1165
  else:
1166
- chat_title = f"語音對話 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
1167
- chat_result = await create_chat(user_id, chat_title)
1168
- if chat_result["success"]:
1169
- chat_id = chat_result["chat"]["chat_id"]
1170
  except Exception as e:
1171
- logger.error(f"創建對話失敗: {e}")
1172
- await websocket.send_json({"type": "error", "message": "無法創建對話"})
1173
- return
1174
-
1175
- # 保存用戶訊息
1176
- await save_message_to_db(user_id, chat_id, "user", transcription)
1177
-
1178
- # 取得語言設定
 
 
 
 
 
1179
  language = client_info.get("language", "auto")
 
 
 
 
 
 
 
1180
 
1181
- # 發送即時情緒的回調函數
1182
- async def _on_emotion_detected(em: str, cm: bool):
1183
- logger.info(f"📤 [即時回調] 發送 emotion_detected: {em}, care_mode={cm}")
1184
- await websocket.send_json({
1185
- "type": "emotion_detected",
1186
- "emotion": em,
1187
- "care_mode": cm
1188
- })
1189
 
1190
- # 處理對話(透過 handle_message,自動處理 pipeline)
1191
  response = await handle_message(
1192
  transcription,
1193
  user_id,
1194
  chat_id,
1195
- [], # messages 參數(會自動從數據庫載入)
1196
- audio_emotion=audio_emotion, # 傳遞音頻情緒
1197
- language=language, # 傳遞語言設定(新增)
1198
  emotion_callback=_on_emotion_detected
1199
  )
1200
 
1201
- # 發送回應
1202
- # 從 PipelineResult 提取情緒
1203
- emotion = None
1204
- care_mode = False
1205
  if isinstance(response, PipelineResult):
1206
- message_text = response.text
1207
- if response.meta:
1208
- emotion = response.meta.get('emotion')
1209
- care_mode = response.meta.get('care_mode', False)
1210
-
1211
  await websocket.send_json({
1212
  "type": "bot_message",
1213
- "message": message_text,
1214
  "timestamp": time.time(),
1215
- "tool_name": None,
1216
- "tool_data": None,
1217
- "emotion": emotion,
1218
- "care_mode": care_mode
1219
  })
 
 
 
1220
  elif isinstance(response, dict):
1221
- tool_name = response.get('tool_name')
1222
- tool_data = response.get('tool_data')
1223
- emotion = response.get('emotion')
1224
- message_text = response.get('message', response.get('content', ''))
1225
-
1226
  await websocket.send_json({
1227
  "type": "bot_message",
1228
- "message": message_text,
1229
  "timestamp": time.time(),
1230
- "tool_name": tool_name,
1231
- "tool_data": tool_data,
1232
- "emotion": emotion
 
 
1233
  })
 
 
1234
  else:
1235
  # 字串回應
1236
  await websocket.send_json({
1237
  "type": "bot_message",
1238
  "message": str(response),
1239
- "timestamp": time.time(),
1240
- "emotion": None
1241
  })
 
 
 
 
 
 
 
 
 
 
 
 
1242
 
1243
- await _process_realtime_chat()
1244
  else:
1245
  logger.debug(f"沒有轉錄文字,返回待機狀態")
 
1246
 
1247
  except Exception as e:
1248
  logger.error(f"❌ 關閉即時轉錄失敗: {e}")
@@ -1270,20 +1398,26 @@ async def websocket_endpoint_with_jwt(
1270
  # 消息處理與AI
1271
  # -----------------------------
1272
  async def handle_message(user_message, user_id, chat_id, messages, request_id: str = None, audio_emotion: dict = None, language: str = None, emotion_callback=None):
 
1273
  logger.info(f"📥 handle_message: 收到訊息='{user_message}', user_id={user_id}, audio_emotion={audio_emotion}, language={language}")
 
1274
 
 
 
 
 
1275
  # 指令優先,避免進入管線造成不必要延遲
1276
- if user_message and user_message.startswith("/"):
1277
  cmd = await handle_command(user_message, user_id)
1278
  if cmd:
1279
  return cmd
1280
 
1281
  feature_router:MCPAgentBridge = app.state.feature_router
1282
 
1283
- async def _detect(msg: str):
1284
  logger.info(f"🎯 Pipeline: 開始意圖偵測,訊息='{msg}'")
1285
  try:
1286
- result = await feature_router.detect_intent(msg)
1287
  logger.info(f"🎯 Pipeline: 意圖偵測結果={result}")
1288
  return result
1289
  except Exception as e:
@@ -1301,7 +1435,59 @@ async def handle_message(user_message, user_id, chat_id, messages, request_id: s
1301
  logger.info(f"🔧 Pipeline: 功能處理結果='{result}'")
1302
  return result
1303
 
1304
- async def _ai(messages_in, cid, model, rid, chat_id, use_care_mode=False, care_emotion=None, emotion_label=None, language=None):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1305
  env_context = {}
1306
  env_service = getattr(app.state, 'env_service', None)
1307
  if env_service:
@@ -1320,37 +1506,49 @@ async def handle_message(user_message, user_id, chat_id, messages, request_id: s
1320
  logger.debug(f"無法取得用戶名稱,使用預設值: {e}")
1321
 
1322
  # 使用傳入的 language 參數(優先)或閉包捕獲的外部變數
1323
- lang = language if language is not None else globals().get('language', 'zh')
1324
 
1325
  # 兼容:如果傳入字串,視為 user_message;如果傳入 list,視為 messages
1326
- if isinstance(messages_in, str):
1327
- return await ai_service.generate_response_for_user(
1328
- user_message=messages_in,
1329
- user_id=cid,
1330
- model=model,
1331
- request_id=rid,
1332
- chat_id=chat_id,
1333
- use_care_mode=use_care_mode,
1334
- care_emotion=care_emotion,
1335
- user_name=user_name,
1336
- emotion_label=emotion_label,
1337
- env_context=env_context,
1338
- language=lang,
1339
- )
1340
- else:
1341
- return await ai_service.generate_response_for_user(
1342
- messages=messages_in,
1343
- user_id=cid,
1344
- model=model,
1345
- request_id=rid,
1346
- chat_id=chat_id,
1347
- use_care_mode=use_care_mode,
1348
- care_emotion=care_emotion,
1349
- user_name=user_name,
1350
- emotion_label=emotion_label,
1351
- env_context=env_context,
1352
- language=lang,
1353
- )
 
 
 
 
 
 
 
 
 
 
 
 
1354
 
1355
  model = settings.OPENAI_MODEL
1356
  # 簡化 Pipeline:移除未使用的記憶管理和摘要決策
@@ -1360,12 +1558,13 @@ async def handle_message(user_message, user_id, chat_id, messages, request_id: s
1360
  _process_feature,
1361
  _ai,
1362
  model=model,
1363
- detect_timeout=10.0, # 意圖檢測超時 (15 → 10)
1364
  feature_timeout=30.0, # 功能處理超時 (15 → 30,新聞摘要生成需要更長時間)
1365
- ai_timeout=20.0, # AI回應超時 (30 → 20)
1366
  )
1367
- logger.info(f"⚙️ 準備調用 ChatPipeline.process,user_message='{user_message}', audio_emotion={audio_emotion}, language={language}")
1368
- res: PipelineResult = await pipeline.process(user_message, user_id=user_id, chat_id=chat_id, request_id=request_id, audio_emotion=audio_emotion, language=language, emotion_callback=emotion_callback)
 
1369
  logger.info(f"⚙️ ChatPipeline.process 完成,結果='{res.text}', is_fallback={res.is_fallback}, reason={res.reason}")
1370
 
1371
  # 檢查是否有工具元數據
@@ -1408,6 +1607,7 @@ async def handle_message(user_message, user_id, chat_id, messages, request_id: s
1408
  # 提取情緒與關懷模式資訊(新增)
1409
  emotion = res.meta.get('emotion') if res.meta else None
1410
  care_mode = res.meta.get('care_mode', False) if res.meta else False
 
1411
 
1412
  logger.info(f"🎭 handle_message 情緒: emotion={emotion}, care_mode={care_mode}, meta={res.meta}")
1413
 
@@ -1420,7 +1620,8 @@ async def handle_message(user_message, user_id, chat_id, messages, request_id: s
1420
  'tool_name': tool_name,
1421
  'tool_data': tool_data,
1422
  'emotion': final_emotion,
1423
- 'care_mode': care_mode
 
1424
  }
1425
 
1426
 
@@ -1800,6 +2001,7 @@ async def voice_login(request: VoiceLoginRequest):
1800
 
1801
  if not result.get("success"):
1802
  error_code = result.get("error", "UNKNOWN_ERROR")
 
1803
  error_messages = {
1804
  "NO_AUDIO": "沒有收到音訊資料",
1805
  "AUDIO_TOO_SHORT": "音訊太短,請錄製至少 3 秒",
@@ -1808,11 +2010,15 @@ async def voice_login(request: VoiceLoginRequest):
1808
  "THRESHOLD_NOT_MET": "無法確認身份,請重試",
1809
  "MODEL_ERROR": "辨識系統錯誤,請稍後重試",
1810
  }
1811
- logger.warning(f"🎙️ 語音辨識失敗: {error_code}")
1812
  return JSONResponse(content={
1813
  "success": False,
1814
  "error": error_messages.get(error_code, f"辨識失敗:{error_code}")
1815
  })
 
 
 
 
1816
 
1817
  # 取得辨識結果
1818
  speaker_label = result.get("label")
@@ -2146,13 +2352,13 @@ async def analyze_image_with_gpt_vision(filename: str, image_base64: str, mime_t
2146
  }
2147
  ]
2148
  try:
2149
- response = ai_service.client.chat.completions.create(
2150
- model="gpt-5-nano",
2151
  messages=messages,
2152
- max_completion_tokens=1500,
2153
- reasoning_effort="medium" # 圖片分析需要較深入理解,使用 medium
 
 
2154
  )
2155
- analysis = response.choices[0].message.content
2156
  return analysis
2157
  except Exception as e:
2158
  logger.error(f"GPT Vision分析錯誤: {str(e)}")
@@ -2401,6 +2607,12 @@ class TTSRequest(BaseModel):
2401
  text: str
2402
  voice: Optional[str] = "nova"
2403
  speed: Optional[float] = 1.0
 
 
 
 
 
 
2404
 
2405
 
2406
  @app.post("/api/tts")
@@ -2430,7 +2642,10 @@ async def synthesize_speech(
2430
  content={"success": False, "error": "文字長度必須在 1-4096 字元之間"}
2431
  )
2432
 
2433
- valid_voices = ["alloy", "echo", "fable", "onyx", "nova", "shimmer"]
 
 
 
2434
  if request.voice not in valid_voices:
2435
  return JSONResponse(
2436
  status_code=400,
@@ -2443,10 +2658,21 @@ async def synthesize_speech(
2443
  content={"success": False, "error": "語速必須在 0.25 到 4.0 之間"}
2444
  )
2445
 
2446
- logger.info(f"🔊 TTS 請求: text={request.text[:50]}..., voice={request.voice}, speed={request.speed}")
 
 
 
 
2447
 
2448
  # 調用 TTS 服務獲取完整音頻
2449
- result = await text_to_speech(request.text, request.voice, request.speed)
 
 
 
 
 
 
 
2450
 
2451
  if not result.get("success"):
2452
  return JSONResponse(
@@ -2474,6 +2700,93 @@ async def synthesize_speech(
2474
  )
2475
 
2476
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2477
 
2478
  # -----------------------------
2479
  # MCP Tools API
 
1
+ # BloomWare Application - Confidence-Driven Agent Loop
2
  import os
3
  import json
4
  import time
 
7
  import logging
8
  import secrets
9
  import jwt
10
+ import unicodedata
11
  from datetime import datetime
12
  from typing import List, Dict, Optional, Any
13
 
 
78
  # 環境 Context 寫入 API
79
  from core.database import set_user_env_current, add_user_env_snapshot
80
  from core.environment import EnvironmentContextService
81
+ from middleware import CSPMiddleware
82
 
83
 
84
  # -----------------------------
 
112
  except Exception:
113
  return None
114
 
115
+
116
+ def _normalize_bcp47_language_tag(tag: Optional[str]) -> Optional[str]:
117
+ raw = str(tag or "").strip()
118
+ if not raw:
119
+ return None
120
+ normalized = raw.replace("_", "-")
121
+ parts = [part for part in normalized.split("-") if part]
122
+ if not parts:
123
+ return None
124
+
125
+ language = parts[0].lower()
126
+ rest: List[str] = []
127
+ for part in parts[1:]:
128
+ if len(part) == 4 and part.isalpha():
129
+ rest.append(part.title())
130
+ elif len(part) in {2, 3} and part.isalpha():
131
+ rest.append(part.upper())
132
+ else:
133
+ rest.append(part)
134
+ return "-".join([language, *rest])
135
+
136
+
137
+ def _preferred_language_from_text(text: str) -> Optional[str]:
138
+ script_counts: Dict[str, int] = {}
139
+ for ch in str(text or ""):
140
+ if ch.isspace():
141
+ continue
142
+ try:
143
+ name = unicodedata.name(ch)
144
+ except ValueError:
145
+ continue
146
+ for script in ("HIRAGANA", "KATAKANA", "HANGUL", "CJK UNIFIED IDEOGRAPH", "LATIN", "CYRILLIC", "THAI"):
147
+ if script in name:
148
+ script_counts[script] = script_counts.get(script, 0) + 1
149
+ break
150
+
151
+ if script_counts.get("HIRAGANA", 0) or script_counts.get("KATAKANA", 0):
152
+ return "ja-JP"
153
+ if script_counts.get("HANGUL", 0):
154
+ return "ko-KR"
155
+ if script_counts.get("THAI", 0):
156
+ return "th-TH"
157
+ if script_counts.get("CYRILLIC", 0):
158
+ return "ru-RU"
159
+ if script_counts.get("LATIN", 0) and not script_counts.get("CJK UNIFIED IDEOGRAPH", 0):
160
+ return "en-US"
161
+ if script_counts.get("CJK UNIFIED IDEOGRAPH", 0):
162
+ return "zh-TW"
163
+ return None
164
+
165
+
166
+ def _resolve_conversation_language(
167
+ user_message: str,
168
+ requested_language: Optional[str],
169
+ locale_hint: Optional[str] = None,
170
+ ) -> str:
171
+ explicit = _normalize_bcp47_language_tag(requested_language)
172
+ if explicit and explicit.lower() != "auto":
173
+ return explicit
174
+
175
+ inferred = _preferred_language_from_text(user_message)
176
+ if inferred:
177
+ return inferred
178
+
179
+ locale_tag = _normalize_bcp47_language_tag(locale_hint)
180
+ if locale_tag and locale_tag.lower() != "auto":
181
+ return locale_tag
182
+
183
+ return "zh-TW"
184
+
185
+
186
  # -----------------------------
187
  # Pydantic 模型(從統一模組導入)
188
  # -----------------------------
 
396
  )
397
 
398
  # CSP Middleware(允許內嵌 script 用於語音沉浸式前端)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  app.add_middleware(CSPMiddleware)
400
 
401
  # 掛載靜態檔案目錄(語音沉浸式前端)
 
542
 
543
  td = app.state.feature_router.get_current_time_data()
544
  # 使用語音登入傳遞的情緒(如果有)
545
+
546
+ # 如果登入情緒是極端情緒,自動啟動關懷模式
547
+ is_care_active = False
548
+ if emotion in ["sad", "angry", "fear"]:
549
+ from core.emotion_care_manager import EmotionCareManager
550
+ # 使用 force=True 確保從登入情緒直接進入,不需等待連續偵測
551
+ is_care_active = EmotionCareManager.check_and_enter_care_mode(
552
+ user_id, emotion, chat_id=current_chat_id, force=True
553
+ )
554
+ if is_care_active:
555
+ logger.info(f"💙 偵測到登入情緒 [{emotion}],自動啟動關懷模式 (user_id={user_id})")
556
+
557
  welcome_msg = compose_welcome(
558
  user_name=user_info.get('name'),
559
  time_data=td,
 
565
  welcome_msg = f"歡迎回來,{user_info['name']}!"
566
 
567
  # 發送歡迎訊息,並附帶 chat_id
568
+ # 通知前端當前情緒與關懷模式狀態
569
+ await websocket.send_json({
570
+ "type": "emotion_detected",
571
+ "emotion": emotion or "neutral",
572
+ "care_mode": is_care_active if 'is_care_active' in locals() else False
573
+ })
574
+
575
  await websocket.send_json({
576
  "type": "system",
577
  "message": welcome_msg,
578
+ "chat_id": current_chat_id,
579
+ "care_mode": is_care_active if 'is_care_active' in locals() else False
580
  })
581
 
582
  while True:
583
+ try:
584
+ # 🎯 2026 穩定性優化:加入 WebSocket 心跳 (Ping) 機制,防止長思考工具調用導致連線中斷
585
+ data = await asyncio.wait_for(websocket.receive_text(), timeout=30.0)
586
+ except asyncio.TimeoutError:
587
+ try:
588
+ await websocket.send_json({"type": "ping", "timestamp": time.time()})
589
+ continue
590
+ except Exception:
591
+ break # 連線已失效
592
+
593
  try:
594
  message_data = json.loads(data)
595
  message_type_raw = message_data.get("type", "")
596
  message_type = (message_type_raw or "").strip().lower()
 
597
  # 更新最後活動時間
598
  manager.user_sessions[user_id]["last_activity"] = datetime.now()
599
 
 
602
  if not user_message:
603
  await manager.send_message("收到空消息", user_id, "error")
604
  continue
605
+ message_language = message_data.get("language") or "auto"
606
 
607
  chat_id = message_data.get("chat_id", None)
608
 
 
649
  "content": (
650
  "你是一個友善、有禮且能夠提供幫助的AI助手。\n\n"
651
  "【重要】語言使用規範:\n"
652
+ "- 回覆用戶時:必須使用對應的語言,保持簡潔清晰的表達\n"
653
  "- 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)\n\n"
 
654
  ),
655
  },
656
  {"role": "user", "content": user_message},
 
663
  try:
664
  logger.info(f"🚀 開始處理訊息: user_id={user_id}, chat_id={chat_id}")
665
 
666
+ async def _on_text_emotion(em: str, cm: bool, payload: Optional[Dict[str, Any]] = None):
667
+ if em == "__bot_delta__" and payload:
668
+ await websocket.send_json({
669
+ "type": "bot_delta",
670
+ "message_id": payload.get("message_id"),
671
+ "delta": payload.get("delta", ""),
672
+ "text": payload.get("text", ""),
673
+ "temporary": True,
674
+ "phase": payload.get("phase", "answering"),
675
+ "timestamp": time.time(),
676
+ })
677
+ return
678
+ if em == "__bot_status__" and payload:
679
+ await websocket.send_json({
680
+ "type": "bot_status",
681
+ "status": payload.get("status", "processing"),
682
+ "message": payload.get("message", "正在處理..."),
683
+ "temporary": True,
684
+ "phase": payload.get("phase", payload.get("status", "processing")),
685
+ "timestamp": time.time(),
686
+ })
687
+ return
688
  logger.info(f"📤 [即時回調] 發送 text emotion_detected: {em}, care_mode={cm}")
689
  await websocket.send_json({
690
  "type": "emotion_detected",
 
692
  "care_mode": cm
693
  })
694
 
695
+ response = await handle_message(user_message, user_id, chat_id, messages_for_handler, request_id=request_id, language=message_language, emotion_callback=_on_text_emotion)
696
  logger.info(f"📥 handle_message 返回: type={type(response)}, response={response}")
697
 
698
  # 【優化】處理空回應:轉換為帶情緒的 dict 格式
 
769
  except Exception as e:
770
  logger.exception(f"❌ _do_process_and_send 發生異常: {e}")
771
 
772
+ asyncio.create_task(_do_process_and_send())
 
773
 
774
  elif message_type == "env_snapshot":
775
  try:
 
847
  sr = 16000
848
 
849
  if mode == "realtime_chat":
850
+ # 🎯 中斷 Barge-in:如果正在處理上一個回覆,立即取消
851
+ await manager.cancel_user_tasks(user_id)
852
+
853
+ # 🎯 2026 穩定性優化:每次開始對話時清除上一次的音頻緩衝與轉錄,防止「語音殘留」污染下一次識別
854
+ client_info = manager.get_client_info(user_id) or {}
855
+ client_info["audio_buffer"] = b""
856
+ client_info["realtime_transcript"] = ""
857
+ manager.set_client_info(user_id, client_info)
858
+
859
+ # === 即時轉錄模式(使用 Google Speech-to-Text)===
860
  try:
861
  from services.realtime_stt_service import RealtimeSTTService
862
 
 
889
  client_info["realtime_transcript"] = full_text
890
  manager.set_client_info(user_id, client_info)
891
 
892
+ async def on_vad_committed(status: str):
893
+ """VAD 偵測到語音狀態變化"""
894
+ if status == "error":
895
+ await websocket.send_json({
896
+ "type": "error",
897
+ "message": "語音識別服務異常 (Stream Timeout),正在自動重置環境..."
898
+ })
899
+ else:
900
+ await websocket.send_json({
901
+ "type": "stt_status",
902
+ "status": status,
903
+ "timestamp": time.time()
904
+ })
905
+ logger.debug(f"🎤 VAD Status: {status}")
906
 
907
  # 從前端獲取語言設定(支援:zh, en, id, ja, vi,或 auto 自動檢測)
908
  language = message_data.get("language", "auto")
909
  logger.info(f"🌐 語言設定: {language}")
910
 
911
+ # 連線到 Google Speech-to-Text 服務
912
  success = await realtime_stt.connect(
913
  on_transcript_delta=on_transcript_delta,
914
  on_transcript_done=on_transcript_done,
915
  on_vad_committed=on_vad_committed,
916
+ language=language,
917
+ sample_rate=sr
918
  )
919
 
920
  if success:
 
927
  await websocket.send_json({
928
  "type": "realtime_stt_status",
929
  "status": "connected",
930
+ "message": "即時轉錄已啟動",
931
+ "language": language,
932
  })
933
  logger.info(f"✅ 用戶 {user_id} 即時轉錄已啟動")
934
  else:
935
+ raise Exception("無法連接到 Google Speech-to-Text")
936
 
937
  except Exception as e:
938
  logger.error(f"❌ 啟動即時轉錄失敗: {e}")
 
961
  realtime_stt = client_info.get("realtime_stt")
962
 
963
  if realtime_stt and b64:
964
+ # === 即時轉錄模式:轉發到 Google Speech-to-Text 緩衝 ===
965
  try:
966
  import base64
967
  audio_bytes = base64.b64decode(b64)
968
  await realtime_stt.send_audio_chunk(audio_bytes)
969
+ logger.debug(f"🎤 轉發音頻到 Google STT: {len(audio_bytes)} bytes")
970
 
971
  # 同時儲存到本地緩衝(用於音頻情緒辨識)
972
+ # 🎯 效能與記憶體優化:實施滑動窗口(Sliding Window),僅保留最近 15 秒音頻
973
+ # 16000Hz * 2bytes/sample * 15s = 480,000 bytes
974
+ MAX_BUFFER_SIZE = 480000
975
  audio_buffer = client_info.get("audio_buffer", b"")
976
  audio_buffer += audio_bytes
977
+ if len(audio_buffer) > MAX_BUFFER_SIZE:
978
+ audio_buffer = audio_buffer[-MAX_BUFFER_SIZE:]
979
+
980
  client_info["audio_buffer"] = audio_buffer
981
  manager.set_client_info(user_id, client_info)
982
 
 
1122
  td = app.state.feature_router.get_current_time_data()
1123
  name = user.get("name") or "用戶"
1124
  emo = result.get("emotion") or {}
1125
+ emo_label = str(emo.get("label") or "neutral")
1126
+ # 🎯 使用原始標籤(包含中文)來生成歡迎詞,確保「心情低落」等詞彙能正確匹配
1127
+ raw_emo_label = str(emo.get("raw_label") or emo_label)
1128
+
1129
  tz_hint = None
1130
  try:
1131
  env_res = await get_user_env_current(user_id)
 
1136
  welcome = compose_welcome(
1137
  user_name=name,
1138
  time_data=td,
1139
+ emotion_label=raw_emo_label,
1140
  timezone=tz_hint,
1141
  )
1142
  except Exception:
 
1155
  logger.error(f"生成 JWT token 失敗: {e}")
1156
  access_token = None
1157
 
1158
+ await websocket.send_json({
1159
+ "type": "emotion_detected",
1160
+ "emotion": emo_label,
1161
+ "care_mode": False,
1162
+ "source": "voice_login"
1163
+ })
1164
+
1165
  await websocket.send_json({
1166
  "type": "voice_login_result",
1167
  "success": True,
 
1199
  })
1200
 
1201
  elif mode == "realtime_chat":
1202
+ # === 即時轉模式:提交 Google STT 並處理轉錄結果 ===
1203
  try:
1204
  client_info = manager.get_client_info(user_id) or {}
1205
  realtime_stt = client_info.get("realtime_stt")
 
1207
  audio_buffer = client_info.get("audio_buffer", b"")
1208
 
1209
  if realtime_stt:
1210
+ await websocket.send_json({
1211
+ "type": "stt_status",
1212
+ "status": "transcribing",
1213
+ "timestamp": time.time()
1214
+ })
1215
+ logger.info(f"📝 等待即時轉錄 final,用戶 {user_id}")
1216
+ final_transcript = await realtime_stt.wait_for_final_transcript(timeout=3.5)
1217
+ if final_transcript and final_transcript != client_info.get("realtime_transcript"):
1218
+ transcription = final_transcript
1219
+ client_info["realtime_transcript"] = final_transcript
1220
+ await websocket.send_json({
1221
+ "type": "stt_final",
1222
+ "text": final_transcript,
1223
+ "timestamp": time.time()
1224
+ })
1225
+ elif final_transcript:
1226
+ transcription = final_transcript
1227
+
1228
  logger.info(f"🔌 關閉即時轉錄連線,用戶 {user_id}")
1229
  await realtime_stt.disconnect()
1230
 
 
1249
  # 立即通知前端開始思考,提升即時響應感
1250
  await websocket.send_json({"type": "typing", "message": "thinking"})
1251
 
1252
+ # === 優化並行處理語音情緒辨識 Agent 邏輯 ===
1253
+ async def _get_audio_emotion():
1254
+ if audio_buffer and len(audio_buffer) >= 16000 * 1.5: # 至少 1.5
1255
+ try:
1256
+ logger.info(f"🎭 [Parallel] 開始語音情緒辨識,長度: {len(audio_buffer)} bytes")
1257
+ res = await predict_emotion_from_audio(audio_buffer, sample_rate=16000)
1258
+ if res.get("success"):
1259
+ # 簡單過濾低信心度
1260
+ if res.get("confidence", 0) > 0.4:
1261
+ res["source"] = "realtime_voice"
1262
+ return res
1263
+ except Exception as e:
1264
+ logger.error(f"❌ 語音情緒辨識異常: {e}")
1265
+ return {"success": False, "source": "realtime_voice"}
1266
+
1267
+ # 定義一個內部函數來處理整個流程,稍後將其作為 Task 執行
1268
+ async def _process_full_request():
1269
+ # 啟動並行任務
1270
+ emotion_task = asyncio.create_task(_get_audio_emotion())
1271
+
1272
+ # 同步準備對話 ID 等基礎工作 (這些很快,不需額外 Task)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1273
  chat_id = message_data.get("chat_id")
 
 
1274
  if not chat_id:
1275
  try:
1276
  user_chats_result = await get_user_chats(user_id)
1277
  if user_chats_result["success"] and user_chats_result["chats"]:
1278
+ chat_id = user_chats_result["chats"][0]["chat_id"]
 
1279
  else:
1280
+ chat_title = f"語音對話 {datetime.now().strftime('%Y-%m-%d %H:%M')}"
1281
+ chat_res = await create_chat(user_id, chat_title)
1282
+ if chat_res["success"]:
1283
+ chat_id = chat_res["chat"]["chat_id"]
1284
  except Exception as e:
1285
+ logger.error(f"準備對話時出錯: {e}")
1286
+
1287
+ # 保存用戶訊息(異步)
1288
+ if chat_id:
1289
+ asyncio.create_task(save_message_to_db(user_id, chat_id, "user", transcription))
1290
+
1291
+ # 等待情緒分析結果(如果還沒出的話,這邊會稍微等一下,但通常會與 GPT 意圖偵測並行)
1292
+ # 為了讓意圖偵測儘早開始,我們甚至可以在 handle_message 內部去 await emotion_task
1293
+ # 但這裡我們採取簡單策略:先啟動 handle_message,並傳入 task 或稍後合併
1294
+
1295
+ # 實際上,handle_message 內部會做 GPT 意圖偵測,這耗時最久。
1296
+ # 我們讓 handle_message 帶入 emotion_task
1297
+
1298
  language = client_info.get("language", "auto")
1299
+
1300
+ async def _on_emotion_detected(em: str, cm: bool, payload: Optional[Dict[str, Any]] = None):
1301
+ # ... (原有回調邏輯)
1302
+ if em.startswith("__bot_"): # 狀態回調
1303
+ await websocket.send_json({"type": em.replace("__", ""), **(payload or {})})
1304
+ return
1305
+ await websocket.send_json({"type": "emotion_detected", "emotion": em, "care_mode": cm})
1306
 
1307
+ # 等待情緒完成,或者設定超時
1308
+ try:
1309
+ audio_emotion_res = await asyncio.wait_for(emotion_task, timeout=2.0)
1310
+ except asyncio.TimeoutError:
1311
+ logger.warning("⚠️ 語音情緒辨識超時,回退至文字分析")
1312
+ audio_emotion_res = {"success": False}
 
 
1313
 
1314
+ # 執行 Agent 邏輯
1315
  response = await handle_message(
1316
  transcription,
1317
  user_id,
1318
  chat_id,
1319
+ [],
1320
+ audio_emotion=audio_emotion_res,
1321
+ language=language,
1322
  emotion_callback=_on_emotion_detected
1323
  )
1324
 
1325
+ # 發送結果
 
 
 
1326
  if isinstance(response, PipelineResult):
 
 
 
 
 
1327
  await websocket.send_json({
1328
  "type": "bot_message",
1329
+ "message": response.text,
1330
  "timestamp": time.time(),
1331
+ "emotion": response.meta.get("emotion") if response.meta else "neutral",
1332
+ "care_mode": response.meta.get("care_mode", False) if response.meta else False,
1333
+ "language": language,
 
1334
  })
1335
+ # 保存 AI 訊息
1336
+ if chat_id:
1337
+ asyncio.create_task(save_message_to_db(user_id, chat_id, "assistant", response.text))
1338
  elif isinstance(response, dict):
 
 
 
 
 
1339
  await websocket.send_json({
1340
  "type": "bot_message",
1341
+ "message": response.get("message", response.get("content", "")),
1342
  "timestamp": time.time(),
1343
+ "tool_name": response.get("tool_name"),
1344
+ "tool_data": response.get("tool_data"),
1345
+ "emotion": response.get("emotion", "neutral"),
1346
+ "care_mode": response.get("care_mode", False),
1347
+ "language": response.get("language", language),
1348
  })
1349
+ if chat_id:
1350
+ asyncio.create_task(save_message_to_db(user_id, chat_id, "assistant", str(response.get("message", ""))))
1351
  else:
1352
  # 字串回應
1353
  await websocket.send_json({
1354
  "type": "bot_message",
1355
  "message": str(response),
1356
+ "timestamp": time.time()
 
1357
  })
1358
+ if chat_id:
1359
+ asyncio.create_task(save_message_to_db(user_id, chat_id, "assistant", str(response)))
1360
+
1361
+ # 啟動整體處理任務
1362
+ # 🎯 註冊任務,以便支援 Barge-in 中斷
1363
+ task = asyncio.create_task(_process_full_request())
1364
+ manager.register_task(user_id, task)
1365
+
1366
+ # 清理緩衝區並直接返回循環,讓連線保持暢通
1367
+ if audio_buffer:
1368
+ client_info.pop("audio_buffer", None)
1369
+ manager.set_client_info(user_id, client_info)
1370
 
 
1371
  else:
1372
  logger.debug(f"沒有轉錄文字,返回待機狀態")
1373
+ await websocket.send_json({"type": "stt_status", "status": "idle"})
1374
 
1375
  except Exception as e:
1376
  logger.error(f"❌ 關閉即時轉錄失敗: {e}")
 
1398
  # 消息處理與AI
1399
  # -----------------------------
1400
  async def handle_message(user_message, user_id, chat_id, messages, request_id: str = None, audio_emotion: dict = None, language: str = None, emotion_callback=None):
1401
+ user_message = (user_message or "").strip()
1402
  logger.info(f"📥 handle_message: 收到訊息='{user_message}', user_id={user_id}, audio_emotion={audio_emotion}, language={language}")
1403
+ resolved_language = _resolve_conversation_language(user_message, language)
1404
 
1405
+ if not user_message:
1406
+ logger.info(f"🚫 攔截到空請求,中斷交由 Agent 處理。")
1407
+ return "不好意思,我剛剛沒有聽清楚或是沒收到內容,可以請您再說一次嗎?"
1408
+
1409
  # 指令優先,避免進入管線造成不必要延遲
1410
+ if user_message.startswith("/"):
1411
  cmd = await handle_command(user_message, user_id)
1412
  if cmd:
1413
  return cmd
1414
 
1415
  feature_router:MCPAgentBridge = app.state.feature_router
1416
 
1417
+ async def _detect(msg: str, tool_context: str = "", language: str = None, **kwargs):
1418
  logger.info(f"🎯 Pipeline: 開始意圖偵測,訊息='{msg}'")
1419
  try:
1420
+ result = await feature_router.detect_intent(msg, tool_context=tool_context, language=language or resolved_language)
1421
  logger.info(f"🎯 Pipeline: 意圖偵測結果={result}")
1422
  return result
1423
  except Exception as e:
 
1435
  logger.info(f"🔧 Pipeline: 功能處理結果='{result}'")
1436
  return result
1437
 
1438
+ stream_message_id = request_id or f"stream_{int(time.time() * 1000)}"
1439
+ stream_accumulator = {"text": ""}
1440
+
1441
+ async def _emit_bot_status(status: str, message: str, phase: Optional[str] = None):
1442
+ if emotion_callback is None:
1443
+ return
1444
+ try:
1445
+ await emotion_callback(
1446
+ "__bot_status__",
1447
+ False,
1448
+ {
1449
+ "status": status,
1450
+ "message": message,
1451
+ "phase": phase or status,
1452
+ "temporary": True,
1453
+ },
1454
+ )
1455
+ except TypeError:
1456
+ return
1457
+
1458
+ async def _on_ai_chunk(delta: Any):
1459
+ if not delta or emotion_callback is None:
1460
+ return
1461
+ if isinstance(delta, dict):
1462
+ delta.setdefault("temporary", True)
1463
+ delta.setdefault("phase", delta.get("status", "processing"))
1464
+ try:
1465
+ await emotion_callback("__bot_status__", False, delta)
1466
+ except TypeError:
1467
+ return
1468
+ return
1469
+
1470
+ stream_accumulator["text"] += str(delta)
1471
+ try:
1472
+ await emotion_callback(
1473
+ "__bot_delta__",
1474
+ False,
1475
+ {
1476
+ "message_id": stream_message_id,
1477
+ "delta": str(delta),
1478
+ "text": stream_accumulator["text"],
1479
+ "language": _preferred_language_from_text(stream_accumulator["text"]) or resolved_language,
1480
+ "phase": "answering",
1481
+ "temporary": True,
1482
+ },
1483
+ )
1484
+ except TypeError:
1485
+ return
1486
+
1487
+ async def _ai(messages_in, cid, model, rid, chat_id, use_care_mode=False, care_emotion=None, emotion_label=None, language=None, tool_context: str = "", is_first_care: bool = False):
1488
+ # 【效能優化】立即通知前端進入 AI 生成階段,這會刷新思考超時
1489
+ await _emit_bot_status("generating", "正在組織語言回答您...", "thinking")
1490
+
1491
  env_context = {}
1492
  env_service = getattr(app.state, 'env_service', None)
1493
  if env_service:
 
1506
  logger.debug(f"無法取得用戶名稱,使用預設值: {e}")
1507
 
1508
  # 使用傳入的 language 參數(優先)或閉包捕獲的外部變數
1509
+ lang = language if language is not None else resolved_language
1510
 
1511
  # 兼容:如果傳入字串,視為 user_message;如果傳入 list,視為 messages
1512
+ try:
1513
+ if isinstance(messages_in, str):
1514
+ return await ai_service.generate_response_for_user(
1515
+ user_message=messages_in,
1516
+ user_id=cid,
1517
+ model=model,
1518
+ request_id=rid,
1519
+ chat_id=chat_id,
1520
+ use_care_mode=use_care_mode,
1521
+ care_emotion=care_emotion,
1522
+ user_name=user_name,
1523
+ emotion_label=emotion_label,
1524
+ env_context=env_context,
1525
+ language=lang,
1526
+ stream=bool(emotion_callback),
1527
+ on_chunk=_on_ai_chunk if emotion_callback else None,
1528
+ tool_context=tool_context,
1529
+ is_first_care=is_first_care,
1530
+ )
1531
+ else:
1532
+ return await ai_service.generate_response_for_user(
1533
+ messages=messages_in,
1534
+ user_id=cid,
1535
+ model=model,
1536
+ request_id=rid,
1537
+ chat_id=chat_id,
1538
+ use_care_mode=use_care_mode,
1539
+ care_emotion=care_emotion,
1540
+ user_name=user_name,
1541
+ emotion_label=emotion_label,
1542
+ env_context=env_context,
1543
+ language=lang,
1544
+ stream=bool(emotion_callback),
1545
+ on_chunk=_on_ai_chunk if emotion_callback else None,
1546
+ tool_context=tool_context,
1547
+ is_first_care=is_first_care,
1548
+ )
1549
+ except Exception as e:
1550
+ logger.error(f"AI 生成過程出錯: {e}")
1551
+ raise
1552
 
1553
  model = settings.OPENAI_MODEL
1554
  # 簡化 Pipeline:移除未使用的記憶管理和摘要決策
 
1558
  _process_feature,
1559
  _ai,
1560
  model=model,
1561
+ detect_timeout=25.0, # 意圖檢測超時:保留 Agent 自主工具判斷空間
1562
  feature_timeout=30.0, # 功能處理超時 (15 → 30,新聞摘要生成需要更長時間)
1563
+ ai_timeout=60.0, # AI回應超時:hosted WebSearch/工具階段可能先無文字 delta
1564
  )
1565
+ logger.info(f"⚙️ 準備調用 ChatPipeline.process,user_message='{user_message}', audio_emotion={audio_emotion}, language={resolved_language}")
1566
+ await _emit_bot_status("planning", "已收到,正在規劃處理方式...", "planning")
1567
+ res: PipelineResult = await pipeline.process(user_message, user_id=user_id, chat_id=chat_id, request_id=request_id, audio_emotion=audio_emotion, language=resolved_language, emotion_callback=emotion_callback)
1568
  logger.info(f"⚙️ ChatPipeline.process 完成,結果='{res.text}', is_fallback={res.is_fallback}, reason={res.reason}")
1569
 
1570
  # 檢查是否有工具元數據
 
1607
  # 提取情緒與關懷模式資訊(新增)
1608
  emotion = res.meta.get('emotion') if res.meta else None
1609
  care_mode = res.meta.get('care_mode', False) if res.meta else False
1610
+ final_language = _preferred_language_from_text(res.text) or _normalize_bcp47_language_tag(language) or "zh-TW"
1611
 
1612
  logger.info(f"🎭 handle_message 情緒: emotion={emotion}, care_mode={care_mode}, meta={res.meta}")
1613
 
 
1620
  'tool_name': tool_name,
1621
  'tool_data': tool_data,
1622
  'emotion': final_emotion,
1623
+ 'care_mode': care_mode,
1624
+ 'language': final_language,
1625
  }
1626
 
1627
 
 
2001
 
2002
  if not result.get("success"):
2003
  error_code = result.get("error", "UNKNOWN_ERROR")
2004
+ quality_warnings = result.get("quality_warnings") or []
2005
  error_messages = {
2006
  "NO_AUDIO": "沒有收到音訊資料",
2007
  "AUDIO_TOO_SHORT": "音訊太短,請錄製至少 3 秒",
 
2010
  "THRESHOLD_NOT_MET": "無法確認身份,請重試",
2011
  "MODEL_ERROR": "辨識系統錯誤,請稍後重試",
2012
  }
2013
+ logger.warning(f"🎙️ 語音辨識失敗: {error_code} quality_warnings={quality_warnings}")
2014
  return JSONResponse(content={
2015
  "success": False,
2016
  "error": error_messages.get(error_code, f"辨識失敗:{error_code}")
2017
  })
2018
+
2019
+ quality_warnings = result.get("quality_warnings") or []
2020
+ if quality_warnings:
2021
+ logger.warning(f"🎙️ 語音登入品質警告(未阻擋): {quality_warnings}")
2022
 
2023
  # 取得辨識結果
2024
  speaker_label = result.get("label")
 
2352
  }
2353
  ]
2354
  try:
2355
+ analysis = await ai_service.generate_response_for_user(
 
2356
  messages=messages,
2357
+ user_id="image_analysis",
2358
+ chat_id=None,
2359
+ max_tokens=1500,
2360
+ reasoning_effort="medium",
2361
  )
 
2362
  return analysis
2363
  except Exception as e:
2364
  logger.error(f"GPT Vision分析錯誤: {str(e)}")
 
2607
  text: str
2608
  voice: Optional[str] = "nova"
2609
  speed: Optional[float] = 1.0
2610
+ language: Optional[str] = None
2611
+ persona: Optional[str] = "xiaohua"
2612
+ mode: Optional[str] = "standard"
2613
+ speaking_rate: Optional[float] = None
2614
+ markup: Optional[str] = None
2615
+ custom_pronunciations: Optional[list[dict]] = None
2616
 
2617
 
2618
  @app.post("/api/tts")
 
2642
  content={"success": False, "error": "文字長度必須在 1-4096 字元之間"}
2643
  )
2644
 
2645
+ valid_voices = [
2646
+ "alloy", "echo", "fable", "onyx", "nova", "shimmer", "coral",
2647
+ "zh-tw", "zh-cn", "en-us", "ja-jp", "ko-kr", "id-id", "vi-vn"
2648
+ ]
2649
  if request.voice not in valid_voices:
2650
  return JSONResponse(
2651
  status_code=400,
 
2658
  content={"success": False, "error": "語速必須在 0.25 到 4.0 之間"}
2659
  )
2660
 
2661
+ logger.info(
2662
+ f"🔊 TTS 請求: text={request.text[:50]}..., voice={request.voice}, speed={request.speed}, "
2663
+ f"language={request.language}, persona={request.persona}, speaking_rate={request.speaking_rate}, "
2664
+ f"has_markup={bool(request.markup)}, custom_pronunciations={len(request.custom_pronunciations or [])}"
2665
+ )
2666
 
2667
  # 調用 TTS 服務獲取完整音頻
2668
+ result = await text_to_speech(
2669
+ request.text,
2670
+ request.voice,
2671
+ request.speed,
2672
+ language=request.language,
2673
+ persona=request.persona,
2674
+ speaking_rate=request.speaking_rate,
2675
+ )
2676
 
2677
  if not result.get("success"):
2678
  return JSONResponse(
 
2700
  )
2701
 
2702
 
2703
+ @app.websocket("/ws/tts")
2704
+ async def tts_stream_websocket(websocket: WebSocket):
2705
+ await websocket.accept()
2706
+ stream_started_at = time.perf_counter()
2707
+ total_chunks = 0
2708
+ total_bytes = 0
2709
+ first_chunk_at = None
2710
+ try:
2711
+ payload = await websocket.receive_json()
2712
+ text = str(payload.get("text") or "").strip()
2713
+ voice = str(payload.get("voice") or "nova")
2714
+ speed = float(payload.get("speed") or 1.0)
2715
+ language = payload.get("language")
2716
+ persona = payload.get("persona") or "xiaohua"
2717
+ speaking_rate = payload.get("speaking_rate")
2718
+ markup = payload.get("markup")
2719
+ custom_pronunciations = payload.get("custom_pronunciations")
2720
+ emotion = payload.get("emotion")
2721
+ care_mode = bool(payload.get("care_mode", False))
2722
+
2723
+ if not text and not markup:
2724
+ await websocket.send_json({"type": "tts_error", "error": "文字不可為空"})
2725
+ await websocket.close()
2726
+ return
2727
+
2728
+ from services.tts_service import tts_service
2729
+
2730
+ await websocket.send_json({
2731
+ "type": "tts_stream_start",
2732
+ "sample_rate": 24000,
2733
+ "encoding": "LINEAR16",
2734
+ "persona": persona,
2735
+ "language": language,
2736
+ })
2737
+
2738
+ async for chunk in tts_service.streaming_synthesize(
2739
+ text=text,
2740
+ voice=voice,
2741
+ speed=speed,
2742
+ language=language,
2743
+ persona=persona,
2744
+ speaking_rate=speaking_rate,
2745
+ markup=markup,
2746
+ custom_pronunciations=custom_pronunciations,
2747
+ emotion=emotion,
2748
+ care_mode=care_mode,
2749
+ ):
2750
+ total_chunks += 1
2751
+ total_bytes += len(chunk)
2752
+ if first_chunk_at is None:
2753
+ first_chunk_at = time.perf_counter()
2754
+ await websocket.send_json({
2755
+ "type": "tts_audio_chunk",
2756
+ "audio_base64": base64.b64encode(chunk).decode("ascii"),
2757
+ })
2758
+
2759
+ logger.debug(
2760
+ "📡 TTS 串流傳送完成: chunks=%d bytes=%d first_chunk_delay=%s total_elapsed=%.2fs",
2761
+ total_chunks,
2762
+ total_bytes,
2763
+ f"{(first_chunk_at - stream_started_at):.2f}s" if first_chunk_at is not None else "none",
2764
+ time.perf_counter() - stream_started_at,
2765
+ )
2766
+ await websocket.send_json({"type": "tts_stream_end"})
2767
+ except WebSocketDisconnect:
2768
+ logger.debug(
2769
+ "🔌 TTS 串流連線已由客戶端關閉: chunks=%d bytes=%d first_chunk_delay=%s total_elapsed=%.2fs",
2770
+ total_chunks,
2771
+ total_bytes,
2772
+ f"{(first_chunk_at - stream_started_at):.2f}s" if first_chunk_at is not None else "none",
2773
+ time.perf_counter() - stream_started_at,
2774
+ )
2775
+ except Exception as e:
2776
+ error_detail = f"{type(e).__name__}: {str(e) or repr(e)}"
2777
+ logger.error(f"❌ TTS 串流失敗: {error_detail}")
2778
+ logger.exception("TTS 串流詳細錯誤堆疊:")
2779
+ try:
2780
+ await websocket.send_json({"type": "tts_error", "error": str(e)})
2781
+ except Exception:
2782
+ pass
2783
+ finally:
2784
+ try:
2785
+ await websocket.close()
2786
+ except Exception:
2787
+ pass
2788
+
2789
+
2790
 
2791
  # -----------------------------
2792
  # MCP Tools API
bloom-ware-login/components/login-form.tsx CHANGED
@@ -238,26 +238,66 @@ export function LoginForm() {
238
  setVoiceStatus('請求麥克風權限...');
239
  console.log('🎤 開始語音登入...');
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  try {
242
  // 請求麥克風權限
243
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
 
 
 
 
 
 
 
 
244
  console.log('✅ 麥克風權限已獲取');
245
 
246
  // 設定錄音參數
247
- const audioContext = new AudioContext({ sampleRate: 16000 });
248
- const source = audioContext.createMediaStreamSource(stream);
249
- const processor = audioContext.createScriptProcessor(4096, 1, 1);
 
 
 
 
 
 
250
 
251
- const audioChunks: Float32Array[] = [];
252
  const recordDuration = 4000; // 4 秒(確保足夠長度)
253
 
254
- processor.onaudioprocess = (e) => {
255
- const inputData = e.inputBuffer.getChannelData(0);
256
- audioChunks.push(new Float32Array(inputData));
257
  };
258
 
259
  source.connect(processor);
260
- processor.connect(audioContext.destination);
261
 
262
  setVoiceStatus('🎙️ 錄音中... 請說話 (4秒)');
263
  console.log('🎙️ 開始錄音 4 秒...');
@@ -266,30 +306,20 @@ export function LoginForm() {
266
  await new Promise(resolve => setTimeout(resolve, recordDuration));
267
 
268
  // 停止錄音
269
- processor.disconnect();
270
- source.disconnect();
271
- stream.getTracks().forEach(track => track.stop());
272
- await audioContext.close();
273
 
274
  setVoiceStatus('辨識中...');
275
  console.log('✅ 錄音完成,處理音訊...');
276
 
277
  // 合併音訊資料
278
  const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.length, 0);
279
- const audioData = new Float32Array(totalLength);
280
  let offset = 0;
281
  for (const chunk of audioChunks) {
282
- audioData.set(chunk, offset);
283
  offset += chunk.length;
284
  }
285
-
286
- // 轉換為 PCM16
287
- const pcm16 = new Int16Array(audioData.length);
288
- for (let i = 0; i < audioData.length; i++) {
289
- const s = Math.max(-1, Math.min(1, audioData[i]));
290
- pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
291
- }
292
-
293
  // 轉換為 base64
294
  const uint8Array = new Uint8Array(pcm16.buffer);
295
  let binary = '';
@@ -335,6 +365,7 @@ export function LoginForm() {
335
  }
336
  setIsLoading(false);
337
  setLoadingType(null);
 
338
  }
339
  }
340
 
 
238
  setVoiceStatus('請求麥克風權限...');
239
  console.log('🎤 開始語音登入...');
240
 
241
+ let stream: MediaStream | null = null;
242
+ let audioContext: AudioContext | null = null;
243
+ let source: MediaStreamAudioSourceNode | null = null;
244
+ let processor: AudioWorkletNode | null = null;
245
+
246
+ const cleanupAudio = async () => {
247
+ if (processor) {
248
+ processor.port.onmessage = null;
249
+ processor.disconnect();
250
+ processor = null;
251
+ }
252
+
253
+ if (source) {
254
+ source.disconnect();
255
+ source = null;
256
+ }
257
+
258
+ if (stream) {
259
+ stream.getTracks().forEach(track => track.stop());
260
+ stream = null;
261
+ }
262
+
263
+ if (audioContext) {
264
+ await audioContext.close().catch(() => undefined);
265
+ audioContext = null;
266
+ }
267
+ };
268
+
269
  try {
270
  // 請求麥克風權限
271
+ stream = await navigator.mediaDevices.getUserMedia({
272
+ audio: {
273
+ channelCount: 1,
274
+ sampleRate: 16000,
275
+ echoCancellation: false,
276
+ noiseSuppression: false,
277
+ autoGainControl: false,
278
+ },
279
+ });
280
  console.log('✅ 麥克風權限已獲取');
281
 
282
  // 設定錄音參數
283
+ audioContext = new AudioContext({ sampleRate: 16000 });
284
+ await audioContext.audioWorklet.addModule('/audio/pcm-recorder-worklet.js');
285
+ await audioContext.resume();
286
+ source = audioContext.createMediaStreamSource(stream);
287
+ processor = new AudioWorkletNode(audioContext, 'pcm-recorder-processor', {
288
+ numberOfInputs: 1,
289
+ numberOfOutputs: 0,
290
+ channelCount: 1,
291
+ });
292
 
293
+ const audioChunks: Int16Array[] = [];
294
  const recordDuration = 4000; // 4 秒(確保足夠長度)
295
 
296
+ processor.port.onmessage = (event) => {
297
+ audioChunks.push(new Int16Array(event.data));
 
298
  };
299
 
300
  source.connect(processor);
 
301
 
302
  setVoiceStatus('🎙️ 錄音中... 請說話 (4秒)');
303
  console.log('🎙️ 開始錄音 4 秒...');
 
306
  await new Promise(resolve => setTimeout(resolve, recordDuration));
307
 
308
  // 停止錄音
309
+ await cleanupAudio();
 
 
 
310
 
311
  setVoiceStatus('辨識中...');
312
  console.log('✅ 錄音完成,處理音訊...');
313
 
314
  // 合併音訊資料
315
  const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.length, 0);
316
+ const pcm16 = new Int16Array(totalLength);
317
  let offset = 0;
318
  for (const chunk of audioChunks) {
319
+ pcm16.set(chunk, offset);
320
  offset += chunk.length;
321
  }
322
+
 
 
 
 
 
 
 
323
  // 轉換為 base64
324
  const uint8Array = new Uint8Array(pcm16.buffer);
325
  let binary = '';
 
365
  }
366
  setIsLoading(false);
367
  setLoadingType(null);
368
+ await cleanupAudio();
369
  }
370
  }
371
 
bloom-ware-login/public/audio/pcm-recorder-worklet.js ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class PCMRecorderProcessor extends AudioWorkletProcessor {
2
+ process(inputs) {
3
+ const channelData = inputs[0]?.[0];
4
+ if (channelData && channelData.length) {
5
+ const pcm16 = new Int16Array(channelData.length);
6
+ for (let i = 0; i < channelData.length; i++) {
7
+ const sample = Math.max(-1, Math.min(1, channelData[i]));
8
+ pcm16[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
9
+ }
10
+ this.port.postMessage(pcm16.buffer, [pcm16.buffer]);
11
+ }
12
+
13
+ return true;
14
+ }
15
+ }
16
+
17
+ registerProcessor('pcm-recorder-processor', PCMRecorderProcessor);
core/ai_client.py CHANGED
@@ -16,6 +16,17 @@ _openai_client = None
16
  _initialized = False
17
 
18
 
 
 
 
 
 
 
 
 
 
 
 
19
  def get_openai_client():
20
  """
21
  取得 OpenAI 客戶端(單例模式)
@@ -37,11 +48,16 @@ def get_openai_client():
37
  _initialized = True
38
  return None
39
 
40
- _openai_client = OpenAI(
41
- api_key=api_key,
42
- timeout=float(settings.OPENAI_TIMEOUT),
43
- max_retries=3,
44
- )
 
 
 
 
 
45
 
46
  _initialized = True
47
  logger.info("✅ OpenAI 客戶端初始化成功")
 
16
  _initialized = False
17
 
18
 
19
+ def _normalize_openai_base_url(base_url: Optional[str]) -> Optional[str]:
20
+ """Normalize custom OpenAI-compatible base URLs for the Python SDK."""
21
+ if not base_url:
22
+ return None
23
+
24
+ normalized = base_url.rstrip("/")
25
+ if normalized.endswith("/v1"):
26
+ return normalized
27
+ return f"{normalized}/v1"
28
+
29
+
30
  def get_openai_client():
31
  """
32
  取得 OpenAI 客戶端(單例模式)
 
48
  _initialized = True
49
  return None
50
 
51
+ client_kwargs = {
52
+ "api_key": api_key,
53
+ "timeout": float(settings.OPENAI_TIMEOUT),
54
+ "max_retries": 3,
55
+ }
56
+ normalized_base_url = _normalize_openai_base_url(settings.OPENAI_BASE_URL)
57
+ if normalized_base_url:
58
+ client_kwargs["base_url"] = normalized_base_url
59
+
60
+ _openai_client = OpenAI(**client_kwargs)
61
 
62
  _initialized = True
63
  logger.info("✅ OpenAI 客戶端初始化成功")
core/config.py CHANGED
@@ -28,6 +28,11 @@ class Settings:
28
  _firebase_creds_base64: Optional[str] = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON_BASE64")
29
  _firebase_service_account_path: Optional[str] = os.getenv("FIREBASE_SERVICE_ACCOUNT_PATH")
30
 
 
 
 
 
 
31
  @classmethod
32
  def get_firebase_credentials(cls) -> Dict[str, Any]:
33
  """
@@ -80,22 +85,111 @@ class Settings:
80
  "3. FIREBASE_SERVICE_ACCOUNT_PATH(檔案路徑)"
81
  )
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  # ===== OpenAI 配置 =====
84
  OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "")
85
- OPENAI_MODEL: str = os.getenv("OPENAI_MODEL", "gpt-5-nano")
 
86
  OPENAI_TIMEOUT: int = int(os.getenv("OPENAI_TIMEOUT", "30"))
87
-
88
- # ===== Google OAuth 配置 =====
 
 
 
 
 
 
 
 
89
  GOOGLE_CLIENT_ID: str = os.getenv("GOOGLE_CLIENT_ID", "")
90
  GOOGLE_CLIENT_SECRET: str = os.getenv("GOOGLE_CLIENT_SECRET", "")
91
  GOOGLE_REDIRECT_URI: str = os.getenv(
92
  "GOOGLE_REDIRECT_URI",
93
  "http://localhost:8080/auth/google/callback" # 開發環境預設值
94
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
 
96
  # ===== 第三方 API Keys =====
97
  WEATHER_API_KEY: str = os.getenv("WEATHER_API_KEY", "")
98
- NEWSDATA_API_KEY: str = os.getenv("NEWSDATA_API_KEY", "")
99
  EXCHANGE_API_KEY: str = os.getenv("EXCHANGE_API_KEY", "")
100
 
101
  # ===== JWT 認證配置 =====
@@ -108,7 +202,7 @@ class Settings:
108
 
109
  # ===== GPT 意圖檢測配置 =====
110
  USE_GPT_INTENT: bool = os.getenv("USE_GPT_INTENT", "true").lower() == "true"
111
- GPT_INTENT_MODEL: str = os.getenv("GPT_INTENT_MODEL", "gpt-5-nano")
112
 
113
  # ===== 背景任務開關 =====
114
  ENABLE_BACKGROUND_JOBS: bool = os.getenv("ENABLE_BACKGROUND_JOBS", "true").lower() == "true"
@@ -190,8 +284,8 @@ class Settings:
190
  logger.error("請檢查 FIREBASE_CREDENTIALS_JSON 或 FIREBASE_SERVICE_ACCOUNT_PATH")
191
  return False
192
 
193
- # 驗證 OpenAI API Key 格式(基本檢查
194
- if not cls.OPENAI_API_KEY.startswith("sk-"):
195
  import logging
196
  logger = logging.getLogger("core.config")
197
  logger.warning("⚠️ OpenAI API Key 格式可能不正確(應以 'sk-' 開頭)")
@@ -236,7 +330,10 @@ class Settings:
236
  firebase_source = "未設定 ❌"
237
  logger.info(f"Firebase 憑證來源: {firebase_source}")
238
  logger.info(f"OpenAI 模型: {cls.OPENAI_MODEL}")
 
 
239
  logger.info(f"OpenAI Timeout: {cls.OPENAI_TIMEOUT}s")
 
240
  logger.info(f"Google OAuth 回調 URI: {cls.GOOGLE_REDIRECT_URI}")
241
  logger.info(f"JWT Token 有效期: {cls.ACCESS_TOKEN_EXPIRE_MINUTES} 分鐘")
242
  logger.info(f"伺服器監聽: {cls.HOST}:{cls.PORT}")
 
28
  _firebase_creds_base64: Optional[str] = os.getenv("FIREBASE_SERVICE_ACCOUNT_JSON_BASE64")
29
  _firebase_service_account_path: Optional[str] = os.getenv("FIREBASE_SERVICE_ACCOUNT_PATH")
30
 
31
+ # Google Cloud Speech / TTS 專用服務帳戶(可與 Firebase 不同 GCP 專案)
32
+ _google_speech_creds_json: Optional[str] = os.getenv("GOOGLE_SPEECH_CREDENTIALS_JSON")
33
+ _google_speech_creds_base64: Optional[str] = os.getenv("GOOGLE_SPEECH_SERVICE_ACCOUNT_JSON_BASE64")
34
+ _google_speech_sa_path: Optional[str] = os.getenv("GOOGLE_SPEECH_SERVICE_ACCOUNT_PATH")
35
+
36
  @classmethod
37
  def get_firebase_credentials(cls) -> Dict[str, Any]:
38
  """
 
85
  "3. FIREBASE_SERVICE_ACCOUNT_PATH(檔案路徑)"
86
  )
87
 
88
+ @classmethod
89
+ def try_get_google_speech_credentials(cls) -> Optional[Dict[str, Any]]:
90
+ """
91
+ 載入 STT/TTS 專用 Google 服務帳戶 JSON(與 Firebase 分離)。
92
+
93
+ 若三種來源皆未設定,回傳 None;若已設定但格式錯誤則拋出 ValueError。
94
+ """
95
+ if cls._google_speech_creds_json:
96
+ try:
97
+ return json.loads(cls._google_speech_creds_json)
98
+ except json.JSONDecodeError as e:
99
+ raise ValueError(f"GOOGLE_SPEECH_CREDENTIALS_JSON 格式錯誤: {e}") from e
100
+ if cls._google_speech_creds_base64:
101
+ try:
102
+ decoded_bytes = base64.b64decode(cls._google_speech_creds_base64)
103
+ return json.loads(decoded_bytes.decode("utf-8"))
104
+ except Exception as e:
105
+ raise ValueError(f"GOOGLE_SPEECH_SERVICE_ACCOUNT_JSON_BASE64 解碼失敗: {e}") from e
106
+ if cls._google_speech_sa_path:
107
+ try:
108
+ with open(cls._google_speech_sa_path, "r", encoding="utf-8") as f:
109
+ return json.load(f)
110
+ except FileNotFoundError:
111
+ raise ValueError(f"GOOGLE_SPEECH_SERVICE_ACCOUNT_PATH 檔案不存在: {cls._google_speech_sa_path}") from None
112
+ except json.JSONDecodeError as e:
113
+ raise ValueError(f"GOOGLE_SPEECH_SERVICE_ACCOUNT_PATH JSON 格式錯誤: {e}") from e
114
+ return None
115
+
116
+ @classmethod
117
+ def resolve_speech_service_account_info(cls) -> tuple[Optional[Dict[str, Any]], str]:
118
+ """
119
+ 解析語音 API 使用的服務帳戶:優先 GOOGLE_SPEECH_*,否則退回 Firebase 憑證(相容舊部署)。
120
+
121
+ Returns:
122
+ (credentials_dict | None, "speech" | "firebase" | "none")
123
+ """
124
+ speech = cls.try_get_google_speech_credentials()
125
+ if speech is not None:
126
+ return speech, "speech"
127
+ try:
128
+ return cls.get_firebase_credentials(), "firebase"
129
+ except ValueError:
130
+ return None, "none"
131
+
132
+ @classmethod
133
+ def get_google_speech_project_id(cls, credential_project_id: Optional[str] = None) -> str:
134
+ """
135
+ Speech-to-Text recognizer 所屬 GCP 專案 ID。
136
+
137
+ 優先順序:GOOGLE_SPEECH_PROJECT_ID → GOOGLE_CLOUD_PROJECT_ID(若為純數字「專案編號」
138
+ 且憑證 JSON 內有字串型 project_id,則改用憑證內 ID,避免誤用編號)→
139
+ 憑證 JSON 內 project_id → FIREBASE_PROJECT_ID
140
+ """
141
+ if cls.GOOGLE_SPEECH_PROJECT_ID.strip():
142
+ return cls.GOOGLE_SPEECH_PROJECT_ID.strip()
143
+ cloud = cls.GOOGLE_CLOUD_PROJECT_ID.strip()
144
+ cred = (credential_project_id or "").strip()
145
+ if cloud.isdigit() and cred and not cred.isdigit():
146
+ return cred
147
+ if cloud:
148
+ return cloud
149
+ if cred:
150
+ return cred
151
+ return cls.FIREBASE_PROJECT_ID.strip()
152
+
153
  # ===== OpenAI 配置 =====
154
  OPENAI_API_KEY: str = os.getenv("OPENAI_API_KEY", "")
155
+ OPENAI_BASE_URL: str = os.getenv("OPENAI_BASE_URL", "")
156
+ OPENAI_MODEL: str = os.getenv("OPENAI_MODEL", "gpt-5.4")
157
  OPENAI_TIMEOUT: int = int(os.getenv("OPENAI_TIMEOUT", "30"))
158
+ OPENAI_RESPONSES_TIMEOUT: int = int(os.getenv("OPENAI_RESPONSES_TIMEOUT", "90"))
159
+ OPENAI_USE_RESPONSES: bool = os.getenv("OPENAI_USE_RESPONSES", "true").lower() == "true"
160
+ OPENAI_MODEL_CONTEXT_WINDOW: int = int(os.getenv("OPENAI_MODEL_CONTEXT_WINDOW", "1000000"))
161
+ OPENAI_MODEL_AUTO_COMPACT_TOKEN_LIMIT: int = int(os.getenv("OPENAI_MODEL_AUTO_COMPACT_TOKEN_LIMIT", "900000"))
162
+ OPENAI_ENABLE_WEB_SEARCH: bool = os.getenv("OPENAI_ENABLE_WEB_SEARCH", "true").lower() == "true"
163
+ OPENAI_ENABLE_REMOTE_MCP: bool = os.getenv("OPENAI_ENABLE_REMOTE_MCP", "false").lower() == "true"
164
+ OPENAI_REMOTE_MCP_SERVERS_JSON: str = os.getenv("OPENAI_REMOTE_MCP_SERVERS_JSON", "[]")
165
+ OPENAI_ENABLE_SKILLS: bool = os.getenv("OPENAI_ENABLE_SKILLS", "false").lower() == "true"
166
+
167
+ # ===== Google OAuth(使用者「登入 Bloom Ware」用,非語音 API)=====
168
  GOOGLE_CLIENT_ID: str = os.getenv("GOOGLE_CLIENT_ID", "")
169
  GOOGLE_CLIENT_SECRET: str = os.getenv("GOOGLE_CLIENT_SECRET", "")
170
  GOOGLE_REDIRECT_URI: str = os.getenv(
171
  "GOOGLE_REDIRECT_URI",
172
  "http://localhost:8080/auth/google/callback" # 開發環境預設值
173
  )
174
+ # ----- Google Cloud「語音」專案(STT/TTS,例:supervisor-project;常與 Firebase 不同)-----
175
+ # GOOGLE_CLOUD_PROJECT_ID:語音相關 REST/專案語境之預設專案 ID(請填「專案 ID」字串,勿只填控制台「專案編號」)
176
+ GOOGLE_CLOUD_PROJECT_ID: str = os.getenv("GOOGLE_CLOUD_PROJECT_ID", os.getenv("FIREBASE_PROJECT_ID", ""))
177
+ # GOOGLE_SPEECH_PROJECT_ID:明確指定 STT recognizer 所屬專案;與 Firebase 分離時必須搭配 GOOGLE_SPEECH_* 服務帳戶
178
+ GOOGLE_SPEECH_PROJECT_ID: str = os.getenv("GOOGLE_SPEECH_PROJECT_ID", "")
179
+ # STT gRPC 臨時除錯用;正式環境請用服務帳戶
180
+ GOOGLE_STT_ACCESS_TOKEN: str = os.getenv("GOOGLE_STT_ACCESS_TOKEN", "")
181
+ # TTS 與部分 REST 用 API Key(屬於語音 GCP;與 STT 串流 gRPC OAuth 分開)
182
+ GOOGLE_SPEECH_API_KEY: str = os.getenv("GOOGLE_SPEECH_API_KEY", os.getenv("GOOGLE_API_KEY", ""))
183
+ GOOGLE_TTS_API_KEY: str = os.getenv("GOOGLE_TTS_API_KEY", os.getenv("GOOGLE_API_KEY", ""))
184
+ GOOGLE_STT_LOCATION: str = os.getenv("GOOGLE_STT_LOCATION", "global")
185
+ GOOGLE_STT_RECOGNIZER_ID: str = os.getenv("GOOGLE_STT_RECOGNIZER_ID", "_")
186
+ GOOGLE_STT_AUTO_LANGUAGE_CODES: str = os.getenv("GOOGLE_STT_AUTO_LANGUAGE_CODES", "cmn-Hant-TW,en-US,ja-JP")
187
+ GOOGLE_TTS_LANGUAGE_CODE: str = os.getenv("GOOGLE_TTS_LANGUAGE_CODE", "cmn-TW")
188
+ GOOGLE_TTS_DEFAULT_VOICE: str = os.getenv("GOOGLE_TTS_DEFAULT_VOICE", "cmn-TW-Wavenet-A")
189
 
190
  # ===== 第三方 API Keys =====
191
  WEATHER_API_KEY: str = os.getenv("WEATHER_API_KEY", "")
192
+ TAVILY_API_KEY: str = os.getenv("TAVILY_API_KEY", "")
193
  EXCHANGE_API_KEY: str = os.getenv("EXCHANGE_API_KEY", "")
194
 
195
  # ===== JWT 認證配置 =====
 
202
 
203
  # ===== GPT 意圖檢測配置 =====
204
  USE_GPT_INTENT: bool = os.getenv("USE_GPT_INTENT", "true").lower() == "true"
205
+ GPT_INTENT_MODEL: str = os.getenv("GPT_INTENT_MODEL", "gpt-5.4")
206
 
207
  # ===== 背景任務開關 =====
208
  ENABLE_BACKGROUND_JOBS: bool = os.getenv("ENABLE_BACKGROUND_JOBS", "true").lower() == "true"
 
284
  logger.error("請檢查 FIREBASE_CREDENTIALS_JSON 或 FIREBASE_SERVICE_ACCOUNT_PATH")
285
  return False
286
 
287
+ # 驗證 OpenAI API Key 格式(OpenAI-compatible relay keys may not use sk-*
288
+ if not cls.OPENAI_BASE_URL and not cls.OPENAI_API_KEY.startswith("sk-"):
289
  import logging
290
  logger = logging.getLogger("core.config")
291
  logger.warning("⚠️ OpenAI API Key 格式可能不正確(應以 'sk-' 開頭)")
 
330
  firebase_source = "未設定 ❌"
331
  logger.info(f"Firebase 憑證來源: {firebase_source}")
332
  logger.info(f"OpenAI 模型: {cls.OPENAI_MODEL}")
333
+ logger.info(f"OpenAI Base URL: {cls.OPENAI_BASE_URL or 'default'}")
334
+ logger.info(f"OpenAI Responses API: {'enabled' if cls.OPENAI_USE_RESPONSES else 'disabled'}")
335
  logger.info(f"OpenAI Timeout: {cls.OPENAI_TIMEOUT}s")
336
+ logger.info(f"OpenAI Responses Timeout: {cls.OPENAI_RESPONSES_TIMEOUT}s")
337
  logger.info(f"Google OAuth 回調 URI: {cls.GOOGLE_REDIRECT_URI}")
338
  logger.info(f"JWT Token 有效期: {cls.ACCESS_TOKEN_EXPIRE_MINUTES} 分鐘")
339
  logger.info(f"伺服器監聽: {cls.HOST}:{cls.PORT}")
core/environment/__init__.py CHANGED
@@ -1,3 +1,9 @@
 
1
  from .context_service import EnvironmentContextService, EnvironmentSnapshot
2
 
3
- __all__ = ["EnvironmentContextService", "EnvironmentSnapshot"]
 
 
 
 
 
 
1
+ from .context_builder import EnvironmentContextBuilder, EnvironmentInjection
2
  from .context_service import EnvironmentContextService, EnvironmentSnapshot
3
 
4
+ __all__ = [
5
+ "EnvironmentContextBuilder",
6
+ "EnvironmentContextService",
7
+ "EnvironmentInjection",
8
+ "EnvironmentSnapshot",
9
+ ]
core/environment/context_builder.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime, timezone
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class EnvironmentInjection:
10
+ summary_text: str
11
+ raw_context: Dict[str, Any]
12
+ metadata: Dict[str, Any]
13
+
14
+
15
+ class EnvironmentContextBuilder:
16
+ """
17
+ 將最新環境快照轉成固定可注入格式。
18
+
19
+ 設計目標:
20
+ 1. 每回合固定注入,但內容結構固定,避免 prompt 漂移
21
+ 2. 同時保留 raw context,供工具與 runtime 做更細緻決策
22
+ 3. 缺資料時明確標記 freshness / completeness,而不是靜默忽略
23
+ """
24
+
25
+ def build(
26
+ self,
27
+ env_context: Optional[Dict[str, Any]],
28
+ *,
29
+ now: Optional[datetime] = None,
30
+ ) -> EnvironmentInjection:
31
+ ctx = dict(env_context or {})
32
+ current_time = now or datetime.now(timezone.utc)
33
+
34
+ lines = [
35
+ f"snapshot_time_utc: {current_time.isoformat()}",
36
+ f"has_location: {self._has_location(ctx)}",
37
+ f"has_address: {bool(ctx.get('detailed_address') or ctx.get('address_display') or ctx.get('label'))}",
38
+ f"has_timezone: {bool(ctx.get('tz'))}",
39
+ f"has_locale: {bool(ctx.get('locale'))}",
40
+ ]
41
+
42
+ if ctx.get("detailed_address"):
43
+ lines.append(f"detailed_address: {ctx['detailed_address']}")
44
+ elif ctx.get("address_display"):
45
+ lines.append(f"address_display: {ctx['address_display']}")
46
+ elif ctx.get("label"):
47
+ lines.append(f"label: {ctx['label']}")
48
+
49
+ if ctx.get("city"):
50
+ lines.append(f"city: {ctx['city']}")
51
+ if ctx.get("admin"):
52
+ lines.append(f"admin: {ctx['admin']}")
53
+ if ctx.get("country_code"):
54
+ lines.append(f"country_code: {ctx['country_code']}")
55
+ if ctx.get("precision"):
56
+ lines.append(f"precision: {ctx['precision']}")
57
+ if ctx.get("poi_label"):
58
+ lines.append(f"poi_label: {ctx['poi_label']}")
59
+ if ctx.get("road"):
60
+ lines.append(f"road: {ctx['road']}")
61
+ if ctx.get("house_number"):
62
+ lines.append(f"house_number: {ctx['house_number']}")
63
+ if ctx.get("tz"):
64
+ lines.append(f"timezone: {ctx['tz']}")
65
+ if ctx.get("locale"):
66
+ lines.append(f"locale: {ctx['locale']}")
67
+ if ctx.get("heading_cardinal"):
68
+ lines.append(f"heading: {ctx['heading_cardinal']}")
69
+ elif ctx.get("heading_deg") is not None:
70
+ lines.append(f"heading_deg: {ctx['heading_deg']}")
71
+ if ctx.get("accuracy_m") is not None:
72
+ lines.append(f"accuracy_m: {ctx['accuracy_m']}")
73
+ if ctx.get("lat") is not None and ctx.get("lon") is not None:
74
+ lines.append(f"coordinates: {ctx['lat']},{ctx['lon']}")
75
+
76
+ metadata = {
77
+ "source": "environment_context_service",
78
+ "freshness": "latest_available" if ctx else "missing",
79
+ "has_location": self._has_location(ctx),
80
+ "has_timezone": bool(ctx.get("tz")),
81
+ "has_locale": bool(ctx.get("locale")),
82
+ }
83
+
84
+ return EnvironmentInjection(
85
+ summary_text="\n".join(lines),
86
+ raw_context=ctx,
87
+ metadata=metadata,
88
+ )
89
+
90
+ @staticmethod
91
+ def _has_location(ctx: Dict[str, Any]) -> bool:
92
+ return ctx.get("lat") is not None and ctx.get("lon") is not None
core/intent_detector.py CHANGED
@@ -17,9 +17,13 @@ from typing import Dict, Any, Optional, Tuple, List
17
 
18
  from core.tool_registry import tool_registry
19
  from core.logging import get_logger
 
 
20
 
21
  logger = get_logger("core.intent_detector")
22
 
 
 
23
 
24
  class IntentDetector:
25
  """
@@ -37,6 +41,14 @@ class IntentDetector:
37
 
38
  def __init__(self):
39
  self._cache: Dict[str, Tuple[bool, Optional[Dict[str, Any]], float]] = {}
 
 
 
 
 
 
 
 
40
 
41
  async def detect(
42
  self,
@@ -143,7 +155,7 @@ class IntentDetector:
143
  messages=messages,
144
  tools=tools,
145
  user_id="intent_detection",
146
- model="gpt-5-nano",
147
  reasoning_effort=optimal_effort,
148
  )
149
 
@@ -159,12 +171,14 @@ class IntentDetector:
159
 
160
  注意:不再描述每個工具,工具定義由 tools 參數傳遞
161
  """
162
- return """你是一個多語言智能助手,根據用戶需求選擇合適的工具。支援中文、英文、日文、印尼文、越南文。
 
 
163
 
164
  【核心規則】
165
  1. 用戶詢問任何可用工具能解決的需求時,必須選擇對應工具
166
  2. 只有純粹的閒聊、問候、情感表達才不選擇工具
167
- 3. 工具參數盡量從用戶消息中提取無法確定的使用合理預設值
168
 
169
  【多語言意圖識別】
170
  無論用戶使用什麼語言,都要識別以下意圖並選擇對應工具:
@@ -251,6 +265,7 @@ class IntentDetector:
251
  "tool_name": tool_name,
252
  "arguments": arguments,
253
  "emotion": emotion,
 
254
  }
255
 
256
  # 沒有工具調用,視為一般聊天
 
17
 
18
  from core.tool_registry import tool_registry
19
  from core.logging import get_logger
20
+ from core.config import settings
21
+ from core.prompts.tool_calling_policy import get_tool_calling_policy
22
 
23
  logger = get_logger("core.intent_detector")
24
 
25
+ MIN_TOOL_CONFIDENCE = 0.90
26
+
27
 
28
  class IntentDetector:
29
  """
 
41
 
42
  def __init__(self):
43
  self._cache: Dict[str, Tuple[bool, Optional[Dict[str, Any]], float]] = {}
44
+
45
+ @staticmethod
46
+ def _estimate_tool_confidence(tool_name: str, arguments: Dict[str, Any]) -> float:
47
+ if not tool_name:
48
+ return 0.0
49
+ if not isinstance(arguments, dict):
50
+ return 0.0
51
+ return 0.95 if arguments else 0.92
52
 
53
  async def detect(
54
  self,
 
155
  messages=messages,
156
  tools=tools,
157
  user_id="intent_detection",
158
+ model=settings.GPT_INTENT_MODEL,
159
  reasoning_effort=optimal_effort,
160
  )
161
 
 
171
 
172
  注意:不再描述每個工具,工具定義由 tools 參數傳遞
173
  """
174
+ return f"""你是一個多語言智能助手,根據用戶需求選擇合適的工具。支援中文、英文、日文、印尼文、越南文。
175
+
176
+ {get_tool_calling_policy()}
177
 
178
  【核心規則】
179
  1. 用戶詢問任何可用工具能解決的需求時,必須選擇對應工具
180
  2. 只有純粹的閒聊、問候、情感表達才不選擇工具
181
+ 3. 工具參數從用戶消息中提取無法確定的可選參數留空,不自行編造預設值
182
 
183
  【多語言意圖識別】
184
  無論用戶使用什麼語言,都要識別以下意圖並選擇對應工具:
 
265
  "tool_name": tool_name,
266
  "arguments": arguments,
267
  "emotion": emotion,
268
+ "confidence": self._estimate_tool_confidence(tool_name, arguments),
269
  }
270
 
271
  # 沒有工具調用,視為一般聊天
core/logging.py CHANGED
@@ -1,105 +1,121 @@
1
- """
2
- 統一日誌配置
3
- 集中管理所有模組的日誌設定
4
-
5
- 使用方式:
6
- from core.logging import get_logger
7
- logger = get_logger(__name__)
8
- """
9
-
10
  import os
11
  import logging
12
  from typing import Optional
13
 
14
- # 全域日誌等級(只讀取一次)
15
- _LOG_LEVEL_NAME = os.getenv("BLOOMWARE_LOG_LEVEL", "WARNING").upper()
16
- _LOG_LEVEL = getattr(logging, _LOG_LEVEL_NAME, logging.WARNING)
17
-
18
- # 日誌格式
19
- _LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
20
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  def get_log_level() -> int:
23
- """獲取日誌等級"""
24
  return _LOG_LEVEL
25
 
26
-
27
  def setup_logging(
28
  name: Optional[str] = None,
29
  level: Optional[int] = None,
30
  ) -> logging.Logger:
31
- """
32
- 設置日誌配置
33
-
34
- Args:
35
- name: 日誌名稱(None 表示 root logger)
36
- level: 日誌等級(None 表示使用環境變數)
37
-
38
- Returns:
39
- 配置好的 Logger 實例
40
- """
41
  if level is None:
42
  level = _LOG_LEVEL
43
 
44
- # 配置格式
45
- formatter = logging.Formatter(_LOG_FORMAT)
46
-
47
- # 獲取或創建 logger
48
  logger = logging.getLogger(name)
49
  logger.setLevel(level)
50
 
51
- # 避免重複添加 handler
52
  if not logger.handlers:
53
- # 控制台 handler
54
  console_handler = logging.StreamHandler()
55
  console_handler.setLevel(level)
56
- console_handler.setFormatter(formatter)
57
  logger.addHandler(console_handler)
58
 
59
- # 防止日誌重複輸出
60
  logger.propagate = False
61
-
62
  return logger
63
 
64
-
65
  def get_logger(name: str) -> logging.Logger:
66
- """
67
- 獲取已配置的 Logger(推薦使用)
68
-
69
- Args:
70
- name: 日誌名稱,建議使用 __name__
71
-
72
- Returns:
73
- Logger 實例
74
-
75
- Example:
76
- from core.logging import get_logger
77
- logger = get_logger(__name__)
78
- logger.info("Hello")
79
- """
80
  return setup_logging(name)
81
 
82
-
83
  def get_level_name() -> str:
84
- """獲取當前日誌等級名稱"""
85
  return _LOG_LEVEL_NAME
86
 
87
-
88
  # 預設配置 root logger
89
  _root_configured = False
90
 
91
  def configure_root_logger():
92
- """配置 root logger(只執行一次)"""
93
  global _root_configured
94
  if not _root_configured:
95
- level = get_log_level()
96
- logging.basicConfig(
97
- level=level,
98
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
99
- handlers=[logging.StreamHandler()]
100
- )
 
 
 
 
 
101
  _root_configured = True
102
 
103
-
104
  # 自動配置
105
  configure_root_logger()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import logging
3
  from typing import Optional
4
 
5
+ # ANSI 轉義序列
6
+ class LogColor:
7
+ DEBUG = "\x1b[38;20m" # 灰色
8
+ INFO = "\x1b[32;20m" # 綠色
9
+ WARNING = "\x1b[33;20m" # 黃色
10
+ ERROR = "\x1b[31;20m" # 紅色
11
+ CRITICAL = "\x1b[31;1m" # 粗體紅
12
+ RESET = "\x1b[0m"
13
+ GRAY = "\x1b[90m" # 淺灰色
14
+ CYAN = "\x1b[36;20m" # 青色
15
+ BLUE = "\x1b[34;20m" # 藍色
16
+ MAGENTA = "\x1b[35;20m" # 品紅
17
+
18
+ class ColoredFormatter(logging.Formatter):
19
+ """自定義彩色日誌格式化器"""
20
+
21
+ LEVEL_COLORS = {
22
+ logging.DEBUG: LogColor.GRAY,
23
+ logging.INFO: LogColor.INFO,
24
+ logging.WARNING: LogColor.WARNING,
25
+ logging.ERROR: LogColor.ERROR,
26
+ logging.CRITICAL: LogColor.CRITICAL
27
+ }
28
+
29
+ def format(self, record):
30
+ level_color = self.LEVEL_COLORS.get(record.levelno, LogColor.RESET)
31
+
32
+ # 格式化時間(淡灰色)
33
+ asctime = self.formatTime(record, self.datefmt)
34
+ asctime_colored = f"{LogColor.GRAY}{asctime}{LogColor.RESET}"
35
+
36
+ # 格式化名稱(青色)
37
+ name_colored = f"{LogColor.CYAN}{record.name}{LogColor.RESET}"
38
+
39
+ # 格式化等級(根據等級變色)
40
+ levelname_colored = f"{level_color}{record.levelname:8}{LogColor.RESET}"
41
+
42
+ # 格式化訊息
43
+ message = record.getMessage()
44
+
45
+ # 自動截斷過長的訊息(如工具調用的完整原始數據)
46
+ if len(message) > 500:
47
+ message = message[:500] + f"{LogColor.GRAY}... [已截斷,共 {len(message)} 字元]{LogColor.RESET}"
48
+
49
+ if "✅" in message:
50
+ message = f"{LogColor.INFO}{message}{LogColor.RESET}"
51
+ elif "❌" in message or "⚠️" in message:
52
+ message = f"{LogColor.ERROR}{message}{LogColor.RESET}"
53
+ elif "🎙️" in message or "🔊" in message:
54
+ message = f"{LogColor.BLUE}{message}{LogColor.RESET}"
55
+ elif "🌐" in message or "MCP" in message:
56
+ message = f"{LogColor.MAGENTA}{message}{LogColor.RESET}"
57
+ else:
58
+ message = f"{level_color}{message}{LogColor.RESET}"
59
+
60
+ return f"{asctime_colored} | {name_colored} | {levelname_colored} | {message}"
61
+
62
+ # 全域日誌等級
63
+ _LOG_LEVEL_NAME = os.getenv("BLOOMWARE_LOG_LEVEL", "INFO").upper()
64
+ _LOG_LEVEL = getattr(logging, _LOG_LEVEL_NAME, logging.INFO)
65
 
66
  def get_log_level() -> int:
 
67
  return _LOG_LEVEL
68
 
 
69
  def setup_logging(
70
  name: Optional[str] = None,
71
  level: Optional[int] = None,
72
  ) -> logging.Logger:
 
 
 
 
 
 
 
 
 
 
73
  if level is None:
74
  level = _LOG_LEVEL
75
 
 
 
 
 
76
  logger = logging.getLogger(name)
77
  logger.setLevel(level)
78
 
 
79
  if not logger.handlers:
 
80
  console_handler = logging.StreamHandler()
81
  console_handler.setLevel(level)
82
+ console_handler.setFormatter(ColoredFormatter())
83
  logger.addHandler(console_handler)
84
 
 
85
  logger.propagate = False
 
86
  return logger
87
 
 
88
  def get_logger(name: str) -> logging.Logger:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  return setup_logging(name)
90
 
 
91
  def get_level_name() -> str:
 
92
  return _LOG_LEVEL_NAME
93
 
 
94
  # 預設配置 root logger
95
  _root_configured = False
96
 
97
  def configure_root_logger():
 
98
  global _root_configured
99
  if not _root_configured:
100
+ root = logging.getLogger()
101
+ root.setLevel(get_log_level())
102
+
103
+ # 清除現有的 handlers
104
+ for handler in root.handlers[:]:
105
+ root.removeHandler(handler)
106
+
107
+ console_handler = logging.StreamHandler()
108
+ console_handler.setFormatter(ColoredFormatter())
109
+ root.addHandler(console_handler)
110
+
111
  _root_configured = True
112
 
 
113
  # 自動配置
114
  configure_root_logger()
115
+
116
+ # 關閉 Speechbrain 等吵雜的日誌
117
+ logging.getLogger("speechbrain").setLevel(logging.WARNING)
118
+ logging.getLogger("werkzeug").setLevel(logging.WARNING)
119
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
120
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
121
+ logging.getLogger("httpx").setLevel(logging.WARNING)
core/memory_system.py CHANGED
@@ -1,6 +1,8 @@
1
  import json
2
  from typing import List, Dict, Any, Optional, Tuple
3
  from datetime import datetime
 
 
4
 
5
  # 統一日誌配置
6
  from core.logging import get_logger
@@ -8,12 +10,122 @@ logger = get_logger("MemorySystem")
8
 
9
  # 統一 OpenAI 客戶端
10
  from core.ai_client import get_openai_client
 
 
11
 
12
  def _get_memory_client():
13
  """取得記憶系統用的 OpenAI 客戶端"""
14
  return get_openai_client()
15
 
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  # 導入數據庫函數
19
  try:
@@ -122,7 +234,44 @@ class MemoryAnalyzer:
122
  """記憶分析器:使用AI分析對話內容"""
123
 
124
  def __init__(self):
125
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
  async def analyze_conversation(self, user_message: str, assistant_response: str = "",
128
  conversation_history: List[Dict] = None) -> List[Dict[str, Any]]:
@@ -174,39 +323,42 @@ class MemoryAnalyzer:
174
  {"role": "user", "content": user_prompt}
175
  ]
176
 
177
- # 嘗試調用OpenAI API,最多重試2次
178
- max_retries = 2
179
- for attempt in range(max_retries + 1):
180
  try:
181
- if attempt > 0:
182
- # 如果是重試,增加token限制
183
- max_tokens_value = 2000 + (attempt * 1000)
184
- logger.info(f"重試AI分析 (嘗試 {attempt + 1}/{max_retries + 1}),增加token限制到 {max_tokens_value}")
185
- else:
186
- max_tokens_value = 2000
187
 
188
- response = client.chat.completions.create(
189
- model="gpt-5-nano",
190
- messages=messages,
191
- max_completion_tokens=max_tokens_value,
192
- reasoning_effort="low"
193
  )
194
  break # 成功後跳出重試循環
195
 
196
  except Exception as api_error:
197
  error_str = str(api_error).lower()
198
- if "max_tokens" in error_str or "token limit" in error_str:
199
- if attempt < max_retries:
200
- logger.warning(f"AI分析遇到token限制錯誤,正在重試 ({attempt + 1}/{max_retries + 1}): {api_error}")
 
 
 
 
 
 
201
  continue
202
- else:
203
- logger.error(f"AI分析在 {max_retries + 1} 次嘗試後仍然遇到token限制錯誤: {api_error}")
204
- return [] # 返回空列表,回退到關鍵字提取
 
 
205
  else:
206
  # 其他類型的錯誤,直接拋出
207
  raise api_error
208
 
209
- result_text = response.choices[0].message.content.strip()
 
 
 
210
 
211
  # 解析JSON結果 - 嘗試多種解析方式
212
  try:
@@ -241,6 +393,9 @@ class MemoryAnalyzer:
241
  return memories
242
 
243
  except Exception as e:
 
 
 
244
  logger.error(f"AI記憶分析時發生錯誤: {e}")
245
  return []
246
 
@@ -268,10 +423,12 @@ class MemoryManager:
268
 
269
  # 2. 使用AI分析提取記憶(如果可用)
270
  ai_memories = []
271
- if _get_memory_client():
272
  ai_memories = await self.analyzer.analyze_conversation(
273
  user_message, assistant_response, conversation_history
274
  )
 
 
275
 
276
  # 3. 合併記憶(去重)
277
  all_memories = self._merge_memories(keyword_memories, ai_memories)
 
1
  import json
2
  from typing import List, Dict, Any, Optional, Tuple
3
  from datetime import datetime
4
+ import asyncio
5
+ import random
6
 
7
  # 統一日誌配置
8
  from core.logging import get_logger
 
10
 
11
  # 統一 OpenAI 客戶端
12
  from core.ai_client import get_openai_client
13
+ from core.config import settings
14
+ from core.responses_runtime import ResponsesAgentRuntime
15
 
16
  def _get_memory_client():
17
  """取得記憶系統用的 OpenAI 客戶端"""
18
  return get_openai_client()
19
 
20
 
21
+ TRANSIENT_MEMORY_ERROR_MARKERS = (
22
+ "502",
23
+ "503",
24
+ "504",
25
+ "bad gateway",
26
+ "upstream",
27
+ "timeout",
28
+ "timed out",
29
+ "connection",
30
+ )
31
+
32
+ TRANSIENT_QUERY_MARKERS = (
33
+ "今天",
34
+ "現在",
35
+ "目前",
36
+ "最新",
37
+ "即時",
38
+ "收盤",
39
+ "開盤",
40
+ "股價",
41
+ "股票",
42
+ "匯率",
43
+ "天氣",
44
+ "新聞",
45
+ "多少",
46
+ "查詢",
47
+ "search",
48
+ "latest",
49
+ "today",
50
+ "now",
51
+ "price",
52
+ "stock",
53
+ "weather",
54
+ "news",
55
+ )
56
+
57
+ MEMORY_ANALYSIS_SCHEMA = {
58
+ "type": "object",
59
+ "properties": {
60
+ "memories": {
61
+ "type": "array",
62
+ "items": {
63
+ "type": "object",
64
+ "properties": {
65
+ "type": {
66
+ "type": "string",
67
+ "enum": ["personal_info", "preferences", "goals"],
68
+ },
69
+ "content": {"type": "string"},
70
+ "importance": {
71
+ "type": "number",
72
+ "minimum": 0,
73
+ "maximum": 1,
74
+ },
75
+ },
76
+ "required": ["type", "content", "importance"],
77
+ "additionalProperties": False,
78
+ },
79
+ }
80
+ },
81
+ "required": ["memories"],
82
+ "additionalProperties": False,
83
+ }
84
+
85
+
86
+ def _is_transient_memory_error(exc: Exception) -> bool:
87
+ if isinstance(exc, TimeoutError) or isinstance(exc, asyncio.TimeoutError):
88
+ return True
89
+ error_text = str(exc).lower()
90
+ return any(marker in error_text for marker in TRANSIENT_MEMORY_ERROR_MARKERS)
91
+
92
+
93
+ def _should_run_ai_memory_analysis(user_message: str, assistant_response: str = "") -> bool:
94
+ text = f"{user_message}\n{assistant_response}".strip().lower()
95
+ if not text:
96
+ return False
97
+
98
+ durable_markers = (
99
+ "我叫",
100
+ "我的名字",
101
+ "我喜歡",
102
+ "我不喜歡",
103
+ "我討厭",
104
+ "我的偏好",
105
+ "我住在",
106
+ "我的工作",
107
+ "我的目標",
108
+ "我想達成",
109
+ "我希望",
110
+ "請記得",
111
+ "記住",
112
+ "下次",
113
+ "my name",
114
+ "i like",
115
+ "i dislike",
116
+ "remember",
117
+ "my goal",
118
+ )
119
+ if any(marker in text for marker in durable_markers):
120
+ return True
121
+
122
+ user_text = (user_message or "").lower()
123
+ if any(marker in user_text for marker in TRANSIENT_QUERY_MARKERS):
124
+ return False
125
+
126
+ return len(user_message.strip()) >= 80
127
+
128
+
129
 
130
  # 導入數據庫函數
131
  try:
 
234
  """記憶分析器:使用AI分析對話內容"""
235
 
236
  def __init__(self):
237
+ self.responses_runtime = ResponsesAgentRuntime()
238
+
239
+ @staticmethod
240
+ def _memory_model() -> str:
241
+ return settings.OPENAI_MODEL or settings.GPT_INTENT_MODEL
242
+
243
+ @staticmethod
244
+ def _memory_timeout() -> float:
245
+ return min(float(getattr(settings, "OPENAI_TIMEOUT", 30)), 20.0)
246
+
247
+ @staticmethod
248
+ async def _transient_backoff(attempt: int) -> None:
249
+ delay = min(0.5 * (2 ** attempt), 3.0) + random.uniform(0, 0.2)
250
+ await asyncio.sleep(delay)
251
+
252
+ def _create_analysis_response(self, client: Any, messages: List[Dict[str, str]], max_tokens_value: int) -> Any:
253
+ model = self._memory_model()
254
+ if settings.OPENAI_USE_RESPONSES and model.startswith("gpt-5"):
255
+ payload = self.responses_runtime.build_payload_from_messages(
256
+ messages=messages,
257
+ model=model,
258
+ max_output_tokens=max_tokens_value,
259
+ text_format={
260
+ "type": "json_schema",
261
+ "name": "memory_analysis",
262
+ "strict": True,
263
+ "schema": MEMORY_ANALYSIS_SCHEMA,
264
+ },
265
+ )
266
+ payload["store"] = False
267
+ return client.responses.create(**payload)
268
+
269
+ return client.chat.completions.create(
270
+ model=model,
271
+ messages=messages,
272
+ max_completion_tokens=max_tokens_value,
273
+ response_format={"type": "json_object"},
274
+ )
275
 
276
  async def analyze_conversation(self, user_message: str, assistant_response: str = "",
277
  conversation_history: List[Dict] = None) -> List[Dict[str, Any]]:
 
323
  {"role": "user", "content": user_prompt}
324
  ]
325
 
326
+ max_attempts = 3
327
+ for attempt in range(max_attempts):
 
328
  try:
329
+ max_tokens_value = 500
 
 
 
 
 
330
 
331
+ response = await asyncio.wait_for(
332
+ asyncio.to_thread(self._create_analysis_response, client, messages, max_tokens_value),
333
+ timeout=self._memory_timeout(),
 
 
334
  )
335
  break # 成功後跳出重試循環
336
 
337
  except Exception as api_error:
338
  error_str = str(api_error).lower()
339
+ if _is_transient_memory_error(api_error):
340
+ if attempt < max_attempts - 1:
341
+ logger.warning(
342
+ "AI記憶分析遇到暫時性上游錯誤,準備重試 (%s/%s): %s",
343
+ attempt + 1,
344
+ max_attempts,
345
+ api_error,
346
+ )
347
+ await self._transient_backoff(attempt)
348
  continue
349
+ logger.error("AI記憶分析連續暫時性上游錯誤,已放棄本輪背景分析: %s", api_error)
350
+ return []
351
+ if "max_tokens" in error_str or "token limit" in error_str:
352
+ logger.error(f"AI分析遇到token限制錯誤: {api_error}")
353
+ return [] # 返回空列表,回退到關鍵字提取
354
  else:
355
  # 其他類型的錯誤,直接拋出
356
  raise api_error
357
 
358
+ if hasattr(response, "choices"):
359
+ result_text = response.choices[0].message.content.strip()
360
+ else:
361
+ result_text = self.responses_runtime.extract_output_text(response)
362
 
363
  # 解析JSON結果 - 嘗試多種解析方式
364
  try:
 
393
  return memories
394
 
395
  except Exception as e:
396
+ if _is_transient_memory_error(e):
397
+ logger.info("AI記憶分析遇到暫時性上游錯誤,跳過本輪背景分析: %s", e)
398
+ return []
399
  logger.error(f"AI記憶分析時發生錯誤: {e}")
400
  return []
401
 
 
423
 
424
  # 2. 使用AI分析提取記憶(如果可用)
425
  ai_memories = []
426
+ if _get_memory_client() and _should_run_ai_memory_analysis(user_message, assistant_response):
427
  ai_memories = await self.analyzer.analyze_conversation(
428
  user_message, assistant_response, conversation_history
429
  )
430
+ else:
431
+ logger.debug("跳過AI記憶分析:本輪內容不具備長期記憶價值或客戶端不可用")
432
 
433
  # 3. 合併記憶(去重)
434
  all_memories = self._merge_memories(keyword_memories, ai_memories)
core/pipeline.py CHANGED
@@ -4,9 +4,13 @@ from dataclasses import dataclass
4
  from typing import Any, Awaitable, Callable, Optional, Dict, Tuple, List
5
 
6
  from core.emotion_care_manager import EmotionCareManager
 
 
7
 
8
  logger = logging.getLogger(__name__)
9
 
 
 
10
 
11
  @dataclass
12
  class PipelineResult:
@@ -35,10 +39,10 @@ class ChatPipeline:
35
  intent_detector: Callable[[str], Awaitable[Tuple[bool, dict]]],
36
  feature_processor: Callable[[dict, str, str, Optional[str]], Awaitable[Any]],
37
  ai_generator: Callable[..., Awaitable[str]],
38
- model: str = "gpt-5-nano",
39
- detect_timeout: float = 5.0, # 2025 最佳實踐:Structured Outputs 通常 2-3秒
40
- feature_timeout: float = 10.0, # MCP 工具已有內部超時(30秒)
41
- ai_timeout: float = 12.0, # 配合 Streaming(首次回應 0.5-1秒)
42
  ) -> None:
43
  self._intent_detector = intent_detector
44
  self._feature_processor = feature_processor
@@ -46,7 +50,7 @@ class ChatPipeline:
46
  self._detect_timeout = detect_timeout
47
  self._feature_timeout = feature_timeout
48
  self._ai_timeout = ai_timeout
49
- self._model = model
50
 
51
  def _is_chinese_message(self, text: str) -> bool:
52
  """
@@ -142,11 +146,15 @@ class ChatPipeline:
142
  {"role": "user", "content": combined_text}
143
  ]
144
 
145
- logger.info(f"🌐 呼叫 GPT 翻譯,模型: gpt-4o-mini")
 
 
 
 
146
  translated = await ai_service.generate_response_async(
147
  messages=messages,
148
- model="gpt-4o-mini", # 升級到 gpt-4o-mini 以提升翻譯品質
149
- reasoning_effort=None, # gpt-4o-mini 不支援此參數
150
  max_tokens=800,
151
  )
152
  logger.info(f"🌐 GPT 翻譯完成,結果長度: {len(translated) if translated else 0}")
@@ -221,207 +229,182 @@ class ChatPipeline:
221
 
222
  # language 參數保留以向後兼容,但不使用(GPT 自動判斷語言)
223
 
224
- # 0) 先進行意圖偵測以提取情緒(需要在關懷模式檢查前執行)
225
- detect_res = await self._with_timeout(
226
- self._intent_detector(user_message), self._detect_timeout, reason="detect"
227
- )
228
- if isinstance(detect_res, PipelineResult):
229
- return detect_res
230
- has_feature, intent_data = detect_res
231
-
232
- # 【情緒融合】雙軌制:音頻情緒優先,文字情緒輔助
233
- text_emotion = intent_data.get("emotion", "neutral") if intent_data else "neutral"
234
- logger.info(f"🎭 [情緒流向-1] 文字情緒: {text_emotion}")
235
-
236
- # 檢查音頻情緒
237
- if audio_emotion:
238
- logger.info(f"🎭 [情緒流向-2] 音頻情緒資料: success={audio_emotion.get('success')}, emotion={audio_emotion.get('emotion')}, confidence={audio_emotion.get('confidence')}")
239
-
240
- # 情緒融合邏輯
241
- emotion_confidence = 0.5 # 預設置信度
242
- if audio_emotion and audio_emotion.get("success"):
243
- audio_emotion_label = audio_emotion.get("emotion", "neutral")
244
- audio_confidence = audio_emotion.get("confidence", 0.0)
245
-
246
- # 【優化】提高門檻到 0.7,避免誤判(太敏感會導致錯誤情緒)
247
- if audio_confidence >= 0.7:
248
- emotion_value = audio_emotion_label
249
- emotion_confidence = audio_confidence
250
- logger.info(f"🎭 [情緒流向-3] ✅ 採用音頻情緒: {emotion_value} (置信度: {audio_confidence:.4f})")
251
- else:
252
- emotion_value = text_emotion
253
- emotion_confidence = 0.5 # 文字情緒預設置信度
254
- logger.info(f"🎭 [情緒流向-3] ⬇️ 音頻置信度過低 ({audio_confidence:.4f}),改用文字情緒: {emotion_value}")
255
- else:
256
- emotion_value = text_emotion
257
- emotion_confidence = 0.5 # 文字情緒預設置信度
258
- logger.info(f"🎭 [情緒流向-3] 📝 無音頻情緒,使用文字情緒: {emotion_value}")
259
-
260
- # 【關鍵】記錄最終情緒
261
- logger.info(f"🎭 [情緒流向-最終] emotion={emotion_value}, confidence={emotion_confidence:.2f}")
262
-
263
- # 1) 檢查是否在關懷模式
264
- if user_id and EmotionCareManager.is_in_care_mode(user_id, chat_id):
265
- # 檢查是否解��關懷模式(傳入情緒資訊)
266
- if EmotionCareManager.check_release(user_id, user_message, chat_id, emotion=emotion_value):
267
- logger.info(f"✅ 用戶 {user_id} 情緒恢復,解除關懷模式,繼續正常流程")
268
- # 解除後繼續正常流程
269
- else:
270
- logger.info(f"💙 用戶 {user_id} 在關懷模式中,跳過工具調用,使用關懷 AI")
271
- # 直接用關懷模式 AI 回應(不檢測意圖,不調用工具)
272
- care_emotion = EmotionCareManager.get_care_emotion(user_id, chat_id)
273
- final_emotion = care_emotion or emotion_value
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  if emotion_callback:
275
  try:
276
- await emotion_callback(final_emotion, True)
277
  except Exception as e:
278
  logger.warning(f"emotion_callback 錯誤: {e}")
279
 
280
- ai_res = await self._with_timeout(
281
- self._ai_generator(
282
- user_message,
283
- user_id,
284
- self._model,
285
- request_id,
286
- chat_id,
287
- use_care_mode=True,
288
- care_emotion=care_emotion,
289
- emotion_label=care_emotion,
290
- ),
291
- self._ai_timeout,
292
- reason="ai-care",
293
- )
294
- if isinstance(ai_res, PipelineResult):
295
- return ai_res
296
- text = str(ai_res or "").strip()
297
- if not text:
298
  return PipelineResult(
299
- text="我在這裡陪你,隨時可以聊聊。",
300
  is_fallback=True,
301
- reason="ai-care-empty",
302
- meta={"care_mode": True, "emotion": care_emotion or "sad"}
303
  )
304
- return PipelineResult(text=text, is_fallback=False, meta={"care_mode": True, "emotion": care_emotion})
305
-
306
- # 2) 檢查是否需要進入關懷模式(傳遞置信度,用於連續性判斷)
307
- if user_id and EmotionCareManager.check_and_enter_care_mode(
308
- user_id, emotion_value, chat_id, confidence=emotion_confidence
309
- ):
310
- logger.warning(f"⚠️ 偵測到極端情緒 [{emotion_value}](置信度: {emotion_confidence:.2f}),進入關懷模式")
311
- # 立即使用關懷模式 AI 回應
312
-
313
- if emotion_callback:
314
- try:
315
- await emotion_callback(emotion_value, True)
316
- except Exception as e:
317
- logger.warning(f"emotion_callback 錯誤: {e}")
318
-
319
- ai_res = await self._with_timeout(
320
- self._ai_generator(
321
- user_message,
322
- user_id,
323
- self._model,
324
- request_id,
325
- chat_id,
326
- use_care_mode=True,
327
- care_emotion=emotion_value,
328
- emotion_label=emotion_value,
329
- ),
330
- self._ai_timeout,
331
- reason="ai-care",
332
- )
333
- if isinstance(ai_res, PipelineResult):
334
- return ai_res
335
- text = str(ai_res or "").strip()
336
- if not text:
337
- text = "我聽到了,我在這裡陪你。"
338
-
339
- # 第一次進入關懷模式時,附加退出提示(新增)
340
- exit_hint = "\n\n💙 關懷模式已啟動。說「我沒事了」可以退出。"
341
- return PipelineResult(text=text + exit_hint, is_fallback=False, meta={"care_mode": True, "emotion": emotion_value})
342
-
343
- if emotion_callback:
344
- try:
345
- await emotion_callback(emotion_value, False)
346
- except Exception as e:
347
- logger.warning(f"emotion_callback 錯誤: {e}")
348
-
349
- # 3) 有功能 → 功能處理(限時)
350
- if has_feature and intent_data:
351
- feat_res = await self._with_timeout(
352
- self._feature_processor(intent_data, user_id, user_message, chat_id),
353
- self._feature_timeout,
354
- reason="feature",
355
- )
356
- if isinstance(feat_res, PipelineResult):
357
- return feat_res
358
- # 如果返回 None,表示這是聊天,不應該被當作功能處理
359
- if feat_res is None:
360
- has_feature = False
361
- intent_data = None
362
- else:
363
- # 檢查是否為字典(包含工具信息)
364
- if isinstance(feat_res, dict):
365
- text = feat_res.get('message', feat_res.get('content', '')).strip()
366
- tool_name = feat_res.get('tool_name')
367
- tool_data = feat_res.get('tool_data')
368
- if not text:
369
- return PipelineResult(
370
- text="抱歉,功能處理沒有產出結果。",
371
- is_fallback=True,
372
- reason="feature-empty",
373
- meta={"emotion": emotion_value, "care_mode": False}
374
- )
375
 
376
- # 簡化翻譯:非中文用戶 翻譯工具卡片
377
- is_chinese = self._is_chinese_message(user_message)
378
- logger.info(f"🌐 語言檢測: user_message='{user_message}', is_chinese={is_chinese}")
379
- if not is_chinese and tool_data:
380
- logger.info(f"🌐 開始翻譯工具卡片: {len(str(tool_data))} chars")
381
- tool_data = await self._translate_tool_data(tool_data, user_message)
382
- logger.info(f"🌐 翻譯完成: {len(str(tool_data))} chars")
383
- elif is_chinese:
384
- logger.info(f"🌐 用戶使用中文,不翻譯工具卡片")
385
- elif not tool_data:
386
- logger.info(f"🌐 無工具資料,跳過翻譯")
387
-
388
- # 返回帶有工具元數據的結果(包含情緒)
389
- meta_dict = {
390
- 'emotion': emotion_value,
391
- 'care_mode': False # 工具調用不是關懷模式
392
- }
393
- if tool_name:
394
- meta_dict['tool_name'] = tool_name
395
- if tool_data:
396
- meta_dict['tool_data'] = tool_data
397
-
398
- return PipelineResult(
399
- text=text,
400
- is_fallback=False,
401
- meta=meta_dict
402
- )
403
  else:
404
- # 正常字串
405
  text = str(feat_res or "").strip()
406
- if not text:
407
- return PipelineResult(
408
- text="抱歉,功處理沒有產出結。",
409
- is_fallback=True,
410
- reason="feature-empty",
411
- meta={"emotion": emotion_value, "care_mode": False}
412
- )
413
-
414
- # 不再翻譯工具回應,讓 GPT 自己處理並用���應語言描述
415
-
416
- return PipelineResult(
417
- text=text,
418
- is_fallback=False,
419
- meta={"emotion": emotion_value, "care_mode": False},
420
- )
421
 
422
- # 4) 無功能 一般聊天限時
423
- # 注意:不傳 messages,改傳 user_message,讓 ai_generator 自動載入歷史對話和記憶
424
- ai_res = await self._with_timeout(
425
  self._ai_generator(
426
  user_message,
427
  user_id or "default",
@@ -430,25 +413,48 @@ class ChatPipeline:
430
  chat_id,
431
  emotion_label=emotion_value,
432
  language=language,
 
433
  ),
434
  self._ai_timeout,
435
- reason="ai",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
436
  )
437
- if isinstance(ai_res, PipelineResult):
438
- return ai_res
439
- text = str(ai_res or "").strip()
440
- if not text:
441
- return PipelineResult(
442
- text="抱歉,我暫時沒有合適的回應。可以換個說法再試試嗎?",
443
- is_fallback=True,
444
- reason="ai-empty",
445
- meta={"emotion": emotion_value, "care_mode": False}
446
- )
447
-
448
- # 一般聊天也包含情緒資訊
449
- meta_dict = {
450
- 'emotion': emotion_value,
451
- 'care_mode': False # 一般聊天不是關懷模式
452
- }
453
 
454
- return PipelineResult(text=text, is_fallback=False, meta=meta_dict)
 
 
 
4
  from typing import Any, Awaitable, Callable, Optional, Dict, Tuple, List
5
 
6
  from core.emotion_care_manager import EmotionCareManager
7
+ from core.config import settings
8
+ from core.voice_care_gate import decide_voice_care, is_voice_context
9
 
10
  logger = logging.getLogger(__name__)
11
 
12
+ MIN_TOOL_CONFIDENCE = 0.90
13
+
14
 
15
  @dataclass
16
  class PipelineResult:
 
39
  intent_detector: Callable[[str], Awaitable[Tuple[bool, dict]]],
40
  feature_processor: Callable[[dict, str, str, Optional[str]], Awaitable[Any]],
41
  ai_generator: Callable[..., Awaitable[str]],
42
+ model: Optional[str] = None,
43
+ detect_timeout: float = 20.0, # 考量到 Function Calling 可能較慢
44
+ feature_timeout: float = 30.0, # MCP 工具內部超時
45
+ ai_timeout: float = 25.0, # 配合 Streaming
46
  ) -> None:
47
  self._intent_detector = intent_detector
48
  self._feature_processor = feature_processor
 
50
  self._detect_timeout = detect_timeout
51
  self._feature_timeout = feature_timeout
52
  self._ai_timeout = ai_timeout
53
+ self._model = model or settings.OPENAI_MODEL
54
 
55
  def _is_chinese_message(self, text: str) -> bool:
56
  """
 
146
  {"role": "user", "content": combined_text}
147
  ]
148
 
149
+ logger.info(f"🌐 呼叫 GPT 翻譯")
150
+ # 格式化回應使用環境變數設定的模型
151
+ model = settings.GPT_INTENT_MODEL or settings.OPENAI_MODEL
152
+ logger.info(f"🎨 使用配置模型進行格式化: {model}")
153
+
154
  translated = await ai_service.generate_response_async(
155
  messages=messages,
156
+ model=model,
157
+ reasoning_effort=None,
158
  max_tokens=800,
159
  )
160
  logger.info(f"🌐 GPT 翻譯完成,結果長度: {len(translated) if translated else 0}")
 
229
 
230
  # language 參數保留以向後兼容,但不使用(GPT 自動判斷語言)
231
 
232
+ has_feature = False
233
+ intent_data = None
234
+ tool_context = ""
235
+ tool_results_list = []
236
+ emotion_value = "neutral"
237
+ care_emotion = None
238
+ use_care_mode = False
239
+ max_loops = 3
240
+ current_loop = 0
241
+ ai_res_text = ""
242
+
243
+ while current_loop < max_loops:
244
+ # 0) 先進行意圖偵測與可回答性評估 (Confidence-driven check)
245
+ detect_res = await self._with_timeout(
246
+ self._intent_detector(user_message, tool_context, language=language), self._detect_timeout, reason="detect"
247
+ )
248
+ if isinstance(detect_res, PipelineResult):
249
+ return detect_res
250
+ has_feature, intent_data = detect_res
251
+
252
+ if current_loop == 0:
253
+ # 只在第一輪提取情緒與進行關懷模式判斷
254
+ if intent_data and "emotion" in intent_data:
255
+ emotion_value = intent_data["emotion"]
256
+ else:
257
+ emotion_value = "neutral"
258
+
259
+ voice_context = is_voice_context(audio_emotion)
260
+ voice_care_decision = None
261
+ if voice_context:
262
+ try:
263
+ voice_care_decision = decide_voice_care(text_emotion=emotion_value, audio_emotion=audio_emotion)
264
+ if voice_care_decision.emotion:
265
+ emotion_value = voice_care_decision.emotion
266
+ except Exception as e:
267
+ logger.warning(f"Voice care decision failed: {e}")
268
+
269
+ emotion_confidence = float(audio_emotion.get("confidence", 0.0)) if isinstance(audio_emotion, dict) else 0.0
270
+
271
+ if EmotionCareManager.is_in_care_mode(user_id):
272
+ exit_match = False
273
+ if "沒事了" in user_message or "謝謝" in user_message or "好多了" in user_message:
274
+ if emotion_value not in ["sad", "angry", "fear"]:
275
+ exit_match = True
276
+ if voice_context and voice_care_decision and not voice_care_decision.allow:
277
+ exit_match = True
278
+
279
+ if exit_match:
280
+ logger.info(f"💙 使用者情緒平穩 [{emotion_value}],退出關懷模式")
281
+ EmotionCareManager.exit_care_mode(user_id)
282
+ use_care_mode = False
283
+ if emotion_callback:
284
+ try:
285
+ await emotion_callback(emotion_value, False)
286
+ except Exception as e:
287
+ logger.warning(f"emotion_callback 錯誤: {e}")
288
+ else:
289
+ logger.info(f"💙 維持關懷模式,情緒=[{emotion_value}]")
290
+ use_care_mode = True
291
+ care_emotion = EmotionCareManager._active_care_users.get(user_id, {}).get("emotion") or emotion_value
292
+ if emotion_callback:
293
+ try:
294
+ await emotion_callback(emotion_value, True)
295
+ except Exception as e:
296
+ logger.warning(f"emotion_callback 錯誤: {e}")
297
+
298
+ ai_res = await self._with_timeout(
299
+ self._ai_generator(
300
+ user_message,
301
+ user_id,
302
+ self._model,
303
+ request_id,
304
+ chat_id,
305
+ use_care_mode=use_care_mode,
306
+ care_emotion=care_emotion,
307
+ emotion_label=emotion_value,
308
+ is_first_care=False,
309
+ ),
310
+ self._ai_timeout,
311
+ reason="ai-care",
312
+ )
313
+ if isinstance(ai_res, PipelineResult):
314
+ return ai_res
315
+ text = str(ai_res or "").strip()
316
+ if not text:
317
+ text = "我在這裡陪你,隨時可以聊聊。"
318
+ return PipelineResult(text=text, is_fallback=False, meta={"care_mode": True, "emotion": care_emotion})
319
+
320
+ # 檢查是否需要進入關懷模式
321
+ can_enter_care = True
322
+ if voice_context and voice_care_decision is not None:
323
+ can_enter_care = voice_care_decision.allow
324
+
325
+ if can_enter_care and user_id and EmotionCareManager.check_and_enter_care_mode(
326
+ user_id, emotion_value, chat_id, confidence=emotion_confidence
327
+ ):
328
+ logger.warning(f"⚠️ 偵測到極端情緒 [{emotion_value}](置信度: {emotion_confidence:.2f}),進入關懷模式")
329
+
330
+ if emotion_callback:
331
+ try:
332
+ await emotion_callback(emotion_value, True)
333
+ except Exception as e:
334
+ logger.warning(f"emotion_callback 錯誤: {e}")
335
+
336
+ ai_res = await self._with_timeout(
337
+ self._ai_generator(
338
+ user_message,
339
+ user_id,
340
+ self._model,
341
+ request_id,
342
+ chat_id,
343
+ use_care_mode=True,
344
+ care_emotion=emotion_value,
345
+ emotion_label=emotion_value,
346
+ is_first_care=True, # 告知 Agent 這是第一次進入,需引導退出
347
+ ),
348
+ self._ai_timeout,
349
+ reason="ai-care",
350
+ )
351
+ if isinstance(ai_res, PipelineResult):
352
+ return ai_res
353
+ text = str(ai_res or "").strip()
354
+ if not text:
355
+ text = "我聽到了,我在這裡陪你。"
356
+
357
+ return PipelineResult(text=text, is_fallback=False, meta={"care_mode": True, "emotion": emotion_value})
358
+
359
  if emotion_callback:
360
  try:
361
+ await emotion_callback(emotion_value, False)
362
  except Exception as e:
363
  logger.warning(f"emotion_callback 錯誤: {e}")
364
 
365
+ if has_feature and intent_data and intent_data.get("type") == "mcp_tool":
366
+ confidence = float(intent_data.get("confidence", 0.0) or 0.0)
367
+ if confidence < MIN_TOOL_CONFIDENCE:
368
+ logger.info("🔒 工具信心度不足 %.2f,禁止調用工具", confidence)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  return PipelineResult(
370
+ text=self._build_low_confidence_tool_message(user_message, confidence),
371
  is_fallback=True,
372
+ reason="low_confidence",
373
+ meta={"confidence": confidence}
374
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
 
376
+ if has_feature and intent_data:
377
+ feat_res = await self._with_timeout(
378
+ self._feature_processor(intent_data, user_id, user_message, chat_id),
379
+ self._feature_timeout,
380
+ reason="feature",
381
+ )
382
+ if isinstance(feat_res, PipelineResult):
383
+ tool_context += f"\n[工具執行結果]:\n{feat_res.text}\n"
384
+ tool_results_list.append({"text": feat_res.text, "meta": feat_res.meta})
385
+ elif isinstance(feat_res, dict):
386
+ t_name = feat_res.get('tool_name', 'unknown')
387
+ t_msg = feat_res.get('message', '')
388
+ t_data = feat_res.get('tool_data', {})
389
+ tool_context += f"\n[工具 {t_name} 執行結果]:\n{t_msg}\n(Data: {str(t_data)[:2000]})\n"
390
+ tool_results_list.append(feat_res)
 
 
 
 
 
 
 
 
 
 
 
 
391
  else:
 
392
  text = str(feat_res or "").strip()
393
+ tool_context += f"\n[工具執行結果]:\n{text}\n"
394
+ tool_results_list.append({"text": text})
395
+ # 【效優化】短路機制:如工具調用信心度為 100%,且是簡單工具,則不進入下一輪驗證
396
+ if confidence >= 1.0:
397
+ logger.info("⚡ 工具執行信心度高且結果明確,跳過冗餘驗證")
398
+ break
399
+
400
+ current_loop += 1
401
+ continue
402
+ else:
403
+ # 如果沒有調用工具,表示 Agent 對目前答案已有 100% 信心,退出循環
404
+ break
 
 
 
405
 
406
+ # 4) 最後 AI 生成回應結合了所有 tool_context
407
+ ai_res_text = await self._with_timeout(
 
408
  self._ai_generator(
409
  user_message,
410
  user_id or "default",
 
413
  chat_id,
414
  emotion_label=emotion_value,
415
  language=language,
416
+ tool_context=tool_context,
417
  ),
418
  self._ai_timeout,
419
+ reason="ai_gen",
420
+ )
421
+ if isinstance(ai_res_text, PipelineResult):
422
+ return ai_res_text
423
+
424
+ meta = {"emotion": emotion_value, "care_mode": use_care_mode}
425
+ if tool_results_list:
426
+ executed_tools = []
427
+ for t in tool_results_list:
428
+ if isinstance(t, dict) and t.get("tool_name"):
429
+ executed_tools.append({
430
+ "tool_name": t.get("tool_name"),
431
+ "tool_data": t.get("tool_data")
432
+ })
433
+ elif hasattr(t, 'meta') and t.meta and t.meta.get("tool_name"):
434
+ executed_tools.append({
435
+ "tool_name": t.meta.get("tool_name"),
436
+ "tool_data": t.meta.get("tool_data")
437
+ })
438
+ if executed_tools:
439
+ meta["executed_tools"] = executed_tools
440
+ # 兼容原有邏輯,將最後一個工具設為主卡片
441
+ last_tool = executed_tools[-1]
442
+ meta["tool_name"] = last_tool["tool_name"]
443
+ meta["tool_data"] = last_tool["tool_data"]
444
+ else:
445
+ last_tool = tool_results_list[-1]
446
+ if isinstance(last_tool, dict):
447
+ meta["tool_name"] = last_tool.get("tool_name")
448
+ meta["tool_data"] = last_tool.get("tool_data")
449
+ elif hasattr(last_tool, 'meta') and last_tool.meta:
450
+ meta.update(last_tool.meta)
451
+
452
+ return PipelineResult(
453
+ text=str(ai_res_text or "").strip(),
454
+ is_fallback=False,
455
+ meta=meta
456
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
 
458
+ def _build_low_confidence_tool_message(self, user_message: str, confidence: float) -> str:
459
+ """建立低信心度工具調用的提示訊息"""
460
+ return "抱歉,我不太確定您的意思。您能說得更具體一點嗎?"
core/prompts/care_mode.py CHANGED
@@ -1,8 +1,16 @@
1
  """
2
  情緒關懷模式 Prompt
3
- 精簡化設計,保持關懷品質
 
 
4
  """
5
 
 
 
 
 
 
 
6
  CARE_MODE_PROMPT = """你是 BloomWare 的情緒關懷助手「小花」。你的任務是傾聽、陪伴。
7
 
8
  【回應原則】
@@ -25,7 +33,9 @@ CARE_MODE_PROMPT = """你是 BloomWare 的情緒關懷助手「小花」。你
25
 
26
  def get_care_prompt(emotion: str = None, user_name: str = None) -> str:
27
  """
28
- 生成關懷模式 Prompt
 
 
29
 
30
  Args:
31
  emotion: 用戶情緒標籤
@@ -34,6 +44,12 @@ def get_care_prompt(emotion: str = None, user_name: str = None) -> str:
34
  Returns:
35
  關懷模式 System Prompt
36
  """
 
 
 
 
 
 
37
  prompt = CARE_MODE_PROMPT
38
 
39
  if emotion:
 
1
  """
2
  情緒關懷模式 Prompt
3
+ ⚠️ DEPRECATED: 此模組的內容已遷移至 services/ai_service.py 中的
4
+ CARE_MODE_BASE_PROMPT / EMOTION_SPECIFIC_PROMPTS / get_care_mode_prompt()。
5
+ 本模組僅保留向後兼容的 re-export,供既有測試使用。
6
  """
7
 
8
+ import warnings as _warnings
9
+
10
+ # ── 向後兼容 re-export ──────────────────────────────────────────
11
+ # 生產環境使用 services.ai_service 中的活躍版本。
12
+ # 此處提供簡化版僅為保持 test_prompts.py 不斷鏈。
13
+
14
  CARE_MODE_PROMPT = """你是 BloomWare 的情緒關懷助手「小花」。你的任務是傾聽、陪伴。
15
 
16
  【回應原則】
 
33
 
34
  def get_care_prompt(emotion: str = None, user_name: str = None) -> str:
35
  """
36
+ 生成關懷模式 Prompt(向後兼容)
37
+
38
+ ⚠️ DEPRECATED: 生產環境請使用 services.ai_service.get_care_mode_prompt()
39
 
40
  Args:
41
  emotion: 用戶情緒標籤
 
44
  Returns:
45
  關懷模式 System Prompt
46
  """
47
+ _warnings.warn(
48
+ "core.prompts.care_mode.get_care_prompt() is deprecated. "
49
+ "Use services.ai_service.get_care_mode_prompt() instead.",
50
+ DeprecationWarning,
51
+ stacklevel=2,
52
+ )
53
  prompt = CARE_MODE_PROMPT
54
 
55
  if emotion:
core/prompts/care_mode_skills.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+ from typing import List
4
+
5
+ CARE_SKILLS_ROOT = Path(__file__).resolve().parents[2] / "features" / "care_mode" / "skills"
6
+
7
+ def load_care_mode_skills() -> str:
8
+ """
9
+ 載入並格式化情緒關懷模式的對話技巧 (Skills)
10
+ """
11
+ if not CARE_SKILLS_ROOT.exists():
12
+ return ""
13
+
14
+ skills_content = []
15
+ skills_content.append("\n【情緒關懷對話技巧 (Care Mode Skills)】")
16
+ skills_content.append("在關懷模式下,請靈活運用以下專業對話技巧來提升共鳴感:")
17
+
18
+ try:
19
+ # 獲取所有 .md 檔案
20
+ skill_files = list(CARE_SKILLS_ROOT.glob("*.md"))
21
+
22
+ for file_path in skill_files:
23
+ content = file_path.read_text(encoding="utf-8")
24
+ # 移除 Frontmatter (--- ... ---)
25
+ if content.startswith("---"):
26
+ parts = content.split("---", 2)
27
+ if len(parts) >= 3:
28
+ content = parts[2].strip()
29
+
30
+ skills_content.append(f"\n--- {file_path.stem} ---\n{content}")
31
+
32
+ return "\n".join(skills_content)
33
+ except Exception as e:
34
+ print(f"載入關懷模式技巧失敗: {e}")
35
+ return ""
36
+
37
+ def get_care_mode_skills_block() -> str:
38
+ """
39
+ 獲取用於 System Prompt 的技巧區塊
40
+ """
41
+ return load_care_mode_skills()
core/prompts/intent_detection.py CHANGED
@@ -1,9 +1,15 @@
1
  """
2
  意圖檢測 Prompt 模板
3
- 精簡化設計,減少 token 消耗約 40%
 
 
4
  """
5
 
6
- # 工具特定規則(按需載入)
 
 
 
 
7
  TOOL_RULES = {
8
  "weather": """天氣查詢:城市必須用英文(台北→Taipei, 高雄→Kaohsiung),預設 Taipei""",
9
 
@@ -32,7 +38,7 @@ TOOL_RULES = {
32
  「怎麼去 X」→ forward_geocode:query=X""",
33
  }
34
 
35
- # 情緒標籤說明
36
  EMOTION_RULES = """情緒判斷:neutral/happy/sad/angry/fear/surprise
37
  - happy: 開心、興奮(「好開心!」「太棒了」)
38
  - sad: 難過、沮喪(「好難過」「心情不好」)
@@ -44,7 +50,10 @@ EMOTION_RULES = """情緒判斷:neutral/happy/sad/angry/fear/surprise
44
 
45
  def get_intent_prompt(tools_description: str, include_rules: list = None) -> str:
46
  """
47
- 生成意圖檢測 Prompt
 
 
 
48
 
49
  Args:
50
  tools_description: 可用工具描述
@@ -53,9 +62,17 @@ def get_intent_prompt(tools_description: str, include_rules: list = None) -> str
53
  Returns:
54
  精簡化的 System Prompt
55
  """
 
 
 
 
 
 
56
  # 基礎 Prompt
57
  base = f"""你是意圖解析助手。分析用戶消息,決定是否調用工具。
58
 
 
 
59
  可用工具:
60
  {tools_description}
61
 
 
1
  """
2
  意圖檢測 Prompt 模板
3
+ ⚠️ DEPRECATED: 此模組的 TOOL_RULES 和 get_intent_prompt() 在生產環境中未被使用。
4
+ 生產意圖偵測由 features/mcp/agent_bridge.py 的 _build_function_calling_prompt() 處理。
5
+ 本模組僅保留向後兼容的定義,供既有測試使用。
6
  """
7
 
8
+ import warnings as _warnings
9
+
10
+ from core.prompts.tool_calling_policy import get_tool_calling_policy
11
+
12
+ # ── 工具特定規則(DEPRECATED — 生產中由 agent_bridge 硬編碼處理) ──
13
  TOOL_RULES = {
14
  "weather": """天氣查詢:城市必須用英文(台北→Taipei, 高雄→Kaohsiung),預設 Taipei""",
15
 
 
38
  「怎麼去 X」→ forward_geocode:query=X""",
39
  }
40
 
41
+ # ── 情緒標籤說明 ──
42
  EMOTION_RULES = """情緒判斷:neutral/happy/sad/angry/fear/surprise
43
  - happy: 開心、興奮(「好開心!」「太棒了」)
44
  - sad: 難過、沮喪(「好難過」「心情不好」)
 
50
 
51
  def get_intent_prompt(tools_description: str, include_rules: list = None) -> str:
52
  """
53
+ 生成意圖檢測 Prompt(向後兼容)
54
+
55
+ ⚠️ DEPRECATED: 生產環境使用 features/mcp/agent_bridge.py 的
56
+ _build_function_calling_prompt() + OpenAI Function Calling。
57
 
58
  Args:
59
  tools_description: 可用工具描述
 
62
  Returns:
63
  精簡化的 System Prompt
64
  """
65
+ _warnings.warn(
66
+ "core.prompts.intent_detection.get_intent_prompt() is deprecated. "
67
+ "Production intent detection uses MCPAgentBridge._build_function_calling_prompt().",
68
+ DeprecationWarning,
69
+ stacklevel=2,
70
+ )
71
  # 基礎 Prompt
72
  base = f"""你是意圖解析助手。分析用戶消息,決定是否調用工具。
73
 
74
+ {get_tool_calling_policy()}
75
+
76
  可用工具:
77
  {tools_description}
78
 
core/prompts/tool_calling_policy.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared policy text for tool-calling agents."""
2
+
3
+
4
+ TOOL_CALLING_POLICY = """【鐵血工具調用政策】
5
+ 1. 反幻覺:只要問題需要最新、精確、外部、檔案、位置、時間、健康、交通、天氣、新聞、匯率或其他工具可驗證資訊,必須優先調用工具;不得憑印象補答案。
6
+ 2. 環境優先:選工具前先把系統注入的時間、位置、使用者設定、狀態視為第一決策依據;若使用者說「附近」「這裡」「我這邊」「現在」,不要編造 city/lat/lon,留空交給系統環境注入。
7
+ 3. 參數紀律:只填使用者明確提供或可無歧義轉換的參數;可選參數不確定時留空,讓工具 schema default 或環境注入處理。
8
+ 4. 工具失敗:不得假裝成功;若工具回錯、缺資料或參數不足,只能修正參數重試、改用可用降級資訊,或明確說明不足。
9
+ 5. 證據約束:工具結果回來後,回答只能依據工具結果、已驗證上下文與系統環境;禁止新增未查證事實。
10
+ 6. 純聊天例外:只有問候、純情緒陪伴、閒聊、或詢問能力說明可以不調工具。
11
+ 7. 信心閘門:只有當工具選擇信心度至少 90% 時才允許工具調用;低於 90% 必須視為沒有可用工具,並請使用者補充地點、時間、路線、幣別、檔名或關鍵字。
12
+ 8. 語言一致:使用者用什麼語言互動,就必須用同一種語言回覆;工具卡片與錯誤說明也應跟隨使用者語言。
13
+ """
14
+
15
+
16
+ def get_tool_calling_policy() -> str:
17
+ """Return the shared non-negotiable tool-calling policy."""
18
+ return TOOL_CALLING_POLICY
core/reasoning_strategy.py CHANGED
@@ -43,10 +43,10 @@ class ReasoningStrategy:
43
  reasoning_effort: minimal/low/medium/high
44
  """
45
 
46
- # 🔥 規則 1:意圖檢測使用 low reasoning(平衡度與準確度
47
  if task_type == "intent_detection":
48
- logger.debug("🧠 意圖檢測 → low reasoning(但準確)")
49
- return "low"
50
 
51
  # 🔥 規則 2:關懷模式優先速度(用戶情緒不佳時不要讓他等)
52
  if user_emotion in ["sad", "angry", "fear"]:
 
43
  reasoning_effort: minimal/low/medium/high
44
  """
45
 
46
+ # 🔥 規則 1:意圖檢測使用 minimal reasoning(模式,減少初始延遲
47
  if task_type == "intent_detection":
48
+ logger.debug("🧠 意圖檢測 → minimal reasoning(回應)")
49
+ return "minimal"
50
 
51
  # 🔥 規則 2:關懷模式優先速度(用戶情緒不佳時不要讓他等)
52
  if user_emotion in ["sad", "angry", "fear"]:
core/responses_runtime.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from core.environment.context_builder import EnvironmentInjection
8
+
9
+
10
+ @dataclass
11
+ class ResponsesRuntimeRequest:
12
+ user_input: str
13
+ model: str
14
+ instructions: Optional[str] = None
15
+ environment: Optional[EnvironmentInjection] = None
16
+ tools: List[Dict[str, Any]] = field(default_factory=list)
17
+ input_items: Optional[List[Dict[str, Any]]] = None
18
+ previous_response_id: Optional[str] = None
19
+ reasoning_effort: Optional[str] = None
20
+ max_output_tokens: Optional[int] = None
21
+ text_format: Optional[Dict[str, Any]] = None
22
+ tool_choice: Any = "auto"
23
+
24
+
25
+ class ResponsesAgentRuntime:
26
+ """
27
+ 新版主 Agent runtime 骨架。
28
+
29
+ 目前先負責:
30
+ 1. 統一組裝 Responses API payload
31
+ 2. 固定附帶 environment injection
32
+ 3. 為 hosted tools / bridge tools 預留同一個組裝入口
33
+ """
34
+
35
+ def build_request_payload(self, request: ResponsesRuntimeRequest) -> Dict[str, Any]:
36
+ input_parts: List[Dict[str, Any]] = list(request.input_items or [])
37
+
38
+ if request.environment:
39
+ input_parts.insert(
40
+ 0,
41
+ {
42
+ "role": "system",
43
+ "content": [
44
+ {
45
+ "type": "input_text",
46
+ "text": "Latest environment context:\n" + request.environment.summary_text,
47
+ }
48
+ ],
49
+ }
50
+ )
51
+
52
+ input_parts.append(
53
+ self.message_to_input_item({"role": "user", "content": request.user_input})
54
+ )
55
+
56
+ payload: Dict[str, Any] = {
57
+ "model": request.model,
58
+ "input": input_parts,
59
+ "tools": self.normalize_tools_for_responses(request.tools),
60
+ }
61
+
62
+ if request.instructions:
63
+ payload["instructions"] = request.instructions
64
+ if request.previous_response_id:
65
+ payload["previous_response_id"] = request.previous_response_id
66
+ if request.reasoning_effort:
67
+ payload["reasoning"] = {"effort": request.reasoning_effort}
68
+ if request.max_output_tokens:
69
+ payload["max_output_tokens"] = request.max_output_tokens
70
+ if request.text_format:
71
+ payload["text"] = {"format": request.text_format}
72
+ if request.tools:
73
+ payload["tool_choice"] = request.tool_choice
74
+
75
+ return payload
76
+
77
+ def build_payload_from_messages(
78
+ self,
79
+ *,
80
+ messages: List[Dict[str, Any]],
81
+ model: str,
82
+ tools: Optional[List[Dict[str, Any]]] = None,
83
+ reasoning_effort: Optional[str] = None,
84
+ max_output_tokens: Optional[int] = None,
85
+ tool_choice: Any = "auto",
86
+ text_format: Optional[Dict[str, Any]] = None,
87
+ ) -> Dict[str, Any]:
88
+ input_items: List[Dict[str, Any]] = []
89
+ instructions: Optional[str] = None
90
+
91
+ for message in messages:
92
+ if message.get("role") == "system":
93
+ content = message.get("content") or ""
94
+ instructions = f"{instructions}\n\n{content}" if instructions else str(content)
95
+ continue
96
+ input_items.append(self.message_to_input_item(message))
97
+
98
+ payload: Dict[str, Any] = {
99
+ "model": model,
100
+ "input": input_items,
101
+ "tools": self.normalize_tools_for_responses(tools or []),
102
+ }
103
+ if instructions:
104
+ payload["instructions"] = instructions
105
+ if reasoning_effort:
106
+ payload["reasoning"] = {"effort": reasoning_effort}
107
+ if max_output_tokens:
108
+ payload["max_output_tokens"] = max_output_tokens
109
+ if text_format:
110
+ payload["text"] = {"format": text_format}
111
+ if tools:
112
+ payload["tool_choice"] = tool_choice
113
+ return payload
114
+
115
+ @staticmethod
116
+ def without_hosted_tools(payload: Dict[str, Any]) -> Dict[str, Any]:
117
+ stripped = dict(payload)
118
+ tools = [
119
+ tool for tool in stripped.get("tools", [])
120
+ if tool.get("type") == "function"
121
+ ]
122
+ stripped["tools"] = tools
123
+ if not tools:
124
+ stripped.pop("tool_choice", None)
125
+ return stripped
126
+
127
+ @staticmethod
128
+ def message_to_input_item(message: Dict[str, Any]) -> Dict[str, Any]:
129
+ role = message.get("role") or "user"
130
+ content = message.get("content") or ""
131
+ if isinstance(content, list):
132
+ return {"role": role, "content": [ResponsesAgentRuntime.normalize_content_part(part, role) for part in content]}
133
+ content_type = "output_text" if role == "assistant" else "input_text"
134
+ return {"role": role, "content": [{"type": content_type, "text": str(content)}]}
135
+
136
+ @staticmethod
137
+ def normalize_content_part(part: Dict[str, Any], role: str) -> Dict[str, Any]:
138
+ part_type = part.get("type")
139
+ if part_type == "text":
140
+ return {
141
+ "type": "output_text" if role == "assistant" else "input_text",
142
+ "text": str(part.get("text", "")),
143
+ }
144
+ if part_type == "image_url":
145
+ image_url = part.get("image_url") or {}
146
+ return {
147
+ "type": "input_image",
148
+ "image_url": image_url.get("url", image_url if isinstance(image_url, str) else ""),
149
+ }
150
+ return dict(part)
151
+
152
+ @staticmethod
153
+ def normalize_tools_for_responses(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
154
+ normalized: List[Dict[str, Any]] = []
155
+ for tool in tools:
156
+ if tool.get("type") != "function" or "function" not in tool:
157
+ normalized.append(dict(tool))
158
+ continue
159
+
160
+ fn = tool.get("function") or {}
161
+ converted = {
162
+ "type": "function",
163
+ "name": fn.get("name"),
164
+ "description": fn.get("description", ""),
165
+ "parameters": fn.get("parameters", {"type": "object", "properties": {}}),
166
+ }
167
+ if "strict" in fn:
168
+ converted["strict"] = fn["strict"]
169
+ normalized.append(converted)
170
+ return normalized
171
+
172
+ @staticmethod
173
+ def extract_output_text(response: Any) -> str:
174
+ text = getattr(response, "output_text", None)
175
+ if isinstance(text, str) and text.strip():
176
+ return text.strip()
177
+
178
+ parts: List[str] = []
179
+ for item in getattr(response, "output", []) or []:
180
+ item_type = getattr(item, "type", None)
181
+ if item_type != "message":
182
+ continue
183
+ for content in getattr(item, "content", []) or []:
184
+ content_text = getattr(content, "text", None)
185
+ if content_text:
186
+ parts.append(str(content_text))
187
+ return "\n".join(parts).strip()
188
+
189
+ @staticmethod
190
+ def extract_function_calls(response: Any) -> List[Dict[str, Any]]:
191
+ calls: List[Dict[str, Any]] = []
192
+ for item in getattr(response, "output", []) or []:
193
+ if getattr(item, "type", None) != "function_call":
194
+ continue
195
+ calls.append(
196
+ {
197
+ "id": getattr(item, "call_id", None) or getattr(item, "id", None),
198
+ "type": "function",
199
+ "function": {
200
+ "name": getattr(item, "name", ""),
201
+ "arguments": getattr(item, "arguments", "{}") or "{}",
202
+ },
203
+ }
204
+ )
205
+ return calls
206
+
207
+ @staticmethod
208
+ def decode_arguments(arguments: str) -> Dict[str, Any]:
209
+ try:
210
+ return json.loads(arguments or "{}")
211
+ except json.JSONDecodeError:
212
+ return {}
core/tool_registry.py CHANGED
@@ -6,6 +6,7 @@
6
  重構版本:整合 Pydantic Schema 自動生成
7
  """
8
 
 
9
  from typing import Dict, List, Any, Optional, Callable, Type
10
  from dataclasses import dataclass, field
11
 
@@ -18,6 +19,7 @@ from core.tool_schema import (
18
  extract_schema_from_mcp_tool,
19
  )
20
 
 
21
  logger = get_logger("core.tool_registry")
22
 
23
 
@@ -246,35 +248,45 @@ def register_mcp_tools_to_registry(mcp_server) -> int:
246
  count = 0
247
 
248
  for tool_name, tool in mcp_server.tools.items():
249
- # 優先嘗試從 MCPTool 類別提取完整 Schema
 
250
  if hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
251
- tool_class = tool.handler.__self__
252
- if tool_registry.register_mcp_tool(type(tool_class)):
253
- count += 1
254
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
 
256
- # 降級:使用舊方法註冊
257
  description = getattr(tool, 'description', f'{tool_name} 工具')
258
- parameters = {"type": "object", "properties": {}, "required": []}
259
-
260
- if hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
261
- tool_class = tool.handler.__self__
262
- if hasattr(tool_class, 'get_input_schema'):
263
- try:
264
- parameters = tool_class.get_input_schema()
265
- except Exception as e:
266
- logger.warning(f"取得 {tool_name} schema 失敗: {e}")
267
-
268
- # 提取關鍵字和範例
269
  keywords = []
270
  examples = []
271
- if hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
272
- tool_class = tool.handler.__self__
273
- keywords = getattr(tool_class, 'KEYWORDS', [])
274
- examples = getattr(tool_class, 'USAGE_TIPS', [])
275
-
276
- # 判斷分類
277
- category = _infer_category(tool_name)
278
 
279
  # 判斷是否需要位置
280
  requires_location = _requires_location(tool_name, parameters)
 
6
  重構版本:整合 Pydantic Schema 自動生成
7
  """
8
 
9
+ import inspect
10
  from typing import Dict, List, Any, Optional, Callable, Type
11
  from dataclasses import dataclass, field
12
 
 
19
  extract_schema_from_mcp_tool,
20
  )
21
 
22
+ from features.mcp.tools.base_tool import MCPTool
23
  logger = get_logger("core.tool_registry")
24
 
25
 
 
248
  count = 0
249
 
250
  for tool_name, tool in mcp_server.tools.items():
251
+ # 1. 嘗試獲取工具類別
252
+ tool_class = None
253
  if hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
254
+ tool_class = type(tool.handler.__self__)
255
+ elif hasattr(tool, 'handler') and hasattr(tool.handler, '__closure__') and tool.handler.__closure__:
256
+ # 嘗試從閉包中找 (例如 classmethod_wrapper or instance_wrapper)
257
+ for cell in tool.handler.__closure__:
258
+ try:
259
+ contents = cell.cell_contents
260
+ # 檢查是否為 MCPTool 類別或實例 (使用鴨子類型,避免模組導入路徑不一致問題)
261
+ if inspect.isclass(contents) and hasattr(contents, 'get_input_schema') and hasattr(contents, 'NAME'):
262
+ tool_class = contents
263
+ break
264
+ elif not inspect.isclass(contents) and hasattr(contents, 'get_input_schema') and hasattr(contents, 'NAME'):
265
+ tool_class = type(contents)
266
+ break
267
+ except:
268
+ continue
269
+
270
+ # 2. 如果能找到類別,使用 register_mcp_tool (這會處理 rich description)
271
+ if tool_class and tool_registry.register_mcp_tool(tool_class):
272
+ count += 1
273
+ continue
274
 
275
+ # 3. 降級:手動提取並註冊
276
  description = getattr(tool, 'description', f'{tool_name} 工具')
277
+ parameters = getattr(tool, 'inputSchema', {"type": "object", "properties": {}, "required": []})
278
+ output_schema = getattr(tool, 'outputSchema', None)
279
+
280
+ # 嘗試從 handler 閉包中找 tool_class (如果有的話)
281
+ # 或者從 tool.metadata 找
 
 
 
 
 
 
282
  keywords = []
283
  examples = []
284
+ if hasattr(tool, 'metadata') and tool.metadata:
285
+ keywords = tool.metadata.get('keywords', [])
286
+ examples = tool.metadata.get('usage_tips', []) or tool.metadata.get('examples', [])
287
+ category = tool.metadata.get('category', 'general')
288
+ else:
289
+ category = _infer_category(tool_name)
 
290
 
291
  # 判斷是否需要位置
292
  requires_location = _requires_location(tool_name, parameters)
core/tool_router.py CHANGED
@@ -28,15 +28,16 @@ class ToolRouter:
28
 
29
  # 分類關鍵字映射
30
  CATEGORY_KEYWORDS = {
31
- "weather": ["天氣", "氣溫", "下雨", "晴天", "陰天", "weather", "溫度", "濕度"],
32
  "transportation": [
33
  "公車", "巴士", "bus", "火車", "台鐵", "高鐵", "捷運", "metro",
34
- "youbike", "ubike", "微笑單車", "共享單車", "停車場", "停車位"
 
35
  ],
36
- "location": ["我在哪", "這是哪", "位置", "地址", "怎麼去", "導航", "路線"],
37
- "information": ["新聞", "消息", "報導", "news"],
38
- "finance": ["匯率", "換算", "美元", "日圓", "歐元", "currency", "exchange"],
39
- "health": ["心率", "步數", "血氧", "睡眠", "健康", "運動"],
40
  }
41
 
42
  # 時間敏感工具(深夜可能不適用)
@@ -89,6 +90,7 @@ class ToolRouter:
89
  logger.debug(f"🎯 檢測到的分類: {detected_categories}")
90
 
91
  # 2. 過濾工具
 
92
  filtered_tools = []
93
  for tool in tools:
94
  tool_name = tool.get("function", {}).get("name", "")
@@ -106,6 +108,12 @@ class ToolRouter:
106
  filtered_tools.append(tool)
107
 
108
  # 3. 排序工具(相關分類優先)
 
 
 
 
 
 
109
  sorted_tools = self._sort_tools(filtered_tools, detected_categories, context)
110
 
111
  # 4. 限制工具數量(減少 token 消耗)
@@ -114,7 +122,7 @@ class ToolRouter:
114
  logger.info(f"📉 工具數量從 {len(sorted_tools)} 限制到 {max_tools}")
115
  sorted_tools = sorted_tools[:max_tools]
116
 
117
- logger.info(f"🔧 過濾後工具: {[t['function']['name'] for t in sorted_tools]}")
118
  return sorted_tools
119
 
120
  def _detect_categories(self, message: str) -> Set[str]:
@@ -234,11 +242,11 @@ class ToolRouter:
234
  return 20
235
 
236
  if len(detected_categories) == 1:
237
- # 單一分類,但仍保留足夠工具(如 directions)
238
- return 12
239
 
240
- # 多個分類
241
- return 15
242
 
243
  def record_tool_usage(self, user_id: str, tool_name: str) -> None:
244
  """記錄工具使用(用於優先級調整)"""
 
28
 
29
  # 分類關鍵字映射
30
  CATEGORY_KEYWORDS = {
31
+ "weather": ["天氣", "氣溫", "下雨", "晴天", "陰天", "weather", "溫度", "濕度", "天気", "雨", "気温"],
32
  "transportation": [
33
  "公車", "巴士", "bus", "火車", "台鐵", "高鐵", "捷運", "metro",
34
+ "youbike", "ubike", "微笑單車", "共享單車", "停車場", "停車位",
35
+ "バス", "電車", "地下鉄", "新幹線", "駐輪場", "駐車場"
36
  ],
37
+ "location": ["我在哪", "這是哪", "位置", "地址", "怎麼去", "導航", "路線", "どこ", "現在地", "住所", "ナビ"],
38
+ "information": ["新聞", "消息", "報導", "news", "ニュース", "報道"],
39
+ "finance": ["匯率", "換算", "美元", "日圓", "歐元", "currency", "exchange", "為替", "レート", "円", "ドル"],
40
+ "health": ["心率", "步數", "血氧", "睡眠", "健康", "運動", "健康", "歩数", "心拍", "運動"],
41
  }
42
 
43
  # 時間敏感工具(深夜可能不適用)
 
90
  logger.debug(f"🎯 檢測到的分類: {detected_categories}")
91
 
92
  # 2. 過濾工具
93
+ language = context.get("language")
94
  filtered_tools = []
95
  for tool in tools:
96
  tool_name = tool.get("function", {}).get("name", "")
 
108
  filtered_tools.append(tool)
109
 
110
  # 3. 排序工具(相關分類優先)
111
+ # 如果是日語,特別提升新聞與天氣的優先級(補償關鍵字可能不全的情況)
112
+ if language == 'ja':
113
+ detected_categories.add("weather")
114
+ detected_categories.add("finance")
115
+ detected_categories.add("information")
116
+
117
  sorted_tools = self._sort_tools(filtered_tools, detected_categories, context)
118
 
119
  # 4. 限制工具數量(減少 token 消耗)
 
122
  logger.info(f"📉 工具數量從 {len(sorted_tools)} 限制到 {max_tools}")
123
  sorted_tools = sorted_tools[:max_tools]
124
 
125
+ logger.info(f"🔧 過濾後工具: {[t['function']['name'] for t in sorted_tools]} (用戶語系: {language})")
126
  return sorted_tools
127
 
128
  def _detect_categories(self, message: str) -> Set[str]:
 
242
  return 20
243
 
244
  if len(detected_categories) == 1:
245
+ # 單一分類,需保留核心工具,顯著減少 LLM 負擔
246
+ return 6
247
 
248
+ # 多個分類,保持在較小範圍
249
+ return 10
250
 
251
  def record_tool_usage(self, user_id: str, tool_name: str) -> None:
252
  """記錄工具使用(用於優先級調整)"""
core/tool_schema.py CHANGED
@@ -5,10 +5,11 @@ Pydantic 工具 Schema 定義
5
  功能:
6
  1. 工具輸入/輸出的 Pydantic 基礎類別
7
  2. 自動生成 OpenAI tools 格式的 JSON Schema
8
- 3. 支援 strict mode 確保 100% 有效輸出
9
  4. 裝飾器模式自動註冊工具
10
  """
11
 
 
12
  from typing import Dict, Any, Optional, List, Callable, Type, TypeVar, get_type_hints
13
  from dataclasses import dataclass, field
14
  from functools import wraps
@@ -54,7 +55,7 @@ class ToolSchema:
54
  轉換為 OpenAI Function Calling 格式
55
 
56
  Args:
57
- strict: 是否啟用 strict mode(確保輸出符合 schema)
58
 
59
  Returns:
60
  OpenAI tools 格式的字典
@@ -110,32 +111,74 @@ class ToolSchema:
110
 
111
  strict mode 要求:
112
  1. additionalProperties: false
113
- 2. 所有屬性都在 required 中(或有 default)
114
- 3. 不支援 oneOf/anyOf/allOf
115
  """
116
- result = dict(schema)
117
 
118
  # 確保是 object 類型
119
  if result.get("type") != "object":
120
  result = {"type": "object", "properties": result}
121
 
122
- # 添加 additionalProperties: false
123
- result["additionalProperties"] = False
124
-
125
- # 確保所有屬性都在 required 中
126
  properties = result.get("properties", {})
127
- existing_required = set(result.get("required", []))
128
-
129
- # 收集所有沒有 default 的屬性
130
- all_required = []
131
- for prop_name, prop_schema in properties.items():
132
- if prop_name in existing_required or "default" not in prop_schema:
133
- all_required.append(prop_name)
134
-
135
- result["required"] = all_required
136
 
137
  return result
138
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  def get_summary(self) -> Dict[str, Any]:
140
  """獲取工具摘要(用於快速意圖匹配)"""
141
  return {
@@ -185,7 +228,6 @@ def extract_schema_from_mcp_tool(tool_class: Type) -> Optional[ToolSchema]:
185
  try:
186
  input_schema = tool_class.get_input_schema()
187
  except Exception as e:
188
- logger.warning(f"提取 {name} input schema 失敗: {e}")
189
  input_schema = {"type": "object", "properties": {}}
190
 
191
  # 提取 output schema(可選)
@@ -253,6 +295,11 @@ class ToolSchemaRegistry:
253
 
254
  def register(self, schema: ToolSchema) -> None:
255
  """註冊工具 Schema"""
 
 
 
 
 
256
  self._schemas[schema.metadata.name] = schema
257
  logger.debug(f"註冊工具 Schema: {schema.metadata.name}")
258
 
 
5
  功能:
6
  1. 工具輸入/輸出的 Pydantic 基礎類別
7
  2. 自動生成 OpenAI tools 格式的 JSON Schema
8
+ 3. 支援 provider strict mode 確保工具參數結構穩定
9
  4. 裝飾器模式自動註冊工具
10
  """
11
 
12
+ from copy import deepcopy
13
  from typing import Dict, Any, Optional, List, Callable, Type, TypeVar, get_type_hints
14
  from dataclasses import dataclass, field
15
  from functools import wraps
 
55
  轉換為 OpenAI Function Calling 格式
56
 
57
  Args:
58
+ strict: 是否啟用 provider strict mode(約束工具參數 schema)
59
 
60
  Returns:
61
  OpenAI tools 格式的字典
 
111
 
112
  strict mode 要求:
113
  1. additionalProperties: false
114
+ 2. 保留 JSON Schema required 語意
115
+ 3. 可選欄位必須透過 default 或 nullable 型別明確表達
116
  """
117
+ result = deepcopy(schema)
118
 
119
  # 確保是 object 類型
120
  if result.get("type") != "object":
121
  result = {"type": "object", "properties": result}
122
 
123
+ # Provider strict mode 要求所有 properties 都列入 required;
124
+ # default 的欄位先保留 default,執行端仍會套用工具 schema 預設值。
125
+ self._apply_provider_strict_object_rules(result)
 
126
  properties = result.get("properties", {})
127
+ result["required"] = list(properties.keys())
 
 
 
 
 
 
 
 
128
 
129
  return result
130
 
131
+ def _apply_provider_strict_object_rules(self, schema: Dict[str, Any]) -> None:
132
+ """遞迴套用 provider strict object schema 規則。"""
133
+ if schema.get("type") == "object":
134
+ schema["additionalProperties"] = False
135
+ properties = schema.get("properties", {})
136
+ if isinstance(properties, dict):
137
+ schema["required"] = list(properties.keys())
138
+ for prop_schema in properties.values():
139
+ if isinstance(prop_schema, dict):
140
+ self._apply_provider_strict_object_rules(prop_schema)
141
+
142
+ for key in ("items",):
143
+ nested = schema.get(key)
144
+ if isinstance(nested, dict):
145
+ self._apply_provider_strict_object_rules(nested)
146
+
147
+ def validate_schema_contract(self) -> List[str]:
148
+ """檢查 input/output schema 是否有會破壞工具調用的契約問題。"""
149
+ issues: List[str] = []
150
+ if not self.metadata.name:
151
+ issues.append("tool name is required")
152
+ if self.input_schema.get("type") != "object":
153
+ issues.append(f"{self.metadata.name}: input_schema.type must be object")
154
+
155
+ properties = self.input_schema.get("properties", {})
156
+ if not isinstance(properties, dict):
157
+ issues.append(f"{self.metadata.name}: input_schema.properties must be object")
158
+
159
+ required = self.input_schema.get("required", [])
160
+ if required and not isinstance(required, list):
161
+ issues.append(f"{self.metadata.name}: input_schema.required must be list")
162
+ for field in required:
163
+ if field not in properties:
164
+ issues.append(f"{self.metadata.name}: required field '{field}' missing from properties")
165
+
166
+ if self.output_schema is not None:
167
+ if self.output_schema.get("type") != "object":
168
+ issues.append(f"{self.metadata.name}: output_schema.type must be object")
169
+ output_props = self.output_schema.get("properties", {})
170
+ if not isinstance(output_props, dict):
171
+ issues.append(f"{self.metadata.name}: output_schema.properties must be object")
172
+
173
+ return issues
174
+
175
+ def contract_warnings(self) -> List[str]:
176
+ """回報不阻擋執行、但會降低模型選工具品質的問題。"""
177
+ warnings: List[str] = []
178
+ if not self.metadata.description:
179
+ warnings.append(f"{self.metadata.name}: description is empty")
180
+ return warnings
181
+
182
  def get_summary(self) -> Dict[str, Any]:
183
  """獲取工具摘要(用於快速意圖匹配)"""
184
  return {
 
228
  try:
229
  input_schema = tool_class.get_input_schema()
230
  except Exception as e:
 
231
  input_schema = {"type": "object", "properties": {}}
232
 
233
  # 提取 output schema(可選)
 
295
 
296
  def register(self, schema: ToolSchema) -> None:
297
  """註冊工具 Schema"""
298
+ issues = schema.validate_schema_contract()
299
+ if issues:
300
+ raise ValueError("; ".join(issues))
301
+ for warning in schema.contract_warnings():
302
+ logger.warning(warning)
303
  self._schemas[schema.metadata.name] = schema
304
  logger.debug(f"註冊工具 Schema: {schema.metadata.name}")
305
 
core/voice_care_gate.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from typing import Any, Dict, Optional
3
+
4
+
5
+ EXTREME_EMOTIONS = {"sad", "angry", "fear"}
6
+ VOICE_EMOTION_CONFIDENCE_THRESHOLD = 0.70
7
+ VOICE_SPEECH_CONFIDENCE_THRESHOLD = 0.70
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class VoiceCareDecision:
12
+ allow: bool
13
+ emotion: str
14
+ confidence: float
15
+ reason: str
16
+ evidence: Dict[str, Any]
17
+
18
+
19
+ def _to_float(value: Any, default: float = 0.0) -> float:
20
+ try:
21
+ return float(value)
22
+ except (TypeError, ValueError):
23
+ return default
24
+
25
+
26
+ def _normalize_emotion(value: Any) -> str:
27
+ text = str(value or "neutral").strip().lower()
28
+ return text if text else "neutral"
29
+
30
+
31
+ def is_voice_context(audio_emotion: Optional[Dict[str, Any]]) -> bool:
32
+ if not audio_emotion:
33
+ return False
34
+ source = str(audio_emotion.get("source") or "").strip().lower()
35
+ return source in {"realtime_voice", "voice", "speech", "audio"}
36
+
37
+
38
+ def decide_voice_care(
39
+ *,
40
+ text_emotion: str,
41
+ audio_emotion: Optional[Dict[str, Any]],
42
+ ) -> VoiceCareDecision:
43
+ """
44
+ Gate for voice-triggered care mode.
45
+
46
+ Voice emotion is allowed to trigger care mode only when the transcript-side
47
+ emotion agrees that the user is in the same extreme-emotion family.
48
+ """
49
+ text_value = _normalize_emotion(text_emotion)
50
+ audio_value = _normalize_emotion((audio_emotion or {}).get("emotion"))
51
+ audio_confidence = _to_float((audio_emotion or {}).get("confidence"))
52
+ speech_confidence_raw = (audio_emotion or {}).get("speech_confidence")
53
+ speech_confidence = (
54
+ _to_float(speech_confidence_raw)
55
+ if speech_confidence_raw is not None
56
+ else None
57
+ )
58
+ evidence = {
59
+ "text_emotion": text_value,
60
+ "audio_emotion": audio_value,
61
+ "audio_emotion_confidence": audio_confidence,
62
+ "speech_confidence": speech_confidence,
63
+ }
64
+
65
+ if not audio_emotion or not audio_emotion.get("success"):
66
+ return VoiceCareDecision(False, text_value, 0.5, "voice-audio-missing", evidence)
67
+
68
+ if speech_confidence is not None and speech_confidence < VOICE_SPEECH_CONFIDENCE_THRESHOLD:
69
+ return VoiceCareDecision(False, text_value, 0.5, "voice-speech-low-confidence", evidence)
70
+
71
+ if audio_confidence < VOICE_EMOTION_CONFIDENCE_THRESHOLD:
72
+ if text_value in EXTREME_EMOTIONS:
73
+ return VoiceCareDecision(True, text_value, 0.5, "voice-text-extreme-audio-low-confidence", evidence)
74
+ return VoiceCareDecision(False, text_value, 0.5, "voice-audio-low-confidence", evidence)
75
+
76
+ if audio_value not in EXTREME_EMOTIONS:
77
+ return VoiceCareDecision(False, text_value, audio_confidence, "voice-audio-not-extreme", evidence)
78
+
79
+ if text_value not in EXTREME_EMOTIONS:
80
+ return VoiceCareDecision(False, text_value, 0.5, "voice-text-not-extreme", evidence)
81
+
82
+ return VoiceCareDecision(True, text_value, audio_confidence, "voice-extreme-family-match", evidence)
features/care_mode/skills/ACTIVE_LISTENING.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: active-listening
3
+ description: "主動傾聽技巧:透過反映與複述,讓用戶感受到被聽見與理解。"
4
+ usage_policy:
5
+ mirror_feelings: true
6
+ reflect_content: true
7
+ avoid_judgement: true
8
+ ---
9
+
10
+ # 主動傾聽 (Active Listening)
11
+
12
+ 當用戶表達情緒或分享經歷時,使用此技能來強化理解感。
13
+
14
+ ### 執行要點:
15
+ 1. **反映感受**:辨識並說出用戶文字背後的情緒。
16
+ - *例子*:「聽起來這件事讓你感到很挫折。」
17
+ 2. **複述核心**:用你自己的話簡單重述用戶提到的關鍵點,確認你的理解。
18
+ - *例子*:「你提到雖然努力了很久,但結果不如預期,這讓你感到很不甘心。」
19
+ 3. **鼓勵續說**:使用簡短的鼓勵詞,邀請用戶進一步宣洩。
20
+ - *例子*:「我在聽,你想多聊聊這部分嗎?」
21
+
22
+ ### 禁忌:
23
+ - 在用戶說完前就給予建議。
24
+ - 使用「我知道你的感受」這種空洞的話(除非你能具體說出是什麼感受)。
features/care_mode/skills/ANGER_HANDLING.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: anger-handling
3
+ description: "憤怒情緒處理技巧:認可憤怒的合理性,不給予壓力。"
4
+ usage_policy:
5
+ calm_empathy: true
6
+ validate_anger: true
7
+ avoid_suppression: true
8
+ ---
9
+
10
+ # 憤怒情緒處理 (Anger Handling)
11
+
12
+ ### 執行要點:
13
+ - **語氣**:冷靜但帶有同理、不卑不亢。
14
+ - **重點**:認可對方的憤怒是有原因的,幫助對方感覺被理解。
15
+ - **避免**:說「冷靜一下」、「別生氣」這類否定情緒的話。
features/care_mode/skills/CARE_CORE_STRATEGY.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: care-core-strategy
3
+ description: "關懷模式核心策略:定義回應的結構與核心原則。"
4
+ usage_policy:
5
+ empathy_first: true
6
+ companionship: true
7
+ open_encouragement: true
8
+ ---
9
+
10
+ # 關懷模式核心策略 (Core Care Strategy)
11
+
12
+ 這是進行情緒關懷對話的基礎框架,必須嚴格遵守。
13
+
14
+ ### 核心職責:
15
+ 1. **深度同理 (Deep Empathy)**:第一句話必須精準反映用戶目前的感受或處境。
16
+ - *例子*:「考零分真的會很難過,這種失落我懂。」
17
+ 2. **溫柔陪伴 (Gentle Companionship)**:表達你在這裡,願意傾聽。
18
+ - *例子*:「我在這裡陪你。」
19
+ 3. **開放式鼓勵 (Open Encouragement)**:適度詢問細節,鼓勵用戶宣洩情緒。
20
+ - *例子*:「想說說發生了什麼嗎?」
21
+
22
+ ### 回應原則:
23
+ - **語氣**:輕柔、自然、像好朋友。
24
+ - **格式**:保持簡潔(約 2-3 句話),確保每句話都有溫度。
25
+ - **禁止**:機械化回答、過度正向的毒雞湯、心理醫生式的說教。
26
+ - **禁止**:重複使用罐頭話術。
features/care_mode/skills/EMOTIONAL_VALIDATION.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: emotional-validation
3
+ description: "情感驗證技巧:認可用戶情緒的合理性,減輕其自我懷疑或羞恥感。"
4
+ usage_policy:
5
+ normalize_emotions: true
6
+ validate_logic: true
7
+ show_empathy: true
8
+ ---
9
+
10
+ # 情感驗證 (Emotional Validation)
11
+
12
+ 當用戶對自己的情緒感到懷疑、羞恥或困惑時,使用此技能。
13
+
14
+ ### 執行要點:
15
+ 1. **正常化情緒**:讓用戶知道在這種情況下有這種感覺是很正常的。
16
+ - *例子*:「換作是我,遇到這種情況也會感到生氣的。」
17
+ 2. **認可合理性**:根據對話背景,解釋為什麼用戶的情緒是合理的。
18
+ - *例子*:「付出了那麼多努力卻沒有回報,難過是很自然的事。」
19
+ 3. **消除孤獨感**:表達這種情緒是普遍的人類經驗。
20
+ - *例子*:「很多人在面對這種轉變時都會感到焦慮,你並不孤單。」
21
+
22
+ ### 禁忌:
23
+ - 質疑用戶情緒的真實性。
24
+ - 說「這沒什麼大不了的」或「別想太多」。
features/care_mode/skills/FEAR_HANDLING.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: fear-handling
3
+ description: "恐懼/焦慮情緒處理技巧:提供穩定感與安全感。"
4
+ usage_policy:
5
+ stable_presence: true
6
+ accept_fear: true
7
+ provide_security: true
8
+ ---
9
+
10
+ # 恐懼/焦慮處理 (Fear & Anxiety Handling)
11
+
12
+ ### 執行要點:
13
+ - **語氣**:穩定、溫暖、帶有安全感。
14
+ - **重點**:讓對方感覺不孤單,恐懼是可以被接納的。
15
+ - **避免**:說「沒什麼好怕的」、「想太多了」這類否定情緒的話。
features/care_mode/skills/FIRST_CONTACT_CARE.md ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: first-contact-care
3
+ description: "初次關懷引導:在進入關懷模式的首個回覆中,引導用戶如何操作。"
4
+ usage_policy:
5
+ conditional_execution: true
6
+ provide_exit_hint: true
7
+ ---
8
+
9
+ # 初次關懷引導 (First Contact Care)
10
+
11
+ 當系統偵測到這是進入「關懷模式」的第一個回覆時(`is_first_care=True`),請執行此引導。
12
+
13
+ ### 執行要點:
14
+ 1. **完成溫暖回應**:首先根據用戶情緒提供深度同理與陪伴的回應。
15
+ 2. **附上退出提示**:在回覆的結尾,換行兩次後,附上以下藍色字體的提示:
16
+ - `💙 關懷模式已啟動。說「我沒事了」可以退出。`
17
+
18
+ ### 範例:
19
+ 「考零分真的會很難過,這種失落我懂。我在這裡陪你,想說說發生什麼嗎?
20
+
21
+ 💙 關懷模式已啟動。說「我沒事了」可以退出。」
features/care_mode/skills/SADNESS_HANDLING.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: sadness-handling
3
+ description: "悲傷情緒處理技巧:提供溫柔理解,避免過度積極。"
4
+ usage_policy:
5
+ gentle_tone: true
6
+ normalize_sadness: true
7
+ avoid_toxic_positivity: true
8
+ ---
9
+
10
+ # 悲傷情緒處理 (Sadness Handling)
11
+
12
+ ### 執行要點:
13
+ - **語氣**:溫柔、輕聲、帶有理解。
14
+ - **重點**:陪伴而非解決問題,讓對方知道悲傷是正常的。
15
+ - **避免**:說「不要難過」、「振作點」這類否定情緒的話。
features/care_mode/skills/SUPPORTIVE_PRESENCE.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: supportive-presence
3
+ description: "支持性陪伴技巧:提供純粹的陪伴感,不急於解決問題,建立安全感。"
4
+ usage_policy:
5
+ be_present: true
6
+ slow_down: true
7
+ offer_space: true
8
+ ---
9
+
10
+ # 支持性陪伴 (Supportive Presence)
11
+
12
+ 當用戶處於情緒低谷,且尚未準備好採取行動或深入探討時,使用此技能。
13
+
14
+ ### 執行要點:
15
+ 1. **傳達在場**:明確表達你現在就在這裡,不會離開。
16
+ - *例子*:「我就在這裡陪你,不需要急著說什麼。」
17
+ 2. **減緩節奏**:給予用戶空間,不要連續追問。
18
+ - *例子*:「如果你累了想靜一下也可以,我會一直都在。」
19
+ 3. **無條件接納**:表達無論用戶現在狀態如何,你都能接受。
20
+ - *例子*:「現在不想說話也沒關係,我可以就這樣靜靜陪著你。」
21
+
22
+ ### 禁忌:
23
+ - 急著提供「解決方案」或「待辦清單」。
24
+ - 表現得像是在趕時間或想要結束對話。
features/mcp/agent_bridge.py CHANGED
@@ -6,11 +6,13 @@ MCP + Agent 橋接層
6
  import json
7
  import logging
8
  import asyncio
 
9
  from typing import Dict, Any, Optional, List, Tuple, Callable, Awaitable
10
  from datetime import datetime
11
  from .server import FeaturesMCPServer
12
  import services.ai_service as ai_service
13
  from services.ai_service import StrictResponseError
 
14
  from core.reasoning_strategy import get_optimal_reasoning_effort
15
  from core.database import get_user_env_current
16
  from .coordinator import ToolCoordinator
@@ -105,38 +107,38 @@ class MCPAgentBridge:
105
  name="weather_query",
106
  requires_env={"lat", "lon", "city"},
107
  env_fallbacks={"city": ["detailed_address", "label"]},
108
- enable_reformat=True,
109
  )
110
  )
111
  register(
112
  ToolMetadata(
113
  name="reverse_geocode",
114
  requires_env={"lat", "lon"},
115
- enable_reformat=True,
116
  )
117
  )
118
  register(
119
  ToolMetadata(
120
  name="exchange_query",
121
- enable_reformat=True,
122
  )
123
  )
124
  register(
125
  ToolMetadata(
126
  name="news_query",
127
- enable_reformat=True,
128
  )
129
  )
130
  register(
131
  ToolMetadata(
132
  name="healthkit_query",
133
- enable_reformat=True,
134
  )
135
  )
136
  register(
137
  ToolMetadata(
138
  name="directions",
139
- enable_reformat=True,
140
  )
141
  )
142
  register(
@@ -223,68 +225,12 @@ class MCPAgentBridge:
223
  logger.info(f"異步初始化完成,完整可用 MCP 工具數量: {len(self.mcp_server.tools)}")
224
 
225
  # 將 MCP Server 的工具註冊到 tool_registry
226
- self._sync_tools_to_registry()
 
227
 
228
  # 快取預熱已移除:啟動時連續調用 7 次 GPT API 增加延遲和成本
229
  # 實際使用中快取會自然累積,無需預熱
230
 
231
- def _sync_tools_to_registry(self) -> int:
232
- """
233
- 將 MCP Server 的工具同步到 tool_registry
234
-
235
- Returns:
236
- 註冊的工具數量
237
- """
238
- from core.tool_registry import tool_registry
239
-
240
- count = 0
241
- for tool_name, tool in self.mcp_server.tools.items():
242
- # 取得工具描述
243
- description = getattr(tool, 'description', f'{tool_name} 工具')
244
-
245
- # 取得參數 Schema
246
- parameters = {"type": "object", "properties": {}, "required": []}
247
- keywords = []
248
- examples = []
249
- negative_examples = []
250
- category = "general"
251
- priority = 100
252
-
253
- if hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
254
- tool_class = tool.handler.__self__
255
-
256
- # 嘗試從 MCPTool 類別提取完整資訊
257
- if hasattr(tool_class, 'get_input_schema'):
258
- try:
259
- parameters = tool_class.get_input_schema()
260
- except Exception as e:
261
- logger.warning(f"取得 {tool_name} schema 失敗: {e}")
262
-
263
- # 提取增強元資料
264
- keywords = getattr(tool_class, 'KEYWORDS', [])
265
- examples = getattr(tool_class, 'USAGE_TIPS', [])
266
- negative_examples = getattr(tool_class, 'NEGATIVE_EXAMPLES', [])
267
- category = getattr(tool_class, 'CATEGORY', 'general')
268
- priority = getattr(tool_class, 'PRIORITY', 100)
269
-
270
- # 判斷是否需要位置
271
- props = parameters.get("properties", {})
272
- requires_location = "lat" in props or "lon" in props
273
-
274
- tool_registry.register(
275
- name=tool_name,
276
- description=description,
277
- parameters=parameters,
278
- handler=getattr(tool, 'handler', None),
279
- category=category,
280
- requires_location=requires_location,
281
- keywords=keywords,
282
- examples=examples,
283
- )
284
- count += 1
285
-
286
- logger.info(f"🔧 同步 {count} 個工具到 tool_registry")
287
- return count
288
 
289
  def _normalize_tool_name(self, raw_name: Optional[str]) -> Optional[str]:
290
  """
@@ -444,92 +390,28 @@ class MCPAgentBridge:
444
  "tool_data": fallback_payload,
445
  }
446
 
447
- def get_current_time_data(self) -> Dict[str, Any]:
448
  """
449
- 當前時間數據,用於生成個性歡迎詞
450
- 返回格式與舊 time_service 兼容
451
- """
452
- now = datetime.now()
453
-
454
- # 獲取時間段
455
- hour = now.hour
456
- if 5 <= hour < 12:
457
- day_period = "上午"
458
- elif 12 <= hour < 18:
459
- day_period = "下午"
460
- elif 18 <= hour < 22:
461
- day_period = "晚上"
462
- else:
463
- day_period = "深夜" if hour >= 22 else "凌晨"
464
-
465
- # 星期幾中文名稱
466
- weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
467
- weekday_full_chinese = weekdays[now.weekday()]
468
-
469
- return {
470
- "year": now.year,
471
- "month": now.month,
472
- "day": now.day,
473
- "hour": hour,
474
- "minute": now.minute,
475
- "second": now.second,
476
- "weekday": now.weekday(), # 0-6, 星期一到星期日
477
- "weekday_full_chinese": weekday_full_chinese,
478
- "day_period": day_period,
479
- "timestamp": now.timestamp(),
480
- "iso_format": now.isoformat()
481
- }
482
-
483
- async def detect_intent(self, message: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
484
- """
485
- 檢測用戶消息中的意圖 (保持與舊 FeatureRouter 相同介面)
486
-
487
- 2025 重構版:使用 OpenAI 原生 Function Calling
488
- - 不再使用巨大的 system_prompt 描述每個工具
489
- - 工具定義由 tools 參數傳遞,GPT 原生選擇
490
- - 新增工具只需註冊到 Registry,不需更新任何 prompt
491
-
492
- 參數:
493
- message (str): 用戶消息
494
-
495
- 返回:
496
- tuple: (是否檢測到意圖, 意圖數據)
497
- """
498
- # 使用新的 IntentDetector(基於 OpenAI Function Calling)
499
- return await self._detect_intent_with_function_calling(message)
500
-
501
- async def _detect_intent_with_function_calling(self, message: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
502
- """
503
- 使用 OpenAI 原生 Function Calling 進行意圖檢測
504
-
505
- 核心改進:
506
- 1. 工具定義自動從 Registry 生成
507
- 2. GPT 原生選擇工具並生成結構化參數
508
- 3. 不需要自定義 prompt 描述每個工具
509
  """
510
  import hashlib
511
- import time as time_module
512
-
513
- # 生成快取鍵
514
- cache_key = hashlib.md5(message.encode()).hexdigest()
515
 
516
  # 檢查快取
517
  if cache_key in self._intent_cache:
518
  has_feature, intent_data, cached_time = self._intent_cache[cache_key]
519
- if time_module.time() - cached_time < self._intent_cache_ttl:
520
  logger.debug(f"💾 意圖快取命中: {message[:50]}...")
521
-
522
- # 【關鍵修復】快取命中時,仍需重新偵測情緒(情緒是即時的)
523
- # 因為同一句話在不同時間說可能帶有不同的情緒強度
524
  try:
525
  fresh_emotion = await self._analyze_emotion_from_message(message)
526
  if fresh_emotion and intent_data:
527
- intent_data = dict(intent_data) # 複製避免修改原快取
528
  intent_data['emotion'] = fresh_emotion
529
  logger.info(f"🎭 快取命中但重新偵測情緒: {fresh_emotion}")
530
  except Exception as e:
531
  logger.warning(f"快取命中時情緒分析失敗: {e}")
532
-
533
  return has_feature, intent_data
534
  else:
535
  del self._intent_cache[cache_key]
@@ -543,7 +425,6 @@ class MCPAgentBridge:
543
  return True, {"type": "special_command", "command": "feature_list"}
544
 
545
  try:
546
- # 從 tool_registry 取得 OpenAI tools 格式
547
  from core.tool_registry import tool_registry
548
  from core.tool_router import tool_router
549
 
@@ -553,39 +434,51 @@ class MCPAgentBridge:
553
  logger.warning("⚠️ 沒有可用的工具,降級為聊天")
554
  return False, {"emotion": "neutral"}
555
 
556
- # 使用 ToolRouter 動態過濾和排序工具
557
- context = {"hour": datetime.now().hour}
558
- tools = tool_router.filter_tools(all_tools, message, context)
 
 
 
 
559
 
560
- logger.info(f"🔧 載入 {len(all_tools)} 個工具,過濾後 {len(tools)} 個")
561
 
562
- # 建構精簡的 system prompt(只處理特殊規則)
563
- system_prompt = self._build_function_calling_prompt()
564
 
565
  messages = [
566
  {"role": "system", "content": system_prompt},
567
  {"role": "user", "content": message}
568
  ]
569
 
570
- # 使用 OpenAI Function Calling
571
  from core.reasoning_strategy import get_optimal_reasoning_effort
572
  optimal_effort = get_optimal_reasoning_effort("intent_detection")
573
  logger.info(f"🧠 意圖檢測推理強度: {optimal_effort}")
574
 
 
 
575
  response = await ai_service.generate_response_with_tools(
576
  messages=messages,
577
  tools=tools,
578
  user_id="intent_detection",
579
- model="gpt-4o-mini", # 使用更強的模型以提升參數提取準確度
580
- reasoning_effort=None, # gpt-4o-mini 不支援 reasoning_effort
581
  tool_choice="auto",
582
  )
583
 
584
- # 解析回應
 
 
 
 
 
 
 
 
585
  tool_calls = response.get("tool_calls", [])
586
 
587
  if tool_calls:
588
- # GPT 選擇了工具
589
  tool_call = tool_calls[0]
590
  function = tool_call.get("function", {})
591
  tool_name = function.get("name", "")
@@ -596,7 +489,6 @@ class MCPAgentBridge:
596
  except json.JSONDecodeError:
597
  arguments = {}
598
 
599
- # 正規化工具名稱
600
  normalized_name = self._normalize_tool_name(tool_name)
601
  if not normalized_name:
602
  logger.warning(f"⚠️ 工具 {tool_name} 無法對應到註冊名稱,降級為聊天")
@@ -605,38 +497,36 @@ class MCPAgentBridge:
605
  logger.info(f"✅ GPT 選擇工具: {normalized_name}")
606
  logger.debug(f"工具參數: {_safe_json(arguments)}")
607
 
608
- # 提取情緒(從 content 或直接從用戶訊息分析)
609
  content = response.get("content", "")
610
  if content:
611
  emotion = self._extract_emotion_from_content(content)
612
  else:
613
- # 當 GPT 只回傳 tool_calls 時,直接從戶訊息分析情緒
614
- logger.debug(f"🔍 GPT content 為空,從用戶訊息分析情緒")
615
- emotion = await self._analyze_emotion_from_message(message)
 
616
 
617
  intent_result = (True, {
618
  "type": "mcp_tool",
619
  "tool_name": normalized_name,
620
  "arguments": arguments,
621
  "emotion": emotion,
 
622
  })
623
 
624
- # 寫入快取
625
- self._intent_cache[cache_key] = (*intent_result, time_module.time())
626
  return intent_result
627
 
628
  else:
629
- # GPT 未選擇工具,視為一般聊天
630
  logger.info("💬 GPT 判斷為一般聊天")
631
  emotion = self._extract_emotion_from_content(response.get("content", ""))
632
 
633
  intent_result = (False, {"emotion": emotion})
634
- self._intent_cache[cache_key] = (*intent_result, time_module.time())
635
  return intent_result
636
 
637
  except Exception as e:
638
  logger.error(f"❌ Function Calling 意圖檢測失敗: {e}")
639
- # 降級:使用關鍵詞匹配
640
  logger.info("🔄 嘗試使用關鍵詞匹配作為降級方案")
641
  try:
642
  fallback_result = self._keyword_intent_detection(message)
@@ -646,20 +536,60 @@ class MCPAgentBridge:
646
  except Exception as fallback_error:
647
  logger.error(f"❌ 關鍵詞匹配也失敗: {fallback_error}")
648
 
649
- # 最終降級:視為一般聊天
650
  logger.info("💬 降級為一般聊天")
651
  return False, {"emotion": "neutral"}
652
 
653
- def _build_function_calling_prompt(self) -> str:
654
  """
655
- 建構精簡的 Function Calling system prompt
656
-
657
- 注意:不再描述每個工具,工具定義由 tools 參數傳遞
658
- 只處理特殊規則和情緒判斷
659
  """
660
- return """You are an intelligent assistant that selects appropriate tools based on user needs.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
 
662
- Rules:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
663
  1. If the user's request can be solved with a tool, select the most appropriate tool
664
  2. Only skip tool selection for pure greetings (hi, hello) or meta questions (what can you do)
665
  3. Extract tool parameters from user message
@@ -676,116 +606,18 @@ Rules:
676
 
677
  【重要】語言使用規範:
678
  - 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)
679
- - 範例:用戶說「台北天氣」或 "Taipei weather" → 參數 {"city": "Taipei"}
680
 
681
  參數語言轉換規則:
682
- - 城市名稱:台北→Taipei, 新北→NewTaipei, 桃園→Taoyuan, 台中→Taichung, 台南→Tainan, 高雄→Kaohsiung, 新竹→Hsinchu
683
  - 國家名稱:台灣→Taiwan, 美國→USA, 日本→Japan, 英國→UK
684
- - 貨幣代碼:美元→USD, 台幣→TWD, 日圓→JPY, 歐元→EUR, 英鎊→GBP
685
-
686
- 重要城市參數提取原則:
687
- - 只有在用戶明確提到城市名稱時才填 city 參數
688
- - 「附近」「這裡」「我這邊」等詞 不填 city 參數,系統會自動從 GPS 判斷
689
- - 「台北的XX」「桃園XX」→ 填對應的英文城市名
690
- - 範例:「附近的 YouBike」→ {},「桃園的 YouBike」→ {"city": "Taoyuan"}
691
-
692
- 匯率查詢(重要!參數提取規則):
693
- 當用戶詢問匯率資訊時,你必須從消息中提取貨幣代碼並填入參數。
694
-
695
- 參數提取規則:
696
- 1. 句型「[貨幣A]轉[貨幣B]」「[貨幣A]換[貨幣B]」「[貨幣A]兌[貨幣B]」→ {"from_currency": "代碼A", "to_currency": "代碼B"}
697
- 2. 句型「[數字][貨幣A]是多少[貨幣B]」→ {"from_currency": "代碼A", "to_currency": "代碼B", "amount": 數字}
698
- 3. 句型「匯率」「美金」「日幣」→ 提取提到的貨幣
699
- 4. 貨幣代碼必須用 ISO 4217 標準(3個大寫字母)
700
-
701
- 常見貨幣代碼對照:
702
- - 美元/美金 → USD
703
- - 台幣/新台幣 → TWD
704
- - 日圓/日幣 → JPY
705
- - 歐元 → EUR
706
- - 英鎊 → GBP
707
- - 人民幣 → CNY
708
- - 港幣 → HKD
709
- - 韓元 → KRW
710
-
711
- 實際範例:
712
- - 「美元轉日幣的匯率」→ {"from_currency": "USD", "to_currency": "JPY"}
713
- - 「台幣換美金」→ {"from_currency": "TWD", "to_currency": "USD"}
714
- - 「100美元是多少台幣」→ {"from_currency": "USD", "to_currency": "TWD", "amount": 100}
715
- - 「歐元兌日圓」→ {"from_currency": "EUR", "to_currency": "JPY"}
716
- - 「匯率」→ {"from_currency": "USD", "to_currency": "TWD"}(預設)
717
-
718
- 重要:必須提取貨幣代碼!不要返回空參數!
719
-
720
- 公車查詢(重要!參數提取規則):
721
- 當用戶詢問公車資訊時,你必須從消息中提取路線號碼並填入參數。
722
-
723
- tdx_bus_arrival 適用場景:
724
- - 查詢「已知路線號碼」的到站時間
725
- - 查詢附近公車站點(不需 route_name)
726
-
727
- 參數提取規則:
728
- 1. 句型「[數字]公車」「[數字]號公車」→ {"route_name": "數字"}
729
- 2. 句型「[顏色][數字]」(如「紅30」)→ {"route_name": "顏色數字"}
730
- 3. 句型「[數字]還要多久」「[數字]什麼時候到」→ {"route_name": "數字"}
731
- 4. 句型「[路線名]公車到站」→ {"route_name": "路線名"}
732
- 5. 「附近公車」「公車站」「有什麼公車」→ {}(系統自動從 GPS 判斷城市)
733
- 6. 城市參數:只在用戶明確提到城市時才填,否則留空讓系統自動判斷
734
-
735
- 實際範例:
736
- - 「261公車什麼時候到」→ {"route_name": "261"}(不填 city)
737
- - 「307還要多久」→ {"route_name": "307"}(不填 city)
738
- - 「台北261公車」→ {"route_name": "261", "city": "Taipei"}(明確提到台北)
739
- - 「桃園紅30公車」→ {"route_name": "紅30", "city": "Taoyuan"}(明確提到桃園)
740
- - 「附近有什麼公車」→ {}(完全空參數,系統自動判斷)
741
-
742
- 不適用場景(應使用 directions):
743
- - 「從A到B的公車」「往XX的公車」→ 這是路線規劃,不是查詢特定路線
744
- - 「去台北的公車」→ 台北是目的地,不是路線號碼
745
-
746
- 重要:如果提到路線號碼,必須提取!城市參數必須用英文!
747
-
748
- 火車查詢(重要!參數提取規則):
749
- 當用戶詢問火車資訊時,你必須從消息中提取站名並填入參數。
750
-
751
- 參數提取規則(適用於任何地名):
752
- 1. 句型「從 [地名A] 往/到 [地名B]」→ {"origin_station": "地名A", "destination_station": "地名B"}
753
- 2. 句型「[地名A] 到/往 [地名B]」→ {"origin_station": "地名A", "destination_station": "地名B"}
754
- 3. 句型「往/去 [地名]」→ {"destination_station": "地名"}
755
- 4. 句型「[車種][數字]次」→ {"train_no": "數字"}
756
- 5. 包含時間 → 提取為 departure_time(HH:MM 格式)
757
-
758
- 實際範例:
759
- - 「從彰化往台北的火車」→ {"origin_station": "彰化", "destination_station": "台北"}
760
- - 「台中到高雄」→ {"origin_station": "台中", "destination_station": "高雄"}
761
- - 「往新竹的火車」→ {"destination_station": "新竹"}
762
- - 「自強號123次」→ {"train_no": "123"}
763
- - 「早上8點台南到台北」→ {"origin_station": "台南", "destination_station": "台北", "departure_time": "08:00"}
764
-
765
- 重要:絕對不要返回空的 {} 參數!必須從用戶消息中提取站名!
766
-
767
- 位置查詢:
768
- - 「我在哪」使用 reverse_geocode,不需要參數
769
- - 「怎麼去XX」使用 forward_geocode 或 directions
770
-
771
- YouBike 查詢(重要!參數提取規則):
772
- 當用戶詢問 YouBike/Ubike/微笑單車時,你必須調用 tdx_youbike 工具。
773
-
774
- 參數提取規則:
775
- 1. 「附近的 YouBike」「Ubike 在哪」→ {}(不填 city,系統自動從 GPS 判斷)
776
- 2. 「市政府 YouBike」「台北車站 Ubike」→ {"station_name": "市政府"}(不填 city)
777
- 3. 「XX站還有車嗎」→ {"station_name": "XX站"}(不填 city)
778
- 4. 「台北的 YouBike」「桃園 YouBike」→ 填對應英文城市名
779
- 5. 站名可用中文,城市必須用英文
780
-
781
- 實際範例:
782
- - 「附近的 YouBike」→ {}(完全空參數,系統自動判斷城市)
783
- - 「市政府 YouBike 還有車嗎」→ {"station_name": "市政府"}(不填 city)
784
- - 「台北車站 Ubike」→ {"station_name": "台北車站"}(不填 city)
785
- - 「台北的 YouBike」→ {"city": "Taipei"}(明確提到台北)
786
- - 「桃園 YouBike」→ {"city": "Taoyuan"}(明確提到桃園)
787
-
788
- 重要:只在用戶明確提到城市時才填 city 參數!站名可保持中文!
789
 
790
  【情緒偵測】(重要!):
791
  - 分析用戶的情緒狀態(根據用詞、語氣、標點符號、表情符號)
@@ -799,6 +631,38 @@ YouBike 查詢(重要!參數提取規則):
799
  * 用戶說「哇!」→ 回應最後加上 [EMOTION:surprise]
800
  * 一般對話 → 回應最後加上 [EMOTION:neutral]
801
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
802
 
803
  def _extract_emotion_from_content(self, content: str) -> str:
804
  """從回應內容中提取情緒標籤 [EMOTION:xxx]"""
@@ -830,9 +694,18 @@ YouBike 查詢(重要!參數提取規則):
830
  import services.ai_service as ai_service
831
 
832
  system_prompt = (
833
- "分析用戶訊息的情緒狀態。\n"
834
- "情緒類型neutral(平靜)、happy(開心)、sad(難過)、angry(生氣)、fear(害怕)、surprise(驚訝)\n"
835
- "只回傳情緒類型的英文單字,不要有任何其他文字。"
 
 
 
 
 
 
 
 
 
836
  )
837
 
838
  messages = [
@@ -842,7 +715,7 @@ YouBike 查詢(重要!參數提取規則):
842
 
843
  emotion = await ai_service.generate_response_async(
844
  messages=messages,
845
- model="gpt-4o-mini",
846
  max_tokens=10,
847
  )
848
 
@@ -991,29 +864,35 @@ YouBike 查詢(重要!參數提取規則):
991
  import re
992
  city_match = re.search(r'([^\s,。!?]+)\s*天氣', message)
993
  city = city_match.group(1) if city_match else "台北"
 
994
 
995
  return True, {
996
  "type": "mcp_tool",
997
  "tool_name": "weather_query",
998
- "arguments": {"city": city}
 
999
  }
1000
 
1001
  # 新聞檢測
1002
  news_keywords = ["新聞", "消息", "報導", "news"]
1003
  if any(kw in message_lower for kw in news_keywords):
 
1004
  return True, {
1005
  "type": "mcp_tool",
1006
  "tool_name": "news_query",
1007
- "arguments": {"language": "zh-TW", "limit": 5}
 
1008
  }
1009
 
1010
  # 匯率檢測
1011
  exchange_keywords = ["匯率", "美元", "台幣", "exchange", "usd", "twd"]
1012
  if any(kw in message_lower for kw in exchange_keywords):
 
1013
  return True, {
1014
  "type": "mcp_tool",
1015
  "tool_name": "exchange_query",
1016
- "arguments": {"from_currency": "USD", "to_currency": "TWD"}
 
1017
  }
1018
 
1019
  return False, None
@@ -1213,6 +1092,9 @@ YouBike 查詢(重要!參數提取規則):
1213
  "⭐ 分析使用者的核心意圖(問溫度?天氣?時間?地點?數量?)\n"
1214
  "⭐ 從工具數據中只提取相關資訊,無關資訊一���省略\n"
1215
  "⭐ **注意:用什麼語言提問,就用什麼語言回答**(日文問→日文答,英文問→英文答)\n\n"
 
 
 
1216
  "【回應要求】\n"
1217
  "1. 使用口語化、親切的語氣(可以用「喔」「呢」「哦」等語氣詞)\n"
1218
  "2. 不要列表式的羅列數據,而是用對話方式描述\n"
@@ -1243,13 +1125,16 @@ YouBike 查詢(重要!參數提取規則):
1243
  {"role": "user", "content": user_prompt}
1244
  ]
1245
 
1246
- # 格式化回應使用 gpt-4o-mini(支援多語言,不需 reasoning_effort)
1247
- response = await ai_service.generate_response_for_user(
 
 
 
1248
  messages=messages,
1249
- user_id="format_response",
1250
- model="gpt-4o-mini", # 升級到 gpt-4o-mini 以支援多語言
1251
- chat_id=None,
1252
- reasoning_effort=None # gpt-4o-mini 不支援此參數
1253
  )
1254
 
1255
  return response
 
6
  import json
7
  import logging
8
  import asyncio
9
+ import time
10
  from typing import Dict, Any, Optional, List, Tuple, Callable, Awaitable
11
  from datetime import datetime
12
  from .server import FeaturesMCPServer
13
  import services.ai_service as ai_service
14
  from services.ai_service import StrictResponseError
15
+ from core.prompts.tool_calling_policy import get_tool_calling_policy
16
  from core.reasoning_strategy import get_optimal_reasoning_effort
17
  from core.database import get_user_env_current
18
  from .coordinator import ToolCoordinator
 
107
  name="weather_query",
108
  requires_env={"lat", "lon", "city"},
109
  env_fallbacks={"city": ["detailed_address", "label"]},
110
+ enable_reformat=False,
111
  )
112
  )
113
  register(
114
  ToolMetadata(
115
  name="reverse_geocode",
116
  requires_env={"lat", "lon"},
117
+ enable_reformat=False,
118
  )
119
  )
120
  register(
121
  ToolMetadata(
122
  name="exchange_query",
123
+ enable_reformat=False,
124
  )
125
  )
126
  register(
127
  ToolMetadata(
128
  name="news_query",
129
+ enable_reformat=False,
130
  )
131
  )
132
  register(
133
  ToolMetadata(
134
  name="healthkit_query",
135
+ enable_reformat=False,
136
  )
137
  )
138
  register(
139
  ToolMetadata(
140
  name="directions",
141
+ enable_reformat=False,
142
  )
143
  )
144
  register(
 
225
  logger.info(f"異步初始化完成,完整可用 MCP 工具數量: {len(self.mcp_server.tools)}")
226
 
227
  # 將 MCP Server 的工具註冊到 tool_registry
228
+ from core.tool_registry import register_mcp_tools_to_registry
229
+ register_mcp_tools_to_registry(self.mcp_server)
230
 
231
  # 快取預熱已移除:啟動時連續調用 7 次 GPT API 增加延遲和成本
232
  # 實際使用中快取會自然累積,無需預熱
233
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
234
 
235
  def _normalize_tool_name(self, raw_name: Optional[str]) -> Optional[str]:
236
  """
 
390
  "tool_data": fallback_payload,
391
  }
392
 
393
+ async def detect_intent(self, message: str, tool_context: str = "", language: Optional[str] = None) -> Tuple[bool, Optional[Dict[str, Any]]]:
394
  """
395
+ 意圖偵測(符合 2025 年最佳實踐:語言感知與快
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  """
397
  import hashlib
398
+ # message, tool_context 與 language 組合後計算 md5
399
+ cache_raw = f"{message}||{tool_context or ''}||{language or ''}"
400
+ cache_key = hashlib.md5(cache_raw.encode()).hexdigest()
 
401
 
402
  # 檢查快取
403
  if cache_key in self._intent_cache:
404
  has_feature, intent_data, cached_time = self._intent_cache[cache_key]
405
+ if time.time() - cached_time < self._intent_cache_ttl:
406
  logger.debug(f"💾 意圖快取命中: {message[:50]}...")
 
 
 
407
  try:
408
  fresh_emotion = await self._analyze_emotion_from_message(message)
409
  if fresh_emotion and intent_data:
410
+ intent_data = dict(intent_data)
411
  intent_data['emotion'] = fresh_emotion
412
  logger.info(f"🎭 快取命中但重新偵測情緒: {fresh_emotion}")
413
  except Exception as e:
414
  logger.warning(f"快取命中時情緒分析失敗: {e}")
 
415
  return has_feature, intent_data
416
  else:
417
  del self._intent_cache[cache_key]
 
425
  return True, {"type": "special_command", "command": "feature_list"}
426
 
427
  try:
 
428
  from core.tool_registry import tool_registry
429
  from core.tool_router import tool_router
430
 
 
434
  logger.warning("⚠️ 沒有可用的工具,降級為聊天")
435
  return False, {"emotion": "neutral"}
436
 
437
+ router_context = {
438
+ "hour": datetime.now().hour,
439
+ "language": language
440
+ }
441
+ tools = tool_router.filter_tools(all_tools, message, router_context)
442
+
443
+ logger.info(f"🔧 載入 {len(all_tools)} 個工具,過濾後 {len(tools)} 個 (語言: {language})")
444
 
445
+ system_prompt = self._build_function_calling_prompt(language)
446
 
447
+ if tool_context:
448
+ system_prompt += f"\n\n【已執行的工具結果與上下文】\n請根據以下資訊判斷是否足夠回答用戶,若不足則繼續調用工具:\n{tool_context}"
449
 
450
  messages = [
451
  {"role": "system", "content": system_prompt},
452
  {"role": "user", "content": message}
453
  ]
454
 
 
455
  from core.reasoning_strategy import get_optimal_reasoning_effort
456
  optimal_effort = get_optimal_reasoning_effort("intent_detection")
457
  logger.info(f"🧠 意圖檢測推理強度: {optimal_effort}")
458
 
459
+ emotion_task = asyncio.create_task(self._analyze_emotion_from_message(message))
460
+
461
  response = await ai_service.generate_response_with_tools(
462
  messages=messages,
463
  tools=tools,
464
  user_id="intent_detection",
465
+ model=None,
466
+ reasoning_effort=optimal_effort,
467
  tool_choice="auto",
468
  )
469
 
470
+ try:
471
+ parallel_emotion = await emotion_task
472
+ except Exception as e:
473
+ logger.warning(f"並行情緒分析失敗: {e}")
474
+ parallel_emotion = "neutral"
475
+ finally:
476
+ if not emotion_task.done():
477
+ emotion_task.cancel()
478
+
479
  tool_calls = response.get("tool_calls", [])
480
 
481
  if tool_calls:
 
482
  tool_call = tool_calls[0]
483
  function = tool_call.get("function", {})
484
  tool_name = function.get("name", "")
 
489
  except json.JSONDecodeError:
490
  arguments = {}
491
 
 
492
  normalized_name = self._normalize_tool_name(tool_name)
493
  if not normalized_name:
494
  logger.warning(f"⚠️ 工具 {tool_name} 無法對應到註冊名稱,降級為聊天")
 
497
  logger.info(f"✅ GPT 選擇工具: {normalized_name}")
498
  logger.debug(f"工具參數: {_safe_json(arguments)}")
499
 
 
500
  content = response.get("content", "")
501
  if content:
502
  emotion = self._extract_emotion_from_content(content)
503
  else:
504
+ logger.debug(f"🔍 使並行情緒分析結果: {parallel_emotion}")
505
+ emotion = parallel_emotion
506
+
507
+ confidence = self._calculate_tool_confidence(normalized_name, arguments)
508
 
509
  intent_result = (True, {
510
  "type": "mcp_tool",
511
  "tool_name": normalized_name,
512
  "arguments": arguments,
513
  "emotion": emotion,
514
+ "confidence": confidence,
515
  })
516
 
517
+ self._intent_cache[cache_key] = (*intent_result, time.time())
 
518
  return intent_result
519
 
520
  else:
 
521
  logger.info("💬 GPT 判斷為一般聊天")
522
  emotion = self._extract_emotion_from_content(response.get("content", ""))
523
 
524
  intent_result = (False, {"emotion": emotion})
525
+ self._intent_cache[cache_key] = (*intent_result, time.time())
526
  return intent_result
527
 
528
  except Exception as e:
529
  logger.error(f"❌ Function Calling 意圖檢測失敗: {e}")
 
530
  logger.info("🔄 嘗試使用關鍵詞匹配作為降級方案")
531
  try:
532
  fallback_result = self._keyword_intent_detection(message)
 
536
  except Exception as fallback_error:
537
  logger.error(f"❌ 關鍵詞匹配也失敗: {fallback_error}")
538
 
 
539
  logger.info("💬 降級為一般聊天")
540
  return False, {"emotion": "neutral"}
541
 
542
+ def get_current_time_data(self) -> Dict[str, Any]:
543
  """
544
+ 獲取當前時間數據,用於生成個性化歡迎詞
545
+ 返回格式與舊 time_service 兼容
 
 
546
  """
547
+ now = datetime.now()
548
+
549
+ # 獲取時間段
550
+ hour = now.hour
551
+ if 5 <= hour < 12:
552
+ day_period = "上午"
553
+ elif 12 <= hour < 18:
554
+ day_period = "下午"
555
+ elif 18 <= hour < 22:
556
+ day_period = "晚上"
557
+ else:
558
+ day_period = "深夜" if hour >= 22 else "凌晨"
559
+
560
+ # 星期幾中文名稱
561
+ weekdays = ["星期一", "星期二", "星期三", "星期四", "星期五", "星期六", "星期日"]
562
+ weekday_full_chinese = weekdays[now.weekday()]
563
+
564
+ return {
565
+ "year": now.year,
566
+ "month": now.month,
567
+ "day": now.day,
568
+ "hour": hour,
569
+ "minute": now.minute,
570
+ "second": now.second,
571
+ "weekday": now.weekday(), # 0-6, 星期一到星期日
572
+ "weekday_full_chinese": weekday_full_chinese,
573
+ "day_period": day_period,
574
+ "timestamp": now.timestamp(),
575
+ "iso_format": now.isoformat()
576
+ }
577
 
578
+ def _build_function_calling_prompt(self, language: Optional[str] = None) -> str:
579
+ """
580
+ 建構精簡的 Function Calling system prompt
581
+ """
582
+ from core.prompts.tool_calling_policy import get_tool_calling_policy
583
+ policy = get_tool_calling_policy()
584
+
585
+ # 根據語系決定基礎提示詞語言
586
+ lang_context = f"目前的用戶語言是:{language or 'zh (繁體中文)'}"
587
+
588
+ return (
589
+ f"You are an intelligent assistant. {lang_context}\n\n"
590
+ + policy
591
+ + "\n\n"
592
+ + """Rules:
593
  1. If the user's request can be solved with a tool, select the most appropriate tool
594
  2. Only skip tool selection for pure greetings (hi, hello) or meta questions (what can you do)
595
  3. Extract tool parameters from user message
 
606
 
607
  【重要】語言使用規範:
608
  - 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)
609
+ - 範例:用戶說「台北天氣」或 "Taipei weather" 或 "東京の天気" → 參數 {"city": "Taipei"} 或 {"city": "Tokyo"}
610
 
611
  參數語言轉換規則:
612
+ - 城市名稱:台北→Taipei, 新北→NewTaipei, 桃園→Taoyuan, 台中→Taichung, 台南→Tainan, 高雄→Kaohsiung, 新竹→Hsinchu, 東京→Tokyo, 大阪→Osaka, 京都→Kyoto
613
  - 國家名稱:台灣→Taiwan, 美國→USA, 日本→Japan, 英國→UK
614
+ - 貨幣代碼:美元→USD, 台幣→TWD, 日圓/円→JPY, 歐元→EUR, 英鎊→GBP
615
+
616
+ Answerability Check - Confidence-Driven Agent Loop
617
+ 1. Evaluate provided "tool_context" (if any) against the user's request.
618
+ 2. If tool_context contains sufficient information to answer the user with ~100% confidence, do NOT call any more tools.
619
+ 3. If information is missing, inconsistent, or outdated, select the appropriate tool to gather more evidence (work_again).
620
+ 4. If the user's question is "Can you find X and then Y?", you must first find X, evaluate its result, then in the next turn find Y based on X.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
621
 
622
  【情緒偵測】(重要!):
623
  - 分析用戶的情緒狀態(根據用詞、語氣、標點符號、表情符號)
 
631
  * 用戶說「哇!」→ 回應最後加上 [EMOTION:surprise]
632
  * 一般對話 → 回應最後加上 [EMOTION:neutral]
633
  """
634
+ )
635
+
636
+ def _calculate_tool_confidence(self, tool_name: str, arguments: Dict[str, Any]) -> float:
637
+ """根據工具所需參數動態計算信心度"""
638
+ if not tool_name:
639
+ return 0.0
640
+
641
+ try:
642
+ from core.tool_registry import tool_registry
643
+ tool_def = tool_registry.get_tool(tool_name)
644
+
645
+ # 如果找不到工具定義,或者該工具沒有 parameters
646
+ if not tool_def or not tool_def.parameters:
647
+ return 0.95 if arguments else 0.92
648
+
649
+ required_params = tool_def.parameters.get("required", [])
650
+
651
+ if not required_params:
652
+ return 1.0 # 不需要任何參數,直接給最高信心度
653
+
654
+ # 檢查缺少的必填參數數量
655
+ missing_count = sum(1 for req in required_params if req not in arguments or arguments[req] is None or str(arguments[req]).strip() == "")
656
+
657
+ if missing_count == 0:
658
+ return 1.0
659
+
660
+ # 每缺少一個必填參數,扣除一定比例的信心度(確保其低於 MIN_TOOL_CONFIDENCE 0.90)
661
+ return max(0.0, 0.95 - (missing_count * 0.5))
662
+ except Exception as e:
663
+ logger.warning(f"⚠️ 計算工具信心度失敗: {e}")
664
+ return 0.95 if arguments else 0.92
665
+
666
 
667
  def _extract_emotion_from_content(self, content: str) -> str:
668
  """從回應內容中提取情緒標籤 [EMOTION:xxx]"""
 
694
  import services.ai_service as ai_service
695
 
696
  system_prompt = (
697
+ "## 任務:純粹情緒分類\n"
698
+ "你是一個專業的情緒分析員。請分析用戶訊息的情緒狀態,並僅從以下列表中選擇一個最符合的標籤:\n"
699
+ "- neutral(平靜/詢問資訊)\n"
700
+ "- happy(開心/興奮)\n"
701
+ "- sad(難過/沮喪)\n"
702
+ "- angry(生氣/不滿)\n"
703
+ "- fear(害怕/焦慮)\n"
704
+ "- surprise(驚訝/震撼)\n\n"
705
+ "## 規則:\n"
706
+ "1. **絕對不要回答用戶的問題**(例如用戶問股價,你絕對不能回答數字)。\n"
707
+ "2. **只能回傳單個英文標籤**,不要有任何解釋或標點符號。\n"
708
+ "3. 如果訊息是中性的事實詢問,請回傳 neutral。"
709
  )
710
 
711
  messages = [
 
715
 
716
  emotion = await ai_service.generate_response_async(
717
  messages=messages,
718
+ model=None,
719
  max_tokens=10,
720
  )
721
 
 
864
  import re
865
  city_match = re.search(r'([^\s,。!?]+)\s*天氣', message)
866
  city = city_match.group(1) if city_match else "台北"
867
+ args = {"city": city}
868
 
869
  return True, {
870
  "type": "mcp_tool",
871
  "tool_name": "weather_query",
872
+ "arguments": args,
873
+ "confidence": self._calculate_tool_confidence("weather_query", args)
874
  }
875
 
876
  # 新聞檢測
877
  news_keywords = ["新聞", "消息", "報導", "news"]
878
  if any(kw in message_lower for kw in news_keywords):
879
+ args = {"language": "zh-TW", "limit": 5}
880
  return True, {
881
  "type": "mcp_tool",
882
  "tool_name": "news_query",
883
+ "arguments": args,
884
+ "confidence": self._calculate_tool_confidence("news_query", args)
885
  }
886
 
887
  # 匯率檢測
888
  exchange_keywords = ["匯率", "美元", "台幣", "exchange", "usd", "twd"]
889
  if any(kw in message_lower for kw in exchange_keywords):
890
+ args = {"from_currency": "USD", "to_currency": "TWD"}
891
  return True, {
892
  "type": "mcp_tool",
893
  "tool_name": "exchange_query",
894
+ "arguments": args,
895
+ "confidence": self._calculate_tool_confidence("exchange_query", args)
896
  }
897
 
898
  return False, None
 
1092
  "⭐ 分析使用者的核心意圖(問溫度?天氣?時間?地點?數量?)\n"
1093
  "⭐ 從工具數據中只提取相關資訊,無關資訊一���省略\n"
1094
  "⭐ **注意:用什麼語言提問,就用什麼語言回答**(日文問→日文答,英文問→英文答)\n\n"
1095
+ "【反幻覺與安全原則】\n"
1096
+ "🚨 嚴禁推測:如果工具返回的資料缺漏,必須誠實告知用戶,絕對不能自己憑空捏造數字或事實\n"
1097
+ "🚨 不得把工具錯誤包裝成成功結果:如果工具返回錯誤或查無資料,不能說「我查到了」\n\n"
1098
  "【回應要求】\n"
1099
  "1. 使用口語化、親切的語氣(可以用「喔」「呢」「哦」等語氣詞)\n"
1100
  "2. 不要列表式的羅列數據,而是用對話方式描述\n"
 
1125
  {"role": "user", "content": user_prompt}
1126
  ]
1127
 
1128
+ # 格式化回應使用環境變數設定的模型
1129
+ model = settings.GPT_INTENT_MODEL or settings.OPENAI_MODEL
1130
+ logger.info(f"🎨 使用配置模型進行格式化: {model}")
1131
+
1132
+ response = await ai_service.generate_response_with_tools(
1133
  messages=messages,
1134
+ tools=None,
1135
+ user_id="reformatting",
1136
+ model=model,
1137
+ reasoning_effort=None,
1138
  )
1139
 
1140
  return response
features/mcp/auto_registry.py CHANGED
@@ -54,14 +54,17 @@ class MCPAutoRegistry:
54
 
55
  logger.info(f"掃描工具目錄: {tools_path}")
56
 
57
- # 掃描所有 Python 文件(包含 *_tool.py 和 tdx_*.py)
58
- tool_files = list(tools_path.glob("*_tool.py")) + list(tools_path.glob("tdx_*.py"))
59
- # 去重(避免 tdx_*_tool.py 被掃描兩次)
60
- tool_files = list(set(tool_files))
61
 
62
  for py_file in tool_files:
 
 
 
63
  tool_name = py_file.stem
64
- module_name = f"{tools_dir}.{tool_name}"
 
 
65
 
66
  try:
67
  # 動態導入模組
@@ -175,7 +178,8 @@ class MCPAutoRegistry:
175
  description=definition["description"],
176
  inputSchema=definition["inputSchema"],
177
  handler=handler,
178
- metadata=definition.get("metadata", {})
 
179
  )
180
 
181
  return tool
@@ -198,7 +202,8 @@ class MCPAutoRegistry:
198
  description=definition["description"],
199
  inputSchema=definition["inputSchema"],
200
  handler=handler,
201
- metadata=definition.get("metadata", {})
 
202
  )
203
  return tool
204
  else:
@@ -211,7 +216,8 @@ class MCPAutoRegistry:
211
  description=definition["description"],
212
  inputSchema=definition["inputSchema"],
213
  handler=handler,
214
- metadata=definition.get("metadata", {})
 
215
  )
216
  return tool
217
 
@@ -293,7 +299,17 @@ class MCPAutoRegistry:
293
  description=description,
294
  inputSchema=input_schema,
295
  handler=placeholder_handler,
296
- metadata=metadata
 
 
 
 
 
 
 
 
 
 
297
  )
298
 
299
  logger.info(f"創建系統工具占位符: {name}")
 
54
 
55
  logger.info(f"掃描工具目錄: {tools_path}")
56
 
57
+ # 掃描所有 Python 文件
58
+ tool_files = list(tools_path.rglob("*.py"))
 
 
59
 
60
  for py_file in tool_files:
61
+ if py_file.name == "__init__.py" or py_file.name == "base_tool.py" or py_file.name == "tdx_base.py":
62
+ continue
63
+
64
  tool_name = py_file.stem
65
+ rel_path = py_file.relative_to(tools_path)
66
+ module_parts = list(rel_path.parts[:-1]) + [tool_name]
67
+ module_name = f"{tools_dir}.{'.'.join(module_parts)}"
68
 
69
  try:
70
  # 動態導入模組
 
178
  description=definition["description"],
179
  inputSchema=definition["inputSchema"],
180
  handler=handler,
181
+ metadata=definition.get("metadata", {}),
182
+ outputSchema=definition.get("outputSchema")
183
  )
184
 
185
  return tool
 
202
  description=definition["description"],
203
  inputSchema=definition["inputSchema"],
204
  handler=handler,
205
+ metadata=definition.get("metadata", {}),
206
+ outputSchema=definition.get("outputSchema")
207
  )
208
  return tool
209
  else:
 
216
  description=definition["description"],
217
  inputSchema=definition["inputSchema"],
218
  handler=handler,
219
+ metadata=definition.get("metadata", {}),
220
+ outputSchema=definition.get("outputSchema")
221
  )
222
  return tool
223
 
 
299
  description=description,
300
  inputSchema=input_schema,
301
  handler=placeholder_handler,
302
+ metadata=metadata,
303
+ outputSchema={
304
+ "type": "object",
305
+ "properties": {
306
+ "success": {"type": "boolean"},
307
+ "content": {"type": "string"},
308
+ "error": {"type": ["string", "null"]},
309
+ "error_code": {"type": ["string", "null"]},
310
+ },
311
+ "required": ["success"],
312
+ }
313
  )
314
 
315
  logger.info(f"創建系統工具占位符: {name}")
features/mcp/coordinator.py CHANGED
@@ -1,14 +1,38 @@
1
  import asyncio
2
  import logging
 
3
  from typing import Any, Awaitable, Callable, Dict, Optional
4
 
5
  from .tool_models import ToolMetadata, ToolResult
6
 
 
 
 
 
 
7
  logger = logging.getLogger(__name__)
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  EnvProvider = Callable[[Optional[str]], Awaitable[Dict[str, Any]]]
10
  ResultFormatter = Callable[[str, str, Dict[str, Any], str], Awaitable[str]]
11
  ToolHandler = Callable[[Dict[str, Any]], Awaitable[Any]]
 
 
 
 
 
12
 
13
 
14
  class ToolCoordinator:
@@ -25,11 +49,13 @@ class ToolCoordinator:
25
  env_provider: EnvProvider,
26
  tool_lookup: Callable[[str], Optional[ToolHandler]],
27
  formatter: ResultFormatter,
 
28
  failure_handlers: Optional[Dict[str, Callable[[Dict[str, Any], Exception], ToolResult]]] = None,
29
  ) -> None:
30
  self._env_provider = env_provider
31
  self._tool_lookup = tool_lookup
32
  self._formatter = formatter
 
33
  self._metadata: Dict[str, ToolMetadata] = {}
34
  self._failure_handlers = failure_handlers or {}
35
 
@@ -78,7 +104,10 @@ class ToolCoordinator:
78
  logger.info(f"📦 [Coordinator] 環境資訊: {env_ctx}")
79
  if env_ctx:
80
  for field in metadata.requires_env:
81
- if merged.get(field) is not None:
 
 
 
82
  continue
83
  env_value = env_ctx.get(field)
84
  # 主欄位為 None 時,嘗試 fallback 欄位
@@ -90,6 +119,7 @@ class ToolCoordinator:
90
  break
91
  # 只注入非 None 的值,避免覆蓋工具的預設值或觸發 schema 驗證錯誤
92
  if env_value is not None:
 
93
  merged[field] = env_value
94
  logger.info(f"📦 [Coordinator] 注入環境變數: {field}={env_value}")
95
  elif not user_id:
@@ -98,6 +128,24 @@ class ToolCoordinator:
98
  logger.info(f"📦 [Coordinator] 最終參數: {merged}")
99
  return merged
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  async def _execute(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
102
  handler = self._tool_lookup(tool_name)
103
  if not handler:
@@ -109,8 +157,13 @@ class ToolCoordinator:
109
  try:
110
  result = await asyncio.wait_for(handler(arguments), timeout=30.0)
111
  if isinstance(result, dict):
 
112
  return result
113
- return {"success": True, "content": str(result)}
 
 
 
 
114
  except Exception as exc: # noqa: BLE001
115
  last_exc = exc
116
  logger.warning("工具 %s 執行失敗 (attempt=%s): %s", tool_name, attempt, exc)
@@ -120,6 +173,21 @@ class ToolCoordinator:
120
  return handler(arguments, last_exc) # type: ignore[arg-type]
121
  raise RuntimeError(f"工具 {tool_name} 執行失敗:{last_exc}") # type: ignore[arg-type]
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  async def _format_result(
124
  self,
125
  tool_name: str,
 
1
  import asyncio
2
  import logging
3
+ import re
4
  from typing import Any, Awaitable, Callable, Dict, Optional
5
 
6
  from .tool_models import ToolMetadata, ToolResult
7
 
8
+ try:
9
+ import jsonschema
10
+ except ImportError:
11
+ jsonschema = None
12
+
13
  logger = logging.getLogger(__name__)
14
 
15
+ CITY_ALIASES = {
16
+ "台北市": "台北",
17
+ "臺北市": "臺北",
18
+ "新北市": "新北",
19
+ "桃園市": "桃園",
20
+ "台中市": "台中",
21
+ "臺中市": "臺中",
22
+ "台南市": "台南",
23
+ "臺南市": "臺南",
24
+ "高雄市": "高雄",
25
+ "新竹市": "新竹",
26
+ }
27
+
28
  EnvProvider = Callable[[Optional[str]], Awaitable[Dict[str, Any]]]
29
  ResultFormatter = Callable[[str, str, Dict[str, Any], str], Awaitable[str]]
30
  ToolHandler = Callable[[Dict[str, Any]], Awaitable[Any]]
31
+ OutputSchemaProvider = Callable[[str], Optional[Dict[str, Any]]]
32
+
33
+
34
+ class ToolOutputValidationError(RuntimeError):
35
+ """Raised when a tool result violates its declared outputSchema."""
36
 
37
 
38
  class ToolCoordinator:
 
49
  env_provider: EnvProvider,
50
  tool_lookup: Callable[[str], Optional[ToolHandler]],
51
  formatter: ResultFormatter,
52
+ output_schema_provider: Optional[OutputSchemaProvider] = None,
53
  failure_handlers: Optional[Dict[str, Callable[[Dict[str, Any], Exception], ToolResult]]] = None,
54
  ) -> None:
55
  self._env_provider = env_provider
56
  self._tool_lookup = tool_lookup
57
  self._formatter = formatter
58
+ self._output_schema_provider = output_schema_provider
59
  self._metadata: Dict[str, ToolMetadata] = {}
60
  self._failure_handlers = failure_handlers or {}
61
 
 
104
  logger.info(f"📦 [Coordinator] 環境資訊: {env_ctx}")
105
  if env_ctx:
106
  for field in metadata.requires_env:
107
+ val = merged.get(field)
108
+ # 如果參數已有值且不是預設佔位符(如 0 或空字串),則跳過注入
109
+ # 這是為了解決 GPT 可能會填入 0 作為座標佔位符的問題
110
+ if val is not None and val != 0 and val != "":
111
  continue
112
  env_value = env_ctx.get(field)
113
  # 主欄位為 None 時,嘗試 fallback 欄位
 
119
  break
120
  # 只注入非 None 的值,避免覆蓋工具的預設值或觸發 schema 驗證錯誤
121
  if env_value is not None:
122
+ env_value = self._normalize_env_value(field, env_value)
123
  merged[field] = env_value
124
  logger.info(f"📦 [Coordinator] 注入環境變數: {field}={env_value}")
125
  elif not user_id:
 
128
  logger.info(f"📦 [Coordinator] 最終參數: {merged}")
129
  return merged
130
 
131
+ @staticmethod
132
+ def _normalize_env_value(field: str, value: Any) -> Any:
133
+ if field != "city" or not isinstance(value, str):
134
+ return value
135
+
136
+ normalized = value.strip()
137
+ if not normalized:
138
+ return value
139
+
140
+ if normalized in CITY_ALIASES:
141
+ return CITY_ALIASES[normalized]
142
+
143
+ exact_match = re.match(r"^(台北|臺北|新北|桃園|台中|臺中|台南|臺南|高雄|新竹)(?:市|縣)?$", normalized)
144
+ if exact_match:
145
+ return exact_match.group(1)
146
+
147
+ return normalized
148
+
149
  async def _execute(self, tool_name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
150
  handler = self._tool_lookup(tool_name)
151
  if not handler:
 
157
  try:
158
  result = await asyncio.wait_for(handler(arguments), timeout=30.0)
159
  if isinstance(result, dict):
160
+ self._validate_output(tool_name, result)
161
  return result
162
+ wrapped = {"success": True, "content": str(result)}
163
+ self._validate_output(tool_name, wrapped)
164
+ return wrapped
165
+ except ToolOutputValidationError:
166
+ raise
167
  except Exception as exc: # noqa: BLE001
168
  last_exc = exc
169
  logger.warning("工具 %s 執行失敗 (attempt=%s): %s", tool_name, attempt, exc)
 
173
  return handler(arguments, last_exc) # type: ignore[arg-type]
174
  raise RuntimeError(f"工具 {tool_name} 執行失敗:{last_exc}") # type: ignore[arg-type]
175
 
176
+ def _validate_output(self, tool_name: str, result: Dict[str, Any]) -> None:
177
+ if not self._output_schema_provider or jsonschema is None:
178
+ return
179
+
180
+ schema = self._output_schema_provider(tool_name)
181
+ if not schema:
182
+ return
183
+
184
+ try:
185
+ jsonschema.validate(result, schema)
186
+ except jsonschema.ValidationError as exc:
187
+ field_path = ".".join(str(part) for part in exc.absolute_path)
188
+ detail = f"{field_path}: {exc.message}" if field_path else exc.message
189
+ raise ToolOutputValidationError(f"工具 {tool_name} 輸出格式不符合契約: {detail}") from exc
190
+
191
  async def _format_result(
192
  self,
193
  tool_name: str,
features/mcp/mcp_client.py CHANGED
@@ -165,6 +165,7 @@ class MCPClient:
165
  name = tool_data.get("name")
166
  description = tool_data.get("description", "")
167
  input_schema = tool_data.get("inputSchema", {"type": "object", "properties": {}})
 
168
 
169
  # 創建代理處理器
170
  async def tool_handler(arguments: Dict[str, Any]) -> Dict[str, Any]:
@@ -174,7 +175,8 @@ class MCPClient:
174
  name=name,
175
  description=description,
176
  inputSchema=input_schema,
177
- handler=tool_handler
 
178
  )
179
 
180
  return tool
@@ -192,7 +194,21 @@ class MCPClient:
192
  })
193
 
194
  if response and response.get("result"):
195
- content = response["result"].get("content", [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  return {
197
  "success": True,
198
  "content": "\n".join([item.get("text", "") for item in content if item.get("type") == "text"])
@@ -356,4 +372,4 @@ class MCPClientManager:
356
 
357
  def is_client_connected(self, server_name: str) -> bool:
358
  """檢查客戶端是否連接"""
359
- return server_name in self.clients and self.clients[server_name].connected
 
165
  name = tool_data.get("name")
166
  description = tool_data.get("description", "")
167
  input_schema = tool_data.get("inputSchema", {"type": "object", "properties": {}})
168
+ output_schema = tool_data.get("outputSchema")
169
 
170
  # 創建代理處理器
171
  async def tool_handler(arguments: Dict[str, Any]) -> Dict[str, Any]:
 
175
  name=name,
176
  description=description,
177
  inputSchema=input_schema,
178
+ handler=tool_handler,
179
+ outputSchema=output_schema
180
  )
181
 
182
  return tool
 
194
  })
195
 
196
  if response and response.get("result"):
197
+ result = response["result"]
198
+ content = result.get("content", [])
199
+ structured = result.get("structuredContent")
200
+ if result.get("isError"):
201
+ if structured:
202
+ structured = dict(structured)
203
+ structured.setdefault("success", False)
204
+ structured.setdefault("error", "\n".join([item.get("text", "") for item in content if item.get("type") == "text"]))
205
+ return structured
206
+ return {
207
+ "success": False,
208
+ "error": "\n".join([item.get("text", "") for item in content if item.get("type") == "text"]) or "外部工具執行失敗"
209
+ }
210
+ if structured:
211
+ return structured
212
  return {
213
  "success": True,
214
  "content": "\n".join([item.get("text", "") for item in content if item.get("type") == "text"])
 
372
 
373
  def is_client_connected(self, server_name: str) -> bool:
374
  """檢查客戶端是否連接"""
375
+ return server_name in self.clients and self.clients[server_name].connected
features/mcp/openai_tools.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from core.config import settings
8
+ from core.logging import get_logger
9
+
10
+ logger = get_logger("mcp.openai_tools")
11
+
12
+ DEFAULT_CONFIG_PATH = Path(__file__).resolve().parents[1] / "mcp_config.json"
13
+
14
+
15
+ def _load_mcp_config(config_path: Optional[Path] = None) -> Dict[str, Any]:
16
+ path = config_path or DEFAULT_CONFIG_PATH
17
+ try:
18
+ with path.open("r", encoding="utf-8") as f:
19
+ return json.load(f)
20
+ except FileNotFoundError:
21
+ logger.warning("MCP 配置不存在: %s", path)
22
+ except json.JSONDecodeError as exc:
23
+ logger.warning("MCP 配置 JSON 無效: %s", exc)
24
+ return {}
25
+
26
+
27
+ def _configured_items(section: Dict[str, Any]) -> List[Dict[str, Any]]:
28
+ items: List[Dict[str, Any]] = []
29
+ for item in section.get("items", []):
30
+ if isinstance(item, dict) and item.get("enabled", True):
31
+ items.append({key: value for key, value in item.items() if key != "enabled"})
32
+ return items
33
+
34
+
35
+ def _configured_remote_mcp_items(section: Dict[str, Any]) -> List[Dict[str, Any]]:
36
+ items = []
37
+ for item in _configured_items(section):
38
+ if item.get("server_url") or item.get("connector_id"):
39
+ items.append(item)
40
+ else:
41
+ logger.info("跳過沒有 server_url/connector_id 的 hosted MCP 設定: %s", item.get("server_label"))
42
+ return items
43
+
44
+
45
+ def _env_json_list(raw: str, *, label: str) -> List[Dict[str, Any]]:
46
+ if not raw:
47
+ return []
48
+ try:
49
+ parsed = json.loads(raw)
50
+ except json.JSONDecodeError as exc:
51
+ logger.warning("%s 不是合法 JSON,已忽略: %s", label, exc)
52
+ return []
53
+ if not isinstance(parsed, list):
54
+ logger.warning("%s 必須是 list,已忽略", label)
55
+ return []
56
+ return [item for item in parsed if isinstance(item, dict)]
57
+
58
+
59
+ def build_openai_hosted_tools(config_path: Optional[Path] = None) -> List[Dict[str, Any]]:
60
+ config = _load_mcp_config(config_path)
61
+ openai_tools = config.get("openai_tools", {})
62
+ specs: List[Dict[str, Any]] = []
63
+
64
+ web_search = openai_tools.get("web_search", {})
65
+ if settings.OPENAI_ENABLE_WEB_SEARCH and web_search.get("enabled", True):
66
+ specs.append({"type": "web_search"})
67
+
68
+ remote_mcp = openai_tools.get("remote_mcp", {})
69
+ if settings.OPENAI_ENABLE_REMOTE_MCP and remote_mcp.get("enabled", False):
70
+ for server in _configured_remote_mcp_items(remote_mcp) + _configured_remote_mcp_items(
71
+ {"items": _env_json_list(
72
+ settings.OPENAI_REMOTE_MCP_SERVERS_JSON,
73
+ label="OPENAI_REMOTE_MCP_SERVERS_JSON",
74
+ )}
75
+ ):
76
+ spec = dict(server)
77
+ spec["type"] = "mcp"
78
+ spec.setdefault("require_approval", remote_mcp.get("approval_default", "always"))
79
+ specs.append(spec)
80
+
81
+ return specs
features/mcp/server.py CHANGED
@@ -11,9 +11,14 @@ import time
11
  import os
12
  from typing import Dict, Any, List, Optional, Callable, Tuple
13
  from enum import Enum
14
- from .types import Tool
15
  from .auto_registry import MCPAutoRegistry
16
 
 
 
 
 
 
17
  LOG_LEVEL_NAME = os.getenv("BLOOMWARE_LOG_LEVEL", "WARNING").upper()
18
  LOG_LEVEL = getattr(logging, LOG_LEVEL_NAME, logging.WARNING)
19
  logging.basicConfig(
@@ -183,7 +188,17 @@ class FeaturesMCPServer:
183
  name=tool_name,
184
  description=description,
185
  inputSchema={"type": "object", "properties": {}},
186
- handler=handler
 
 
 
 
 
 
 
 
 
 
187
  )
188
  self.register_tool(tool)
189
 
@@ -215,6 +230,60 @@ class FeaturesMCPServer:
215
  """註冊工具"""
216
  self.tools[tool.name] = tool
217
  logger.info(f"註冊工具: {tool.name}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
  def get_tools_summary(self) -> List[Dict[str, Any]]:
220
  """
@@ -352,22 +421,29 @@ class FeaturesMCPServer:
352
  if tool.handler:
353
  try:
354
  result = await tool.handler(arguments)
355
-
356
- # 統一回應格式
357
- if isinstance(result, dict) and result.get("success"):
358
- content = result.get("content", "")
359
- return {"content": [{"type": "text", "text": content}]}
360
- elif isinstance(result, dict) and not result.get("success"):
361
- error_msg = result.get("error", "工具執行失敗")
362
- return {"content": [{"type": "text", "text": f"❌ {error_msg}"}], "isError": True}
363
- else:
364
- return {"content": [{"type": "text", "text": str(result)}]}
365
 
366
  except Exception as e:
367
  logger.error(f"工具執行錯誤 {tool_name}: {e}")
368
- return {"content": [{"type": "text", "text": f"❌ 執行錯誤: {str(e)}"}], "isError": True}
369
-
370
- return {"content": [{"type": "text", "text": "工具未實作"}]}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
371
 
372
  async def cleanup(self):
373
  """清理資源"""
 
11
  import os
12
  from typing import Dict, Any, List, Optional, Callable, Tuple
13
  from enum import Enum
14
+ from .types import Tool, ToolCallResult
15
  from .auto_registry import MCPAutoRegistry
16
 
17
+ try:
18
+ import jsonschema
19
+ except ImportError:
20
+ jsonschema = None
21
+
22
  LOG_LEVEL_NAME = os.getenv("BLOOMWARE_LOG_LEVEL", "WARNING").upper()
23
  LOG_LEVEL = getattr(logging, LOG_LEVEL_NAME, logging.WARNING)
24
  logging.basicConfig(
 
188
  name=tool_name,
189
  description=description,
190
  inputSchema={"type": "object", "properties": {}},
191
+ handler=handler,
192
+ outputSchema={
193
+ "type": "object",
194
+ "properties": {
195
+ "success": {"type": "boolean"},
196
+ "content": {"type": "string"},
197
+ "error": {"type": ["string", "null"]},
198
+ "error_code": {"type": ["string", "null"]},
199
+ },
200
+ "required": ["success"],
201
+ }
202
  )
203
  self.register_tool(tool)
204
 
 
230
  """註冊工具"""
231
  self.tools[tool.name] = tool
232
  logger.info(f"註冊工具: {tool.name}")
233
+
234
+ def _format_tool_result(self, tool_name: str, result: Any) -> Dict[str, Any]:
235
+ """轉成 MCP tools/call result,保留 structuredContent。"""
236
+ if isinstance(result, ToolCallResult):
237
+ return result.to_dict()
238
+
239
+ if isinstance(result, dict):
240
+ is_error = result.get("success") is False
241
+ content = result.get("content")
242
+ if not content:
243
+ content = result.get("error") if is_error else json.dumps(result, ensure_ascii=False)
244
+
245
+ output_issue = self._validate_tool_output(tool_name, result)
246
+ if output_issue:
247
+ return ToolCallResult(
248
+ content=[{"type": "text", "text": "工具輸出格式不符合契約"}],
249
+ structuredContent={
250
+ "success": False,
251
+ "error_code": "TOOL_OUTPUT_VALIDATION",
252
+ "tool_name": tool_name,
253
+ "details": output_issue,
254
+ },
255
+ isError=True,
256
+ ).to_dict()
257
+
258
+ payload = ToolCallResult(
259
+ content=[{"type": "text", "text": str(content)}],
260
+ structuredContent=result,
261
+ isError=is_error,
262
+ ).to_dict()
263
+ if is_error and "error_code" not in payload["structuredContent"]:
264
+ payload["structuredContent"]["error_code"] = "TOOL_EXECUTION_ERROR"
265
+ return payload
266
+
267
+ return ToolCallResult(
268
+ content=[{"type": "text", "text": str(result)}],
269
+ structuredContent={"tool_name": tool_name, "value": result},
270
+ isError=False,
271
+ ).to_dict()
272
+
273
+ def _validate_tool_output(self, tool_name: str, result: Dict[str, Any]) -> Optional[str]:
274
+ """用工具 outputSchema 驗證 structuredContent。"""
275
+ tool = self.tools.get(tool_name)
276
+ if not tool or not tool.outputSchema or jsonschema is None:
277
+ return None
278
+
279
+ try:
280
+ jsonschema.validate(result, tool.outputSchema)
281
+ return None
282
+ except jsonschema.ValidationError as exc:
283
+ field_path = ".".join(str(part) for part in exc.absolute_path)
284
+ if field_path:
285
+ return f"{field_path}: {exc.message}"
286
+ return exc.message
287
 
288
  def get_tools_summary(self) -> List[Dict[str, Any]]:
289
  """
 
421
  if tool.handler:
422
  try:
423
  result = await tool.handler(arguments)
424
+ return self._format_tool_result(tool_name, result)
 
 
 
 
 
 
 
 
 
425
 
426
  except Exception as e:
427
  logger.error(f"工具執行錯誤 {tool_name}: {e}")
428
+ return ToolCallResult(
429
+ content=[{"type": "text", "text": "工具執行失��"}],
430
+ structuredContent={
431
+ "success": False,
432
+ "error_code": "TOOL_EXECUTION_ERROR",
433
+ "tool_name": tool_name,
434
+ },
435
+ isError=True,
436
+ ).to_dict()
437
+
438
+ return ToolCallResult(
439
+ content=[{"type": "text", "text": "工具未實作"}],
440
+ structuredContent={
441
+ "success": False,
442
+ "error_code": "TOOL_NOT_IMPLEMENTED",
443
+ "tool_name": tool_name,
444
+ },
445
+ isError=True,
446
+ ).to_dict()
447
 
448
  async def cleanup(self):
449
  """清理資源"""
features/mcp/skills.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Dict, Iterable, List
5
+
6
+ import json
7
+
8
+ SKILLS_ROOT = Path(__file__).resolve().parent / "skills"
9
+ DEFAULT_CONFIG_PATH = Path(__file__).resolve().parents[1] / "mcp_config.json"
10
+
11
+
12
+ def _load_mcp_config(config_path: Path = DEFAULT_CONFIG_PATH) -> Dict[str, Any]:
13
+ try:
14
+ return json.loads(config_path.read_text(encoding="utf-8"))
15
+ except FileNotFoundError:
16
+ return {}
17
+
18
+
19
+ def _slug(name: str) -> str:
20
+ return "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in name).strip("-").lower()
21
+
22
+
23
+ def _yaml_scalar(value: Any) -> str:
24
+ if value is None:
25
+ return "null"
26
+ text = str(value).replace('"', '\\"')
27
+ return f'"{text}"'
28
+
29
+
30
+ def _yaml_list(values: Iterable[Any], indent: int = 2) -> str:
31
+ prefix = " " * indent
32
+ items = list(values)
33
+ if not items:
34
+ return f"{prefix}[]"
35
+ return "\n".join(f"{prefix}- {_yaml_scalar(item)}" for item in items)
36
+
37
+
38
+ def tool_skill_path(tool_name: str) -> Path:
39
+ return SKILLS_ROOT / _slug(tool_name) / "SKILL.md"
40
+
41
+
42
+ def render_tool_skill(tool_name: str, tool_info: Dict[str, Any]) -> str:
43
+ examples = tool_info.get("examples") or []
44
+ return "\n".join(
45
+ [
46
+ "---",
47
+ f"name: mcp-{tool_name}",
48
+ f"description: { _yaml_scalar('Use when the user request matches the Bloom Ware MCP tool ' + tool_name + ' usage scenario.') }",
49
+ "tool_contract:",
50
+ f" name: {_yaml_scalar(tool_name)}",
51
+ f" category: {_yaml_scalar(tool_info.get('category', 'general'))}",
52
+ f" module: {_yaml_scalar(tool_info.get('module', ''))}",
53
+ f" class: {_yaml_scalar(tool_info.get('class', ''))}",
54
+ f" description: {_yaml_scalar(tool_info.get('description', ''))}",
55
+ " examples:",
56
+ _yaml_list(examples, indent=4),
57
+ "routing:",
58
+ " invocation_mode: \"local_mcp_bridge_function_calling\"",
59
+ " openai_hosted_mcp: \"disabled_unless_remote_server_url_is_configured\"",
60
+ " required_action: \"Select this tool only when the request maps to its category and required inputs can be extracted or safely derived from environment context.\"",
61
+ "safety:",
62
+ " do_not_fabricate_missing_data: true",
63
+ " preserve_tool_failure_semantics: true",
64
+ " user_approval_required_for_high_impact_actions: true",
65
+ "---",
66
+ "",
67
+ "Use this skill as the authoritative routing note for this Bloom Ware MCP tool.",
68
+ "The actual tool call must go through the local MCP bridge/function-calling path.",
69
+ "",
70
+ ]
71
+ )
72
+
73
+
74
+ def write_tool_skills(config_path: Path = DEFAULT_CONFIG_PATH) -> List[Path]:
75
+ config = _load_mcp_config(config_path)
76
+ written: List[Path] = []
77
+ for tool_name, tool_info in sorted((config.get("tools") or {}).items()):
78
+ if not (tool_info.get("module") and tool_info.get("class")):
79
+ continue
80
+ path = tool_skill_path(tool_name)
81
+ path.parent.mkdir(parents=True, exist_ok=True)
82
+ path.write_text(render_tool_skill(tool_name, tool_info), encoding="utf-8")
83
+ written.append(path)
84
+ return written
85
+
86
+
87
+ def skills_prompt_block(config_path: Path = DEFAULT_CONFIG_PATH) -> str:
88
+ config = _load_mcp_config(config_path)
89
+ lines = [
90
+ "Bloom Ware tool skills:",
91
+ "Use these routing notes when selecting local MCP bridge tools or OpenAI hosted tools. Do not call hosted MCP for local tools unless a remote server_url is configured.",
92
+ "- web_search: category=hosted_openai_tool; use_when=current, recent, time-sensitive, public, or externally verifiable information is needed; call_via=responses_hosted_tool_auto; read_skill=features/mcp/skills/web_search/SKILL.md; rule=use time/environment context and source timestamps to decide, without domain-specific hardcoding",
93
+ ]
94
+ for tool_name, tool_info in sorted((config.get("tools") or {}).items()):
95
+ if not (tool_info.get("module") and tool_info.get("class")):
96
+ continue
97
+ examples = ", ".join(tool_info.get("examples") or [])
98
+ lines.append(
99
+ f"- {tool_name}: category={tool_info.get('category', 'general')}; "
100
+ f"use_when={tool_info.get('description', '')}; examples={examples}; "
101
+ f"call_via=local_function_calling_tool_schema"
102
+ )
103
+ return "\n".join(lines)
features/mcp/skills/directions/SKILL.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: mcp-directions
3
+ description: "Use when the user request matches the Bloom Ware MCP tool directions usage scenario."
4
+ tool_contract:
5
+ name: "directions"
6
+ category: "location"
7
+ module: "features.mcp.tools.location.directions_tool"
8
+ class: "DirectionsTool"
9
+ description: "規劃兩點之間的路線"
10
+ examples:
11
+ - "從這裡到台北車站怎麼走"
12
+ - "幫我規劃路線"
13
+ routing:
14
+ invocation_mode: "local_mcp_bridge_function_calling"
15
+ openai_hosted_mcp: "disabled_unless_remote_server_url_is_configured"
16
+ required_action: "Select this tool only when the request maps to its category and required inputs can be extracted or safely derived from environment context."
17
+ safety:
18
+ do_not_fabricate_missing_data: true
19
+ preserve_tool_failure_semantics: true
20
+ user_approval_required_for_high_impact_actions: true
21
+ ---
22
+
23
+ Use this skill as the authoritative routing note for this Bloom Ware MCP tool.
24
+ The actual tool call must go through the local MCP bridge/function-calling path.
features/mcp/skills/environment_context/SKILL.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: mcp-environment_context
3
+ description: "Use when the user request matches the Bloom Ware MCP tool environment_context usage scenario."
4
+ tool_contract:
5
+ name: "environment_context"
6
+ category: "environment"
7
+ module: "features.mcp.tools.environment.context_tool"
8
+ class: "EnvironmentContextTool"
9
+ description: "取得使用者目前環境感知資料(位置、時區、語言、裝置、活動狀態)"
10
+ examples:
11
+ - "我現在在哪"
12
+ - "目前環境資訊"
13
+ routing:
14
+ invocation_mode: "local_mcp_bridge_function_calling"
15
+ openai_hosted_mcp: "disabled_unless_remote_server_url_is_configured"
16
+ required_action: "Select this tool only when the request maps to its category and required inputs can be extracted or safely derived from environment context."
17
+ safety:
18
+ do_not_fabricate_missing_data: true
19
+ preserve_tool_failure_semantics: true
20
+ user_approval_required_for_high_impact_actions: true
21
+ ---
22
+
23
+ Use this skill as the authoritative routing note for this Bloom Ware MCP tool.
24
+ The actual tool call must go through the local MCP bridge/function-calling path.
features/mcp/skills/exchange_query/SKILL.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: mcp-exchange_query
3
+ description: "Use when the user request matches the Bloom Ware MCP tool exchange_query usage scenario."
4
+ tool_contract:
5
+ name: "exchange_query"
6
+ category: "utility"
7
+ module: "features.mcp.tools.utility.exchange_tool"
8
+ class: "ExchangeTool"
9
+ description: "查詢即時匯率並換算貨幣"
10
+ examples:
11
+ - "100 美元換台幣"
12
+ - "日圓匯率"
13
+ routing:
14
+ invocation_mode: "local_mcp_bridge_function_calling"
15
+ openai_hosted_mcp: "disabled_unless_remote_server_url_is_configured"
16
+ required_action: "Select this tool only when the request maps to its category and required inputs can be extracted or safely derived from environment context."
17
+ safety:
18
+ do_not_fabricate_missing_data: true
19
+ preserve_tool_failure_semantics: true
20
+ user_approval_required_for_high_impact_actions: true
21
+ ---
22
+
23
+ Use this skill as the authoritative routing note for this Bloom Ware MCP tool.
24
+ The actual tool call must go through the local MCP bridge/function-calling path.
features/mcp/skills/forward_geocode/SKILL.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: mcp-forward_geocode
3
+ description: "Use when the user request matches the Bloom Ware MCP tool forward_geocode usage scenario."
4
+ tool_contract:
5
+ name: "forward_geocode"
6
+ category: "location"
7
+ module: "features.mcp.tools.location.geocoding_tool"
8
+ class: "ForwardGeocodeTool"
9
+ description: "將地點名稱轉換成座標"
10
+ examples:
11
+ - "銘傳大學在哪"
12
+ - "台北車站座標"
13
+ routing:
14
+ invocation_mode: "local_mcp_bridge_function_calling"
15
+ openai_hosted_mcp: "disabled_unless_remote_server_url_is_configured"
16
+ required_action: "Select this tool only when the request maps to its category and required inputs can be extracted or safely derived from environment context."
17
+ safety:
18
+ do_not_fabricate_missing_data: true
19
+ preserve_tool_failure_semantics: true
20
+ user_approval_required_for_high_impact_actions: true
21
+ ---
22
+
23
+ Use this skill as the authoritative routing note for this Bloom Ware MCP tool.
24
+ The actual tool call must go through the local MCP bridge/function-calling path.
features/mcp/skills/healthkit_query/SKILL.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: mcp-healthkit_query
3
+ description: "Use when the user request matches the Bloom Ware MCP tool healthkit_query usage scenario."
4
+ tool_contract:
5
+ name: "healthkit_query"
6
+ category: "utility"
7
+ module: "features.mcp.tools.utility.healthkit_tool"
8
+ class: "HealthKitTool"
9
+ description: "查詢使用者健康資料(心率、步數、血氧、睡眠等)"
10
+ examples:
11
+ - "我今天走幾步"
12
+ - "最近心率如何"
13
+ routing:
14
+ invocation_mode: "local_mcp_bridge_function_calling"
15
+ openai_hosted_mcp: "disabled_unless_remote_server_url_is_configured"
16
+ required_action: "Select this tool only when the request maps to its category and required inputs can be extracted or safely derived from environment context."
17
+ safety:
18
+ do_not_fabricate_missing_data: true
19
+ preserve_tool_failure_semantics: true
20
+ user_approval_required_for_high_impact_actions: true
21
+ ---
22
+
23
+ Use this skill as the authoritative routing note for this Bloom Ware MCP tool.
24
+ The actual tool call must go through the local MCP bridge/function-calling path.
features/mcp/skills/news_query/SKILL.md ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 新聞與即時資訊查詢 (Tavily AI)
2
+
3
+ 這個 Skill 使用 Tavily API 提供基於 AI 的新聞與即時資訊搜尋。相比傳統新聞 API,Tavily 能夠更好地過濾噪音、提供即時動態並對搜尋結果進行智慧摘要。
4
+
5
+ ## 功能特點
6
+ - **極致時效性**:直接串接最新搜尋引擎索引,獲取分秒必爭的時事。
7
+ - **AI 摘要**:自動整合多個來源,提供一句話或一段話的精華總結。
8
+ - **深度搜尋**:支援 basic 與 advanced 兩種深度,應對簡單查詢或深入研究。
9
+
10
+ ## 參數說明
11
+ - `query` (string, 必填): 搜尋關鍵詞。例如:「台積電收盤價」、「2024 奧運獎牌榜」。
12
+ - `limit` (integer, 可選): 返回新聞數量,預設 5,上限 10。
13
+ - `search_depth` (string, 可選): `basic` (快速) 或 `advanced` (深入)。
14
+
15
+ ## 使用範例
16
+ - 「查看今天最重要的科技新聞」 -> `news_query(query="今天科技新聞", limit=5)`
17
+ - 「搜尋關於 SpaceX 最近發射任務的詳細報導」 -> `news_query(query="SpaceX 最近發射任務", search_depth="advanced")`
18
+ - 「台積電今天收盤多少?」 -> `news_query(query="台積電 股票 收盤價 今天")`
19
+
20
+ ## 注意事項
21
+ - Tavily 會自動嘗試理解問題並給出 `answer` (AI 摘要),這對語音助手快速回報非常有用。
22
+ - 本工具已移除舊有的 NewsData.io 邏輯,不再支援 `country` 或 `category` 的硬性篩選,改由 AI 語義搜尋達成。
features/mcp/skills/reverse_geocode/SKILL.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: mcp-reverse_geocode
3
+ description: "Use when the user request matches the Bloom Ware MCP tool reverse_geocode usage scenario."
4
+ tool_contract:
5
+ name: "reverse_geocode"
6
+ category: "location"
7
+ module: "features.mcp.tools.location.geocode_tool"
8
+ class: "ReverseGeocodeTool"
9
+ description: "將座標轉換成地址、城市與行政區"
10
+ examples:
11
+ - "我在哪裡"
12
+ - "這個座標是哪裡"
13
+ routing:
14
+ invocation_mode: "local_mcp_bridge_function_calling"
15
+ openai_hosted_mcp: "disabled_unless_remote_server_url_is_configured"
16
+ required_action: "Select this tool only when the request maps to its category and required inputs can be extracted or safely derived from environment context."
17
+ safety:
18
+ do_not_fabricate_missing_data: true
19
+ preserve_tool_failure_semantics: true
20
+ user_approval_required_for_high_impact_actions: true
21
+ ---
22
+
23
+ Use this skill as the authoritative routing note for this Bloom Ware MCP tool.
24
+ The actual tool call must go through the local MCP bridge/function-calling path.
features/mcp/skills/tdx_bus_arrival/SKILL.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: mcp-tdx_bus_arrival
3
+ description: "Use when the user request matches the Bloom Ware MCP tool tdx_bus_arrival usage scenario."
4
+ tool_contract:
5
+ name: "tdx_bus_arrival"
6
+ category: "transportation"
7
+ module: "features.mcp.tools.transportation.tdx_bus_arrival"
8
+ class: "TDXBusArrivalTool"
9
+ description: "查詢公車即時到站時間(自動感知用戶位置,找最近站點)"
10
+ examples:
11
+ - "307 公車還要多久"
12
+ - "附近有什麼公車"
13
+ - "桃園火車站附近公車"
14
+ routing:
15
+ invocation_mode: "local_mcp_bridge_function_calling"
16
+ openai_hosted_mcp: "disabled_unless_remote_server_url_is_configured"
17
+ required_action: "Select this tool only when the request maps to its category and required inputs can be extracted or safely derived from environment context. For precise stop areas, landmarks, roads, or intersections, fill location_query."
18
+ safety:
19
+ do_not_fabricate_missing_data: true
20
+ preserve_tool_failure_semantics: true
21
+ user_approval_required_for_high_impact_actions: true
22
+ ---
23
+
24
+ Use this skill as the authoritative routing note for this Bloom Ware MCP tool.
25
+ The actual tool call must go through the local MCP bridge/function-calling path.
features/mcp/skills/tdx_metro/SKILL.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: mcp-tdx_metro
3
+ description: "Use when the user request matches the Bloom Ware MCP tool tdx_metro usage scenario."
4
+ tool_contract:
5
+ name: "tdx_metro"
6
+ category: "transportation"
7
+ module: "features.mcp.tools.transportation.tdx_metro"
8
+ class: "TDXMetroTool"
9
+ description: "查詢捷運即時到站、最近車站(台北/高雄/桃園/台中捷運)"
10
+ examples:
11
+ - "最近的捷運站在哪"
12
+ - "台北車站捷運幾分鐘到"
13
+ - "桃園火車站附近捷運站"
14
+ routing:
15
+ invocation_mode: "local_mcp_bridge_function_calling"
16
+ openai_hosted_mcp: "disabled_unless_remote_server_url_is_configured"
17
+ required_action: "Select this tool only when the request maps to its category and required inputs can be extracted or safely derived from environment context. For precise landmarks or addresses, fill location_query before falling back to city/operator."
18
+ safety:
19
+ do_not_fabricate_missing_data: true
20
+ preserve_tool_failure_semantics: true
21
+ user_approval_required_for_high_impact_actions: true
22
+ ---
23
+
24
+ Use this skill as the authoritative routing note for this Bloom Ware MCP tool.
25
+ The actual tool call must go through the local MCP bridge/function-calling path.
features/mcp/skills/tdx_parking/SKILL.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: mcp-tdx_parking
3
+ description: "Use when the user request matches the Bloom Ware MCP tool tdx_parking usage scenario."
4
+ tool_contract:
5
+ name: "tdx_parking"
6
+ category: "transportation"
7
+ module: "features.mcp.tools.transportation.tdx_parking"
8
+ class: "TDXParkingTool"
9
+ description: "查詢附近停車場資訊和即時空位"
10
+ examples:
11
+ - "附近停車場"
12
+ - "台北車站附近停車位"
13
+ - "中正路100號附近停車場"
14
+ routing:
15
+ invocation_mode: "local_mcp_bridge_function_calling"
16
+ openai_hosted_mcp: "disabled_unless_remote_server_url_is_configured"
17
+ required_action: "Select this tool only when the request maps to its category and required inputs can be extracted or safely derived from environment context. For precise addresses, landmarks, or intersections, fill location_query instead of forcing city."
18
+ safety:
19
+ do_not_fabricate_missing_data: true
20
+ preserve_tool_failure_semantics: true
21
+ user_approval_required_for_high_impact_actions: true
22
+ ---
23
+
24
+ Use this skill as the authoritative routing note for this Bloom Ware MCP tool.
25
+ The actual tool call must go through the local MCP bridge/function-calling path.