Spaces:
Sleeping
Sleeping
Commit ·
801ae57
1
Parent(s): 523a32a
feat: 新增地理編碼與導航工具,優化資料庫快取與情緒管理
Browse files- 新增 directions_tool.py (導航工具)
- 新增 geocode_tool.py (地理編碼工具)
- 優化 database cache 與 base 模組
- 更新 emotion_care_manager 邏輯
- 調整前端登入與 WebSocket 處理
- 更新文檔與環境配置
- 移除舊版測試文件
- .env.production.example +1 -0
- AGENTS.md +104 -45
- README.md +1 -0
- app.py +63 -0
- core/database/__init__.py +21 -0
- core/database/base.py +216 -22
- core/database/cache.py +34 -2
- core/emotion_care_manager.py +25 -2
- features/mcp/tools/directions_tool.py +107 -0
- features/mcp/tools/geocode_tool.py +102 -0
- features/mcp/tools/weather_tool.py +46 -6
- requirements.txt +4 -1
- services/ai_service.py +89 -35
- static/frontend/js/login.js +15 -7
- static/frontend/js/websocket.js +56 -0
- tests/conftest.py +0 -8
- tests/services/test_voice_login_cnn.py +0 -86
.env.production.example
CHANGED
|
@@ -35,6 +35,7 @@ GOOGLE_REDIRECT_URI=https://your-app.onrender.com/auth/google/callback
|
|
| 35 |
WEATHER_API_KEY=YOUR_OPENWEATHERMAP_API_KEY
|
| 36 |
NEWSDATA_API_KEY=YOUR_NEWSDATA_API_KEY
|
| 37 |
EXCHANGE_API_KEY=YOUR_EXCHANGERATE_API_KEY
|
|
|
|
| 38 |
|
| 39 |
# === JWT 認證配置 ===
|
| 40 |
# 重要:生產環境必須使用新的 Secret Key
|
|
|
|
| 35 |
WEATHER_API_KEY=YOUR_OPENWEATHERMAP_API_KEY
|
| 36 |
NEWSDATA_API_KEY=YOUR_NEWSDATA_API_KEY
|
| 37 |
EXCHANGE_API_KEY=YOUR_EXCHANGERATE_API_KEY
|
| 38 |
+
OPENROUTESERVICE_API_KEY=YOUR_ORS_API_KEY
|
| 39 |
|
| 40 |
# === JWT 認證配置 ===
|
| 41 |
# 重要:生產環境必須使用新的 Secret Key
|
AGENTS.md
CHANGED
|
@@ -1,45 +1,104 @@
|
|
| 1 |
-
# Repository Guidelines
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
-
|
| 7 |
-
-
|
| 8 |
-
-
|
| 9 |
-
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
-
|
| 13 |
-
-
|
| 14 |
-
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
-
|
| 20 |
-
-
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
-
|
| 32 |
-
-
|
| 33 |
-
-
|
| 34 |
-
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
- 語
|
| 43 |
-
-
|
| 44 |
-
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Repository Guidelines + System Architecture
|
| 2 |
+
|
| 3 |
+
結論先講:本專案是 FastAPI 單體後端,走 WebSocket 主動互動、REST 做周邊能力,資料層以 Firestore 為核心,功能透過 MCP Tools 解耦;本檔已補齊實際架構、流程、環境與風險,照著做就不會踩雷。
|
| 4 |
+
|
| 5 |
+
**目錄速覽**
|
| 6 |
+
- `app.py`:ASGI 入口(FastAPI)。載入設定、掛載靜態檔、CORS/CSP、中介層、背景任務、WebSocket `/ws`、OAuth 與 REST API。
|
| 7 |
+
- `core/`:設定(`config.py`)、認證(`auth/` JWT + Google OAuth PKCE)、資料庫(`database/` Firestore + 快取 + 最佳化)、記憶系統、情緒關懷、聊天處理管線。
|
| 8 |
+
- `features/mcp/`:MCP 伺服器與工具(天氣、新聞、匯率、HealthKit 查詢等),`agent_bridge.py` 負責意圖偵測與工具串接。
|
| 9 |
+
- `services/`:AI 產生(OpenAI SDK)、TTS/STT、語音登入、批次排程等服務層。
|
| 10 |
+
- `models/`:語音情緒與說話者辨識相關模型與腳本。
|
| 11 |
+
- `static/`:前端靜態頁(登入、對話 UI 等)。
|
| 12 |
+
- `tests/`:Pytest 測試,鏡射模組路徑(目前以 `services/voice_login` 為主)。
|
| 13 |
+
- `render.yaml`、`Dockerfile`、`runtime.txt`、`.env.production.example`:部署與環境樣板。
|
| 14 |
+
- `bloom-ware-login/`:獨立 Next.js(登入)樣板,非後端運行必要。
|
| 15 |
+
|
| 16 |
+
—
|
| 17 |
+
|
| 18 |
+
**執行與開發(本機)**
|
| 19 |
+
- 建環境:`python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt`
|
| 20 |
+
- 直跑:`python app.py`(預設 0.0.0.0:8080)
|
| 21 |
+
- ASGI 模式:`uvicorn app:app --reload`(開發用)
|
| 22 |
+
- 前端入口:`/static/login.html` → 走 Google OAuth 登入 → 以 JWT 綁 WebSocket `/ws?token=...`。
|
| 23 |
+
- 測試:`pytest -q` 或 `pytest -q -k voice_login`。
|
| 24 |
+
|
| 25 |
+
提示:Docker/HF Spaces 預設 `PORT=7860`;Render 預設 `PORT=10000`。程式以環境變數為準。
|
| 26 |
+
|
| 27 |
+
—
|
| 28 |
+
|
| 29 |
+
**系統架構(重點元件)**
|
| 30 |
+
- Web 層:FastAPI + 中介層(CORS、CSP)。靜態檔以 `static/frontend` 掛載到 `/static`。
|
| 31 |
+
- WebSocket:`/ws` 單點,JWT 驗證(Query `token`),集中處理聊天、typing 與工具回傳;`ConnectionManager` 管理會話。
|
| 32 |
+
- 意圖與工具:`features/mcp/agent_bridge.py` 以 OpenAI Structured Outputs 做意圖偵測,命中則調 `features/mcp/tools/*`(天氣/新聞/匯率/HealthKit 等)。
|
| 33 |
+
- 聊天管線:`core/pipeline.ChatPipeline` 先意圖→工具→AI 產生;支援「情緒關懷模式」(極端情緒時���用工具、改走關懷 Prompt)。
|
| 34 |
+
- AI 服務:`services/ai_service.py` 封裝 OpenAI SDK;模型由環境 `OPENAI_MODEL` 控(預設 `gpt-5-nano`)。
|
| 35 |
+
- 資料層:Firestore(`core/database/base.py`)+最佳化存取與 LRU 快取(`optimized.py`/`cache.py`);集合:`users`、`chats`、`messages`、`health_data`、`device_bindings`。
|
| 36 |
+
- 背景任務:啟動時依 `ENABLE_BACKGROUND_JOBS` 啟動快取維護、清理、批次排程(每日摘要/週報)。
|
| 37 |
+
|
| 38 |
+
—
|
| 39 |
+
|
| 40 |
+
**請求流程(典型路徑)**
|
| 41 |
+
- 登入:前端打 `/auth/google/url` 取得授權連結 → Google 回調 `/auth/google/callback` → 交換 Token → 產出 JWT。
|
| 42 |
+
- 對話:前端以 `/ws?token=JWT` 連上;訊息先經「語音綁定 FSM」攔截(若用語音綁定流程)→ 進入 `ChatPipeline` → 視意圖走 MCP 工具或一般聊天 → 落庫 `chats/messages`。
|
| 43 |
+
- 檔案分析:`/api/upload-file` 或 `/api/analyze-file-base64`,文字/PDF/圖片分流到對應分析邏輯,底層仍透過 OpenAI。
|
| 44 |
+
- 健康資料:建議透過 MCP `healthkit_tool` 查 Firestore(iOS 端直寫 `health_data`)。
|
| 45 |
+
|
| 46 |
+
—
|
| 47 |
+
|
| 48 |
+
**環境與設定(`core/config.Settings`)**
|
| 49 |
+
- 必填:`FIREBASE_PROJECT_ID`、`FIREBASE_CREDENTIALS_JSON`(或 `FIREBASE_SERVICE_ACCOUNT_PATH`)、`OPENAI_API_KEY`、`GOOGLE_CLIENT_ID/SECRET`、`JWT_SECRET_KEY`。
|
| 50 |
+
- 其他:`OPENAI_MODEL`(預設 `gpt-5-nano`)、`HOST`、`PORT`、`ENABLE_BACKGROUND_JOBS`、第三方金鑰(天氣/新聞/匯率)。
|
| 51 |
+
- 生產:Render 用 `PORT=10000`,HF Spaces/Docker 用 `PORT=7860`。
|
| 52 |
+
|
| 53 |
+
—
|
| 54 |
+
|
| 55 |
+
**部署模式**
|
| 56 |
+
- Render(`render.yaml`):平台注入環境變數,直接 `python3 app.py` 啟動。
|
| 57 |
+
- HF Spaces(`Dockerfile`):以 `uvicorn` 啟動,`PORT` 由平台給;請關閉排程(`ENABLE_BACKGROUND_JOBS=false`)。
|
| 58 |
+
|
| 59 |
+
—
|
| 60 |
+
|
| 61 |
+
**測試策略(TDD)**
|
| 62 |
+
- 測試放 `tests/`,鏡射原始碼路徑,命名 `test_*.py`。
|
| 63 |
+
- 先紅後綠再重構;關鍵路徑優先(`core/`、`services/`)。
|
| 64 |
+
- 範例:`tests/services/test_voice_login_cnn.py` 透過 stub/injection 減少重量相依。
|
| 65 |
+
|
| 66 |
+
—
|
| 67 |
+
|
| 68 |
+
**MCP 工具擴充規範(快速上手)**
|
| 69 |
+
- 位置:`features/mcp/tools/`;繼承 `MCPTool`,實作 `get_input_schema`、`get_output_schema`、`execute`。
|
| 70 |
+
- 自動註冊:`features/mcp/auto_registry.py` 會掃描並註冊;若需要外部進程,交由 `server.start_external_servers()`。
|
| 71 |
+
- 輸出格式:以 `create_success_response(content=..., data=...)` 回傳;錯誤用 `create_error_response(code=..., error=...)`。
|
| 72 |
+
- 工具 metadata:`CATEGORY/TAGS/USAGE_TIPS` 會回到 `/api/mcp/tools` 供前端工具卡片用。
|
| 73 |
+
|
| 74 |
+
—
|
| 75 |
+
|
| 76 |
+
**安全與維運建議(務必看完)**
|
| 77 |
+
- CORS:目前設定為 `allow_origins=["*"]` 且 `allow_credentials=True`,生產環境建議收斂來源網域,避免 Cookie/Authorization 外洩風險。
|
| 78 |
+
- JWT:請務必提供穩定的 `JWT_SECRET_KEY`;否則服務重啟會因隨機 Secret 導致既有 Token 全失效。
|
| 79 |
+
- CSP:為了語音前端放寬到 `'unsafe-inline'/'unsafe-eval'`,生產環境請只在 `/static` 下放寬,嚴禁波及 API 路徑。
|
| 80 |
+
- 上傳限制:檔案上限 10MB,白名單含 PDF/影像/程式碼等,後端已驗型別但仍需注意前端檔案來源。
|
| 81 |
+
- Firestore 配額:已實作 LRU 快取、請求合併與批次寫入;高流量時請觀察 `/api/performance/stats`。
|
| 82 |
+
|
| 83 |
+
—
|
| 84 |
+
|
| 85 |
+
**已知問題(歡迎開 PR 修)**
|
| 86 |
+
- `requirements.txt` 尾端疑似誤合併文字,出現 `transformersservices:`;若安裝失敗,請將 `transformers` 與 `services:` 拆正(`render.yaml` 應在獨立檔)。
|
| 87 |
+
- `app.py` 的 CORS 設定呼叫了兩次,可合併為一次以避免重複中介層。
|
| 88 |
+
- `GET /api/health/query` 仍殘留 Mongo-style 的 `find()`/`async for` 寫法,與 Firestore 用法不符;建議改用 MCP `healthkit_tool` 或重寫為 Firestore 查詢。
|
| 89 |
+
|
| 90 |
+
—
|
| 91 |
+
|
| 92 |
+
**Coding Style & Conventions**
|
| 93 |
+
- Python 3.10+;`ruff/black` 預設(88 cols, 4-space, UTF‑8)。
|
| 94 |
+
- 模組 `snake_case.py`;類別 `PascalCase`;函式/變數 `snake_case`。
|
| 95 |
+
- 分層:Controller(API) → Service → Core/Utils;資料層封在 `core/database/*`。
|
| 96 |
+
- 環境變數透過 `os.environ` 讀取;範例見 `.env.production.example`。
|
| 97 |
+
|
| 98 |
+
—
|
| 99 |
+
|
| 100 |
+
**Agent-Specific(給在此倉工作之助理)**
|
| 101 |
+
- 語言與語氣:全程繁中(台灣),「先結論、後細節」,可微嗆不冒犯。
|
| 102 |
+
- TDD:先寫測試(紅燈)→ 最小實作(綠燈)→ 重構,每個功能至少跑完一輪。
|
| 103 |
+
- OpenAI 使用:以 Python SDK;模型由 `OPENAI_MODEL` 控制(預設 `gpt-5-nano`);若改版,請只改環境變數,不要在程式寫死。
|
| 104 |
+
- 測試放置:所有測試集中於 `tests/`,`test_*.py`;結構鏡射模組路徑。
|
README.md
CHANGED
|
@@ -44,6 +44,7 @@ git push
|
|
| 44 |
## 🧠 設定小抄
|
| 45 |
- `ENABLE_BACKGROUND_JOBS`:在 HuggingFace 建議設 `false`,避免快取維護、批次任務、清理排程耗掉寶貴 CPU;若搬回 Render 或其他長駐環境,再調回 `true`。
|
| 46 |
- 其他 Render 時期的環境變數照舊,沒有特別兼容性的 hack。
|
|
|
|
| 47 |
|
| 48 |
## 🤝 團隊資訊
|
| 49 |
我們是銘傳大學人工智慧應用學系的 **槓上開發**,專注把 AI 工具塞進實際應用。如果覺得好用,請幫 Space 點顆 ⭐️,或開 Issue/PR 跟我們互嗆(友善互動)。歡迎合作 🙌
|
|
|
|
| 44 |
## 🧠 設定小抄
|
| 45 |
- `ENABLE_BACKGROUND_JOBS`:在 HuggingFace 建議設 `false`,避免快取維護、批次任務、清理排程耗掉寶貴 CPU;若搬回 Render 或其他長駐環境,再調回 `true`。
|
| 46 |
- 其他 Render 時期的環境變數照舊,沒有特別兼容性的 hack。
|
| 47 |
+
- `OPENROUTESERVICE_API_KEY`:啟用路徑規劃工具(OpenRouteService)。
|
| 48 |
|
| 49 |
## 🤝 團隊資訊
|
| 50 |
我們是銘傳大學人工智慧應用學系的 **槓上開發**,專注把 AI 工具塞進實際應用。如果覺得好用,請幫 Space 點顆 ⭐️,或開 Issue/PR 跟我們互嗆(友善互動)。歡迎合作 🙌
|
app.py
CHANGED
|
@@ -1178,6 +1178,69 @@ async def websocket_endpoint_with_jwt(websocket: WebSocket, token: str = Query(N
|
|
| 1178 |
"timestamp": time.time()
|
| 1179 |
})
|
| 1180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1181 |
# 保存 Agent 回應(已在 handle_message 中保存)
|
| 1182 |
|
| 1183 |
_async_lib.create_task(_process_voice_chat())
|
|
|
|
| 1178 |
"timestamp": time.time()
|
| 1179 |
})
|
| 1180 |
|
| 1181 |
+
else:
|
| 1182 |
+
# ===== 環境快照上報 =====
|
| 1183 |
+
if message_type == "env_snapshot":
|
| 1184 |
+
try:
|
| 1185 |
+
lat = float(message_data.get("lat")) if message_data.get("lat") is not None else None
|
| 1186 |
+
lon = float(message_data.get("lon")) if message_data.get("lon") is not None else None
|
| 1187 |
+
acc = message_data.get("accuracy_m")
|
| 1188 |
+
acc = float(acc) if acc is not None else None
|
| 1189 |
+
heading_deg = message_data.get("heading_deg")
|
| 1190 |
+
heading_deg = float(heading_deg) if heading_deg is not None else None
|
| 1191 |
+
tz = message_data.get("tz")
|
| 1192 |
+
locale = message_data.get("locale")
|
| 1193 |
+
device = message_data.get("device")
|
| 1194 |
+
|
| 1195 |
+
# 後端節流:距離<100m且方位差<25度則忽略
|
| 1196 |
+
do_write_snapshot = False
|
| 1197 |
+
last = manager.last_env.get(user_id)
|
| 1198 |
+
if last and lat is not None and lon is not None and last.get("lat") is not None:
|
| 1199 |
+
dist = _haversine_m(last.get("lat",0), last.get("lon",0), lat, lon)
|
| 1200 |
+
deg_diff = abs((heading_deg or 0) - (last.get("heading_deg") or 0))
|
| 1201 |
+
if dist >= 100 or deg_diff >= 25:
|
| 1202 |
+
do_write_snapshot = True
|
| 1203 |
+
else:
|
| 1204 |
+
do_write_snapshot = True
|
| 1205 |
+
|
| 1206 |
+
from geohash2 import encode as gh_encode
|
| 1207 |
+
geohash7 = gh_encode(lat, lon, precision=7) if (lat is not None and lon is not None) else None
|
| 1208 |
+
heading_cardinal = _heading_to_cardinal(heading_deg) if heading_deg is not None else None
|
| 1209 |
+
env_payload = {
|
| 1210 |
+
"lat": lat,
|
| 1211 |
+
"lon": lon,
|
| 1212 |
+
"accuracy_m": acc,
|
| 1213 |
+
"heading_deg": heading_deg,
|
| 1214 |
+
"heading_cardinal": heading_cardinal,
|
| 1215 |
+
"tz": tz,
|
| 1216 |
+
"locale": locale,
|
| 1217 |
+
"device": device,
|
| 1218 |
+
"geohash_7": geohash7,
|
| 1219 |
+
}
|
| 1220 |
+
|
| 1221 |
+
# 更新會話暫存
|
| 1222 |
+
manager.last_env[user_id] = env_payload
|
| 1223 |
+
info = manager.get_client_info(user_id) or {}
|
| 1224 |
+
info['env_context'] = env_payload
|
| 1225 |
+
manager.set_client_info(user_id, info)
|
| 1226 |
+
|
| 1227 |
+
try:
|
| 1228 |
+
await set_user_env_current(user_id, env_payload)
|
| 1229 |
+
except Exception as e:
|
| 1230 |
+
logger.warning(f"寫入環境現況失敗: {e}")
|
| 1231 |
+
|
| 1232 |
+
if do_write_snapshot:
|
| 1233 |
+
try:
|
| 1234 |
+
snap = env_payload.copy()
|
| 1235 |
+
snap['reason'] = 'threshold'
|
| 1236 |
+
await add_user_env_snapshot(user_id, snap)
|
| 1237 |
+
except Exception as e:
|
| 1238 |
+
logger.warning(f"寫入環境快照失敗: {e}")
|
| 1239 |
+
|
| 1240 |
+
await websocket.send_json({"type": "env_ack", "success": True, "geohash_7": geohash7, "heading": heading_cardinal})
|
| 1241 |
+
except Exception as e:
|
| 1242 |
+
logger.error(f"處理 env_snapshot 失敗: {e}")
|
| 1243 |
+
await websocket.send_json({"type": "env_ack", "success": False, "error": str(e)})
|
| 1244 |
# 保存 Agent 回應(已在 handle_message 中保存)
|
| 1245 |
|
| 1246 |
_async_lib.create_task(_process_voice_chat())
|
core/database/__init__.py
CHANGED
|
@@ -27,6 +27,7 @@ from .base import (
|
|
| 27 |
from .base import (
|
| 28 |
create_chat,
|
| 29 |
save_message,
|
|
|
|
| 30 |
update_chat_title,
|
| 31 |
delete_chat,
|
| 32 |
set_chat_emotion,
|
|
@@ -40,6 +41,15 @@ from .base import (
|
|
| 40 |
cleanup_old_memories,
|
| 41 |
get_user_history,
|
| 42 |
create_or_login_google_user,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
)
|
| 44 |
|
| 45 |
# 從 optimized 導入優化版操作(帶緩存)
|
|
@@ -77,6 +87,7 @@ __all__ = [
|
|
| 77 |
"get_chat", # 優化版
|
| 78 |
"get_user_chats", # 優化版
|
| 79 |
"save_chat_message", # 優化版
|
|
|
|
| 80 |
"update_chat_title",
|
| 81 |
"delete_chat",
|
| 82 |
"set_chat_emotion",
|
|
@@ -100,3 +111,13 @@ __all__ = [
|
|
| 100 |
"db_cache",
|
| 101 |
"periodic_cache_maintenance",
|
| 102 |
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
from .base import (
|
| 28 |
create_chat,
|
| 29 |
save_message,
|
| 30 |
+
get_chat_messages,
|
| 31 |
update_chat_title,
|
| 32 |
delete_chat,
|
| 33 |
set_chat_emotion,
|
|
|
|
| 41 |
cleanup_old_memories,
|
| 42 |
get_user_history,
|
| 43 |
create_or_login_google_user,
|
| 44 |
+
# 環境 Context
|
| 45 |
+
set_user_env_current,
|
| 46 |
+
add_user_env_snapshot,
|
| 47 |
+
get_user_env_current,
|
| 48 |
+
# 地理/路線快取
|
| 49 |
+
get_geo_cache,
|
| 50 |
+
set_geo_cache,
|
| 51 |
+
get_route_cache,
|
| 52 |
+
set_route_cache,
|
| 53 |
)
|
| 54 |
|
| 55 |
# 從 optimized 導入優化版操作(帶緩存)
|
|
|
|
| 87 |
"get_chat", # 優化版
|
| 88 |
"get_user_chats", # 優化版
|
| 89 |
"save_chat_message", # 優化版
|
| 90 |
+
"get_chat_messages",
|
| 91 |
"update_chat_title",
|
| 92 |
"delete_chat",
|
| 93 |
"set_chat_emotion",
|
|
|
|
| 111 |
"db_cache",
|
| 112 |
"periodic_cache_maintenance",
|
| 113 |
]
|
| 114 |
+
# 環境 Context
|
| 115 |
+
"set_user_env_current",
|
| 116 |
+
"add_user_env_snapshot",
|
| 117 |
+
"get_user_env_current",
|
| 118 |
+
|
| 119 |
+
# 地理/路線快取
|
| 120 |
+
"get_geo_cache",
|
| 121 |
+
"set_geo_cache",
|
| 122 |
+
"get_route_cache",
|
| 123 |
+
"set_route_cache",
|
core/database/base.py
CHANGED
|
@@ -29,6 +29,8 @@ messages_collection = None
|
|
| 29 |
memories_collection = None
|
| 30 |
health_data_collection = None
|
| 31 |
device_bindings_collection = None
|
|
|
|
|
|
|
| 32 |
|
| 33 |
# 記憶儲存相關設定
|
| 34 |
MAX_MEMORIES_PER_USER = 500
|
|
@@ -91,6 +93,11 @@ def connect_to_firestore():
|
|
| 91 |
health_data_collection = firestore_db.collection('health_data')
|
| 92 |
device_bindings_collection = firestore_db.collection('device_bindings')
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
logger.info(f"✅ Firestore連接成功,專案ID:{firebase_project_id}")
|
| 95 |
print(f"\n✅ Firebase Firestore連接成功!專案ID:{firebase_project_id}\n")
|
| 96 |
return True
|
|
@@ -405,48 +412,101 @@ async def get_chat(chat_id):
|
|
| 405 |
logger.warning(f"對話 {chat_id} 不存在")
|
| 406 |
return {"success": False, "error": "對話不存在"}
|
| 407 |
|
| 408 |
-
chat = doc.to_dict()
|
| 409 |
chat["chat_id"] = doc.id
|
| 410 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
return {"success": True, "chat": chat}
|
| 412 |
except Exception as e:
|
| 413 |
logger.error(f"獲取對話時發生錯誤: {e}")
|
| 414 |
return {"success": False, "error": str(e)}
|
| 415 |
|
| 416 |
async def save_chat_message(chat_id, sender, content):
|
| 417 |
-
|
|
|
|
| 418 |
logger.error("Firestore尚未連接,無法保存消息")
|
| 419 |
return {"success": False, "error": "數據庫未連接"}
|
| 420 |
try:
|
| 421 |
import asyncio as _asyncio
|
| 422 |
|
| 423 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
doc_ref = chats_collection.document(chat_id)
|
| 425 |
-
|
| 426 |
-
if not
|
| 427 |
-
return
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
"content": content,
|
| 431 |
-
"timestamp": datetime.now(),
|
| 432 |
-
}
|
| 433 |
-
doc_ref.update({
|
| 434 |
-
"messages": ArrayUnion([message]),
|
| 435 |
-
"updated_at": datetime.now(),
|
| 436 |
-
})
|
| 437 |
-
return message
|
| 438 |
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
|
| 444 |
-
logger.info(f"消息已保存到
|
| 445 |
return {"success": True, "message": message}
|
| 446 |
except Exception as e:
|
| 447 |
logger.error(f"保存消息時發生錯誤: {e}")
|
| 448 |
return {"success": False, "error": str(e)}
|
| 449 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
async def update_chat_title(chat_id, title):
|
| 451 |
if chats_collection is None:
|
| 452 |
logger.error("Firestore尚未連接,無法更新對話標題")
|
|
@@ -726,6 +786,140 @@ async def save_memory(
|
|
| 726 |
return {"success": False, "error": str(e)}
|
| 727 |
|
| 728 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
async def get_user_memories(
|
| 730 |
user_id: str,
|
| 731 |
memory_type: str | None = None,
|
|
|
|
| 29 |
memories_collection = None
|
| 30 |
health_data_collection = None
|
| 31 |
device_bindings_collection = None
|
| 32 |
+
geo_cache_collection = None
|
| 33 |
+
route_cache_collection = None
|
| 34 |
|
| 35 |
# 記憶儲存相關設定
|
| 36 |
MAX_MEMORIES_PER_USER = 500
|
|
|
|
| 93 |
health_data_collection = firestore_db.collection('health_data')
|
| 94 |
device_bindings_collection = firestore_db.collection('device_bindings')
|
| 95 |
|
| 96 |
+
# 其他集合
|
| 97 |
+
global geo_cache_collection, route_cache_collection
|
| 98 |
+
geo_cache_collection = firestore_db.collection('geo_cache')
|
| 99 |
+
route_cache_collection = firestore_db.collection('route_cache')
|
| 100 |
+
|
| 101 |
logger.info(f"✅ Firestore連接成功,專案ID:{firebase_project_id}")
|
| 102 |
print(f"\n✅ Firebase Firestore連接成功!專案ID:{firebase_project_id}\n")
|
| 103 |
return True
|
|
|
|
| 412 |
logger.warning(f"對話 {chat_id} 不存在")
|
| 413 |
return {"success": False, "error": "對話不存在"}
|
| 414 |
|
| 415 |
+
chat = doc.to_dict() or {}
|
| 416 |
chat["chat_id"] = doc.id
|
| 417 |
+
|
| 418 |
+
# 從 messages 集合讀取完整對話(按時間升序)
|
| 419 |
+
try:
|
| 420 |
+
from google.cloud import firestore as _fs
|
| 421 |
+
|
| 422 |
+
def _fetch_msgs():
|
| 423 |
+
q = (
|
| 424 |
+
messages_collection
|
| 425 |
+
.where("chat_id", "==", chat_id)
|
| 426 |
+
.order_by("timestamp", direction=_fs.Query.ASCENDING)
|
| 427 |
+
)
|
| 428 |
+
return [d.to_dict() for d in q.stream()]
|
| 429 |
+
|
| 430 |
+
msgs = await _asyncio.to_thread(_fetch_msgs)
|
| 431 |
+
chat["messages"] = msgs
|
| 432 |
+
logger.info(f"獲取到對話 {chat_id},包含 {len(msgs)} 條消息(messages 集合)")
|
| 433 |
+
except Exception as _e:
|
| 434 |
+
# 向後相容:若讀取失敗,退回文件內嵌 messages(若存在)
|
| 435 |
+
msgs_fallback = chat.get('messages', []) or []
|
| 436 |
+
chat["messages"] = msgs_fallback
|
| 437 |
+
logger.warning(f"讀取 messages 集合失敗,使用內嵌 messages。原因: {_e}")
|
| 438 |
+
|
| 439 |
return {"success": True, "chat": chat}
|
| 440 |
except Exception as e:
|
| 441 |
logger.error(f"獲取對話時發生錯誤: {e}")
|
| 442 |
return {"success": False, "error": str(e)}
|
| 443 |
|
| 444 |
async def save_chat_message(chat_id, sender, content):
|
| 445 |
+
"""保存對話消息(使用 messages 集合作為單一事實來源)"""
|
| 446 |
+
if messages_collection is None or chats_collection is None:
|
| 447 |
logger.error("Firestore尚未連接,無法保存消息")
|
| 448 |
return {"success": False, "error": "數據庫未連接"}
|
| 449 |
try:
|
| 450 |
import asyncio as _asyncio
|
| 451 |
|
| 452 |
+
now = datetime.now()
|
| 453 |
+
message = {
|
| 454 |
+
"chat_id": chat_id,
|
| 455 |
+
"sender": sender,
|
| 456 |
+
"content": content,
|
| 457 |
+
"timestamp": now,
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
def _write_message():
|
| 461 |
+
messages_collection.add(message)
|
| 462 |
+
|
| 463 |
+
def _touch_chat():
|
| 464 |
doc_ref = chats_collection.document(chat_id)
|
| 465 |
+
snap = doc_ref.get()
|
| 466 |
+
if not snap.exists:
|
| 467 |
+
return False
|
| 468 |
+
doc_ref.update({"updated_at": now})
|
| 469 |
+
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 470 |
|
| 471 |
+
await _asyncio.to_thread(_write_message)
|
| 472 |
+
touched = await _asyncio.to_thread(_touch_chat)
|
| 473 |
+
if not touched:
|
| 474 |
+
logger.warning(f"對話 {chat_id} 不存在,但消息已寫入 messages 集合")
|
| 475 |
|
| 476 |
+
logger.info(f"消息已保存到 messages 集合(chat_id={chat_id})")
|
| 477 |
return {"success": True, "message": message}
|
| 478 |
except Exception as e:
|
| 479 |
logger.error(f"保存消息時發生錯誤: {e}")
|
| 480 |
return {"success": False, "error": str(e)}
|
| 481 |
|
| 482 |
+
|
| 483 |
+
async def get_chat_messages(chat_id: str, limit: int | None = None, ascending: bool = True):
|
| 484 |
+
"""讀取指定對話的消息(來自 messages 集合)"""
|
| 485 |
+
if messages_collection is None:
|
| 486 |
+
logger.error("Firestore尚未連接,無法讀取消息")
|
| 487 |
+
return []
|
| 488 |
+
try:
|
| 489 |
+
import asyncio as _asyncio
|
| 490 |
+
from google.cloud import firestore as _fs
|
| 491 |
+
|
| 492 |
+
def _query():
|
| 493 |
+
q = messages_collection.where("chat_id", "==", chat_id)
|
| 494 |
+
direction = _fs.Query.ASCENDING if ascending else _fs.Query.DESCENDING
|
| 495 |
+
q = q.order_by("timestamp", direction=direction)
|
| 496 |
+
if limit and limit > 0:
|
| 497 |
+
q = q.limit(limit)
|
| 498 |
+
docs = q.stream()
|
| 499 |
+
res = [d.to_dict() for d in docs]
|
| 500 |
+
if not ascending:
|
| 501 |
+
res.reverse() # 若取降序+limit,回傳前再反轉為時間正序
|
| 502 |
+
return res
|
| 503 |
+
|
| 504 |
+
messages = await _asyncio.to_thread(_query)
|
| 505 |
+
return messages
|
| 506 |
+
except Exception as e:
|
| 507 |
+
logger.error(f"讀取對話消息失敗: {e}")
|
| 508 |
+
return []
|
| 509 |
+
|
| 510 |
async def update_chat_title(chat_id, title):
|
| 511 |
if chats_collection is None:
|
| 512 |
logger.error("Firestore尚未連接,無法更新對話標題")
|
|
|
|
| 786 |
return {"success": False, "error": str(e)}
|
| 787 |
|
| 788 |
|
| 789 |
+
# ===== 環境 Context(位置/方位/時序) =====
|
| 790 |
+
|
| 791 |
+
async def set_user_env_current(user_id: str, ctx: Dict[str, Any]) -> Dict[str, Any]:
|
| 792 |
+
"""更新使用者環境現況 users/{uid}/context/current(含 TTL/新鮮度由讀取端判斷)。"""
|
| 793 |
+
if users_collection is None:
|
| 794 |
+
return {"success": False, "error": "數據庫未連接"}
|
| 795 |
+
try:
|
| 796 |
+
import asyncio as _asyncio
|
| 797 |
+
now = datetime.now()
|
| 798 |
+
|
| 799 |
+
def _update():
|
| 800 |
+
user_doc = _get_user_doc_ref(user_id)
|
| 801 |
+
ctx_ref = user_doc.collection('context').document('current')
|
| 802 |
+
payload = ctx.copy()
|
| 803 |
+
payload['updated_at'] = now
|
| 804 |
+
ctx_ref.set(payload, merge=True)
|
| 805 |
+
return True
|
| 806 |
+
|
| 807 |
+
await _asyncio.to_thread(_update)
|
| 808 |
+
return {"success": True}
|
| 809 |
+
except Exception as e:
|
| 810 |
+
logger.error(f"更新環境現況失敗: {e}")
|
| 811 |
+
return {"success": False, "error": str(e)}
|
| 812 |
+
|
| 813 |
+
|
| 814 |
+
async def add_user_env_snapshot(user_id: str, snapshot: Dict[str, Any]) -> Dict[str, Any]:
|
| 815 |
+
"""新增使用者環境快照 users/{uid}/context/snapshots。僅保留短期歷史。"""
|
| 816 |
+
if users_collection is None:
|
| 817 |
+
return {"success": False, "error": "數據庫未連接"}
|
| 818 |
+
try:
|
| 819 |
+
import asyncio as _asyncio
|
| 820 |
+
now = datetime.now()
|
| 821 |
+
|
| 822 |
+
def _write():
|
| 823 |
+
user_doc = _get_user_doc_ref(user_id)
|
| 824 |
+
col = user_doc.collection('context').document('meta').collection('snapshots')
|
| 825 |
+
payload = snapshot.copy()
|
| 826 |
+
payload['created_at'] = now
|
| 827 |
+
col.add(payload)
|
| 828 |
+
return True
|
| 829 |
+
|
| 830 |
+
await _asyncio.to_thread(_write)
|
| 831 |
+
return {"success": True}
|
| 832 |
+
except Exception as e:
|
| 833 |
+
logger.error(f"寫入環境快照失敗: {e}")
|
| 834 |
+
return {"success": False, "error": str(e)}
|
| 835 |
+
|
| 836 |
+
|
| 837 |
+
async def get_user_env_current(user_id: str) -> Dict[str, Any]:
|
| 838 |
+
"""讀取使用者環境現況。"""
|
| 839 |
+
if users_collection is None:
|
| 840 |
+
return {"success": False, "error": "數據庫未連接"}
|
| 841 |
+
try:
|
| 842 |
+
import asyncio as _asyncio
|
| 843 |
+
|
| 844 |
+
def _read():
|
| 845 |
+
user_doc = _get_user_doc_ref(user_id)
|
| 846 |
+
ctx_ref = user_doc.collection('context').document('current')
|
| 847 |
+
snap = ctx_ref.get()
|
| 848 |
+
return snap.to_dict() if snap.exists else None
|
| 849 |
+
|
| 850 |
+
data = await _asyncio.to_thread(_read)
|
| 851 |
+
if not data:
|
| 852 |
+
return {"success": False, "error": "NOT_FOUND"}
|
| 853 |
+
return {"success": True, "context": data}
|
| 854 |
+
except Exception as e:
|
| 855 |
+
logger.error(f"讀取環境現況失敗: {e}")
|
| 856 |
+
return {"success": False, "error": str(e)}
|
| 857 |
+
|
| 858 |
+
|
| 859 |
+
# ===== 反地理/路線 全域快取集合 =====
|
| 860 |
+
|
| 861 |
+
async def get_geo_cache(geohash7: str) -> Optional[Dict[str, Any]]:
|
| 862 |
+
if geo_cache_collection is None:
|
| 863 |
+
return None
|
| 864 |
+
try:
|
| 865 |
+
import asyncio as _asyncio
|
| 866 |
+
def _read():
|
| 867 |
+
doc = geo_cache_collection.document(geohash7).get()
|
| 868 |
+
return doc.to_dict() if doc.exists else None
|
| 869 |
+
return await _asyncio.to_thread(_read)
|
| 870 |
+
except Exception as e:
|
| 871 |
+
logger.warning(f"讀取 geo_cache 失敗: {e}")
|
| 872 |
+
return None
|
| 873 |
+
|
| 874 |
+
|
| 875 |
+
async def set_geo_cache(geohash7: str, payload: Dict[str, Any]) -> bool:
|
| 876 |
+
if geo_cache_collection is None:
|
| 877 |
+
return False
|
| 878 |
+
try:
|
| 879 |
+
import asyncio as _asyncio
|
| 880 |
+
now = datetime.now()
|
| 881 |
+
def _write():
|
| 882 |
+
data = payload.copy()
|
| 883 |
+
data['cached_at'] = now
|
| 884 |
+
geo_cache_collection.document(geohash7).set(data, merge=True)
|
| 885 |
+
return True
|
| 886 |
+
return await _asyncio.to_thread(_write)
|
| 887 |
+
except Exception as e:
|
| 888 |
+
logger.warning(f"寫入 geo_cache 失敗: {e}")
|
| 889 |
+
return False
|
| 890 |
+
|
| 891 |
+
|
| 892 |
+
async def get_route_cache(key: str) -> Optional[Dict[str, Any]]:
|
| 893 |
+
if route_cache_collection is None:
|
| 894 |
+
return None
|
| 895 |
+
try:
|
| 896 |
+
import asyncio as _asyncio
|
| 897 |
+
def _read():
|
| 898 |
+
doc = route_cache_collection.document(key).get()
|
| 899 |
+
return doc.to_dict() if doc.exists else None
|
| 900 |
+
return await _asyncio.to_thread(_read)
|
| 901 |
+
except Exception as e:
|
| 902 |
+
logger.warning(f"讀取 route_cache 失敗: {e}")
|
| 903 |
+
return None
|
| 904 |
+
|
| 905 |
+
|
| 906 |
+
async def set_route_cache(key: str, payload: Dict[str, Any]) -> bool:
|
| 907 |
+
if route_cache_collection is None:
|
| 908 |
+
return False
|
| 909 |
+
try:
|
| 910 |
+
import asyncio as _asyncio
|
| 911 |
+
now = datetime.now()
|
| 912 |
+
def _write():
|
| 913 |
+
data = payload.copy()
|
| 914 |
+
data['cached_at'] = now
|
| 915 |
+
route_cache_collection.document(key).set(data, merge=True)
|
| 916 |
+
return True
|
| 917 |
+
return await _asyncio.to_thread(_write)
|
| 918 |
+
except Exception as e:
|
| 919 |
+
logger.warning(f"寫入 route_cache 失敗: {e}")
|
| 920 |
+
return False
|
| 921 |
+
|
| 922 |
+
|
| 923 |
async def get_user_memories(
|
| 924 |
user_id: str,
|
| 925 |
memory_type: str | None = None,
|
core/database/cache.py
CHANGED
|
@@ -120,7 +120,12 @@ class DatabaseCache:
|
|
| 120 |
# 請求合併(同一查詢只執行一次)
|
| 121 |
self.pending_requests: Dict[str, asyncio.Future] = {}
|
| 122 |
self.pending_lock = asyncio.Lock()
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
logger.info("數據庫緩存管理器初始化完成")
|
| 125 |
|
| 126 |
def _generate_cache_key(self, operation: str, **kwargs) -> str:
|
|
@@ -280,6 +285,9 @@ class DatabaseCache:
|
|
| 280 |
"chat_cache": self.chat_cache.get_stats(),
|
| 281 |
"message_cache": self.message_cache.get_stats(),
|
| 282 |
"memory_cache": self.memory_cache.get_stats(),
|
|
|
|
|
|
|
|
|
|
| 283 |
"write_buffer": {k: len(v) for k, v in self.write_buffer.items()}
|
| 284 |
}
|
| 285 |
|
|
@@ -291,6 +299,31 @@ class DatabaseCache:
|
|
| 291 |
await self.memory_cache.clear()
|
| 292 |
logger.info("所有緩存已清空")
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
|
| 295 |
# 全局緩存實例
|
| 296 |
db_cache = DatabaseCache()
|
|
@@ -314,4 +347,3 @@ async def periodic_cache_maintenance():
|
|
| 314 |
|
| 315 |
except Exception as e:
|
| 316 |
logger.error(f"緩存維護任務出錯: {e}")
|
| 317 |
-
|
|
|
|
| 120 |
# 請求合併(同一查詢只執行一次)
|
| 121 |
self.pending_requests: Dict[str, asyncio.Future] = {}
|
| 122 |
self.pending_lock = asyncio.Lock()
|
| 123 |
+
|
| 124 |
+
# 其他快取:環境、反地理、路徑
|
| 125 |
+
self.env_ctx_cache = LRUCache(max_size=1000, ttl_seconds=600) # 使用者環境快取:10 分鐘
|
| 126 |
+
self.geo_cache = LRUCache(max_size=5000, ttl_seconds=604800) # 反地理快取:7 天
|
| 127 |
+
self.route_cache = LRUCache(max_size=5000, ttl_seconds=86400) # 路線快取:1 天
|
| 128 |
+
|
| 129 |
logger.info("數據庫緩存管理器初始化完成")
|
| 130 |
|
| 131 |
def _generate_cache_key(self, operation: str, **kwargs) -> str:
|
|
|
|
| 285 |
"chat_cache": self.chat_cache.get_stats(),
|
| 286 |
"message_cache": self.message_cache.get_stats(),
|
| 287 |
"memory_cache": self.memory_cache.get_stats(),
|
| 288 |
+
"env_ctx_cache": self.env_ctx_cache.get_stats(),
|
| 289 |
+
"geo_cache": self.geo_cache.get_stats(),
|
| 290 |
+
"route_cache": self.route_cache.get_stats(),
|
| 291 |
"write_buffer": {k: len(v) for k, v in self.write_buffer.items()}
|
| 292 |
}
|
| 293 |
|
|
|
|
| 299 |
await self.memory_cache.clear()
|
| 300 |
logger.info("所有緩存已清空")
|
| 301 |
|
| 302 |
+
# ===== 環境/地理/路線 快取 API =====
|
| 303 |
+
async def get_env_ctx_cached(self, user_id: str) -> Optional[Dict[str, Any]]:
|
| 304 |
+
key = self._generate_cache_key("env_ctx", user_id=user_id)
|
| 305 |
+
return await self.env_ctx_cache.get(key)
|
| 306 |
+
|
| 307 |
+
async def set_env_ctx_cache(self, user_id: str, ctx: Dict[str, Any]):
|
| 308 |
+
key = self._generate_cache_key("env_ctx", user_id=user_id)
|
| 309 |
+
await self.env_ctx_cache.set(key, ctx)
|
| 310 |
+
|
| 311 |
+
async def get_geo_cached(self, geohash7: str) -> Optional[Dict[str, Any]]:
|
| 312 |
+
key = self._generate_cache_key("geo", geohash=geohash7)
|
| 313 |
+
return await self.geo_cache.get(key)
|
| 314 |
+
|
| 315 |
+
async def set_geo_cache(self, geohash7: str, payload: Dict[str, Any]):
|
| 316 |
+
key = self._generate_cache_key("geo", geohash=geohash7)
|
| 317 |
+
await self.geo_cache.set(key, payload)
|
| 318 |
+
|
| 319 |
+
async def get_route_cached(self, cache_key: str) -> Optional[Dict[str, Any]]:
|
| 320 |
+
key = self._generate_cache_key("route", key=cache_key)
|
| 321 |
+
return await self.route_cache.get(key)
|
| 322 |
+
|
| 323 |
+
async def set_route_cache(self, cache_key: str, payload: Dict[str, Any]):
|
| 324 |
+
key = self._generate_cache_key("route", key=cache_key)
|
| 325 |
+
await self.route_cache.set(key, payload)
|
| 326 |
+
|
| 327 |
|
| 328 |
# 全局緩存實例
|
| 329 |
db_cache = DatabaseCache()
|
|
|
|
| 347 |
|
| 348 |
except Exception as e:
|
| 349 |
logger.error(f"緩存維護任務出錯: {e}")
|
|
|
core/emotion_care_manager.py
CHANGED
|
@@ -18,6 +18,10 @@ class EmotionCareManager:
|
|
| 18 |
# 極端情緒定義(需要進入關懷模式的情緒)
|
| 19 |
EXTREME_EMOTIONS = {"sad", "angry", "fear"}
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
# 解除關懷模式的關鍵字
|
| 22 |
RELEASE_KEYWORDS = [
|
| 23 |
"我沒事了", "我好了", "沒事了", "好多了", "好一點了",
|
|
@@ -62,11 +66,20 @@ class EmotionCareManager:
|
|
| 62 |
if not emotion or emotion not in cls.EXTREME_EMOTIONS:
|
| 63 |
return False
|
| 64 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
# 進入關懷模式
|
| 66 |
cls._set_state(user_id, chat_id, {
|
| 67 |
"in_care_mode": True,
|
| 68 |
"emotion": emotion,
|
| 69 |
-
"start_time": time.time()
|
|
|
|
| 70 |
})
|
| 71 |
|
| 72 |
logger.warning(f"⚠️ 用戶 {user_id}(chat={chat_id or 'default'})偵測到極端情緒 [{emotion}],進入關懷模式")
|
|
@@ -97,6 +110,7 @@ class EmotionCareManager:
|
|
| 97 |
duration = time.time() - state.get("start_time", 0)
|
| 98 |
|
| 99 |
state["in_care_mode"] = False
|
|
|
|
| 100 |
|
| 101 |
logger.info(f"✅ 用戶 {user_id}(chat={chat_id or 'default'})情緒恢復({emotion} → 正常),解除關懷模式(持續 {duration:.1f}秒)")
|
| 102 |
return True
|
|
@@ -117,7 +131,16 @@ class EmotionCareManager:
|
|
| 117 |
state = cls._get_state(user_id, chat_id)
|
| 118 |
if not state:
|
| 119 |
return False
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
@classmethod
|
| 123 |
def get_care_emotion(cls, user_id: str, chat_id: Optional[str] = None) -> Optional[str]:
|
|
|
|
| 18 |
# 極端情緒定義(需要進入關懷模式的情緒)
|
| 19 |
EXTREME_EMOTIONS = {"sad", "angry", "fear"}
|
| 20 |
|
| 21 |
+
# 模式存活與冷卻(避免反覆觸發)
|
| 22 |
+
CARE_TTL_SECONDS = 20 * 60 # 20 分鐘自動失效
|
| 23 |
+
COOLDOWN_SECONDS = 10 * 60 # 10 分鐘內不重入
|
| 24 |
+
|
| 25 |
# 解除關懷模式的關鍵字
|
| 26 |
RELEASE_KEYWORDS = [
|
| 27 |
"我沒事了", "我好了", "沒事了", "好多了", "好一點了",
|
|
|
|
| 66 |
if not emotion or emotion not in cls.EXTREME_EMOTIONS:
|
| 67 |
return False
|
| 68 |
|
| 69 |
+
# 冷卻期防抖:若剛退出不久,避免馬上重入
|
| 70 |
+
key = cls._resolve_chat_key(chat_id)
|
| 71 |
+
user_states = cls._user_states.get(user_id) or {}
|
| 72 |
+
prev_state = user_states.get(key) or {}
|
| 73 |
+
last_exit = prev_state.get("last_exit_time", 0.0)
|
| 74 |
+
if last_exit and (time.time() - last_exit) < cls.COOLDOWN_SECONDS:
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
# 進入關懷模式
|
| 78 |
cls._set_state(user_id, chat_id, {
|
| 79 |
"in_care_mode": True,
|
| 80 |
"emotion": emotion,
|
| 81 |
+
"start_time": time.time(),
|
| 82 |
+
"last_exit_time": prev_state.get("last_exit_time", 0.0),
|
| 83 |
})
|
| 84 |
|
| 85 |
logger.warning(f"⚠️ 用戶 {user_id}(chat={chat_id or 'default'})偵測到極端情緒 [{emotion}],進入關懷模式")
|
|
|
|
| 110 |
duration = time.time() - state.get("start_time", 0)
|
| 111 |
|
| 112 |
state["in_care_mode"] = False
|
| 113 |
+
state["last_exit_time"] = time.time()
|
| 114 |
|
| 115 |
logger.info(f"✅ 用戶 {user_id}(chat={chat_id or 'default'})情緒恢復({emotion} → 正常),解除關懷模式(持續 {duration:.1f}秒)")
|
| 116 |
return True
|
|
|
|
| 131 |
state = cls._get_state(user_id, chat_id)
|
| 132 |
if not state:
|
| 133 |
return False
|
| 134 |
+
if not state.get("in_care_mode", False):
|
| 135 |
+
return False
|
| 136 |
+
# TTL:超時自動解除
|
| 137 |
+
start = state.get("start_time", 0.0)
|
| 138 |
+
if start and (time.time() - start) > cls.CARE_TTL_SECONDS:
|
| 139 |
+
state["in_care_mode"] = False
|
| 140 |
+
state["last_exit_time"] = time.time()
|
| 141 |
+
logger.info(f"⏳ 用戶 {user_id}(chat={chat_id or 'default'})關懷模式逾時自動解除")
|
| 142 |
+
return False
|
| 143 |
+
return True
|
| 144 |
|
| 145 |
@classmethod
|
| 146 |
def get_care_emotion(cls, user_id: str, chat_id: Optional[str] = None) -> Optional[str]:
|
features/mcp/tools/directions_tool.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
路徑規劃工具(OpenRouteService)
|
| 3 |
+
使用免費 ORS API,搭配 DB/記憶體快取
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import json
|
| 8 |
+
import logging
|
| 9 |
+
import aiohttp
|
| 10 |
+
from typing import Dict, Any
|
| 11 |
+
|
| 12 |
+
from .base_tool import MCPTool, StandardToolSchemas, ExecutionError, ValidationError
|
| 13 |
+
from core.config import settings
|
| 14 |
+
from core.database import get_route_cache, set_route_cache
|
| 15 |
+
from core.database.cache import db_cache
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger("mcp.tools.directions")
|
| 18 |
+
|
| 19 |
+
ORS_API_KEY = os.getenv("OPENROUTESERVICE_API_KEY", "")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class DirectionsTool(MCPTool):
|
| 23 |
+
NAME = "directions"
|
| 24 |
+
DESCRIPTION = "規劃兩點之間的路線(walk/drive/cycle),返回距離、時間與 polyline"
|
| 25 |
+
CATEGORY = "地理"
|
| 26 |
+
TAGS = ["route", "navigation", "directions"]
|
| 27 |
+
USAGE_TIPS = ["提供起訖兩點經緯度"]
|
| 28 |
+
|
| 29 |
+
@classmethod
|
| 30 |
+
def get_input_schema(cls) -> Dict[str, Any]:
|
| 31 |
+
return StandardToolSchemas.create_input_schema({
|
| 32 |
+
"origin_lat": {"type": "number"},
|
| 33 |
+
"origin_lon": {"type": "number"},
|
| 34 |
+
"dest_lat": {"type": "number"},
|
| 35 |
+
"dest_lon": {"type": "number"},
|
| 36 |
+
"mode": {"type": "string", "enum": ["driving-car", "foot-walking", "cycling-regular"], "default": "foot-walking"}
|
| 37 |
+
}, required=["origin_lat", "origin_lon", "dest_lat", "dest_lon"])
|
| 38 |
+
|
| 39 |
+
@classmethod
|
| 40 |
+
def get_output_schema(cls) -> Dict[str, Any]:
|
| 41 |
+
schema = StandardToolSchemas.create_output_schema()
|
| 42 |
+
schema["properties"].update({
|
| 43 |
+
"distance_m": {"type": "number"},
|
| 44 |
+
"duration_s": {"type": "number"},
|
| 45 |
+
"polyline": {"type": "string"}
|
| 46 |
+
})
|
| 47 |
+
return schema
|
| 48 |
+
|
| 49 |
+
@classmethod
|
| 50 |
+
async def execute(cls, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 51 |
+
if not ORS_API_KEY:
|
| 52 |
+
raise ExecutionError("未設定 OPENROUTESERVICE_API_KEY")
|
| 53 |
+
|
| 54 |
+
o_lat = float(arguments.get("origin_lat"))
|
| 55 |
+
o_lon = float(arguments.get("origin_lon"))
|
| 56 |
+
d_lat = float(arguments.get("dest_lat"))
|
| 57 |
+
d_lon = float(arguments.get("dest_lon"))
|
| 58 |
+
mode = arguments.get("mode", "foot-walking")
|
| 59 |
+
|
| 60 |
+
# 快取鍵(geohash 簡化)
|
| 61 |
+
try:
|
| 62 |
+
from geohash2 import encode as gh_encode
|
| 63 |
+
key = f"{gh_encode(o_lat, o_lon, precision=7)}->{gh_encode(d_lat, d_lon, precision=7)}#{mode}"
|
| 64 |
+
except Exception:
|
| 65 |
+
key = f"{round(o_lat,4)},{round(o_lon,4)}->{round(d_lat,4)},{round(d_lon,4)}#{mode}"
|
| 66 |
+
|
| 67 |
+
cached = await db_cache.get_route_cached(key)
|
| 68 |
+
if cached:
|
| 69 |
+
return cls.create_success_response(content=f"距離 {int(cached['distance_m'])}m,約 {int(cached['duration_s']/60)} 分鐘", data=cached)
|
| 70 |
+
|
| 71 |
+
db_cached = await get_route_cache(key)
|
| 72 |
+
if db_cached:
|
| 73 |
+
await db_cache.set_route_cache(key, db_cached)
|
| 74 |
+
return cls.create_success_response(content=f"距離 {int(db_cached['distance_m'])}m,約 {int(db_cached['duration_s']/60)} 分鐘", data=db_cached)
|
| 75 |
+
|
| 76 |
+
# 呼叫 ORS Directions
|
| 77 |
+
url = f"https://api.openrouteservice.org/v2/directions/{mode}"
|
| 78 |
+
headers = {"Authorization": ORS_API_KEY, "Content-Type": "application/json"}
|
| 79 |
+
body = {
|
| 80 |
+
"coordinates": [
|
| 81 |
+
[o_lon, o_lat],
|
| 82 |
+
[d_lon, d_lat]
|
| 83 |
+
]
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
async with aiohttp.ClientSession() as session:
|
| 87 |
+
async with session.post(url, headers=headers, data=json.dumps(body), timeout=15) as resp:
|
| 88 |
+
if resp.status != 200:
|
| 89 |
+
raise ExecutionError(f"ORS 失敗: HTTP {resp.status}")
|
| 90 |
+
data = await resp.json()
|
| 91 |
+
|
| 92 |
+
try:
|
| 93 |
+
feat = data["features"][0]
|
| 94 |
+
summary = feat["properties"]["summary"]
|
| 95 |
+
distance_m = float(summary["distance"]) # meters
|
| 96 |
+
duration_s = float(summary["duration"]) # seconds
|
| 97 |
+
polyline = feat["geometry"]["coordinates"] # LineString 座標
|
| 98 |
+
payload = {"distance_m": distance_m, "duration_s": duration_s, "polyline": json.dumps(polyline)}
|
| 99 |
+
except Exception as e:
|
| 100 |
+
raise ExecutionError(f"解析 ORS 回應失敗: {e}")
|
| 101 |
+
|
| 102 |
+
# 回寫快取
|
| 103 |
+
await db_cache.set_route_cache(key, payload)
|
| 104 |
+
await set_route_cache(key, payload)
|
| 105 |
+
|
| 106 |
+
return cls.create_success_response(content=f"距離 {int(distance_m)}m,約 {int(duration_s/60)} 分鐘", data=payload)
|
| 107 |
+
|
features/mcp/tools/geocode_tool.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
反地理與時區工具(免費 API 優先)
|
| 3 |
+
- reverse_geocode: 使用 Nominatim(OSM)反查城市/行政區(先查 DB/記憶體快取)
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import aiohttp
|
| 7 |
+
import asyncio
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Dict, Any
|
| 10 |
+
|
| 11 |
+
from .base_tool import MCPTool, StandardToolSchemas, ExecutionError
|
| 12 |
+
from core.database import get_geo_cache, set_geo_cache
|
| 13 |
+
from core.database.cache import db_cache
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger("mcp.tools.geocode")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ReverseGeocodeTool(MCPTool):
|
| 19 |
+
NAME = "reverse_geocode"
|
| 20 |
+
DESCRIPTION = "以經緯度反查城市/行政區(優先使用快取)"
|
| 21 |
+
CATEGORY = "地理"
|
| 22 |
+
TAGS = ["geocode", "reverse", "city"]
|
| 23 |
+
USAGE_TIPS = ["提供 lat/lon 即可"]
|
| 24 |
+
|
| 25 |
+
@classmethod
|
| 26 |
+
def get_input_schema(cls) -> Dict[str, Any]:
|
| 27 |
+
return StandardToolSchemas.create_input_schema({
|
| 28 |
+
"lat": {"type": "number", "description": "緯度"},
|
| 29 |
+
"lon": {"type": "number", "description": "經度"}
|
| 30 |
+
}, required=["lat", "lon"])
|
| 31 |
+
|
| 32 |
+
@classmethod
|
| 33 |
+
def get_output_schema(cls) -> Dict[str, Any]:
|
| 34 |
+
schema = StandardToolSchemas.create_output_schema()
|
| 35 |
+
schema["properties"].update({
|
| 36 |
+
"city": {"type": "string"},
|
| 37 |
+
"admin": {"type": "string"},
|
| 38 |
+
"country_code": {"type": "string"}
|
| 39 |
+
})
|
| 40 |
+
return schema
|
| 41 |
+
|
| 42 |
+
@classmethod
|
| 43 |
+
async def execute(cls, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 44 |
+
lat = arguments.get("lat")
|
| 45 |
+
lon = arguments.get("lon")
|
| 46 |
+
if lat is None or lon is None:
|
| 47 |
+
raise ExecutionError("缺少經緯度")
|
| 48 |
+
|
| 49 |
+
# 先用 geohash7 當鍵查快取
|
| 50 |
+
try:
|
| 51 |
+
from geohash2 import encode as gh_encode
|
| 52 |
+
geokey = gh_encode(lat, lon, precision=7)
|
| 53 |
+
except Exception:
|
| 54 |
+
geokey = f"{round(lat,4)},{round(lon,4)}"
|
| 55 |
+
|
| 56 |
+
# 記憶體快取
|
| 57 |
+
cached = await db_cache.get_geo_cached(geokey)
|
| 58 |
+
if cached:
|
| 59 |
+
return cls.create_success_response(
|
| 60 |
+
content=f"{cached.get('city')}, {cached.get('admin')}",
|
| 61 |
+
data=cached
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
# DB 快取
|
| 65 |
+
db_cached = await get_geo_cache(geokey)
|
| 66 |
+
if db_cached:
|
| 67 |
+
await db_cache.set_geo_cache(geokey, db_cached)
|
| 68 |
+
return cls.create_success_response(
|
| 69 |
+
content=f"{db_cached.get('city')}, {db_cached.get('admin')}",
|
| 70 |
+
data=db_cached
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# 外呼 Nominatim(公共端點,務必節流)
|
| 74 |
+
url = "https://nominatim.openstreetmap.org/reverse"
|
| 75 |
+
params = {
|
| 76 |
+
"format": "jsonv2",
|
| 77 |
+
"lat": lat,
|
| 78 |
+
"lon": lon,
|
| 79 |
+
"zoom": 10,
|
| 80 |
+
"addressdetails": 1
|
| 81 |
+
}
|
| 82 |
+
headers = {
|
| 83 |
+
"User-Agent": "BloomWare/1.0 (contact@example.com)"
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
async with aiohttp.ClientSession(headers=headers) as session:
|
| 87 |
+
async with session.get(url, params=params, timeout=10) as resp:
|
| 88 |
+
if resp.status != 200:
|
| 89 |
+
raise ExecutionError(f"Nominatim 失敗: {resp.status}")
|
| 90 |
+
data = await resp.json()
|
| 91 |
+
addr = data.get("address", {})
|
| 92 |
+
city = addr.get("city") or addr.get("town") or addr.get("village") or addr.get("county")
|
| 93 |
+
admin = addr.get("state") or addr.get("county") or ""
|
| 94 |
+
country_code = (addr.get("country_code") or "").upper()
|
| 95 |
+
payload = {"city": city or "", "admin": admin or "", "country_code": country_code}
|
| 96 |
+
|
| 97 |
+
# 回寫快取
|
| 98 |
+
await db_cache.set_geo_cache(geokey, payload)
|
| 99 |
+
await set_geo_cache(geokey, payload)
|
| 100 |
+
|
| 101 |
+
return cls.create_success_response(content=f"{payload['city']}, {payload['admin']}", data=payload)
|
| 102 |
+
|
features/mcp/tools/weather_tool.py
CHANGED
|
@@ -41,15 +41,18 @@ class WeatherTool(MCPTool):
|
|
| 41 |
return StandardToolSchemas.create_input_schema({
|
| 42 |
"city": {
|
| 43 |
"type": "string",
|
| 44 |
-
"description": "城市名稱(
|
|
|
|
| 45 |
},
|
|
|
|
|
|
|
| 46 |
"language": {
|
| 47 |
"type": "string",
|
| 48 |
"description": "回覆語言 (zh_tw, en, zh_cn)",
|
| 49 |
"default": "zh_tw",
|
| 50 |
"enum": ["zh_tw", "en", "zh_cn"]
|
| 51 |
}
|
| 52 |
-
}, ["city"])
|
| 53 |
|
| 54 |
@classmethod
|
| 55 |
def get_output_schema(cls) -> Dict[str, Any]:
|
|
@@ -65,15 +68,20 @@ class WeatherTool(MCPTool):
|
|
| 65 |
"""執行天氣查詢"""
|
| 66 |
city = arguments.get("city")
|
| 67 |
language = arguments.get("language", "zh_tw")
|
|
|
|
|
|
|
| 68 |
|
| 69 |
-
if not city:
|
| 70 |
-
raise ValidationError("city", "未提供城市
|
| 71 |
|
| 72 |
if not WEATHER_API_KEY:
|
| 73 |
raise ExecutionError("未設置天氣 API 密鑰,請在 .env 文件中添加 WEATHER_API_KEY")
|
| 74 |
|
| 75 |
try:
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
if weather_data.get("success"):
|
| 79 |
# 生成結構化數據供 AI 格式化
|
|
@@ -94,6 +102,38 @@ class WeatherTool(MCPTool):
|
|
| 94 |
logger.error(f"天氣查詢錯誤: {e}")
|
| 95 |
raise ExecutionError(f"天氣查詢時發生錯誤: {str(e)}", e)
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
@staticmethod
|
| 98 |
async def _get_weather_data(city: str, language: str = "zh_tw") -> Dict[str, Any]:
|
| 99 |
"""獲取天氣數據"""
|
|
@@ -215,4 +255,4 @@ class WeatherTool(MCPTool):
|
|
| 215 |
"Squall": "💨",
|
| 216 |
"Tornado": "🌪️"
|
| 217 |
}
|
| 218 |
-
return weather_emojis.get(weather_main, "🌡️")
|
|
|
|
| 41 |
return StandardToolSchemas.create_input_schema({
|
| 42 |
"city": {
|
| 43 |
"type": "string",
|
| 44 |
+
"description": "城市名稱(英文)或座標字串 (lat,lon),若提供 lat/lon 會優先生效",
|
| 45 |
+
"default": ""
|
| 46 |
},
|
| 47 |
+
"lat": {"type": "number", "description": "緯度(優先於 city)", "default": None},
|
| 48 |
+
"lon": {"type": "number", "description": "經度(優先於 city)", "default": None},
|
| 49 |
"language": {
|
| 50 |
"type": "string",
|
| 51 |
"description": "回覆語言 (zh_tw, en, zh_cn)",
|
| 52 |
"default": "zh_tw",
|
| 53 |
"enum": ["zh_tw", "en", "zh_cn"]
|
| 54 |
}
|
| 55 |
+
}, ["city"]) # city 可留空,若 lat/lon 有值則忽略
|
| 56 |
|
| 57 |
@classmethod
|
| 58 |
def get_output_schema(cls) -> Dict[str, Any]:
|
|
|
|
| 68 |
"""執行天氣查詢"""
|
| 69 |
city = arguments.get("city")
|
| 70 |
language = arguments.get("language", "zh_tw")
|
| 71 |
+
lat_arg = arguments.get("lat")
|
| 72 |
+
lon_arg = arguments.get("lon")
|
| 73 |
|
| 74 |
+
if (lat_arg is None or lon_arg is None) and not city:
|
| 75 |
+
raise ValidationError("city", "未提供城市或經緯度")
|
| 76 |
|
| 77 |
if not WEATHER_API_KEY:
|
| 78 |
raise ExecutionError("未設置天氣 API 密鑰,請在 .env 文件中添加 WEATHER_API_KEY")
|
| 79 |
|
| 80 |
try:
|
| 81 |
+
if lat_arg is not None and lon_arg is not None:
|
| 82 |
+
weather_data = await cls._get_weather_data_by_coord(float(lat_arg), float(lon_arg), language)
|
| 83 |
+
else:
|
| 84 |
+
weather_data = await cls._get_weather_data(city, language)
|
| 85 |
|
| 86 |
if weather_data.get("success"):
|
| 87 |
# 生成結構化數據供 AI 格式化
|
|
|
|
| 102 |
logger.error(f"天氣查詢錯誤: {e}")
|
| 103 |
raise ExecutionError(f"天氣查詢時發生錯誤: {str(e)}", e)
|
| 104 |
|
| 105 |
+
@staticmethod
|
| 106 |
+
async def _get_weather_data_by_coord(lat: float, lon: float, language: str = "zh_tw") -> Dict[str, Any]:
|
| 107 |
+
try:
|
| 108 |
+
logger.info(f"查詢座標天氣: lat={lat}, lon={lon}")
|
| 109 |
+
params = {
|
| 110 |
+
"appid": WEATHER_API_KEY,
|
| 111 |
+
"units": "metric",
|
| 112 |
+
"lang": language,
|
| 113 |
+
"lat": lat,
|
| 114 |
+
"lon": lon,
|
| 115 |
+
}
|
| 116 |
+
async with aiohttp.ClientSession() as session:
|
| 117 |
+
async with session.get(WEATHER_API_URL, params=params, timeout=10) as response:
|
| 118 |
+
if response.status == 200:
|
| 119 |
+
data = await response.json()
|
| 120 |
+
return {"success": True, "data": data}
|
| 121 |
+
elif response.status == 401:
|
| 122 |
+
return {"success": False, "error": "天氣 API 授權失敗,請檢查 API 密鑰"}
|
| 123 |
+
elif response.status == 404:
|
| 124 |
+
return {"success": False, "error": "找不到座標對應天氣資訊"}
|
| 125 |
+
elif response.status == 429:
|
| 126 |
+
return {"success": False, "error": "天氣 API 請求次數超限,請稍後再試"}
|
| 127 |
+
else:
|
| 128 |
+
return {"success": False, "error": f"天氣 API 請求失敗,狀態碼: {response.status}"}
|
| 129 |
+
except asyncio.TimeoutError:
|
| 130 |
+
return {"success": False, "error": "獲取天氣資訊超時"}
|
| 131 |
+
except aiohttp.ClientError:
|
| 132 |
+
return {"success": False, "error": "網絡連接錯誤,無法獲取天氣資訊"}
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.error(f"獲取天氣資訊錯誤: {e}")
|
| 135 |
+
return {"success": False, "error": str(e)}
|
| 136 |
+
|
| 137 |
@staticmethod
|
| 138 |
async def _get_weather_data(city: str, language: str = "zh_tw") -> Dict[str, Any]:
|
| 139 |
"""獲取天氣數據"""
|
|
|
|
| 255 |
"Squall": "💨",
|
| 256 |
"Tornado": "🌪️"
|
| 257 |
}
|
| 258 |
+
return weather_emojis.get(weather_main, "🌡️")
|
requirements.txt
CHANGED
|
@@ -23,6 +23,9 @@ python-multipart==0.0.20
|
|
| 23 |
matplotlib==3.7.1
|
| 24 |
jsonschema>=4.17.0
|
| 25 |
|
|
|
|
|
|
|
|
|
|
| 26 |
# Machine Learning dependencies
|
| 27 |
numpy>=1.24.0,<2.0.0
|
| 28 |
torch==2.8.0
|
|
@@ -33,4 +36,4 @@ librosa==0.11.0
|
|
| 33 |
soundfile>=0.12.0
|
| 34 |
noisereduce>=0.4.3
|
| 35 |
pyaudio
|
| 36 |
-
transformers
|
|
|
|
| 23 |
matplotlib==3.7.1
|
| 24 |
jsonschema>=4.17.0
|
| 25 |
|
| 26 |
+
# Geospatial / directions
|
| 27 |
+
geohash2==1.1
|
| 28 |
+
|
| 29 |
# Machine Learning dependencies
|
| 30 |
numpy>=1.24.0,<2.0.0
|
| 31 |
torch==2.8.0
|
|
|
|
| 36 |
soundfile>=0.12.0
|
| 37 |
noisereduce>=0.4.3
|
| 38 |
pyaudio
|
| 39 |
+
transformers
|
services/ai_service.py
CHANGED
|
@@ -68,7 +68,7 @@ except Exception as e:
|
|
| 68 |
|
| 69 |
# 導入DB函數
|
| 70 |
try:
|
| 71 |
-
from core.database import
|
| 72 |
db_available = True
|
| 73 |
except ImportError:
|
| 74 |
db_available = False
|
|
@@ -147,6 +147,7 @@ def _compose_messages_with_context(
|
|
| 147 |
base_prompt: str,
|
| 148 |
history_entries: List[Dict[str, str]],
|
| 149 |
memory_context: str,
|
|
|
|
| 150 |
current_request: str,
|
| 151 |
user_id: Optional[str],
|
| 152 |
chat_id: Optional[str],
|
|
@@ -161,6 +162,10 @@ def _compose_messages_with_context(
|
|
| 161 |
|
| 162 |
sections.append(f"【歷史對話摘要】\n{history_text}")
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
memory_context = (memory_context or "").strip()
|
| 165 |
if memory_context:
|
| 166 |
sections.append(f"【用戶重要記憶】\n{memory_context}")
|
|
@@ -584,46 +589,51 @@ async def _generate_response_with_chat_db(
|
|
| 584 |
except Exception as e:
|
| 585 |
logger.warning(f"保存用戶消息到DB失敗: {e}")
|
| 586 |
|
| 587 |
-
# 從DB加載對話歷史
|
| 588 |
chat_history = []
|
| 589 |
if db_available:
|
| 590 |
try:
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
except Exception as e:
|
| 622 |
logger.warning(f"從DB加載對話歷史失敗: {e}")
|
| 623 |
|
| 624 |
# 載入長期記憶
|
|
|
|
| 625 |
memory_context = ""
|
| 626 |
-
if user_id:
|
| 627 |
try:
|
| 628 |
from core.memory_system import memory_system
|
| 629 |
context_tags: List[str] = []
|
|
@@ -643,6 +653,27 @@ async def _generate_response_with_chat_db(
|
|
| 643 |
except Exception as e:
|
| 644 |
logger.warning(f"載入記憶失敗: {e}")
|
| 645 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
base_prompt = _build_base_system_prompt(
|
| 647 |
use_care_mode=use_care_mode,
|
| 648 |
care_emotion=care_emotion,
|
|
@@ -653,6 +684,7 @@ async def _generate_response_with_chat_db(
|
|
| 653 |
base_prompt=base_prompt,
|
| 654 |
history_entries=chat_history,
|
| 655 |
memory_context=memory_context,
|
|
|
|
| 656 |
current_request=user_message,
|
| 657 |
user_id=user_id,
|
| 658 |
chat_id=chat_id,
|
|
@@ -757,19 +789,40 @@ async def _generate_response_with_global_history(
|
|
| 757 |
conversation_history[user_id] = []
|
| 758 |
conversation_history[user_id].append({"role": "user", "content": user_message})
|
| 759 |
|
| 760 |
-
history_limit =
|
| 761 |
prior_history = conversation_history[user_id][:-1]
|
| 762 |
if prior_history:
|
| 763 |
prior_history = prior_history[-history_limit:]
|
| 764 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 765 |
base_prompt = _build_base_system_prompt(
|
| 766 |
use_care_mode=use_care_mode,
|
| 767 |
care_emotion=care_emotion,
|
| 768 |
user_name=user_name,
|
| 769 |
)
|
| 770 |
|
|
|
|
| 771 |
memory_context = ""
|
| 772 |
-
if user_id:
|
| 773 |
try:
|
| 774 |
from core.memory_system import memory_system
|
| 775 |
context_tags: List[str] = []
|
|
@@ -792,6 +845,7 @@ async def _generate_response_with_global_history(
|
|
| 792 |
base_prompt=base_prompt,
|
| 793 |
history_entries=prior_history,
|
| 794 |
memory_context=memory_context,
|
|
|
|
| 795 |
current_request=user_message,
|
| 796 |
user_id=user_id,
|
| 797 |
chat_id=None,
|
|
|
|
| 68 |
|
| 69 |
# 導入DB函數
|
| 70 |
try:
|
| 71 |
+
from core.database import get_chat_messages, save_chat_message, get_user_env_current
|
| 72 |
db_available = True
|
| 73 |
except ImportError:
|
| 74 |
db_available = False
|
|
|
|
| 147 |
base_prompt: str,
|
| 148 |
history_entries: List[Dict[str, str]],
|
| 149 |
memory_context: str,
|
| 150 |
+
env_context: str,
|
| 151 |
current_request: str,
|
| 152 |
user_id: Optional[str],
|
| 153 |
chat_id: Optional[str],
|
|
|
|
| 162 |
|
| 163 |
sections.append(f"【歷史對話摘要】\n{history_text}")
|
| 164 |
|
| 165 |
+
env_context = (env_context or "").strip()
|
| 166 |
+
if env_context:
|
| 167 |
+
sections.append(f"【環境訊號】\n{env_context}")
|
| 168 |
+
|
| 169 |
memory_context = (memory_context or "").strip()
|
| 170 |
if memory_context:
|
| 171 |
sections.append(f"【用戶重要記憶】\n{memory_context}")
|
|
|
|
| 589 |
except Exception as e:
|
| 590 |
logger.warning(f"保存用戶消息到DB失敗: {e}")
|
| 591 |
|
| 592 |
+
# 從DB加載對話歷史(messages 集合)
|
| 593 |
chat_history = []
|
| 594 |
if db_available:
|
| 595 |
try:
|
| 596 |
+
history_limit = 3 if use_care_mode else 12
|
| 597 |
+
# 取 limit+1 以排除當前 user_message(最後一筆)
|
| 598 |
+
msgs = await get_chat_messages(chat_id, limit=history_limit + 1, ascending=True)
|
| 599 |
+
historical_messages = msgs[:-1] if len(msgs) > 0 else []
|
| 600 |
+
|
| 601 |
+
def _clean_text(t: str) -> str:
|
| 602 |
+
if not t:
|
| 603 |
+
return ""
|
| 604 |
+
txt = str(t)
|
| 605 |
+
for kw in ["關懷模式", "我在這裡陪你", "說「我沒事了」", "退出關懷模式"]:
|
| 606 |
+
txt = txt.replace(kw, "")
|
| 607 |
+
return txt.strip()
|
| 608 |
+
|
| 609 |
+
for msg in historical_messages:
|
| 610 |
+
content = msg.get("content")
|
| 611 |
+
if isinstance(content, dict):
|
| 612 |
+
content = content.get("message") or content.get("text") or str(content)
|
| 613 |
+
elif not isinstance(content, str):
|
| 614 |
+
content = str(content) if content else ""
|
| 615 |
+
|
| 616 |
+
# 過濾掉錯誤訊息(避免污染上下文)
|
| 617 |
+
if "抱歉,生成回應時遇到問題" in content or "請重試" in content:
|
| 618 |
+
continue
|
| 619 |
+
|
| 620 |
+
content = _clean_text(content)
|
| 621 |
+
if not content:
|
| 622 |
+
continue
|
| 623 |
+
|
| 624 |
+
chat_history.append({
|
| 625 |
+
"role": msg.get("sender"),
|
| 626 |
+
"content": content
|
| 627 |
+
})
|
| 628 |
+
|
| 629 |
+
logger.debug(f"📚 載入 {len(chat_history)} 條歷史對話(messages 集合)")
|
| 630 |
except Exception as e:
|
| 631 |
logger.warning(f"從DB加載對話歷史失敗: {e}")
|
| 632 |
|
| 633 |
# 載入長期記憶
|
| 634 |
+
# 關懷模式不帶長期記憶,避免噪音
|
| 635 |
memory_context = ""
|
| 636 |
+
if user_id and not use_care_mode:
|
| 637 |
try:
|
| 638 |
from core.memory_system import memory_system
|
| 639 |
context_tags: List[str] = []
|
|
|
|
| 653 |
except Exception as e:
|
| 654 |
logger.warning(f"載入記憶失敗: {e}")
|
| 655 |
|
| 656 |
+
# 讀取環境現況(僅組裝,不外呼)
|
| 657 |
+
env_context_text = ""
|
| 658 |
+
if db_available and user_id:
|
| 659 |
+
try:
|
| 660 |
+
env_res = await get_user_env_current(user_id)
|
| 661 |
+
if env_res.get("success"):
|
| 662 |
+
ctx = env_res.get("context") or {}
|
| 663 |
+
city = ctx.get("city")
|
| 664 |
+
tz = ctx.get("tz")
|
| 665 |
+
heading = ctx.get("heading_cardinal") or ctx.get("heading_deg")
|
| 666 |
+
acc = ctx.get("accuracy_m")
|
| 667 |
+
freshness = "" # updated_at 轉 freshness_sec 可在前端或後端計算
|
| 668 |
+
parts = []
|
| 669 |
+
if city: parts.append(f"城市: {city}")
|
| 670 |
+
if tz: parts.append(f"時區: {tz}")
|
| 671 |
+
if heading: parts.append(f"方位: {heading}")
|
| 672 |
+
if acc is not None: parts.append(f"定位精度±{int(acc)}m")
|
| 673 |
+
env_context_text = "\n".join(parts)
|
| 674 |
+
except Exception as e:
|
| 675 |
+
logger.debug(f"讀取環境現況失敗: {e}")
|
| 676 |
+
|
| 677 |
base_prompt = _build_base_system_prompt(
|
| 678 |
use_care_mode=use_care_mode,
|
| 679 |
care_emotion=care_emotion,
|
|
|
|
| 684 |
base_prompt=base_prompt,
|
| 685 |
history_entries=chat_history,
|
| 686 |
memory_context=memory_context,
|
| 687 |
+
env_context=env_context_text,
|
| 688 |
current_request=user_message,
|
| 689 |
user_id=user_id,
|
| 690 |
chat_id=chat_id,
|
|
|
|
| 789 |
conversation_history[user_id] = []
|
| 790 |
conversation_history[user_id].append({"role": "user", "content": user_message})
|
| 791 |
|
| 792 |
+
history_limit = 3 if use_care_mode else 12
|
| 793 |
prior_history = conversation_history[user_id][:-1]
|
| 794 |
if prior_history:
|
| 795 |
prior_history = prior_history[-history_limit:]
|
| 796 |
|
| 797 |
+
# 讀取環境現況
|
| 798 |
+
env_context_text = ""
|
| 799 |
+
if db_available and user_id:
|
| 800 |
+
try:
|
| 801 |
+
env_res = await get_user_env_current(user_id)
|
| 802 |
+
if env_res.get("success"):
|
| 803 |
+
ctx = env_res.get("context") or {}
|
| 804 |
+
city = ctx.get("city")
|
| 805 |
+
tz = ctx.get("tz")
|
| 806 |
+
heading = ctx.get("heading_cardinal") or ctx.get("heading_deg")
|
| 807 |
+
acc = ctx.get("accuracy_m")
|
| 808 |
+
parts = []
|
| 809 |
+
if city: parts.append(f"城市: {city}")
|
| 810 |
+
if tz: parts.append(f"時區: {tz}")
|
| 811 |
+
if heading: parts.append(f"方位: {heading}")
|
| 812 |
+
if acc is not None: parts.append(f"定位精度±{int(acc)}m")
|
| 813 |
+
env_context_text = "\n".join(parts)
|
| 814 |
+
except Exception as ex:
|
| 815 |
+
logger.debug(f"讀取環境現況失敗: {ex}")
|
| 816 |
+
|
| 817 |
base_prompt = _build_base_system_prompt(
|
| 818 |
use_care_mode=use_care_mode,
|
| 819 |
care_emotion=care_emotion,
|
| 820 |
user_name=user_name,
|
| 821 |
)
|
| 822 |
|
| 823 |
+
# 關懷模式不帶長期記憶
|
| 824 |
memory_context = ""
|
| 825 |
+
if user_id and not use_care_mode:
|
| 826 |
try:
|
| 827 |
from core.memory_system import memory_system
|
| 828 |
context_tags: List[str] = []
|
|
|
|
| 845 |
base_prompt=base_prompt,
|
| 846 |
history_entries=prior_history,
|
| 847 |
memory_context=memory_context,
|
| 848 |
+
env_context=env_context_text,
|
| 849 |
current_request=user_message,
|
| 850 |
user_id=user_id,
|
| 851 |
chat_id=None,
|
static/frontend/js/login.js
CHANGED
|
@@ -426,23 +426,31 @@ class VoiceLoginManager {
|
|
| 426 |
console.log('😊 情緒:', data.emotion?.label);
|
| 427 |
console.log('💬 歡迎詞:', data.welcome);
|
| 428 |
|
| 429 |
-
// 顯示歡迎
|
| 430 |
-
this.showStatus(
|
| 431 |
|
| 432 |
// 模擬生成 JWT(實際應該從後端取得)
|
| 433 |
// 這裡假設後端已經將 JWT 包含在 voice_login_result 中
|
| 434 |
if (data.token) {
|
| 435 |
localStorage.setItem('jwt_token', data.token);
|
| 436 |
} else {
|
| 437 |
-
|
| 438 |
-
console.warn('⚠️ 後端未返回 JWT,使用臨時方案');
|
| 439 |
-
// TODO: 後端需要在 voice_login_result 中加入 JWT token
|
| 440 |
}
|
| 441 |
|
| 442 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 443 |
setTimeout(() => {
|
| 444 |
window.location.href = '/static/index.html';
|
| 445 |
-
},
|
| 446 |
|
| 447 |
} else {
|
| 448 |
console.error('❌ 語音登入失敗:', data.error);
|
|
|
|
| 426 |
console.log('😊 情緒:', data.emotion?.label);
|
| 427 |
console.log('💬 歡迎詞:', data.welcome);
|
| 428 |
|
| 429 |
+
// 成功僅提示登入完成,不在登入頁顯示歡迎詞
|
| 430 |
+
this.showStatus('✅ 登入成功,正在跳轉…', 'success');
|
| 431 |
|
| 432 |
// 模擬生成 JWT(實際應該從後端取得)
|
| 433 |
// 這裡假設後端已經將 JWT 包含在 voice_login_result 中
|
| 434 |
if (data.token) {
|
| 435 |
localStorage.setItem('jwt_token', data.token);
|
| 436 |
} else {
|
| 437 |
+
console.warn('⚠️ 後端未返回 JWT');
|
|
|
|
|
|
|
| 438 |
}
|
| 439 |
|
| 440 |
+
// 將辨識到的情緒帶到聊天室主題(由 agent.js 啟動時套用)
|
| 441 |
+
try {
|
| 442 |
+
const emo = (data.emotion && (data.emotion.label || data.emotion)) || '';
|
| 443 |
+
if (emo) localStorage.setItem('lastEmotion', String(emo));
|
| 444 |
+
} catch (_) {}
|
| 445 |
+
|
| 446 |
+
// 關閉 WS 與音訊資源,避免殘留
|
| 447 |
+
try { this.ws && this.ws.readyState === WebSocket.OPEN && this.ws.close(1000, 'voice login done'); } catch(_) {}
|
| 448 |
+
this.cleanup();
|
| 449 |
+
|
| 450 |
+
// 快速跳轉到聊天室(縮短等待體感更順)
|
| 451 |
setTimeout(() => {
|
| 452 |
window.location.href = '/static/index.html';
|
| 453 |
+
}, 800);
|
| 454 |
|
| 455 |
} else {
|
| 456 |
console.error('❌ 語音登入失敗:', data.error);
|
static/frontend/js/websocket.js
CHANGED
|
@@ -73,6 +73,48 @@ class WebSocketManager {
|
|
| 73 |
if (cid) {
|
| 74 |
this.send({ type: 'chat_focus', chat_id: cid });
|
| 75 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
}
|
| 77 |
|
| 78 |
// 處理 WebSocket 訊息
|
|
@@ -679,6 +721,7 @@ function initializeWebSocket(token) {
|
|
| 679 |
const emotionValue = typeof data.emotion === 'string' ? data.emotion : data.emotion.label;
|
| 680 |
console.log('😊 應用情緒主題:', emotionValue);
|
| 681 |
applyEmotion(emotionValue);
|
|
|
|
| 682 |
}
|
| 683 |
break;
|
| 684 |
|
|
@@ -724,6 +767,16 @@ function initializeWebSocket(token) {
|
|
| 724 |
handleVoiceBindingReady();
|
| 725 |
break;
|
| 726 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 727 |
default:
|
| 728 |
console.log('🔍 未處理的訊息類型:', data.type);
|
| 729 |
}
|
|
@@ -756,6 +809,8 @@ function handleVoiceLoginResult(data) {
|
|
| 756 |
if (data.emotion) {
|
| 757 |
const emotionValue = typeof data.emotion === 'string' ? data.emotion : data.emotion.label;
|
| 758 |
applyEmotion(emotionValue);
|
|
|
|
|
|
|
| 759 |
}
|
| 760 |
|
| 761 |
// 顯示歡迎詞
|
|
@@ -860,3 +915,4 @@ async function handleVoiceBindingReady() {
|
|
| 860 |
}
|
| 861 |
|
| 862 |
console.log('✅ WebSocket 模組已載入(完整版)');
|
|
|
|
|
|
| 73 |
if (cid) {
|
| 74 |
this.send({ type: 'chat_focus', chat_id: cid });
|
| 75 |
}
|
| 76 |
+
|
| 77 |
+
// 嘗試上報一次環境快照(位置/方位/時區/語系/裝置)
|
| 78 |
+
try {
|
| 79 |
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
| 80 |
+
const locale = navigator.language || 'zh-TW';
|
| 81 |
+
const device = `${navigator.platform || ''}`;
|
| 82 |
+
|
| 83 |
+
const sendSnapshot = (lat, lon, acc, heading) => {
|
| 84 |
+
this.send({
|
| 85 |
+
type: 'env_snapshot',
|
| 86 |
+
lat, lon,
|
| 87 |
+
accuracy_m: acc,
|
| 88 |
+
heading_deg: heading,
|
| 89 |
+
tz, locale, device
|
| 90 |
+
});
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
// 裝置方向(可能受限權限)
|
| 94 |
+
let heading = undefined;
|
| 95 |
+
try {
|
| 96 |
+
if (window.screen && window.screen.orientation && window.screen.orientation.angle !== undefined) {
|
| 97 |
+
heading = window.screen.orientation.angle; // 粗略
|
| 98 |
+
}
|
| 99 |
+
} catch (_) {}
|
| 100 |
+
|
| 101 |
+
if (navigator.geolocation) {
|
| 102 |
+
navigator.geolocation.getCurrentPosition(
|
| 103 |
+
(pos) => {
|
| 104 |
+
const c = pos.coords || {};
|
| 105 |
+
sendSnapshot(c.latitude, c.longitude, c.accuracy, heading);
|
| 106 |
+
},
|
| 107 |
+
(_err) => {
|
| 108 |
+
sendSnapshot(undefined, undefined, undefined, heading);
|
| 109 |
+
},
|
| 110 |
+
{ enableHighAccuracy: true, maximumAge: 60000, timeout: 5000 }
|
| 111 |
+
);
|
| 112 |
+
} else {
|
| 113 |
+
sendSnapshot(undefined, undefined, undefined, heading);
|
| 114 |
+
}
|
| 115 |
+
} catch (e) {
|
| 116 |
+
console.warn('環境快照上報失敗', e);
|
| 117 |
+
}
|
| 118 |
}
|
| 119 |
|
| 120 |
// 處理 WebSocket 訊息
|
|
|
|
| 721 |
const emotionValue = typeof data.emotion === 'string' ? data.emotion : data.emotion.label;
|
| 722 |
console.log('😊 應用情緒主題:', emotionValue);
|
| 723 |
applyEmotion(emotionValue);
|
| 724 |
+
try { if (emotionValue) localStorage.setItem('lastEmotion', String(emotionValue)); } catch(_) {}
|
| 725 |
}
|
| 726 |
break;
|
| 727 |
|
|
|
|
| 767 |
handleVoiceBindingReady();
|
| 768 |
break;
|
| 769 |
|
| 770 |
+
case 'voice_binding_success':
|
| 771 |
+
// 綁定成功:不重覆顯示訊息,只更新本地狀態(供後續使用)
|
| 772 |
+
try {
|
| 773 |
+
if (data.speaker_label) {
|
| 774 |
+
localStorage.setItem('speaker_label', data.speaker_label);
|
| 775 |
+
}
|
| 776 |
+
} catch (_) {}
|
| 777 |
+
console.log('✅ 語音綁定完成(已更新本地狀態)');
|
| 778 |
+
break;
|
| 779 |
+
|
| 780 |
default:
|
| 781 |
console.log('🔍 未處理的訊息類型:', data.type);
|
| 782 |
}
|
|
|
|
| 809 |
if (data.emotion) {
|
| 810 |
const emotionValue = typeof data.emotion === 'string' ? data.emotion : data.emotion.label;
|
| 811 |
applyEmotion(emotionValue);
|
| 812 |
+
// 持久化情緒以便重新整理或跳頁仍保持主題
|
| 813 |
+
try { if (emotionValue) localStorage.setItem('lastEmotion', String(emotionValue)); } catch(_) {}
|
| 814 |
}
|
| 815 |
|
| 816 |
// 顯示歡迎詞
|
|
|
|
| 915 |
}
|
| 916 |
|
| 917 |
console.log('✅ WebSocket 模組已載入(完整版)');
|
| 918 |
+
break;
|
tests/conftest.py
DELETED
|
@@ -1,8 +0,0 @@
|
|
| 1 |
-
import sys
|
| 2 |
-
from pathlib import Path
|
| 3 |
-
|
| 4 |
-
# 確保專案根目錄在 sys.path,便於 tests 直接 import 本地模組
|
| 5 |
-
ROOT = Path(__file__).resolve().parents[1]
|
| 6 |
-
if str(ROOT) not in sys.path:
|
| 7 |
-
sys.path.insert(0, str(ROOT))
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/services/test_voice_login_cnn.py
DELETED
|
@@ -1,86 +0,0 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import sys
|
| 3 |
-
import types
|
| 4 |
-
import base64
|
| 5 |
-
import tempfile
|
| 6 |
-
from pathlib import Path
|
| 7 |
-
|
| 8 |
-
import numpy as np
|
| 9 |
-
|
| 10 |
-
from services.voice_login import VoiceAuthService, VoiceLoginConfig
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
def make_pcm16(duration_s: float, sr: int = 16000) -> bytes:
|
| 14 |
-
t = np.linspace(0, duration_s, int(sr * duration_s), endpoint=False, dtype=np.float32)
|
| 15 |
-
# 單純 220Hz 正弦波 + 微量雜訊避免全零
|
| 16 |
-
x = 0.2 * np.sin(2 * np.pi * 220.0 * t) + 0.01 * np.random.randn(t.size).astype(np.float32)
|
| 17 |
-
x = np.clip(x, -1.0, 1.0)
|
| 18 |
-
y = (x * 32767.0).astype(np.int16)
|
| 19 |
-
return y.tobytes()
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
def test_voice_login_success_with_cnn_stub(monkeypatch):
|
| 23 |
-
# 準備臨時 CNN 模型目錄(空權重但有檔名,避免 __init__ 報缺檔)
|
| 24 |
-
with tempfile.TemporaryDirectory() as tmp:
|
| 25 |
-
tmpdir = Path(tmp)
|
| 26 |
-
(tmpdir / "speaker_id_model.pth").write_bytes(b"stub")
|
| 27 |
-
(tmpdir / "classes.txt").write_text("alice\nbob\n", encoding="utf-8")
|
| 28 |
-
monkeypatch.setenv("VOICE_CNN_MODEL_DIR", str(tmpdir))
|
| 29 |
-
|
| 30 |
-
# 在 VoiceAuthService 初始化前,先以假模組覆蓋 inference,避免實際載入大型相依(如 torchaudio)
|
| 31 |
-
dummy = types.SimpleNamespace(
|
| 32 |
-
predict_files=lambda model_dir, inputs, threshold=0.0: [{
|
| 33 |
-
"file": str(inputs[0]),
|
| 34 |
-
"pred": "alice",
|
| 35 |
-
"score": 0.92,
|
| 36 |
-
"top": [("alice", 0.92), ("bob", 0.08)],
|
| 37 |
-
"is_unknown": False,
|
| 38 |
-
}]
|
| 39 |
-
)
|
| 40 |
-
monkeypatch.setitem(sys.modules, 'scripts.inference', dummy)
|
| 41 |
-
|
| 42 |
-
svc = VoiceAuthService(config=VoiceLoginConfig(
|
| 43 |
-
window_seconds=1,
|
| 44 |
-
required_windows=1,
|
| 45 |
-
sample_rate=16000,
|
| 46 |
-
prob_threshold=0.80,
|
| 47 |
-
margin_threshold=0.20,
|
| 48 |
-
min_snr_db=0.0,
|
| 49 |
-
))
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
user_id = "u1"
|
| 53 |
-
svc.start_session(user_id, sample_rate=16000)
|
| 54 |
-
pcm = make_pcm16(1.0, 16000)
|
| 55 |
-
b64 = base64.b64encode(pcm).decode("ascii")
|
| 56 |
-
svc.append_chunk_base64(user_id, b64)
|
| 57 |
-
|
| 58 |
-
out = svc.stop_and_authenticate(user_id)
|
| 59 |
-
assert out.get("success") is True
|
| 60 |
-
assert out.get("label") == "alice"
|
| 61 |
-
assert out.get("avg_prob", 0.0) >= 0.80
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def test_voice_login_no_audio_returns_error(monkeypatch):
|
| 65 |
-
with tempfile.TemporaryDirectory() as tmp:
|
| 66 |
-
tmpdir = Path(tmp)
|
| 67 |
-
(tmpdir / "speaker_id_model.pth").write_bytes(b"stub")
|
| 68 |
-
(tmpdir / "classes.txt").write_text("alice\nbob\n", encoding="utf-8")
|
| 69 |
-
monkeypatch.setenv("VOICE_CNN_MODEL_DIR", str(tmpdir))
|
| 70 |
-
|
| 71 |
-
# 同樣先注入假 inference 模組
|
| 72 |
-
dummy = types.SimpleNamespace(
|
| 73 |
-
predict_files=lambda model_dir, inputs, threshold=0.0: []
|
| 74 |
-
)
|
| 75 |
-
monkeypatch.setitem(sys.modules, 'scripts.inference', dummy)
|
| 76 |
-
|
| 77 |
-
svc = VoiceAuthService(config=VoiceLoginConfig(
|
| 78 |
-
window_seconds=1,
|
| 79 |
-
required_windows=1,
|
| 80 |
-
sample_rate=16000,
|
| 81 |
-
))
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
out = svc.stop_and_authenticate("u2")
|
| 85 |
-
assert out.get("success") is False
|
| 86 |
-
assert out.get("error") == "NO_AUDIO"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|