Spaces:
Running
Running
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 filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +83 -0
- .gitignore +4 -1
- AGENTS.md +0 -74
- DEPLOY.md +0 -173
- app.py +518 -205
- bloom-ware-login/components/login-form.tsx +54 -23
- bloom-ware-login/public/audio/pcm-recorder-worklet.js +17 -0
- core/ai_client.py +21 -5
- core/config.py +104 -7
- core/environment/__init__.py +7 -1
- core/environment/context_builder.py +92 -0
- core/intent_detector.py +18 -3
- core/logging.py +79 -63
- core/memory_system.py +180 -23
- core/pipeline.py +221 -215
- core/prompts/care_mode.py +18 -2
- core/prompts/care_mode_skills.py +41 -0
- core/prompts/intent_detection.py +21 -4
- core/prompts/tool_calling_policy.py +18 -0
- core/reasoning_strategy.py +3 -3
- core/responses_runtime.py +212 -0
- core/tool_registry.py +36 -24
- core/tool_router.py +19 -11
- core/tool_schema.py +66 -19
- core/voice_care_gate.py +82 -0
- features/care_mode/skills/ACTIVE_LISTENING.md +24 -0
- features/care_mode/skills/ANGER_HANDLING.md +15 -0
- features/care_mode/skills/CARE_CORE_STRATEGY.md +26 -0
- features/care_mode/skills/EMOTIONAL_VALIDATION.md +24 -0
- features/care_mode/skills/FEAR_HANDLING.md +15 -0
- features/care_mode/skills/FIRST_CONTACT_CARE.md +21 -0
- features/care_mode/skills/SADNESS_HANDLING.md +15 -0
- features/care_mode/skills/SUPPORTIVE_PRESENCE.md +24 -0
- features/mcp/agent_bridge.py +170 -285
- features/mcp/auto_registry.py +25 -9
- features/mcp/coordinator.py +70 -2
- features/mcp/mcp_client.py +19 -3
- features/mcp/openai_tools.py +81 -0
- features/mcp/server.py +91 -15
- features/mcp/skills.py +103 -0
- features/mcp/skills/directions/SKILL.md +24 -0
- features/mcp/skills/environment_context/SKILL.md +24 -0
- features/mcp/skills/exchange_query/SKILL.md +24 -0
- features/mcp/skills/forward_geocode/SKILL.md +24 -0
- features/mcp/skills/healthkit_query/SKILL.md +24 -0
- features/mcp/skills/news_query/SKILL.md +22 -0
- features/mcp/skills/reverse_geocode/SKILL.md +24 -0
- features/mcp/skills/tdx_bus_arrival/SKILL.md +25 -0
- features/mcp/skills/tdx_metro/SKILL.md +25 -0
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"- 回覆用戶時:必須使用
|
| 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 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 789 |
-
"""VAD 偵測到語音
|
| 790 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 791 |
|
| 792 |
# 從前端獲取語言設定(支援:zh, en, id, ja, vi,或 auto 自動檢測)
|
| 793 |
language = message_data.get("language", "auto")
|
| 794 |
logger.info(f"🌐 語言設定: {language}")
|
| 795 |
|
| 796 |
-
# 連線到
|
| 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 |
-
|
| 802 |
-
|
| 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("無法連接到
|
| 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 |
-
|
| 849 |
try:
|
| 850 |
import base64
|
| 851 |
audio_bytes = base64.b64decode(b64)
|
| 852 |
await realtime_stt.send_audio_chunk(audio_bytes)
|
| 853 |
-
logger.debug(f"🎤 轉發音頻到
|
| 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=
|
| 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 |
-
# === 即時轉
|
| 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 |
-
# ===
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
|
| 1116 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
|
| 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 |
-
|
| 1164 |
-
chat_id = latest_chat["chat_id"]
|
| 1165 |
else:
|
| 1166 |
-
chat_title = f"語音對話 {datetime.now().strftime('%Y-%m-%d %H:%M
|
| 1167 |
-
|
| 1168 |
-
if
|
| 1169 |
-
chat_id =
|
| 1170 |
except Exception as e:
|
| 1171 |
-
logger.error(f"
|
| 1172 |
-
|
| 1173 |
-
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1179 |
language = client_info.get("language", "auto")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1180 |
|
| 1181 |
-
#
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
"care_mode": cm
|
| 1188 |
-
})
|
| 1189 |
|
| 1190 |
-
#
|
| 1191 |
response = await handle_message(
|
| 1192 |
transcription,
|
| 1193 |
user_id,
|
| 1194 |
chat_id,
|
| 1195 |
-
[],
|
| 1196 |
-
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":
|
| 1214 |
"timestamp": time.time(),
|
| 1215 |
-
"
|
| 1216 |
-
"
|
| 1217 |
-
"
|
| 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":
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 1324 |
|
| 1325 |
# 兼容:如果傳入字串,視為 user_message;如果傳入 list,視為 messages
|
| 1326 |
-
|
| 1327 |
-
|
| 1328 |
-
|
| 1329 |
-
|
| 1330 |
-
|
| 1331 |
-
|
| 1332 |
-
|
| 1333 |
-
|
| 1334 |
-
|
| 1335 |
-
|
| 1336 |
-
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
|
| 1340 |
-
|
| 1341 |
-
|
| 1342 |
-
|
| 1343 |
-
|
| 1344 |
-
|
| 1345 |
-
|
| 1346 |
-
|
| 1347 |
-
|
| 1348 |
-
|
| 1349 |
-
|
| 1350 |
-
|
| 1351 |
-
|
| 1352 |
-
|
| 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=
|
| 1364 |
feature_timeout=30.0, # 功能處理超時 (15 → 30,新聞摘要生成需要更長時間)
|
| 1365 |
-
ai_timeout=
|
| 1366 |
)
|
| 1367 |
-
logger.info(f"⚙️ 準備調用 ChatPipeline.process,user_message='{user_message}', audio_emotion={audio_emotion}, language={
|
| 1368 |
-
|
|
|
|
| 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 |
-
|
| 2150 |
-
model="gpt-5-nano",
|
| 2151 |
messages=messages,
|
| 2152 |
-
|
| 2153 |
-
|
|
|
|
|
|
|
| 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 = [
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2447 |
|
| 2448 |
# 調用 TTS 服務獲取完整音頻
|
| 2449 |
-
result = await text_to_speech(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
console.log('✅ 麥克風權限已獲取');
|
| 245 |
|
| 246 |
// 設定錄音參數
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
|
| 251 |
-
const audioChunks:
|
| 252 |
const recordDuration = 4000; // 4 秒(確保足夠長度)
|
| 253 |
|
| 254 |
-
processor.
|
| 255 |
-
|
| 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 |
-
|
| 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
|
| 280 |
let offset = 0;
|
| 281 |
for (const chunk of audioChunks) {
|
| 282 |
-
|
| 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 |
-
|
| 41 |
-
api_key
|
| 42 |
-
timeout
|
| 43 |
-
max_retries
|
| 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 |
-
|
|
|
|
| 86 |
OPENAI_TIMEOUT: int = int(os.getenv("OPENAI_TIMEOUT", "30"))
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
#
|
| 19 |
-
|
| 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(
|
| 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 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 178 |
-
|
| 179 |
-
for attempt in range(max_retries + 1):
|
| 180 |
try:
|
| 181 |
-
|
| 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 =
|
| 189 |
-
|
| 190 |
-
|
| 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
|
| 199 |
-
if attempt <
|
| 200 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
continue
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
|
|
|
|
|
|
| 205 |
else:
|
| 206 |
# 其他類型的錯誤,直接拋出
|
| 207 |
raise api_error
|
| 208 |
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 39 |
-
detect_timeout: float =
|
| 40 |
-
feature_timeout: float =
|
| 41 |
-
ai_timeout: float =
|
| 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 翻譯
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
translated = await ai_service.generate_response_async(
|
| 147 |
messages=messages,
|
| 148 |
-
model=
|
| 149 |
-
reasoning_effort=None,
|
| 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 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
if emotion_callback:
|
| 275 |
try:
|
| 276 |
-
await emotion_callback(
|
| 277 |
except Exception as e:
|
| 278 |
logger.warning(f"emotion_callback 錯誤: {e}")
|
| 279 |
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 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="
|
| 302 |
-
meta={"
|
| 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 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 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 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
is_fallback=False,
|
| 419 |
-
meta={"emotion": emotion_value, "care_mode": False},
|
| 420 |
-
)
|
| 421 |
|
| 422 |
-
# 4)
|
| 423 |
-
|
| 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="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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:意圖檢測使用
|
| 47 |
if task_type == "intent_detection":
|
| 48 |
-
logger.debug("🧠 意圖檢測 →
|
| 49 |
-
return "
|
| 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 |
-
#
|
|
|
|
| 250 |
if hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
|
| 251 |
-
tool_class = tool.handler.__self__
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
-
# 降級:
|
| 257 |
description = getattr(tool, 'description', f'{tool_name} 工具')
|
| 258 |
-
parameters = {"type": "object", "properties": {}, "required": []}
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 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, '
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 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 |
-
# 單一分類,
|
| 238 |
-
return
|
| 239 |
|
| 240 |
-
# 多個分類
|
| 241 |
-
return
|
| 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 確保
|
| 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 |
-
|
| 58 |
|
| 59 |
Returns:
|
| 60 |
OpenAI tools 格式的字典
|
|
@@ -110,32 +111,74 @@ class ToolSchema:
|
|
| 110 |
|
| 111 |
strict mode 要求:
|
| 112 |
1. additionalProperties: false
|
| 113 |
-
2.
|
| 114 |
-
3.
|
| 115 |
"""
|
| 116 |
-
result =
|
| 117 |
|
| 118 |
# 確保是 object 類型
|
| 119 |
if result.get("type") != "object":
|
| 120 |
result = {"type": "object", "properties": result}
|
| 121 |
|
| 122 |
-
#
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
# 確保所有屬性都在 required 中
|
| 126 |
properties = result.get("properties", {})
|
| 127 |
-
|
| 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=
|
| 109 |
)
|
| 110 |
)
|
| 111 |
register(
|
| 112 |
ToolMetadata(
|
| 113 |
name="reverse_geocode",
|
| 114 |
requires_env={"lat", "lon"},
|
| 115 |
-
enable_reformat=
|
| 116 |
)
|
| 117 |
)
|
| 118 |
register(
|
| 119 |
ToolMetadata(
|
| 120 |
name="exchange_query",
|
| 121 |
-
enable_reformat=
|
| 122 |
)
|
| 123 |
)
|
| 124 |
register(
|
| 125 |
ToolMetadata(
|
| 126 |
name="news_query",
|
| 127 |
-
enable_reformat=
|
| 128 |
)
|
| 129 |
)
|
| 130 |
register(
|
| 131 |
ToolMetadata(
|
| 132 |
name="healthkit_query",
|
| 133 |
-
enable_reformat=
|
| 134 |
)
|
| 135 |
)
|
| 136 |
register(
|
| 137 |
ToolMetadata(
|
| 138 |
name="directions",
|
| 139 |
-
enable_reformat=
|
| 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 |
-
|
|
|
|
| 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
|
| 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 |
-
|
| 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
|
| 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 |
-
|
| 557 |
-
|
| 558 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 559 |
|
| 560 |
-
|
| 561 |
|
| 562 |
-
|
| 563 |
-
|
| 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=
|
| 580 |
-
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 |
-
|
| 614 |
-
|
| 615 |
-
|
|
|
|
| 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,
|
| 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
|
| 654 |
"""
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
注意:不再描述每個工具,工具定義由 tools 參數傳遞
|
| 658 |
-
只處理特殊規則和情緒判斷
|
| 659 |
"""
|
| 660 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
|
| 662 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 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 |
-
"
|
| 834 |
-
"情緒
|
| 835 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 836 |
)
|
| 837 |
|
| 838 |
messages = [
|
|
@@ -842,7 +715,7 @@ YouBike 查詢(重要!參數提取規則):
|
|
| 842 |
|
| 843 |
emotion = await ai_service.generate_response_async(
|
| 844 |
messages=messages,
|
| 845 |
-
model=
|
| 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":
|
|
|
|
| 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":
|
|
|
|
| 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":
|
|
|
|
| 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 |
-
# 格式化回應使用
|
| 1247 |
-
|
|
|
|
|
|
|
|
|
|
| 1248 |
messages=messages,
|
| 1249 |
-
|
| 1250 |
-
|
| 1251 |
-
|
| 1252 |
-
reasoning_effort=None
|
| 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 文件
|
| 58 |
-
tool_files = list(tools_path.
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 369 |
-
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|