XiaoBai1221 commited on
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 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
- ## Project Structure & Module Organization
4
- - Root entrypoint: `app.py` (FastAPI/Flask-style server).
5
- - Core logic: `core/` (utilities, config), domain models: `models/`.
6
- - Features and routes: `features/` and `services/` (service layer, APIs).
7
- - Static assets: `static/`.
8
- - Deployment/config: `render.yaml`, `runtime.txt`, `.env.production.example`.
9
- - Tests: place under `features/tests/` or `core/tests/` mirroring module paths.
10
-
11
- ## Build, Test, and Development Commands
12
- - Create venv: `python3 -m venv .venv && source .venv/bin/activate`.
13
- - Install deps: `pip install -r requirements.txt`.
14
- - Run locally: `python app.py` (or `uvicorn app:app --reload` if ASGI).
15
- - Lint/format (if installed): `ruff check .`, `ruff format .` or `black .`.
16
- - Type check (optional): `mypy .`.
17
-
18
- ## Coding Style & Naming Conventions
19
- - Python 3.10+. Use `ruff`/`black` defaults: 88 cols, 4-space indent, UTF-8.
20
- - Modules: `snake_case.py`; classes: `PascalCase`; functions/vars: `snake_case`.
21
- - Keep I/O, HTTP, and DB logic separated: controllers → services → core/utils.
22
- - Environment config via `os.environ`; example values in `.env.production.example`.
23
-
24
- ## Testing Guidelines
25
- - Framework: `pytest` (recommended). Name tests `test_*.py`.
26
- - 測試目錄一律放在根目錄 `tests/`,並以子資料夾鏡射原始碼結構。
27
- - Run tests: `pytest -q` (add `-k <pattern>` to filter).
28
- - Aim for critical-path coverage in `core/` and `services/` modules.
29
-
30
- ## Commit & Pull Request Guidelines
31
- - Commits: imperative mood, concise scope prefix when helpful, e.g. `core: add rate limiter`.
32
- - Include why + what changed; group related changes.
33
- - PRs: clear description, linked issues, repro steps, and screenshots for UI.
34
- - CI/readiness: ensure app starts locally and tests/lint pass before requesting review.
35
-
36
- ## Security & Configuration
37
- - Never commit real secrets. Use `.env` locally and keep examples in `.env.production.example`.
38
- - Validate all external inputs at service boundaries.
39
- - Review `render.yaml` changes for least-privilege and environment parity.
40
-
41
- ## Agent-Specific Instructions
42
- - 語言與氣:全使用繁體中文(台灣口吻,先給結論再補細節;可微嗆但不冒犯
43
- - TDD 原則先寫測試(紅燈)→ 最小實作(綠燈)→ 重構;每個功能至少跑完一輪
44
- - Python 與 OpenAI使用 Python3;OpenAI 一律 `gpt-4o-mini` 並透過 MCP 協議呼叫;程式碼中禁止使用命令參數
45
- - 測試放置:所有測試集中於 `tests/` 資料夾,檔名 `test_*.py`,結構鏡射模組路徑。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- logger.info(f"獲取到對話 {chat_id},包含 {len(chat.get('messages', []))} 條消息")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if chats_collection is None:
 
418
  logger.error("Firestore尚未連接,無法保存消息")
419
  return {"success": False, "error": "數據庫未連接"}
420
  try:
421
  import asyncio as _asyncio
422
 
423
- def _update_doc():
 
 
 
 
 
 
 
 
 
 
 
424
  doc_ref = chats_collection.document(chat_id)
425
- doc = doc_ref.get()
426
- if not doc.exists:
427
- return None
428
- message = {
429
- "sender": sender,
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
- message = await _asyncio.to_thread(_update_doc)
440
- if message is None:
441
- logger.warning(f"對話 {chat_id} 不存在,無法保存消息")
442
- return {"success": False, "error": "對話不存在"}
443
 
444
- logger.info(f"消息已保存到對話 {chat_id}")
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
- return state.get("in_care_mode", False)
 
 
 
 
 
 
 
 
 
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": "城市名稱(請使用英文,例如:Taipei, Tokyo, London, New York)或座標 (lat,lon 格式)"
 
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
- weather_data = await cls._get_weather_data(city, language)
 
 
 
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 get_chat, save_chat_message
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
- chat_result = await get_chat(chat_id)
592
- if chat_result.get("success"):
593
- chat_messages = chat_result["chat"].get("messages", [])
594
-
595
- # 關懷模式只載入最近 5 條,一般模式載入 10 條(減少上下文)
596
- history_limit = 5 if use_care_mode else 10
597
-
598
- # ⚠️ 關鍵修復:排除當前用戶訊息(避免 Agent 混淆歷史對話)
599
- # 只載入歷史對話,不包含剛保存的 user_message
600
- historical_messages = chat_messages[:-1] if len(chat_messages) > 0 else []
601
-
602
- # 轉換DB格式到OpenAI格式
603
- for msg in historical_messages[-history_limit:]:
604
- content = msg.get("content")
605
- # 確保 content 是字串(修正)
606
- if isinstance(content, dict):
607
- content = content.get("message") or content.get("text") or str(content)
608
- elif not isinstance(content, str):
609
- content = str(content) if content else ""
610
-
611
- # 過濾掉錯誤訊息(避免污染上下文)
612
- if "抱歉,生成回應時遇到問題" in content or "請重試" in content:
613
- continue
614
-
615
- chat_history.append({
616
- "role": msg.get("sender"),
617
- "content": content
618
- })
619
-
620
- logger.debug(f"📚 載入 {len(chat_history)} 條歷史對話(排除當前訊息,確保請求隔離)")
 
 
 
 
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 = 5 if use_care_mode else 10
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(`${data.welcome || '登入成功'}`, 'success');
431
 
432
  // 模擬生成 JWT(實際應該從後端取得)
433
  // 這裡假設後端已經將 JWT 包含在 voice_login_result 中
434
  if (data.token) {
435
  localStorage.setItem('jwt_token', data.token);
436
  } else {
437
- // 臨時方案:用 user.id 當作 token(實際生產環境需改善)
438
- console.warn('⚠️ 後端未返回 JWT,使用臨時方案');
439
- // TODO: 後端需要在 voice_login_result 中加入 JWT token
440
  }
441
 
442
- // 3 秒後跳轉到聊天室
 
 
 
 
 
 
 
 
 
 
443
  setTimeout(() => {
444
  window.location.href = '/static/index.html';
445
- }, 3000);
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"