LawrenceBai commited on
Commit
26b9faa
·
1 Parent(s): 522e4b6
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. app.py +380 -315
  2. bloom-ware-login/app/layout.tsx +3 -0
  3. bloom-ware-login/components/login-form.tsx +168 -29
  4. bloom-ware-login/components/offline-indicator.tsx +63 -0
  5. core/ai_client.py +83 -0
  6. core/config.py +44 -3
  7. core/database/base.py +60 -12
  8. core/emotion_care_manager.py +17 -4
  9. core/exceptions.py +267 -0
  10. core/intent_detector.py +272 -0
  11. core/logging.py +105 -0
  12. core/memory_system.py +20 -28
  13. core/pipeline.py +24 -24
  14. core/prompts/__init__.py +13 -0
  15. core/prompts/care_mode.py +45 -0
  16. core/prompts/intent_detection.py +93 -0
  17. core/reasoning_strategy.py +3 -3
  18. core/retry.py +150 -0
  19. core/tool_registry.py +330 -0
  20. core/tool_router.py +255 -0
  21. core/tool_schema.py +355 -0
  22. features/mcp/agent_bridge.py +347 -337
  23. features/mcp/tools/exchange_tool.py +8 -6
  24. features/mcp/tools/geocode_tool.py +1 -1
  25. features/mcp/tools/healthkit_tool.py +25 -13
  26. features/mcp/tools/news_tool.py +13 -4
  27. features/mcp/tools/tdx_bus_arrival.py +15 -6
  28. features/mcp/tools/tdx_train.py +12 -6
  29. features/mcp/tools/tdx_youbike.py +73 -7
  30. features/mcp/tools/weather_tool.py +11 -5
  31. middleware/__init__.py +18 -0
  32. middleware/compression.py +106 -0
  33. middleware/csp.py +36 -0
  34. middleware/exception_handler.py +99 -0
  35. middleware/rate_limit.py +165 -0
  36. models/schemas.py +123 -0
  37. models/speaker_identification/models_cnn/speaker_db.pkl +3 -0
  38. models/speaker_identification/scripts/inference.py +81 -181
  39. models/speaker_identification/scripts/process_audio.py +0 -52
  40. models/speaker_identification/scripts/train_speaker_id.py +0 -306
  41. render.yaml +0 -28
  42. routers/__init__.py +20 -0
  43. routers/auth.py +159 -0
  44. routers/chat.py +200 -0
  45. routers/files.py +228 -0
  46. routers/health.py +220 -0
  47. routers/system.py +115 -0
  48. routers/voice.py +239 -0
  49. services/ai_service.py +134 -60
  50. services/batch_scheduler.py +159 -14
app.py CHANGED
@@ -5,6 +5,7 @@ import base64
5
  import mimetypes
6
  import logging
7
  import secrets
 
8
  from datetime import datetime
9
  from typing import List, Dict, Optional, Any
10
 
@@ -108,94 +109,26 @@ def serialize_for_json(obj: Any) -> Any:
108
  return None
109
 
110
  # -----------------------------
111
- # Pydantic 模型
112
  # -----------------------------
113
- class UserCreate(BaseModel):
114
- name: str
115
- email: EmailStr
116
- password: str = Field(min_length=6)
117
-
118
-
119
- class UserLogin(BaseModel):
120
- email: EmailStr
121
- password: str
122
-
123
-
124
- class ChatCreateRequest(BaseModel):
125
- user_id: str
126
- title: Optional[str] = "新對話"
127
-
128
-
129
- class MessageCreateRequest(BaseModel):
130
- sender: str
131
- content: str
132
-
133
-
134
- class ChatTitleUpdateRequest(BaseModel):
135
- title: str
136
-
137
-
138
- class UserInfo(BaseModel):
139
- id: str
140
- name: str
141
- email: EmailStr
142
- created_at: datetime
143
-
144
-
145
- class UserPublic(BaseModel):
146
- success: bool
147
- user: UserInfo
148
-
149
-
150
- class UserLoginPublicResponse(BaseModel):
151
- success: bool
152
- user: UserInfo
153
- token: Optional[str] = None
154
-
155
-
156
- class ChatPublic(BaseModel):
157
- chat_id: str
158
- user_id: str
159
- title: str
160
- created_at: datetime
161
- updated_at: datetime
162
-
163
-
164
- class MessagePublic(BaseModel):
165
- sender: str
166
- content: str
167
- timestamp: datetime
168
-
169
-
170
- class ChatDetailResponse(ChatPublic):
171
- messages: List[MessagePublic]
172
-
173
-
174
- class ChatSummary(BaseModel):
175
- chat_id: str
176
- title: str
177
- updated_at: datetime
178
-
179
-
180
- class ChatListResponse(BaseModel):
181
- chats: List[ChatSummary]
182
-
183
-
184
- class FileAnalysisRequest(BaseModel):
185
- filename: str
186
- content: str
187
- mime_type: str
188
- user_prompt: Optional[str] = "請分析這個檔案的內容"
189
-
190
-
191
- class FileAnalysisResponse(BaseModel):
192
- success: bool
193
- filename: str
194
- analysis: Optional[str] = None
195
- error: Optional[str] = None
196
-
197
- class SpeakerLabelBindRequest(BaseModel):
198
- speaker_label: str
199
 
200
 
201
  # -----------------------------
@@ -278,8 +211,8 @@ async def lifespan(app: FastAPI):
278
  window_seconds=3,
279
  required_windows=1,
280
  sample_rate=16000,
281
- prob_threshold=0.40,
282
- margin_threshold=0.01,
283
  min_snr_db=12.0,
284
  ))
285
  except Exception as e:
@@ -351,8 +284,8 @@ async def periodic_cleanup():
351
  """定期清理過期的會話和數據"""
352
  while True:
353
  try:
354
- # 每30分鐘清理一次
355
- await asyncio.sleep(1800) # 30分鐘
356
 
357
  # 清理過期的WebSocket會話
358
  await manager.cleanup_expired_sessions()
@@ -378,10 +311,10 @@ async def periodic_cleanup():
378
 
379
  app = FastAPI(title="聊天機器人API(整合版)", lifespan=lifespan)
380
 
381
- # CORS 設定
382
  app.add_middleware(
383
  CORSMiddleware,
384
- allow_origins=["*"],
385
  allow_credentials=True,
386
  allow_methods=["*"],
387
  allow_headers=["*"],
@@ -419,7 +352,7 @@ app.add_middleware(CSPMiddleware)
419
 
420
  # 掛載靜態檔案目錄(語音沉浸式前端)
421
  static_dir = Path("static/frontend")
422
- login_dir = Path("static/frontend/login")
423
 
424
  if static_dir.exists() and static_dir.is_dir():
425
  app.mount("/static", StaticFiles(directory=str(static_dir), html=True), name="frontend")
@@ -433,7 +366,7 @@ if login_dir.exists() and login_dir.is_dir():
433
  app.mount("/login", StaticFiles(directory=str(login_dir), html=True), name="login_static")
434
  logger.info(f"✅ 已掛載登入頁面: /login → {login_dir}")
435
  else:
436
- logger.warning("⚠️ 未找到 static/frontend/login/ 目錄,請先 build bloom-ware-login 專案")
437
 
438
  # 環境設定
439
  app.state.intent_model = settings.OPENAI_MODEL
@@ -452,226 +385,30 @@ def get_client_ip(request: Request) -> str:
452
  return ip
453
  return request.client.host if request.client else "unknown"
454
 
455
- # CORS
456
- app.add_middleware(
457
- CORSMiddleware,
458
- allow_origins=["*"],
459
- allow_credentials=True,
460
- allow_methods=["*"],
461
- allow_headers=["*"],
462
- )
463
-
464
 
465
  # -----------------------------
466
- # WebSocket 連線管理(JWT認證
467
  # -----------------------------
468
- class ConnectionManager:
469
- def __init__(self):
470
- self.active_connections: Dict[str, WebSocket] = {}
471
- self.client_info: Dict[str, dict] = {}
472
- self.user_sessions: Dict[str, Dict[str, Any]] = {} # 用戶會話信息
473
- self.last_env: Dict[str, Dict[str, Any]] = {} # 最近的環境快照
474
-
475
- async def connect(self, websocket: WebSocket, user_id: str, user_info: Dict[str, Any]):
476
- await websocket.accept()
477
- self.active_connections[user_id] = websocket
478
- self.user_sessions[user_id] = user_info
479
- logger.info(f"新的WebSocket連接: {user_id}")
480
-
481
- def disconnect(self, user_id: str):
482
- if user_id in self.active_connections:
483
- del self.active_connections[user_id]
484
- if user_id in self.user_sessions:
485
- del self.user_sessions[user_id]
486
- logger.info(f"WebSocket連接關閉: {user_id}")
487
-
488
- async def send_message(self, message: str, user_id: str, message_type: str = "bot_message"):
489
- if user_id in self.active_connections:
490
- try:
491
- payload = {"type": message_type, "message": message, "timestamp": time.time()}
492
- await self.active_connections[user_id].send_json(
493
- payload
494
- )
495
- try:
496
- preview = (str(message) or "").strip().replace("\n", " ")
497
- if len(preview) > 120:
498
- preview = preview[:120] + "..."
499
- logger.info(f"WebSocket已發送 → client={user_id} type={message_type} bytes≈{len((str(message) or '').encode('utf-8'))} preview=\"{preview}\"")
500
- except Exception:
501
- pass
502
- except Exception as e:
503
- logger.error(f"發送消息到客戶端 {user_id} 時出錯: {str(e)}")
504
-
505
- def set_client_info(self, user_id: str, info: dict):
506
- self.client_info[user_id] = info
507
-
508
- def get_client_info(self, user_id: str) -> dict:
509
- return self.client_info.get(user_id, {})
510
-
511
- def get_user_session(self, user_id: str) -> Optional[Dict[str, Any]]:
512
- """獲取用戶會話信息"""
513
- return self.user_sessions.get(user_id)
514
-
515
- async def cleanup_expired_sessions(self):
516
- """清理過期的用戶會話"""
517
- current_time = datetime.now()
518
- expired_users = []
519
-
520
- for user_id, session_info in self.user_sessions.items():
521
- # 如果會話超過30分鐘沒有活動,標記為過期
522
- last_activity = session_info.get("last_activity", current_time)
523
- if (current_time - last_activity).total_seconds() > 1800: # 30分鐘
524
- expired_users.append(user_id)
525
-
526
- for user_id in expired_users:
527
- logger.info(f"清理過期會話: {user_id}")
528
- self.disconnect(user_id)
529
-
530
-
531
- manager = ConnectionManager()
532
-
533
 
534
  # -----------------------------
535
- # 語音綁定狀態管理器(關鍵字匹配,無 GPT
536
  # -----------------------------
537
- class VoiceBindingStateMachine:
538
- """
539
- 語音帳號綁定狀態機(硬編碼關鍵字匹配)
540
-
541
- 流程:
542
- 1. 用戶說「我要綁定語音登入」
543
- 2. Agent 回應「好的,你現在要綁定誰?」
544
- 3. 用戶提供名稱
545
- 4. 系統綁定 speaker_label 到用戶帳號
546
- 5. Agent 回應「綁定成功!」
547
- """
548
-
549
- def __init__(self):
550
- # 用戶狀態:{user_id: {state: str, speaker_label: str}}
551
- self.user_states: Dict[str, Dict[str, Any]] = {}
552
-
553
- def check_binding_trigger(self, user_id: str, message: str) -> Optional[str]:
554
- """
555
- 檢查是否觸發綁定流程
556
-
557
- Returns:
558
- - "TRIGGER": 觸發綁定流程
559
- - "AWAITING_NAME": 等待用戶提供名稱
560
- - None: 不是綁定相關訊息
561
- """
562
- message_lower = message.lower().replace(" ", "")
563
-
564
- # 檢測觸發關鍵字
565
- trigger_keywords = ["綁定語音登入", "語音登入綁定", "綁定語音", "設定語音登入"]
566
- for keyword in trigger_keywords:
567
- if keyword.replace(" ", "") in message_lower:
568
- # 進入等待狀態
569
- self.user_states[user_id] = {
570
- "state": "AWAITING_NAME",
571
- "timestamp": datetime.now()
572
- }
573
- return "TRIGGER"
574
-
575
- # 檢查是否在等待名稱狀態
576
- if user_id in self.user_states:
577
- state_info = self.user_states[user_id]
578
- if state_info.get("state") == "AWAITING_NAME":
579
- # 檢查是否超時(5分鐘)
580
- if (datetime.now() - state_info.get("timestamp")).total_seconds() > 300:
581
- del self.user_states[user_id]
582
- return None
583
- return "AWAITING_NAME"
584
-
585
- return None
586
-
587
- async def handle_binding_flow(
588
- self,
589
- user_id: str,
590
- message: str,
591
- websocket: WebSocket,
592
- voice_service: Optional[VoiceAuthService] = None
593
- ) -> bool:
594
- """
595
- 處理綁定流程
596
-
597
- Returns:
598
- True: 已處理(不要繼續到 Agent)
599
- False: 未處理(繼續到 Agent)
600
- """
601
- state = self.check_binding_trigger(user_id, message)
602
-
603
- if state == "TRIGGER":
604
- # 用戶觸發綁定 - 先檢查是否已經綁定過
605
- logger.info(f"🎙️ 用戶 {user_id} 觸發語音綁定流程")
606
-
607
- # 檢查使用者是否已經綁定過 speaker_label
608
- from core.database import get_user_by_id
609
- try:
610
- user_data = await get_user_by_id(user_id)
611
- if user_data and user_data.get("speaker_label"):
612
- # 已經綁定過了
613
- existing_label = user_data.get("speaker_label")
614
- logger.info(f"⚠️ 用戶 {user_id} 已綁定 speaker_label: {existing_label}")
615
-
616
- await websocket.send_json({
617
- "type": "bot_message",
618
- "message": f"你已經綁定過語音了!目前的聲紋標籤是:{existing_label}。如果需要重新綁定,請聯繫管理員。",
619
- "timestamp": time.time()
620
- })
621
-
622
- # 清理 FSM 狀態
623
- self.clear_state(user_id)
624
- return True
625
- except Exception as e:
626
- logger.error(f"❌ 檢查使用者綁定狀態失敗: {e}")
627
- await websocket.send_json({
628
- "type": "error",
629
- "message": "系統錯誤,無法檢查綁定狀態"
630
- })
631
- return True
632
-
633
- # 未綁定,繼續綁定流程
634
- logger.info(f"✅ 用戶 {user_id} 尚未綁定,啟動綁定流程")
635
-
636
- # 標記用戶進入語音綁定等待狀態
637
- user_session = manager.get_client_info(user_id) or {}
638
- user_session["voice_binding_pending"] = True
639
- user_session["voice_binding_started_at"] = datetime.now()
640
- manager.set_client_info(user_id, user_session)
641
-
642
- await websocket.send_json({
643
- "type": "bot_message",
644
- "message": "好的,請錄製一段語音(約3-5秒),用於建立你的聲紋特徵。系統會自動識別並綁定到你的帳號。",
645
- "timestamp": time.time()
646
- })
647
- await websocket.send_json({
648
- "type": "voice_binding_ready",
649
- "message": "請點擊錄音按鈕開始錄製"
650
- })
651
- return True
652
-
653
- elif state == "AWAITING_NAME":
654
- # 這個狀態已不再使用,因為我們改為直接錄音綁定
655
- # 但保留以防萬一
656
- pass
657
-
658
- return False
659
-
660
- def clear_state(self, user_id: str):
661
- """清理用戶狀態"""
662
- self.user_states.pop(user_id, None)
663
-
664
-
665
- voice_binding_fsm = VoiceBindingStateMachine()
666
 
667
 
668
  # -----------------------------
669
  # 統一 WebSocket 端點(JWT認證)
670
  # -----------------------------
671
  @app.websocket("/ws")
672
- async def websocket_endpoint_with_jwt(websocket: WebSocket, token: str = Query(None)):
 
 
 
 
673
  """JWT認證的WebSocket端點(支援語音登入匿名連線)"""
674
- logger.info("WebSocket連接請求 - JWT認證")
675
 
676
  # 特殊處理:語音登入匿名連線
677
  is_voice_login_mode = token == "anonymous_voice_login"
@@ -756,11 +493,11 @@ async def websocket_endpoint_with_jwt(websocket: WebSocket, token: str = Query(N
756
  logger.debug(f"讀取使用者時區失敗: {tz_err}")
757
 
758
  td = app.state.feature_router.get_current_time_data()
759
- # WebSocket 連線時沒有語音情緒,使用空字串
760
  welcome_msg = compose_welcome(
761
  user_name=user_info.get('name'),
762
  time_data=td,
763
- emotion_label="",
764
  timezone=tz_hint,
765
  )
766
  except Exception as e:
@@ -833,7 +570,10 @@ async def websocket_endpoint_with_jwt(websocket: WebSocket, token: str = Query(N
833
  {
834
  "role": "system",
835
  "content": (
836
- "你是一個友善、有禮且能夠提供幫助的AI助手。請使用繁體中文回覆,保持簡潔清晰的表達。"
 
 
 
837
  "另外,請勿自稱為 GPT-4 或其他版本。若需要自我介紹,請表述為 '基於 gpt-5-nano 模型'。"
838
  ),
839
  },
@@ -971,28 +711,119 @@ async def websocket_endpoint_with_jwt(websocket: WebSocket, token: str = Query(N
971
  await websocket.send_json({"type": "error", "message": f"CHAT_FOCUS_ERROR: {str(e)}"})
972
 
973
  elif message_type == "audio_start":
974
- # 語音處理邏輯(保持不變
 
 
975
  try:
976
  sr = int(message_data.get("sample_rate", 16000))
977
  except Exception:
978
  sr = 16000
979
- try:
980
- if hasattr(app.state, "voice_auth") and app.state.voice_auth:
981
- app.state.voice_auth.start_session(user_id, sr)
982
- await websocket.send_json({"type": "voice_login_status", "message": "recording_started"})
983
- else:
984
- await websocket.send_json({"type": "voice_login_result", "success": False, "error": "VOICE_AUTH_NOT_AVAILABLE"})
985
- except Exception as e:
986
- await websocket.send_json({"type": "voice_login_result", "success": False, "error": f"START_ERROR: {str(e)}"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
987
 
988
  elif message_type == "audio_chunk":
989
  try:
990
  b64 = message_data.get("pcm16_base64", "")
991
- if b64 and hasattr(app.state, "voice_auth") and app.state.voice_auth:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
992
  app.state.voice_auth.append_chunk_base64(user_id, b64)
993
  # 添加調試日誌
994
  current_buffer_size = len(app.state.voice_auth._buffers.get(user_id, b""))
995
  logger.info(f"🎤 收到音頻chunk,用戶 {user_id},當前緩衝區大小: {current_buffer_size} bytes")
 
996
  except Exception as e:
997
  await websocket.send_json({"type": "voice_login_result", "success": False, "error": f"CHUNK_ERROR: {str(e)}"})
998
 
@@ -1191,6 +1022,112 @@ async def websocket_endpoint_with_jwt(websocket: WebSocket, token: str = Query(N
1191
  "detail": {k: v for k, v in result.items() if k not in {"success"}},
1192
  })
1193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1194
  elif mode == "chat":
1195
  # === 新的對話模式:並行執行 STT + 情緒辨識 ===
1196
  try:
@@ -1838,6 +1775,134 @@ async def logout():
1838
  "message": "登出成功"
1839
  }
1840
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1841
  """
1842
  Google OAuth 2.0 登入端點 (向後兼容)
1843
  接收前端傳來的 Google JWT token,驗證後創建或登入用戶
 
5
  import mimetypes
6
  import logging
7
  import secrets
8
+ import jwt
9
  from datetime import datetime
10
  from typing import List, Dict, Optional, Any
11
 
 
109
  return None
110
 
111
  # -----------------------------
112
+ # Pydantic 模型(從統一模組導入)
113
  # -----------------------------
114
+ from models.schemas import (
115
+ UserCreate,
116
+ UserLogin,
117
+ ChatCreateRequest,
118
+ MessageCreateRequest,
119
+ ChatTitleUpdateRequest,
120
+ UserInfo,
121
+ UserPublic,
122
+ UserLoginPublicResponse,
123
+ ChatPublic,
124
+ MessagePublic,
125
+ ChatDetailResponse,
126
+ ChatSummary,
127
+ ChatListResponse,
128
+ FileAnalysisRequest,
129
+ FileAnalysisResponse,
130
+ SpeakerLabelBindRequest,
131
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
 
134
  # -----------------------------
 
211
  window_seconds=3,
212
  required_windows=1,
213
  sample_rate=16000,
214
+ prob_threshold=0.50, # ECAPA-TDNN 餘弦相似度 + 0.35 加成後門檻
215
+ margin_threshold=0.05,
216
  min_snr_db=12.0,
217
  ))
218
  except Exception as e:
 
284
  """定期清理過期的會話和數據"""
285
  while True:
286
  try:
287
+ # 定期清理(使用配置常數)
288
+ await asyncio.sleep(settings.CLEANUP_INTERVAL)
289
 
290
  # 清理過期的WebSocket會話
291
  await manager.cleanup_expired_sessions()
 
311
 
312
  app = FastAPI(title="聊天機器人API(整合版)", lifespan=lifespan)
313
 
314
+ # CORS 設定(從環境變數讀取,生產環境應設定具體來源)
315
  app.add_middleware(
316
  CORSMiddleware,
317
+ allow_origins=settings.get_cors_origins(),
318
  allow_credentials=True,
319
  allow_methods=["*"],
320
  allow_headers=["*"],
 
352
 
353
  # 掛載靜態檔案目錄(語音沉浸式前端)
354
  static_dir = Path("static/frontend")
355
+ login_dir = Path("bloom-ware-login/out") # 直接使用 Next.js 專案的輸出目錄
356
 
357
  if static_dir.exists() and static_dir.is_dir():
358
  app.mount("/static", StaticFiles(directory=str(static_dir), html=True), name="frontend")
 
366
  app.mount("/login", StaticFiles(directory=str(login_dir), html=True), name="login_static")
367
  logger.info(f"✅ 已掛載登入頁面: /login → {login_dir}")
368
  else:
369
+ logger.warning(f"⚠️ 未找到 {login_dir} 目錄,請先執行: cd bloom-ware-login && npm run build")
370
 
371
  # 環境設定
372
  app.state.intent_model = settings.OPENAI_MODEL
 
385
  return ip
386
  return request.client.host if request.client else "unknown"
387
 
388
+ # 注意:CORS 已在上方配置,此處移除重複配置
 
 
 
 
 
 
 
 
389
 
390
  # -----------------------------
391
+ # WebSocket 連線管理(從統一模組導入
392
  # -----------------------------
393
+ from websocket import manager
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
394
 
395
  # -----------------------------
396
+ # 語音綁定狀態管理器(從統一模組導入
397
  # -----------------------------
398
+ from services.voice_binding import voice_binding_fsm
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
 
400
 
401
  # -----------------------------
402
  # 統一 WebSocket 端點(JWT認證)
403
  # -----------------------------
404
  @app.websocket("/ws")
405
+ async def websocket_endpoint_with_jwt(
406
+ websocket: WebSocket,
407
+ token: str = Query(None),
408
+ emotion: str = Query("")
409
+ ):
410
  """JWT認證的WebSocket端點(支援語音登入匿名連線)"""
411
+ logger.info(f"WebSocket連接請求 - JWT認證 (emotion={emotion})")
412
 
413
  # 特殊處理:語音登入匿名連線
414
  is_voice_login_mode = token == "anonymous_voice_login"
 
493
  logger.debug(f"讀取使用者時區失敗: {tz_err}")
494
 
495
  td = app.state.feature_router.get_current_time_data()
496
+ # 使用語音登入傳遞的情緒(如果有)
497
  welcome_msg = compose_welcome(
498
  user_name=user_info.get('name'),
499
  time_data=td,
500
+ emotion_label=emotion,
501
  timezone=tz_hint,
502
  )
503
  except Exception as e:
 
570
  {
571
  "role": "system",
572
  "content": (
573
+ "你是一個友善、有禮且能夠提供幫助的AI助手。\n\n"
574
+ "【重要】語���使用規範:\n"
575
+ "- 回覆用戶時:必須使用繁體中文,保持簡潔清晰的表達\n"
576
+ "- 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)\n\n"
577
  "另外,請勿自稱為 GPT-4 或其他版本。若需要自我介紹,請表述為 '基於 gpt-5-nano 模型'。"
578
  ),
579
  },
 
711
  await websocket.send_json({"type": "error", "message": f"CHAT_FOCUS_ERROR: {str(e)}"})
712
 
713
  elif message_type == "audio_start":
714
+ # 語音處理邏輯(支援多種模式
715
+ mode = message_data.get("mode", "voice_login")
716
+
717
  try:
718
  sr = int(message_data.get("sample_rate", 16000))
719
  except Exception:
720
  sr = 16000
721
+
722
+ if mode == "realtime_chat":
723
+ # === 即時轉錄模式(使用 OpenAI Realtime API)===
724
+ try:
725
+ from services.realtime_stt_service import RealtimeSTTService
726
+
727
+ logger.info(f"🎙️ 啟動即時轉錄模式,用戶 {user_id}")
728
+
729
+ # 建立 Realtime STT 服務實例
730
+ realtime_stt = RealtimeSTTService()
731
+
732
+ # 定義轉錄回調函數
733
+ async def on_transcript_delta(delta_text: str):
734
+ """接收部分轉錄結果並即時發送給前端"""
735
+ await websocket.send_json({
736
+ "type": "stt_delta",
737
+ "text": delta_text,
738
+ "timestamp": time.time()
739
+ })
740
+ logger.debug(f"📤 STT Delta: {delta_text}")
741
+
742
+ async def on_transcript_done(full_text: str):
743
+ """接收完整轉錄結果"""
744
+ await websocket.send_json({
745
+ "type": "stt_final",
746
+ "text": full_text,
747
+ "timestamp": time.time()
748
+ })
749
+ logger.info(f"✅ STT Final: {full_text}")
750
+
751
+ # 儲存轉錄文字到 client_info,供 audio_stop 使用
752
+ client_info = manager.get_client_info(user_id) or {}
753
+ client_info["realtime_transcript"] = full_text
754
+ manager.set_client_info(user_id, client_info)
755
+
756
+ async def on_vad_committed(item_id: str):
757
+ """VAD 偵測到語音段結束"""
758
+ logger.debug(f"🎤 VAD Committed: {item_id}")
759
+
760
+ # 連線到 OpenAI Realtime API
761
+ success = await realtime_stt.connect(
762
+ on_transcript_delta=on_transcript_delta,
763
+ on_transcript_done=on_transcript_done,
764
+ on_vad_committed=on_vad_committed,
765
+ model="gpt-4o-mini-transcribe",
766
+ language="zh"
767
+ )
768
+
769
+ if success:
770
+ # 儲存 Realtime STT 實例到 client info
771
+ client_info = manager.get_client_info(user_id) or {}
772
+ client_info["realtime_stt"] = realtime_stt
773
+ manager.set_client_info(user_id, client_info)
774
+
775
+ await websocket.send_json({
776
+ "type": "realtime_stt_status",
777
+ "status": "connected",
778
+ "message": "即時轉錄已啟動"
779
+ })
780
+ logger.info(f"✅ 用戶 {user_id} 即時轉錄已啟動")
781
+ else:
782
+ raise Exception("無法連接到 OpenAI Realtime API")
783
+
784
+ except Exception as e:
785
+ logger.error(f"❌ 啟動即時轉錄失敗: {e}")
786
+ await websocket.send_json({
787
+ "type": "error",
788
+ "message": f"即時轉錄啟動失敗: {str(e)}"
789
+ })
790
+
791
+ else:
792
+ # === 傳統模式(語音登入或語音綁定)===
793
+ try:
794
+ if hasattr(app.state, "voice_auth") and app.state.voice_auth:
795
+ app.state.voice_auth.start_session(user_id, sr)
796
+ await websocket.send_json({"type": "voice_login_status", "message": "recording_started"})
797
+ else:
798
+ await websocket.send_json({"type": "voice_login_result", "success": False, "error": "VOICE_AUTH_NOT_AVAILABLE"})
799
+ except Exception as e:
800
+ await websocket.send_json({"type": "voice_login_result", "success": False, "error": f"START_ERROR: {str(e)}"})
801
 
802
  elif message_type == "audio_chunk":
803
  try:
804
  b64 = message_data.get("pcm16_base64", "")
805
+
806
+ # 檢查是否為即時轉錄模式
807
+ client_info = manager.get_client_info(user_id) or {}
808
+ realtime_stt = client_info.get("realtime_stt")
809
+
810
+ if realtime_stt and b64:
811
+ # === 即時轉錄模式:轉發到 OpenAI Realtime API ===
812
+ try:
813
+ import base64
814
+ audio_bytes = base64.b64decode(b64)
815
+ await realtime_stt.send_audio_chunk(audio_bytes)
816
+ logger.debug(f"🎤 轉發音頻到 OpenAI: {len(audio_bytes)} bytes")
817
+ except Exception as e:
818
+ logger.error(f"❌ 轉發音頻失敗: {e}")
819
+
820
+ elif b64 and hasattr(app.state, "voice_auth") and app.state.voice_auth:
821
+ # === 傳統模式:存到 buffer ===
822
  app.state.voice_auth.append_chunk_base64(user_id, b64)
823
  # 添加調試日誌
824
  current_buffer_size = len(app.state.voice_auth._buffers.get(user_id, b""))
825
  logger.info(f"🎤 收到音頻chunk,用戶 {user_id},當前緩衝區大小: {current_buffer_size} bytes")
826
+
827
  except Exception as e:
828
  await websocket.send_json({"type": "voice_login_result", "success": False, "error": f"CHUNK_ERROR: {str(e)}"})
829
 
 
1022
  "detail": {k: v for k, v in result.items() if k not in {"success"}},
1023
  })
1024
 
1025
+ elif mode == "realtime_chat":
1026
+ # === 即時轉錄模式:關閉 OpenAI Realtime 連線並處理轉錄結果 ===
1027
+ try:
1028
+ client_info = manager.get_client_info(user_id) or {}
1029
+ realtime_stt = client_info.get("realtime_stt")
1030
+ transcription = client_info.get("realtime_transcript", "")
1031
+
1032
+ if realtime_stt:
1033
+ logger.info(f"🔌 關閉即時轉錄連線,用戶 {user_id}")
1034
+ await realtime_stt.disconnect()
1035
+
1036
+ # 清理 client info
1037
+ client_info.pop("realtime_stt", None)
1038
+ client_info.pop("realtime_transcript", None)
1039
+ manager.set_client_info(user_id, client_info)
1040
+
1041
+ await websocket.send_json({
1042
+ "type": "realtime_stt_status",
1043
+ "status": "disconnected",
1044
+ "message": "即時轉錄已結束"
1045
+ })
1046
+ logger.info(f"✅ 用戶 {user_id} 即時轉錄已結束")
1047
+ else:
1048
+ logger.warning(f"⚠️ 找不到 realtime_stt 實例,用戶 {user_id}")
1049
+
1050
+ # 如果有轉錄文字,送給 AI Agent 處理
1051
+ if transcription:
1052
+ logger.info(f"🤖 處理即時轉錄結果: {transcription}")
1053
+
1054
+ # 通知前端開始思考
1055
+ await websocket.send_json({"type": "typing", "message": "thinking"})
1056
+
1057
+ # 異步處理對話邏輯
1058
+ async def _process_realtime_chat():
1059
+ chat_id = message_data.get("chat_id")
1060
+
1061
+ # 如果沒有 chat_id,創建新對話
1062
+ if not chat_id:
1063
+ try:
1064
+ user_chats_result = await get_user_chats(user_id)
1065
+ if user_chats_result["success"] and user_chats_result["chats"]:
1066
+ latest_chat = user_chats_result["chats"][0]
1067
+ chat_id = latest_chat["chat_id"]
1068
+ else:
1069
+ chat_title = f"語音對話 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
1070
+ chat_result = await create_chat(user_id, chat_title)
1071
+ if chat_result["success"]:
1072
+ chat_id = chat_result["chat"]["chat_id"]
1073
+ except Exception as e:
1074
+ logger.error(f"創建對話失敗: {e}")
1075
+ await websocket.send_json({"type": "error", "message": "無法創建對話"})
1076
+ return
1077
+
1078
+ # 保存用戶訊息
1079
+ await save_message_to_db(user_id, chat_id, "user", transcription)
1080
+
1081
+ # 處理對話(透過 handle_message,自動處理 pipeline)
1082
+ response = await handle_message(
1083
+ transcription,
1084
+ user_id,
1085
+ chat_id,
1086
+ [] # messages 參數(會自動從數據庫載入)
1087
+ )
1088
+
1089
+ # 發送回應
1090
+ if isinstance(response, PipelineResult):
1091
+ message_text = response.text
1092
+
1093
+ await websocket.send_json({
1094
+ "type": "bot_message",
1095
+ "message": message_text,
1096
+ "timestamp": time.time(),
1097
+ "tool_name": None,
1098
+ "tool_data": None
1099
+ })
1100
+ elif isinstance(response, dict):
1101
+ tool_name = response.get('tool_name')
1102
+ tool_data = response.get('tool_data')
1103
+ message_text = response.get('message', response.get('content', ''))
1104
+
1105
+ await websocket.send_json({
1106
+ "type": "bot_message",
1107
+ "message": message_text,
1108
+ "timestamp": time.time(),
1109
+ "tool_name": tool_name,
1110
+ "tool_data": tool_data
1111
+ })
1112
+ else:
1113
+ # 字串回應
1114
+ await websocket.send_json({
1115
+ "type": "bot_message",
1116
+ "message": str(response),
1117
+ "timestamp": time.time()
1118
+ })
1119
+
1120
+ await _process_realtime_chat()
1121
+ else:
1122
+ logger.debug(f"沒有轉錄文字,返回待機狀態")
1123
+
1124
+ except Exception as e:
1125
+ logger.error(f"❌ 關閉即時轉錄失敗: {e}")
1126
+ await websocket.send_json({
1127
+ "type": "error",
1128
+ "message": f"關閉即時轉錄失敗: {str(e)}"
1129
+ })
1130
+
1131
  elif mode == "chat":
1132
  # === 新的對話模式:並行執行 STT + 情緒辨識 ===
1133
  try:
 
1775
  "message": "登出成功"
1776
  }
1777
 
1778
+
1779
+ # -----------------------------
1780
+ # 語音登入 API
1781
+ # -----------------------------
1782
+ class VoiceLoginRequest(BaseModel):
1783
+ """語音登入請求"""
1784
+ audio_base64: str # base64 編碼的 PCM16 音訊
1785
+ sample_rate: int = 16000
1786
+
1787
+
1788
+ @app.post("/auth/voice/login")
1789
+ async def voice_login(request: VoiceLoginRequest):
1790
+ """
1791
+ 語音登入 API
1792
+
1793
+ 流程:
1794
+ 1. 接收 base64 編碼的音訊
1795
+ 2. 執行身份辨識 + 情緒辨識
1796
+ 3. 查詢 speaker_label 對應的用戶
1797
+ 4. 生成 JWT token
1798
+ 5. 回傳 token + 情緒
1799
+ """
1800
+ import base64
1801
+
1802
+ try:
1803
+ # 取得 VoiceAuthService 實例
1804
+ voice_auth = getattr(app.state, "voice_auth", None)
1805
+ if not voice_auth:
1806
+ logger.error("❌ VoiceAuthService 未初始化")
1807
+ return JSONResponse(status_code=503, content={
1808
+ "success": False,
1809
+ "error": "語音辨識服務未就緒,請稍後再試"
1810
+ })
1811
+
1812
+ # 解碼音訊
1813
+ try:
1814
+ audio_bytes = base64.b64decode(request.audio_base64)
1815
+ except Exception as e:
1816
+ logger.error(f"❌ 音訊解碼失敗: {e}")
1817
+ return JSONResponse(status_code=400, content={
1818
+ "success": False,
1819
+ "error": "音訊格式錯誤"
1820
+ })
1821
+
1822
+ logger.info(f"🎙️ 收到語音登入請求,音訊大小: {len(audio_bytes)} bytes")
1823
+
1824
+ # 建立臨時 session 並處理音訊
1825
+ temp_user_id = f"voice_login_{datetime.now().timestamp()}"
1826
+ voice_auth.start_session(temp_user_id, request.sample_rate)
1827
+ voice_auth._buffers[temp_user_id] = bytearray(audio_bytes)
1828
+
1829
+ # 執行辨識
1830
+ result = voice_auth.stop_and_authenticate(temp_user_id)
1831
+
1832
+ # 清理 session
1833
+ voice_auth.clear_session(temp_user_id)
1834
+
1835
+ if not result.get("success"):
1836
+ error_code = result.get("error", "UNKNOWN_ERROR")
1837
+ error_messages = {
1838
+ "NO_AUDIO": "沒有收到音訊資料",
1839
+ "AUDIO_TOO_SHORT": "音訊太短,請錄製至少 3 秒",
1840
+ "LOW_SNR": "環境太吵,請在安靜的地方重試",
1841
+ "INCONSISTENT_WINDOWS": "無法確認身份,請重試",
1842
+ "THRESHOLD_NOT_MET": "無法確認身份,請重試",
1843
+ "MODEL_ERROR": "辨識系統錯誤,請稍後重試",
1844
+ }
1845
+ logger.warning(f"🎙️ 語音辨識失敗: {error_code}")
1846
+ return JSONResponse(content={
1847
+ "success": False,
1848
+ "error": error_messages.get(error_code, f"辨識失敗:{error_code}")
1849
+ })
1850
+
1851
+ # 取得辨識結果
1852
+ speaker_label = result.get("label")
1853
+ emotion = result.get("emotion", {})
1854
+ emotion_label = emotion.get("label", "neutral") if isinstance(emotion, dict) else "neutral"
1855
+
1856
+ logger.info(f"🎙️ 語音辨識成功: speaker={speaker_label}, emotion={emotion_label}")
1857
+
1858
+ # 查詢對應的用戶
1859
+ from core.database import get_user_by_speaker_label
1860
+ user = await get_user_by_speaker_label(speaker_label)
1861
+
1862
+ if not user:
1863
+ logger.warning(f"🎙️ 找不到綁定的帳號: speaker_label={speaker_label}")
1864
+ return JSONResponse(content={
1865
+ "success": False,
1866
+ "error": f"找不到綁定的帳號。請先使用 Google 登入並綁定語音。"
1867
+ })
1868
+
1869
+ # 生成 JWT token
1870
+ user_id = user.get("id")
1871
+ user_name = user.get("name", "用戶")
1872
+ user_email = user.get("email", "")
1873
+
1874
+ payload = {
1875
+ "sub": user_id,
1876
+ "name": user_name,
1877
+ "email": user_email,
1878
+ "iat": datetime.utcnow(),
1879
+ "exp": datetime.utcnow() + timedelta(days=7),
1880
+ "login_method": "voice",
1881
+ "emotion": emotion_label,
1882
+ }
1883
+
1884
+ token = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
1885
+
1886
+ logger.info(f"✅ 語音登入成功: user={user_name}, emotion={emotion_label}")
1887
+
1888
+ return {
1889
+ "success": True,
1890
+ "access_token": token,
1891
+ "user": {
1892
+ "id": user_id,
1893
+ "name": user_name,
1894
+ "email": user_email,
1895
+ },
1896
+ "emotion": emotion_label,
1897
+ }
1898
+
1899
+ except Exception as e:
1900
+ logger.exception(f"❌ 語音登入失敗: {e}")
1901
+ return JSONResponse(status_code=500, content={
1902
+ "success": False,
1903
+ "error": f"系統錯誤:{str(e)}"
1904
+ })
1905
+
1906
  """
1907
  Google OAuth 2.0 登入端點 (向後兼容)
1908
  接收前端傳來的 Google JWT token,驗證後創建或登入用戶
bloom-ware-login/app/layout.tsx CHANGED
@@ -13,6 +13,8 @@ export const metadata: Metadata = {
13
  generator: "v0.app",
14
  }
15
 
 
 
16
  export default function RootLayout({
17
  children,
18
  }: Readonly<{
@@ -21,6 +23,7 @@ export default function RootLayout({
21
  return (
22
  <html lang="en">
23
  <body className={`font-sans antialiased`}>
 
24
  {children}
25
  </body>
26
  </html>
 
13
  generator: "v0.app",
14
  }
15
 
16
+ import { OfflineIndicator } from "@/components/offline-indicator"
17
+
18
  export default function RootLayout({
19
  children,
20
  }: Readonly<{
 
23
  return (
24
  <html lang="en">
25
  <body className={`font-sans antialiased`}>
26
+ <OfflineIndicator />
27
  {children}
28
  </body>
29
  </html>
bloom-ware-login/components/login-form.tsx CHANGED
@@ -3,12 +3,15 @@
3
  import { useEffect, useRef, useCallback, useState } from "react"
4
  import { Button } from "@/components/ui/button"
5
  import { TulipIllustration } from "@/components/tulip-illustration"
6
- import { Mic, ExternalLink } from "lucide-react"
7
 
8
  export function LoginForm() {
9
  const popupRef = useRef<Window | null>(null)
10
  const popupCheckIntervalRef = useRef<NodeJS.Timeout | null>(null)
11
  const [isInIframe, setIsInIframe] = useState(false)
 
 
 
12
 
13
  // 檢測是否在 iframe 中(HF Space 嵌入模式)
14
  useEffect(() => {
@@ -153,6 +156,9 @@ export function LoginForm() {
153
  }
154
 
155
  const handleGoogleLogin = async () => {
 
 
 
156
  // 如果在 iframe 中,引導用戶在新分頁開啟
157
  if (isInIframe) {
158
  console.log('📦 檢測到 iframe 環境,引導用戶在新分頁開啟');
@@ -160,6 +166,9 @@ export function LoginForm() {
160
  return;
161
  }
162
 
 
 
 
163
  try {
164
  console.log('🚀 開始 Google OAuth 登入流程(Popup 模式)...');
165
 
@@ -204,6 +213,8 @@ export function LoginForm() {
204
  popupCheckIntervalRef.current = setInterval(() => {
205
  if (popupRef.current && popupRef.current.closed) {
206
  console.log('📪 Popup 視窗已關閉');
 
 
207
  if (popupCheckIntervalRef.current) {
208
  clearInterval(popupCheckIntervalRef.current);
209
  }
@@ -212,14 +223,119 @@ export function LoginForm() {
212
 
213
  } catch (error) {
214
  console.error('❌ OAuth 初始化失敗:', error);
215
- alert('Google 登入初始化失敗,請稍後再試');
 
 
216
  }
217
  }
218
 
219
- const handleVoiceLogin = () => {
 
 
 
 
 
 
220
  console.log('🎤 開始語音登入...');
221
- localStorage.setItem('jwt_token', 'anonymous_voice_login');
222
- window.location.href = '/static/';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  }
224
 
225
  return (
@@ -236,6 +352,13 @@ export function LoginForm() {
236
  </div>
237
 
238
  <div className="w-full space-y-3 sm:space-y-4">
 
 
 
 
 
 
 
239
  {/* iframe 環境提示 - 簡約風格 */}
240
  {isInIframe && (
241
  <p className="text-[#8B7355] text-[11px] sm:text-xs text-center tracking-wide opacity-80">
@@ -245,40 +368,56 @@ export function LoginForm() {
245
 
246
  {/* Google Login */}
247
  <Button
 
248
  onClick={handleGoogleLogin}
249
- className="w-full h-11 sm:h-12 bg-white hover:bg-gray-50 text-[#2C2C2C] shadow-md hover:shadow-lg transition-all duration-200 rounded-lg border border-gray-200 text-sm sm:text-base"
 
250
  variant="outline"
251
  >
252
- <svg className="w-4 h-4 sm:w-5 sm:h-5 mr-2 sm:mr-3" viewBox="0 0 24 24">
253
- <path
254
- fill="#4285F4"
255
- d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
256
- />
257
- <path
258
- fill="#34A853"
259
- d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
260
- />
261
- <path
262
- fill="#FBBC05"
263
- d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
264
- />
265
- <path
266
- fill="#EA4335"
267
- d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
268
- />
269
- </svg>
270
- <span className="font-medium">Continue with Google</span>
271
- {isInIframe && <ExternalLink className="w-3 h-3 ml-2 opacity-50" />}
 
 
 
 
 
 
272
  </Button>
273
 
274
  {/* Voice Login */}
275
  <Button
 
276
  onClick={handleVoiceLogin}
277
- className="w-full h-11 sm:h-12 bg-white hover:bg-gray-50 text-[#2C2C2C] shadow-md hover:shadow-lg transition-all duration-200 rounded-lg border border-gray-200 text-sm sm:text-base"
 
278
  variant="outline"
279
  >
280
- <Mic className="w-4 h-4 sm:w-5 sm:h-5 mr-2 sm:mr-3" />
281
- <span className="font-medium">Voice Login</span>
 
 
 
 
 
 
282
  </Button>
283
  </div>
284
 
 
3
  import { useEffect, useRef, useCallback, useState } from "react"
4
  import { Button } from "@/components/ui/button"
5
  import { TulipIllustration } from "@/components/tulip-illustration"
6
+ import { Mic, ExternalLink, Loader2 } from "lucide-react"
7
 
8
  export function LoginForm() {
9
  const popupRef = useRef<Window | null>(null)
10
  const popupCheckIntervalRef = useRef<NodeJS.Timeout | null>(null)
11
  const [isInIframe, setIsInIframe] = useState(false)
12
+ const [isLoading, setIsLoading] = useState(false)
13
+ const [loadingType, setLoadingType] = useState<'google' | 'voice' | null>(null)
14
+ const [error, setError] = useState<string | null>(null)
15
 
16
  // 檢測是否在 iframe 中(HF Space 嵌入模式)
17
  useEffect(() => {
 
156
  }
157
 
158
  const handleGoogleLogin = async () => {
159
+ // 清除之前的錯誤
160
+ setError(null);
161
+
162
  // 如果在 iframe 中,引導用戶在新分頁開啟
163
  if (isInIframe) {
164
  console.log('📦 檢測到 iframe 環境,引導用戶在新分頁開啟');
 
166
  return;
167
  }
168
 
169
+ setIsLoading(true);
170
+ setLoadingType('google');
171
+
172
  try {
173
  console.log('🚀 開始 Google OAuth 登入流程(Popup 模式)...');
174
 
 
213
  popupCheckIntervalRef.current = setInterval(() => {
214
  if (popupRef.current && popupRef.current.closed) {
215
  console.log('📪 Popup 視窗已關閉');
216
+ setIsLoading(false);
217
+ setLoadingType(null);
218
  if (popupCheckIntervalRef.current) {
219
  clearInterval(popupCheckIntervalRef.current);
220
  }
 
223
 
224
  } catch (error) {
225
  console.error('❌ OAuth 初始化失敗:', error);
226
+ setError('Google 登入初始化失敗,請稍後再試');
227
+ setIsLoading(false);
228
+ setLoadingType(null);
229
  }
230
  }
231
 
232
+ const [voiceStatus, setVoiceStatus] = useState<string>('');
233
+
234
+ const handleVoiceLogin = async () => {
235
+ setError(null);
236
+ setIsLoading(true);
237
+ setLoadingType('voice');
238
+ setVoiceStatus('請求麥克風權限...');
239
  console.log('🎤 開始語音登入...');
240
+
241
+ try {
242
+ // 請求麥克風權限
243
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
244
+ console.log('✅ 麥克風權限已獲取');
245
+
246
+ // 設定錄音參數
247
+ const audioContext = new AudioContext({ sampleRate: 16000 });
248
+ const source = audioContext.createMediaStreamSource(stream);
249
+ const processor = audioContext.createScriptProcessor(4096, 1, 1);
250
+
251
+ const audioChunks: Float32Array[] = [];
252
+ const recordDuration = 4000; // 4 秒(確保足夠長度)
253
+
254
+ processor.onaudioprocess = (e) => {
255
+ const inputData = e.inputBuffer.getChannelData(0);
256
+ audioChunks.push(new Float32Array(inputData));
257
+ };
258
+
259
+ source.connect(processor);
260
+ processor.connect(audioContext.destination);
261
+
262
+ setVoiceStatus('🎙️ 錄音中... 請說話 (4秒)');
263
+ console.log('🎙️ 開始錄音 4 秒...');
264
+
265
+ // 錄音 3 秒
266
+ await new Promise(resolve => setTimeout(resolve, recordDuration));
267
+
268
+ // 停止錄音
269
+ processor.disconnect();
270
+ source.disconnect();
271
+ stream.getTracks().forEach(track => track.stop());
272
+ await audioContext.close();
273
+
274
+ setVoiceStatus('辨識中...');
275
+ console.log('✅ 錄音完成,處理音訊...');
276
+
277
+ // 合併音訊資料
278
+ const totalLength = audioChunks.reduce((acc, chunk) => acc + chunk.length, 0);
279
+ const audioData = new Float32Array(totalLength);
280
+ let offset = 0;
281
+ for (const chunk of audioChunks) {
282
+ audioData.set(chunk, offset);
283
+ offset += chunk.length;
284
+ }
285
+
286
+ // 轉換為 PCM16
287
+ const pcm16 = new Int16Array(audioData.length);
288
+ for (let i = 0; i < audioData.length; i++) {
289
+ const s = Math.max(-1, Math.min(1, audioData[i]));
290
+ pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
291
+ }
292
+
293
+ // 轉換為 base64
294
+ const uint8Array = new Uint8Array(pcm16.buffer);
295
+ let binary = '';
296
+ for (let i = 0; i < uint8Array.length; i++) {
297
+ binary += String.fromCharCode(uint8Array[i]);
298
+ }
299
+ const audioBase64 = btoa(binary);
300
+
301
+ console.log('📤 發送語音到後端進行辨識...');
302
+
303
+ // 呼叫語音登入 API
304
+ const response = await fetch('/auth/voice/login', {
305
+ method: 'POST',
306
+ headers: {
307
+ 'Content-Type': 'application/json',
308
+ },
309
+ body: JSON.stringify({
310
+ audio_base64: audioBase64,
311
+ sample_rate: 16000,
312
+ }),
313
+ });
314
+
315
+ const data = await response.json();
316
+
317
+ if (data.success) {
318
+ console.log('✅ 語音登入成功!');
319
+ localStorage.setItem('jwt_token', data.access_token);
320
+ // 儲存情緒標籤供歡迎詞使用
321
+ if (data.emotion) {
322
+ localStorage.setItem('voice_login_emotion', data.emotion);
323
+ }
324
+ window.location.href = '/static/';
325
+ } else {
326
+ throw new Error(data.error || '語音登入失敗');
327
+ }
328
+
329
+ } catch (error: any) {
330
+ console.error('❌ 語音登入失敗:', error);
331
+ if (error.name === 'NotAllowedError') {
332
+ setError('請允許麥克風權限以使用語音登入');
333
+ } else {
334
+ setError(error.message || '語音登入失敗,請重試');
335
+ }
336
+ setIsLoading(false);
337
+ setLoadingType(null);
338
+ }
339
  }
340
 
341
  return (
 
352
  </div>
353
 
354
  <div className="w-full space-y-3 sm:space-y-4">
355
+ {/* 錯誤訊息 */}
356
+ {error && (
357
+ <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-lg text-sm text-center">
358
+ {error}
359
+ </div>
360
+ )}
361
+
362
  {/* iframe 環境提示 - 簡約風格 */}
363
  {isInIframe && (
364
  <p className="text-[#8B7355] text-[11px] sm:text-xs text-center tracking-wide opacity-80">
 
368
 
369
  {/* Google Login */}
370
  <Button
371
+ type="button"
372
  onClick={handleGoogleLogin}
373
+ disabled={isLoading}
374
+ className="w-full h-11 sm:h-12 bg-white hover:bg-gray-50 text-[#2C2C2C] shadow-md hover:shadow-lg transition-all duration-200 rounded-lg border border-gray-200 text-sm sm:text-base disabled:opacity-50 disabled:cursor-not-allowed"
375
  variant="outline"
376
  >
377
+ {isLoading && loadingType === 'google' ? (
378
+ <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 mr-2 sm:mr-3 animate-spin" />
379
+ ) : (
380
+ <svg className="w-4 h-4 sm:w-5 sm:h-5 mr-2 sm:mr-3" viewBox="0 0 24 24">
381
+ <path
382
+ fill="#4285F4"
383
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
384
+ />
385
+ <path
386
+ fill="#34A853"
387
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
388
+ />
389
+ <path
390
+ fill="#FBBC05"
391
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
392
+ />
393
+ <path
394
+ fill="#EA4335"
395
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
396
+ />
397
+ </svg>
398
+ )}
399
+ <span className="font-medium">
400
+ {isLoading && loadingType === 'google' ? '登入中...' : 'Continue with Google'}
401
+ </span>
402
+ {isInIframe && !isLoading && <ExternalLink className="w-3 h-3 ml-2 opacity-50" />}
403
  </Button>
404
 
405
  {/* Voice Login */}
406
  <Button
407
+ type="button"
408
  onClick={handleVoiceLogin}
409
+ disabled={isLoading}
410
+ className="w-full h-11 sm:h-12 bg-white hover:bg-gray-50 text-[#2C2C2C] shadow-md hover:shadow-lg transition-all duration-200 rounded-lg border border-gray-200 text-sm sm:text-base disabled:opacity-50 disabled:cursor-not-allowed"
411
  variant="outline"
412
  >
413
+ {isLoading && loadingType === 'voice' ? (
414
+ <Loader2 className="w-4 h-4 sm:w-5 sm:h-5 mr-2 sm:mr-3 animate-spin" />
415
+ ) : (
416
+ <Mic className="w-4 h-4 sm:w-5 sm:h-5 mr-2 sm:mr-3" />
417
+ )}
418
+ <span className="font-medium">
419
+ {isLoading && loadingType === 'voice' ? (voiceStatus || '處理中...') : 'Voice Login'}
420
+ </span>
421
  </Button>
422
  </div>
423
 
bloom-ware-login/components/offline-indicator.tsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useEffect, useState } from "react"
4
+ import { WifiOff, Wifi } from "lucide-react"
5
+
6
+ export function OfflineIndicator() {
7
+ const [isOnline, setIsOnline] = useState(true)
8
+ const [showReconnected, setShowReconnected] = useState(false)
9
+
10
+ useEffect(() => {
11
+ // 初始狀態
12
+ setIsOnline(navigator.onLine)
13
+
14
+ const handleOnline = () => {
15
+ setIsOnline(true)
16
+ setShowReconnected(true)
17
+ // 3 秒後隱藏「已恢復連線」提示
18
+ setTimeout(() => setShowReconnected(false), 3000)
19
+ }
20
+
21
+ const handleOffline = () => {
22
+ setIsOnline(false)
23
+ setShowReconnected(false)
24
+ }
25
+
26
+ window.addEventListener("online", handleOnline)
27
+ window.addEventListener("offline", handleOffline)
28
+
29
+ return () => {
30
+ window.removeEventListener("online", handleOnline)
31
+ window.removeEventListener("offline", handleOffline)
32
+ }
33
+ }, [])
34
+
35
+ // 在線且不需要顯示恢復提示時,不渲染任何內容
36
+ if (isOnline && !showReconnected) {
37
+ return null
38
+ }
39
+
40
+ return (
41
+ <div
42
+ className={`fixed top-4 left-1/2 transform -translate-x-1/2 z-50
43
+ px-4 py-2 rounded-full shadow-lg flex items-center gap-2
44
+ transition-all duration-300 ${
45
+ isOnline
46
+ ? "bg-green-500 text-white"
47
+ : "bg-red-500 text-white animate-pulse"
48
+ }`}
49
+ >
50
+ {isOnline ? (
51
+ <>
52
+ <Wifi className="w-4 h-4" />
53
+ <span className="text-sm font-medium">已恢復連線</span>
54
+ </>
55
+ ) : (
56
+ <>
57
+ <WifiOff className="w-4 h-4" />
58
+ <span className="text-sm font-medium">網路已斷線</span>
59
+ </>
60
+ )}
61
+ </div>
62
+ )
63
+ }
core/ai_client.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 統一 OpenAI 客戶端管理
3
+ 單一真理來源,避免重複初始化
4
+ """
5
+
6
+ import logging
7
+ from typing import Optional
8
+
9
+ from core.config import settings
10
+ from core.logging import get_logger
11
+
12
+ logger = get_logger("core.ai_client")
13
+
14
+ # 全域 OpenAI 客戶端
15
+ _openai_client = None
16
+ _initialized = False
17
+
18
+
19
+ def get_openai_client():
20
+ """
21
+ 取得 OpenAI 客戶端(單例模式)
22
+
23
+ Returns:
24
+ OpenAI 客戶端實例,若初始化失敗則返回 None
25
+ """
26
+ global _openai_client, _initialized
27
+
28
+ if _initialized:
29
+ return _openai_client
30
+
31
+ try:
32
+ from openai import OpenAI
33
+
34
+ api_key = settings.OPENAI_API_KEY
35
+ if not api_key:
36
+ logger.error("❌ OpenAI API Key 未設定")
37
+ _initialized = True
38
+ return None
39
+
40
+ _openai_client = OpenAI(
41
+ api_key=api_key,
42
+ timeout=float(settings.OPENAI_TIMEOUT),
43
+ max_retries=3,
44
+ )
45
+
46
+ _initialized = True
47
+ logger.info("✅ OpenAI 客戶端初始化成功")
48
+ return _openai_client
49
+
50
+ except ImportError:
51
+ logger.error("❌ 無法導入 OpenAI SDK")
52
+ _initialized = True
53
+ return None
54
+
55
+ except Exception as e:
56
+ logger.error(f"❌ OpenAI 客戶端初始化失敗: {e}")
57
+ _initialized = True
58
+ return None
59
+
60
+
61
+ def reset_client() -> None:
62
+ """
63
+ 重置客戶端(用於測試或重新初始化)
64
+ """
65
+ global _openai_client, _initialized
66
+ _openai_client = None
67
+ _initialized = False
68
+ logger.info("OpenAI 客戶端已重置")
69
+
70
+
71
+ def is_available() -> bool:
72
+ """
73
+ 檢查 OpenAI 服務是否可用
74
+
75
+ Returns:
76
+ True 如果客戶端已初始化且可用
77
+ """
78
+ client = get_openai_client()
79
+ return client is not None
80
+
81
+
82
+ # 便捷別名
83
+ client = property(lambda self: get_openai_client())
core/config.py CHANGED
@@ -118,6 +118,40 @@ class Settings:
118
  ENV_CONTEXT_HEADING_THRESHOLD: float = float(os.getenv("ENV_CONTEXT_HEADING_THRESHOLD", "25"))
119
  ENV_CONTEXT_TTL_SECONDS: float = float(os.getenv("ENV_CONTEXT_TTL_SECONDS", "300"))
120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  @classmethod
122
  def validate(cls) -> bool:
123
  """
@@ -156,9 +190,16 @@ class Settings:
156
  if not cls.OPENAI_API_KEY.startswith("sk-"):
157
  print("⚠️ OpenAI API Key 格式可能不正確(應以 'sk-' 開頭)")
158
 
159
- # 驗證 JWT Secret 長度
160
- if len(cls.JWT_SECRET_KEY) < 32:
161
- print("⚠️ JWT Secret Key 長度建議至少 32 個字符")
 
 
 
 
 
 
 
162
 
163
  return True
164
 
 
118
  ENV_CONTEXT_HEADING_THRESHOLD: float = float(os.getenv("ENV_CONTEXT_HEADING_THRESHOLD", "25"))
119
  ENV_CONTEXT_TTL_SECONDS: float = float(os.getenv("ENV_CONTEXT_TTL_SECONDS", "300"))
120
 
121
+ # ===== CORS 安全設定 =====
122
+ # 生產環境應設定具體的允許來源,多個來源用逗號分隔
123
+ # 例如:CORS_ORIGINS=https://example.com,https://app.example.com
124
+ _cors_origins_raw: str = os.getenv("CORS_ORIGINS", "*")
125
+
126
+ @classmethod
127
+ def get_cors_origins(cls) -> list:
128
+ """取得 CORS 允許的來源列表"""
129
+ if cls._cors_origins_raw == "*":
130
+ return ["*"]
131
+ return [origin.strip() for origin in cls._cors_origins_raw.split(",") if origin.strip()]
132
+
133
+ # ===== 安全性設定 =====
134
+ # 登入失敗封鎖閾值
135
+ FAILED_LOGIN_THRESHOLD: int = int(os.getenv("FAILED_LOGIN_THRESHOLD", "5"))
136
+ # 封鎖時間(秒)
137
+ LOGIN_BLOCK_DURATION: int = int(os.getenv("LOGIN_BLOCK_DURATION", "900")) # 15 分鐘
138
+ # JWT Secret 最小長度
139
+ JWT_SECRET_MIN_LENGTH: int = 32
140
+
141
+ # ===== 效能調優常數 =====
142
+ # WebSocket 會話超時(秒)
143
+ WEBSOCKET_SESSION_TIMEOUT: int = int(os.getenv("WEBSOCKET_SESSION_TIMEOUT", "1800")) # 30 分鐘
144
+ # 定期清理間隔(秒)
145
+ CLEANUP_INTERVAL: int = int(os.getenv("CLEANUP_INTERVAL", "1800")) # 30 分鐘
146
+ # 記憶重要性閾值
147
+ MEMORY_IMPORTANCE_THRESHOLD: float = float(os.getenv("MEMORY_IMPORTANCE_THRESHOLD", "0.6"))
148
+ # 意圖快取 TTL(秒)
149
+ INTENT_CACHE_TTL: int = int(os.getenv("INTENT_CACHE_TTL", "300")) # 5 分鐘
150
+ # 對話歷史載入限制
151
+ CHAT_HISTORY_LIMIT: int = int(os.getenv("CHAT_HISTORY_LIMIT", "12"))
152
+ # 關懷模式對話歷史限制
153
+ CARE_MODE_HISTORY_LIMIT: int = int(os.getenv("CARE_MODE_HISTORY_LIMIT", "3"))
154
+
155
  @classmethod
156
  def validate(cls) -> bool:
157
  """
 
190
  if not cls.OPENAI_API_KEY.startswith("sk-"):
191
  print("⚠️ OpenAI API Key 格式可能不正確(應以 'sk-' 開頭)")
192
 
193
+ # 驗證 JWT Secret 長度(強制檢查)
194
+ if len(cls.JWT_SECRET_KEY) < cls.JWT_SECRET_MIN_LENGTH:
195
+ print(f" JWT Secret Key 長度必須至少 {cls.JWT_SECRET_MIN_LENGTH} 個字符")
196
+ if cls.IS_PRODUCTION:
197
+ return False
198
+ print("⚠️ 開發環境允許繼續,但生產環境將拒絕啟動")
199
+
200
+ # 生產環境 CORS 檢查
201
+ if cls.IS_PRODUCTION and cls._cors_origins_raw == "*":
202
+ print("⚠️ 生產環境建議設定具體的 CORS_ORIGINS,而非 '*'")
203
 
204
  return True
205
 
core/database/base.py CHANGED
@@ -39,6 +39,35 @@ route_cache_collection = None
39
  MAX_MEMORIES_PER_USER = 500
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  def _get_user_doc_ref(user_id: str) -> DocumentReference:
43
  if users_collection is None:
44
  raise RuntimeError("Firestore尚未連接,無法操作使用者資料")
@@ -143,13 +172,15 @@ async def get_user_by_id(user_id: str):
143
 
144
  user_doc = docs[0]
145
  user_data = user_doc.to_dict()
146
-
147
- return {
148
  "id": user_data["user_id"],
149
  "name": user_data.get("name", ""),
150
  "email": user_data.get("email", ""),
151
  "created_at": user_data.get("created_at"),
152
  }
 
 
153
  except Exception as e:
154
  logger.error(f"查找使用者時發生錯誤: {e}")
155
  return None
@@ -192,7 +223,8 @@ async def get_user_history(user_id, limit=20):
192
 
193
  messages = await _asyncio.to_thread(_fetch_messages)
194
  logger.info(f"已獲取用戶 {user_id} 的 {len(messages)} 條歷史記錄")
195
- return messages
 
196
  except Exception as e:
197
  logger.error(f"獲取歷史記錄時發生錯誤: {e}")
198
  return []
@@ -371,7 +403,9 @@ async def create_chat(user_id, title="新對話"):
371
  "created_at": chat["created_at"],
372
  "updated_at": chat["updated_at"],
373
  }
374
- return {"success": True, "chat": chat_info}
 
 
375
  except Exception as e:
376
  logger.error(f"創建對話時發生錯誤: {e}")
377
  return {"success": False, "error": str(e)}
@@ -401,7 +435,9 @@ async def get_user_chats(user_id):
401
 
402
  chats = await _asyncio.to_thread(_fetch_chats)
403
  logger.info(f"獲取到用戶 {user_id} 的 {len(chats)} 個對話")
404
- return {"success": True, "chats": chats}
 
 
405
  except Exception as e:
406
  logger.error(f"獲取用戶對話時發生錯誤: {e}")
407
  return {"success": False, "error": str(e)}
@@ -524,7 +560,8 @@ async def get_chat_messages(chat_id: str, limit: int | None = None, ascending: b
524
 
525
  messages = await _asyncio.to_thread(_query)
526
  if messages:
527
- return messages
 
528
 
529
  # 向後相容:若子集合無資料,嘗試讀取舊頂層 messages 集合
530
  if messages_collection is None:
@@ -557,7 +594,8 @@ async def get_chat_messages(chat_id: str, limit: int | None = None, ascending: b
557
  logger.warning(f"回填 legacy messages 失敗(可忽略): {backfill_err}")
558
 
559
  await _asyncio.to_thread(_backfill)
560
- return view_messages
 
561
  except Exception as e:
562
  logger.error(f"讀取對話消息失敗: {e}")
563
  return []
@@ -730,12 +768,14 @@ async def get_user_by_speaker_label(speaker_label: str):
730
  return None
731
 
732
  data = doc.to_dict()
733
- return {
734
  "id": data.get("user_id"),
735
  "name": data.get("name", ""),
736
  "email": data.get("email", ""),
737
  "created_at": data.get("created_at"),
738
  }
 
 
739
  except Exception as e:
740
  logger.error(f"查詢語音標籤對應用戶時發生錯誤: {e}")
741
  return None
@@ -912,7 +952,9 @@ async def get_user_env_current(user_id: str) -> Dict[str, Any]:
912
  data = await _asyncio.to_thread(_read)
913
  if not data:
914
  return {"success": False, "error": "NOT_FOUND"}
915
- return {"success": True, "context": data}
 
 
916
  except Exception as e:
917
  logger.error(f"讀取環境現況失敗: {e}")
918
  return {"success": False, "error": str(e)}
@@ -928,7 +970,9 @@ async def get_geo_cache(geohash7: str) -> Optional[Dict[str, Any]]:
928
  def _read():
929
  doc = geo_cache_collection.document(geohash7).get()
930
  return doc.to_dict() if doc.exists else None
931
- return await _asyncio.to_thread(_read)
 
 
932
  except Exception as e:
933
  logger.warning(f"讀取 geo_cache 失敗: {e}")
934
  return None
@@ -959,7 +1003,9 @@ async def get_route_cache(key: str) -> Optional[Dict[str, Any]]:
959
  def _read():
960
  doc = route_cache_collection.document(key).get()
961
  return doc.to_dict() if doc.exists else None
962
- return await _asyncio.to_thread(_read)
 
 
963
  except Exception as e:
964
  logger.warning(f"讀取 route_cache 失敗: {e}")
965
  return None
@@ -1024,7 +1070,9 @@ async def get_user_memories(
1024
  await _asyncio.to_thread(_mark_accessed, [m["memory_id"] for m in memories])
1025
 
1026
  logger.info(f"獲取到用戶 {user_id} 的 {len(memories)} 條記憶")
1027
- return {"success": True, "memories": memories}
 
 
1028
 
1029
  except Exception as e:
1030
  logger.error(f"獲取記憶時發生錯誤: {e}")
 
39
  MAX_MEMORIES_PER_USER = 500
40
 
41
 
42
+ def _serialize_firestore_data(data: Any) -> Any:
43
+ """
44
+ 遞迴轉換 Firestore 資料中的 DatetimeWithNanoseconds 物件為 ISO 字串
45
+
46
+ Args:
47
+ data: Firestore 回傳的資料(可能包含 DatetimeWithNanoseconds)
48
+
49
+ Returns:
50
+ JSON 可序列化的資料
51
+ """
52
+ from google.cloud.firestore_v1._helpers import DatetimeWithNanoseconds
53
+
54
+ if isinstance(data, DatetimeWithNanoseconds):
55
+ # 轉成 ISO 8601 字串
56
+ return data.isoformat()
57
+ elif isinstance(data, datetime):
58
+ # 一般 Python datetime 也轉成字串
59
+ return data.isoformat()
60
+ elif isinstance(data, dict):
61
+ # 遞迴處理字典
62
+ return {k: _serialize_firestore_data(v) for k, v in data.items()}
63
+ elif isinstance(data, list):
64
+ # 遞迴處理列表
65
+ return [_serialize_firestore_data(item) for item in data]
66
+ else:
67
+ # 其他型別直接回傳
68
+ return data
69
+
70
+
71
  def _get_user_doc_ref(user_id: str) -> DocumentReference:
72
  if users_collection is None:
73
  raise RuntimeError("Firestore尚未連接,無法操作使用者資料")
 
172
 
173
  user_doc = docs[0]
174
  user_data = user_doc.to_dict()
175
+
176
+ result = {
177
  "id": user_data["user_id"],
178
  "name": user_data.get("name", ""),
179
  "email": user_data.get("email", ""),
180
  "created_at": user_data.get("created_at"),
181
  }
182
+ # 序列化 Firestore 時間物件,避免 JSON 序列化炸裂
183
+ return _serialize_firestore_data(result)
184
  except Exception as e:
185
  logger.error(f"查找使用者時發生錯誤: {e}")
186
  return None
 
223
 
224
  messages = await _asyncio.to_thread(_fetch_messages)
225
  logger.info(f"已獲取用戶 {user_id} 的 {len(messages)} 條歷史記錄")
226
+ # 序列化 Firestore 時間物件,避免 JSON 序列化炸裂
227
+ return _serialize_firestore_data(messages)
228
  except Exception as e:
229
  logger.error(f"獲取歷史記錄時發生錯誤: {e}")
230
  return []
 
403
  "created_at": chat["created_at"],
404
  "updated_at": chat["updated_at"],
405
  }
406
+ # 序列化時間物件,避免 JSON 序列化炸裂
407
+ serialized_chat_info = _serialize_firestore_data(chat_info)
408
+ return {"success": True, "chat": serialized_chat_info}
409
  except Exception as e:
410
  logger.error(f"創建對話時發生錯誤: {e}")
411
  return {"success": False, "error": str(e)}
 
435
 
436
  chats = await _asyncio.to_thread(_fetch_chats)
437
  logger.info(f"獲取到用戶 {user_id} 的 {len(chats)} 個對話")
438
+ # 序列化 Firestore 時間物件,避免 JSON 序列化炸裂
439
+ serialized_chats = _serialize_firestore_data(chats)
440
+ return {"success": True, "chats": serialized_chats}
441
  except Exception as e:
442
  logger.error(f"獲取用戶對話時發生錯誤: {e}")
443
  return {"success": False, "error": str(e)}
 
560
 
561
  messages = await _asyncio.to_thread(_query)
562
  if messages:
563
+ # 序列化 Firestore 時間物件,避免 JSON 序列化炸裂
564
+ return _serialize_firestore_data(messages)
565
 
566
  # 向後相容:若子集合無資料,嘗試讀取舊頂層 messages 集合
567
  if messages_collection is None:
 
594
  logger.warning(f"回填 legacy messages 失敗(可忽略): {backfill_err}")
595
 
596
  await _asyncio.to_thread(_backfill)
597
+ # 序列化 Firestore 時間物件,避免 JSON 序列化炸裂
598
+ return _serialize_firestore_data(view_messages)
599
  except Exception as e:
600
  logger.error(f"讀取對話消息失敗: {e}")
601
  return []
 
768
  return None
769
 
770
  data = doc.to_dict()
771
+ result = {
772
  "id": data.get("user_id"),
773
  "name": data.get("name", ""),
774
  "email": data.get("email", ""),
775
  "created_at": data.get("created_at"),
776
  }
777
+ # 序列化 Firestore 時間物件,避免 JSON 序列化炸裂
778
+ return _serialize_firestore_data(result)
779
  except Exception as e:
780
  logger.error(f"查詢語音標籤對應用戶時發生錯誤: {e}")
781
  return None
 
952
  data = await _asyncio.to_thread(_read)
953
  if not data:
954
  return {"success": False, "error": "NOT_FOUND"}
955
+ # 序列化 Firestore 時間物件,避免 JSON 序列化炸裂
956
+ serialized_data = _serialize_firestore_data(data)
957
+ return {"success": True, "context": serialized_data}
958
  except Exception as e:
959
  logger.error(f"讀取環境現況失敗: {e}")
960
  return {"success": False, "error": str(e)}
 
970
  def _read():
971
  doc = geo_cache_collection.document(geohash7).get()
972
  return doc.to_dict() if doc.exists else None
973
+ result = await _asyncio.to_thread(_read)
974
+ # 序列化 Firestore 時間物件,避免 JSON 序列化炸裂
975
+ return _serialize_firestore_data(result) if result else None
976
  except Exception as e:
977
  logger.warning(f"讀取 geo_cache 失敗: {e}")
978
  return None
 
1003
  def _read():
1004
  doc = route_cache_collection.document(key).get()
1005
  return doc.to_dict() if doc.exists else None
1006
+ result = await _asyncio.to_thread(_read)
1007
+ # 序列化 Firestore 時間物件,避免 JSON 序列化炸裂
1008
+ return _serialize_firestore_data(result) if result else None
1009
  except Exception as e:
1010
  logger.warning(f"讀取 route_cache 失敗: {e}")
1011
  return None
 
1070
  await _asyncio.to_thread(_mark_accessed, [m["memory_id"] for m in memories])
1071
 
1072
  logger.info(f"獲取到用戶 {user_id} 的 {len(memories)} 條記憶")
1073
+ # 序列化 Firestore 時間物件,避免 JSON 序列化炸裂
1074
+ serialized_memories = _serialize_firestore_data(memories)
1075
+ return {"success": True, "memories": serialized_memories}
1076
 
1077
  except Exception as e:
1078
  logger.error(f"獲取記憶時發生錯誤: {e}")
core/emotion_care_manager.py CHANGED
@@ -90,13 +90,15 @@ class EmotionCareManager:
90
  return True
91
 
92
  @classmethod
93
- def check_release(cls, user_id: str, message: str, chat_id: Optional[str] = None) -> bool:
94
  """
95
- 檢查用戶訊息是否包含解除關鍵字
96
 
97
  參數:
98
  user_id: 用戶 ID
99
  message: 用戶訊息
 
 
100
 
101
  返回:
102
  bool: 是否解除關懷模式(True=解除,False=繼續關懷)
@@ -105,18 +107,29 @@ class EmotionCareManager:
105
  if not state or not state.get("in_care_mode", False):
106
  return False
107
 
 
 
 
 
 
 
 
 
 
 
 
108
  # 檢查是否包含解除關鍵字
109
  message_lower = message.lower().strip()
110
  for keyword in cls.RELEASE_KEYWORDS:
111
  if keyword in message_lower:
112
  # 解除關懷模式
113
- emotion = state.get("emotion", "unknown")
114
  duration = time.time() - state.get("start_time", 0)
115
 
116
  state["in_care_mode"] = False
117
  state["last_exit_time"] = time.time()
118
 
119
- logger.info(f"✅ 用戶 {user_id}(chat={chat_id or 'default'})情緒恢復({emotion} → 正常),解除關懷模式(持續 {duration:.1f}秒)")
120
  return True
121
 
122
  return False
 
90
  return True
91
 
92
  @classmethod
93
+ def check_release(cls, user_id: str, message: str, chat_id: Optional[str] = None, emotion: Optional[str] = None) -> bool:
94
  """
95
+ 檢查用戶訊息是否包含解除關鍵字或情緒恢復為 neutral
96
 
97
  參數:
98
  user_id: 用戶 ID
99
  message: 用戶訊息
100
+ chat_id: 對話 ID(可選)
101
+ emotion: 當前偵測到的情緒(可選)
102
 
103
  返回:
104
  bool: 是否解除關懷模式(True=解除,False=繼續關懷)
 
107
  if not state or not state.get("in_care_mode", False):
108
  return False
109
 
110
+ # 優先檢查情緒:如果偵測到 neutral,立即解除關懷模式
111
+ if emotion and emotion.lower() == "neutral":
112
+ original_emotion = state.get("emotion", "unknown")
113
+ duration = time.time() - state.get("start_time", 0)
114
+
115
+ state["in_care_mode"] = False
116
+ state["last_exit_time"] = time.time()
117
+
118
+ logger.info(f"✅ 用戶 {user_id}(chat={chat_id or 'default'})情緒恢復為 neutral({original_emotion} → neutral),解除關懷模式(持續 {duration:.1f}秒)")
119
+ return True
120
+
121
  # 檢查是否包含解除關鍵字
122
  message_lower = message.lower().strip()
123
  for keyword in cls.RELEASE_KEYWORDS:
124
  if keyword in message_lower:
125
  # 解除關懷模式
126
+ original_emotion = state.get("emotion", "unknown")
127
  duration = time.time() - state.get("start_time", 0)
128
 
129
  state["in_care_mode"] = False
130
  state["last_exit_time"] = time.time()
131
 
132
+ logger.info(f"✅ 用戶 {user_id}(chat={chat_id or 'default'})情緒恢復({original_emotion} → 正常),解除關懷模式(持續 {duration:.1f}秒)")
133
  return True
134
 
135
  return False
core/exceptions.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 統一異常處理
3
+ 定義自訂異常類別和錯誤響應格式
4
+ """
5
+
6
+ from typing import Optional, Dict, Any
7
+ from fastapi import HTTPException
8
+ from fastapi.responses import JSONResponse
9
+
10
+
11
+ class BloomWareException(Exception):
12
+ """Bloom Ware 基礎異常類別"""
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ code: str = "UNKNOWN_ERROR",
18
+ status_code: int = 500,
19
+ details: Optional[Dict[str, Any]] = None,
20
+ ):
21
+ self.message = message
22
+ self.code = code
23
+ self.status_code = status_code
24
+ self.details = details or {}
25
+ super().__init__(message)
26
+
27
+ def to_dict(self) -> Dict[str, Any]:
28
+ """轉換為字典格式"""
29
+ return {
30
+ "success": False,
31
+ "error": {
32
+ "code": self.code,
33
+ "message": self.message,
34
+ "details": self.details,
35
+ }
36
+ }
37
+
38
+ def to_response(self) -> JSONResponse:
39
+ """轉換為 JSON 響應"""
40
+ return JSONResponse(
41
+ status_code=self.status_code,
42
+ content=self.to_dict(),
43
+ )
44
+
45
+
46
+ # ==================== 認證相關異常 ====================
47
+
48
+ class AuthenticationError(BloomWareException):
49
+ """認證錯誤"""
50
+
51
+ def __init__(self, message: str = "認證失敗", details: Optional[Dict[str, Any]] = None):
52
+ super().__init__(
53
+ message=message,
54
+ code="AUTHENTICATION_ERROR",
55
+ status_code=401,
56
+ details=details,
57
+ )
58
+
59
+
60
+ class TokenExpiredError(AuthenticationError):
61
+ """Token 過期"""
62
+
63
+ def __init__(self):
64
+ super().__init__(
65
+ message="Token 已過期,請重新登入",
66
+ details={"reason": "token_expired"},
67
+ )
68
+
69
+
70
+ class InvalidTokenError(AuthenticationError):
71
+ """無效的 Token"""
72
+
73
+ def __init__(self):
74
+ super().__init__(
75
+ message="無效的認證令牌",
76
+ details={"reason": "invalid_token"},
77
+ )
78
+
79
+
80
+ class PermissionDeniedError(BloomWareException):
81
+ """權限不足"""
82
+
83
+ def __init__(self, message: str = "權限不足"):
84
+ super().__init__(
85
+ message=message,
86
+ code="PERMISSION_DENIED",
87
+ status_code=403,
88
+ )
89
+
90
+
91
+ # ==================== 資源相關異常 ====================
92
+
93
+ class ResourceNotFoundError(BloomWareException):
94
+ """資源不存在"""
95
+
96
+ def __init__(self, resource_type: str, resource_id: str):
97
+ super().__init__(
98
+ message=f"{resource_type} 不存在",
99
+ code="RESOURCE_NOT_FOUND",
100
+ status_code=404,
101
+ details={
102
+ "resource_type": resource_type,
103
+ "resource_id": resource_id,
104
+ },
105
+ )
106
+
107
+
108
+ class ChatNotFoundError(ResourceNotFoundError):
109
+ """對話不存在"""
110
+
111
+ def __init__(self, chat_id: str):
112
+ super().__init__("對話", chat_id)
113
+
114
+
115
+ class UserNotFoundError(ResourceNotFoundError):
116
+ """用戶不存在"""
117
+
118
+ def __init__(self, user_id: str):
119
+ super().__init__("用戶", user_id)
120
+
121
+
122
+ # ==================== 驗證相關異常 ====================
123
+
124
+ class ValidationError(BloomWareException):
125
+ """驗證錯誤"""
126
+
127
+ def __init__(self, field: str, message: str):
128
+ super().__init__(
129
+ message=f"參數 '{field}' 驗證失敗: {message}",
130
+ code="VALIDATION_ERROR",
131
+ status_code=400,
132
+ details={"field": field},
133
+ )
134
+
135
+
136
+ class RateLimitExceededError(BloomWareException):
137
+ """請求頻率超限"""
138
+
139
+ def __init__(self, retry_after: int = 60):
140
+ super().__init__(
141
+ message="請求頻率超過限制,請稍後再試",
142
+ code="RATE_LIMIT_EXCEEDED",
143
+ status_code=429,
144
+ details={"retry_after": retry_after},
145
+ )
146
+
147
+
148
+ # ==================== 服務相關異常 ====================
149
+
150
+ class ServiceUnavailableError(BloomWareException):
151
+ """服務不可用"""
152
+
153
+ def __init__(self, service_name: str):
154
+ super().__init__(
155
+ message=f"{service_name} 服務暫時不可用",
156
+ code="SERVICE_UNAVAILABLE",
157
+ status_code=503,
158
+ details={"service": service_name},
159
+ )
160
+
161
+
162
+ class DatabaseError(BloomWareException):
163
+ """數據庫錯誤"""
164
+
165
+ def __init__(self, message: str = "數據庫操作失敗"):
166
+ super().__init__(
167
+ message=message,
168
+ code="DATABASE_ERROR",
169
+ status_code=500,
170
+ )
171
+
172
+
173
+ class AIServiceError(BloomWareException):
174
+ """AI 服務錯誤"""
175
+
176
+ def __init__(self, message: str = "AI 服務暫時不可用"):
177
+ super().__init__(
178
+ message=message,
179
+ code="AI_SERVICE_ERROR",
180
+ status_code=503,
181
+ )
182
+
183
+
184
+ class ExternalAPIError(BloomWareException):
185
+ """外部 API 錯誤"""
186
+
187
+ def __init__(self, api_name: str, message: str):
188
+ super().__init__(
189
+ message=f"{api_name} API 錯誤: {message}",
190
+ code="EXTERNAL_API_ERROR",
191
+ status_code=502,
192
+ details={"api": api_name},
193
+ )
194
+
195
+
196
+ # ==================== 語音相關異常 ====================
197
+
198
+ class VoiceAuthError(BloomWareException):
199
+ """語音認證錯誤"""
200
+
201
+ def __init__(self, message: str, reason: str):
202
+ super().__init__(
203
+ message=message,
204
+ code="VOICE_AUTH_ERROR",
205
+ status_code=400,
206
+ details={"reason": reason},
207
+ )
208
+
209
+
210
+ class SpeakerLabelTakenError(VoiceAuthError):
211
+ """語音標籤已被使用"""
212
+
213
+ def __init__(self):
214
+ super().__init__(
215
+ message="此語音標籤已被其他用戶綁定",
216
+ reason="speaker_label_taken",
217
+ )
218
+
219
+
220
+ # ==================== 異常處理器 ====================
221
+
222
+ def create_error_response(
223
+ code: str,
224
+ message: str,
225
+ status_code: int = 500,
226
+ details: Optional[Dict[str, Any]] = None,
227
+ ) -> JSONResponse:
228
+ """創建標準錯誤響應"""
229
+ return JSONResponse(
230
+ status_code=status_code,
231
+ content={
232
+ "success": False,
233
+ "error": {
234
+ "code": code,
235
+ "message": message,
236
+ "details": details or {},
237
+ }
238
+ }
239
+ )
240
+
241
+
242
+ def handle_exception(exc: Exception) -> JSONResponse:
243
+ """
244
+ 統一異常處理
245
+
246
+ 將各種異常轉換為標準 JSON 響應
247
+ """
248
+ if isinstance(exc, BloomWareException):
249
+ return exc.to_response()
250
+
251
+ if isinstance(exc, HTTPException):
252
+ return create_error_response(
253
+ code="HTTP_ERROR",
254
+ message=exc.detail,
255
+ status_code=exc.status_code,
256
+ )
257
+
258
+ # 未知異常
259
+ import logging
260
+ logger = logging.getLogger("core.exceptions")
261
+ logger.exception(f"未處理的異常: {exc}")
262
+
263
+ return create_error_response(
264
+ code="INTERNAL_ERROR",
265
+ message="內部伺服器錯誤",
266
+ status_code=500,
267
+ )
core/intent_detector.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 意圖檢測器
3
+ 2025 最佳實踐:使用 OpenAI 原生 Function Calling 進行意圖檢測
4
+
5
+ 核心改進:
6
+ 1. 不再使用巨大的 system_prompt 描述每個工具
7
+ 2. 直接使用 OpenAI tools 參數傳遞工具定義
8
+ 3. GPT 原生選擇工具並生成結構化參數
9
+ 4. 新增工具只需註冊到 Registry,不需更新任何 prompt
10
+ """
11
+
12
+ import json
13
+ import hashlib
14
+ import time
15
+ import logging
16
+ from typing import Dict, Any, Optional, Tuple, List
17
+
18
+ from core.tool_registry import tool_registry
19
+ from core.logging import get_logger
20
+
21
+ logger = get_logger("core.intent_detector")
22
+
23
+
24
+ class IntentDetector:
25
+ """
26
+ 意圖檢測器
27
+
28
+ 使用 OpenAI 原生 Function Calling 進行意圖檢測,
29
+ 不需要自定義 prompt 描述每個工具。
30
+ """
31
+
32
+ # 情緒列表
33
+ EMOTIONS = ["neutral", "happy", "sad", "angry", "fear", "surprise"]
34
+
35
+ # 快取 TTL(秒)
36
+ CACHE_TTL = 300.0
37
+
38
+ def __init__(self):
39
+ self._cache: Dict[str, Tuple[bool, Optional[Dict[str, Any]], float]] = {}
40
+
41
+ async def detect(
42
+ self,
43
+ message: str,
44
+ user_id: Optional[str] = None,
45
+ include_location_tools: bool = True,
46
+ ) -> Tuple[bool, Optional[Dict[str, Any]]]:
47
+ """
48
+ 檢測用戶消息中的意圖
49
+
50
+ Args:
51
+ message: 用戶消息
52
+ user_id: 用戶 ID(用於日誌)
53
+ include_location_tools: 是否包含需要位置的工具
54
+
55
+ Returns:
56
+ (是否檢測到工具調用, 意圖數據)
57
+ """
58
+ # 檢查快取
59
+ cache_key = hashlib.md5(message.encode()).hexdigest()
60
+ if cache_key in self._cache:
61
+ has_intent, intent_data, cached_time = self._cache[cache_key]
62
+ if time.time() - cached_time < self.CACHE_TTL:
63
+ logger.debug(f"💾 意圖快取命中: {message[:50]}...")
64
+ return has_intent, intent_data
65
+ else:
66
+ del self._cache[cache_key]
67
+
68
+ logger.info(f"🔍 檢測意圖: \"{message[:100]}...\"")
69
+
70
+ # 檢查特殊命令
71
+ special_result = self._check_special_commands(message)
72
+ if special_result:
73
+ return special_result
74
+
75
+ # 使用 OpenAI Function Calling 進行意圖檢測
76
+ try:
77
+ result = await self._detect_with_function_calling(
78
+ message,
79
+ include_location_tools=include_location_tools,
80
+ )
81
+
82
+ # 寫入快取
83
+ self._cache[cache_key] = (*result, time.time())
84
+
85
+ return result
86
+
87
+ except Exception as e:
88
+ logger.error(f"❌ 意圖檢測失敗: {e}")
89
+ # 降級:使用關鍵字匹配
90
+ return self._keyword_fallback(message)
91
+
92
+ def _check_special_commands(self, message: str) -> Optional[Tuple[bool, Dict[str, Any]]]:
93
+ """檢查特殊命令"""
94
+ for command in ["功能列表", "有什麼功能", "能做什麼"]:
95
+ if command in message:
96
+ logger.info(f"檢測到特殊命令: {command}")
97
+ return True, {
98
+ "type": "special_command",
99
+ "command": "feature_list"
100
+ }
101
+ return None
102
+
103
+ async def _detect_with_function_calling(
104
+ self,
105
+ message: str,
106
+ include_location_tools: bool = True,
107
+ ) -> Tuple[bool, Optional[Dict[str, Any]]]:
108
+ """
109
+ 使用 OpenAI Function Calling 進行意圖檢測
110
+
111
+ 核心邏輯:
112
+ 1. 將所有工具以 OpenAI tools 格式傳遞
113
+ 2. GPT 自動選擇最適合的工具
114
+ 3. 如果 GPT 不選擇任何工具,視為一般聊天
115
+ """
116
+ import services.ai_service as ai_service
117
+ from core.reasoning_strategy import get_optimal_reasoning_effort
118
+
119
+ # 取得所有工具定義(OpenAI 格式)
120
+ tools = tool_registry.get_openai_tools(
121
+ include_location_tools=include_location_tools,
122
+ strict=True,
123
+ )
124
+
125
+ if not tools:
126
+ logger.warning("⚠️ 沒有可用的工具")
127
+ return False, {"emotion": "neutral"}
128
+
129
+ # 建構精簡的 system prompt(只處理情緒和特殊規則)
130
+ system_prompt = self._build_system_prompt()
131
+
132
+ messages = [
133
+ {"role": "system", "content": system_prompt},
134
+ {"role": "user", "content": message}
135
+ ]
136
+
137
+ # 使用 OpenAI Function Calling
138
+ optimal_effort = get_optimal_reasoning_effort("intent_detection")
139
+ logger.info(f"🧠 意圖檢測推理強度: {optimal_effort}")
140
+
141
+ try:
142
+ response = await ai_service.generate_response_with_tools(
143
+ messages=messages,
144
+ tools=tools,
145
+ user_id="intent_detection",
146
+ model="gpt-5-nano",
147
+ reasoning_effort=optimal_effort,
148
+ )
149
+
150
+ return self._parse_function_calling_response(response)
151
+
152
+ except Exception as e:
153
+ logger.error(f"❌ Function Calling 失敗: {e}")
154
+ raise
155
+
156
+ def _build_system_prompt(self) -> str:
157
+ """
158
+ 建構精簡的 system prompt
159
+
160
+ 注意:不再描述每個工具,工具定義由 tools 參數傳遞
161
+ """
162
+ return """你是一個智能助手,根據用戶需求選擇合適的工具。
163
+
164
+ 規則:
165
+ 1. 如果用戶需求可以用工具解決,選擇最適合的工具
166
+ 2. 如果是一般聊天或問候,不要選擇任何工具
167
+ 3. 工具參數盡量從用戶消息中提取,無法確定的使用合理預設值
168
+
169
+ 特殊處理:
170
+ - 天氣查詢:城市名稱使用英文(台北→Taipei, 東京→Tokyo)
171
+ - 匯率查詢:貨幣使用 ISO 4217 代碼(美元→USD, 台幣→TWD)
172
+ - 公車查詢:route_name 必須是路線號碼(如 307、紅30),不是目的地名稱
173
+ - 火車查詢:「往XX」表示 destination_station,不是 origin_station
174
+ - 位置查詢:「我在哪」使用 reverse_geocode,不需要參數
175
+ - YouBike 查詢:任何提到 YouBike/Ubike/微笑單車 的請求使用 tdx_youbike
176
+
177
+ 情緒判斷:
178
+ 根據用戶消息的語氣判斷情緒,在回應中包含 emotion 欄位:
179
+ - neutral: 平靜、中性
180
+ - happy: 開心、興奮
181
+ - sad: 難過、沮喪
182
+ - angry: 生氣、煩躁
183
+ - fear: 恐懼、擔心
184
+ - surprise: 驚訝、意外"""
185
+
186
+ def _parse_function_calling_response(
187
+ self,
188
+ response: Dict[str, Any],
189
+ ) -> Tuple[bool, Optional[Dict[str, Any]]]:
190
+ """
191
+ 解析 Function Calling 回應
192
+
193
+ Args:
194
+ response: OpenAI API 回應
195
+
196
+ Returns:
197
+ (是否檢測到工具調用, 意圖數據)
198
+ """
199
+ # 檢查是否有 tool_calls
200
+ tool_calls = response.get("tool_calls", [])
201
+
202
+ if tool_calls:
203
+ # 取第一個工具調用
204
+ tool_call = tool_calls[0]
205
+ function = tool_call.get("function", {})
206
+ tool_name = function.get("name", "")
207
+ arguments_str = function.get("arguments", "{}")
208
+
209
+ try:
210
+ arguments = json.loads(arguments_str)
211
+ except json.JSONDecodeError:
212
+ arguments = {}
213
+
214
+ logger.info(f"✅ GPT 選擇工具: {tool_name}")
215
+ logger.debug(f"工具參數: {arguments}")
216
+
217
+ # 提取情緒(從 content 或預設)
218
+ emotion = self._extract_emotion_from_response(response)
219
+
220
+ return True, {
221
+ "type": "mcp_tool",
222
+ "tool_name": tool_name,
223
+ "arguments": arguments,
224
+ "emotion": emotion,
225
+ }
226
+
227
+ # 沒有工具調用,視為一般聊天
228
+ logger.info("💬 GPT 判斷為一般聊天")
229
+ emotion = self._extract_emotion_from_response(response)
230
+
231
+ return False, {"emotion": emotion}
232
+
233
+ def _extract_emotion_from_response(self, response: Dict[str, Any]) -> str:
234
+ """從回應中提取情緒"""
235
+ # 嘗試從 content 中提取
236
+ content = response.get("content", "")
237
+ if content:
238
+ for emotion in self.EMOTIONS:
239
+ if emotion in content.lower():
240
+ return emotion
241
+
242
+ return "neutral"
243
+
244
+ def _keyword_fallback(self, message: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
245
+ """關鍵字匹配降級方案"""
246
+ message_lower = message.lower()
247
+
248
+ # 取得所有工具摘要
249
+ summaries = tool_registry.get_summaries()
250
+
251
+ for summary in summaries:
252
+ keywords = summary.get("keywords", [])
253
+ for keyword in keywords:
254
+ if keyword.lower() in message_lower:
255
+ logger.info(f"🔑 關鍵字匹配: {keyword} → {summary['name']}")
256
+ return True, {
257
+ "type": "mcp_tool",
258
+ "tool_name": summary["name"],
259
+ "arguments": {},
260
+ "emotion": "neutral",
261
+ }
262
+
263
+ return False, {"emotion": "neutral"}
264
+
265
+ def clear_cache(self) -> None:
266
+ """清除快取"""
267
+ self._cache.clear()
268
+ logger.info("🗑️ 意圖快取已清除")
269
+
270
+
271
+ # 全域單例
272
+ intent_detector = IntentDetector()
core/logging.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 統一日誌配置
3
+ 集中管理所有模組的日誌設定
4
+
5
+ 使用方式:
6
+ from core.logging import get_logger
7
+ logger = get_logger(__name__)
8
+ """
9
+
10
+ import os
11
+ import logging
12
+ from typing import Optional
13
+
14
+ # 全域日誌等級(只讀取一次)
15
+ _LOG_LEVEL_NAME = os.getenv("BLOOMWARE_LOG_LEVEL", "WARNING").upper()
16
+ _LOG_LEVEL = getattr(logging, _LOG_LEVEL_NAME, logging.WARNING)
17
+
18
+ # 日誌格式
19
+ _LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
20
+
21
+
22
+ def get_log_level() -> int:
23
+ """獲取日誌等級"""
24
+ return _LOG_LEVEL
25
+
26
+
27
+ def setup_logging(
28
+ name: Optional[str] = None,
29
+ level: Optional[int] = None,
30
+ ) -> logging.Logger:
31
+ """
32
+ 設置日誌配置
33
+
34
+ Args:
35
+ name: 日誌名稱(None 表示 root logger)
36
+ level: 日誌等級(None 表示使用環境變數)
37
+
38
+ Returns:
39
+ 配置好的 Logger 實例
40
+ """
41
+ if level is None:
42
+ level = _LOG_LEVEL
43
+
44
+ # 配置格式
45
+ formatter = logging.Formatter(_LOG_FORMAT)
46
+
47
+ # 獲取或創建 logger
48
+ logger = logging.getLogger(name)
49
+ logger.setLevel(level)
50
+
51
+ # 避免重複添加 handler
52
+ if not logger.handlers:
53
+ # 控制台 handler
54
+ console_handler = logging.StreamHandler()
55
+ console_handler.setLevel(level)
56
+ console_handler.setFormatter(formatter)
57
+ logger.addHandler(console_handler)
58
+
59
+ # 防止日誌重複輸出
60
+ logger.propagate = False
61
+
62
+ return logger
63
+
64
+
65
+ def get_logger(name: str) -> logging.Logger:
66
+ """
67
+ 獲取已配置的 Logger(推薦使用)
68
+
69
+ Args:
70
+ name: 日誌名稱,建議使用 __name__
71
+
72
+ Returns:
73
+ Logger 實例
74
+
75
+ Example:
76
+ from core.logging import get_logger
77
+ logger = get_logger(__name__)
78
+ logger.info("Hello")
79
+ """
80
+ return setup_logging(name)
81
+
82
+
83
+ def get_level_name() -> str:
84
+ """獲取當前日誌等級名稱"""
85
+ return _LOG_LEVEL_NAME
86
+
87
+
88
+ # 預設配置 root logger
89
+ _root_configured = False
90
+
91
+ def configure_root_logger():
92
+ """配置 root logger(只執行一次)"""
93
+ global _root_configured
94
+ if not _root_configured:
95
+ level = get_log_level()
96
+ logging.basicConfig(
97
+ level=level,
98
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
99
+ handlers=[logging.StreamHandler()]
100
+ )
101
+ _root_configured = True
102
+
103
+
104
+ # 自動配置
105
+ configure_root_logger()
core/memory_system.py CHANGED
@@ -1,31 +1,19 @@
1
- import os
2
- import logging
3
  from typing import List, Dict, Any, Optional, Tuple
4
  from datetime import datetime
5
- import json
6
 
7
- # 設置日誌
8
- LOG_LEVEL_NAME = os.getenv("BLOOMWARE_LOG_LEVEL", "WARNING").upper()
9
- LOG_LEVEL = getattr(logging, LOG_LEVEL_NAME, logging.WARNING)
10
- logging.basicConfig(level=LOG_LEVEL, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
11
- logger = logging.getLogger("MemorySystem")
12
- logger.setLevel(LOG_LEVEL)
 
 
 
 
13
 
14
- # 載入環境變數
15
- from dotenv import load_dotenv
16
- load_dotenv()
17
 
18
- # 嘗試導入 OpenAI
19
- try:
20
- from openai import OpenAI
21
- memory_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
22
- logger.info("記憶系統成功導入 OpenAI SDK")
23
- except ImportError:
24
- logger.error("Failed to import OpenAI for memory system")
25
- memory_client = None
26
- except Exception as e:
27
- logger.error(f"初始化記憶系統 OpenAI 客戶端時出錯: {e}")
28
- memory_client = None
29
 
30
  # 導入數據庫函數
31
  try:
@@ -139,7 +127,8 @@ class MemoryAnalyzer:
139
  async def analyze_conversation(self, user_message: str, assistant_response: str = "",
140
  conversation_history: List[Dict] = None) -> List[Dict[str, Any]]:
141
  """使用AI分析對話內容,提取重要記憶"""
142
- if not memory_client:
 
143
  logger.warning("OpenAI客戶端不可用,跳過AI記憶分析")
144
  return []
145
 
@@ -196,11 +185,11 @@ class MemoryAnalyzer:
196
  else:
197
  max_tokens_value = 2000
198
 
199
- response = memory_client.chat.completions.create(
200
  model="gpt-5-nano",
201
  messages=messages,
202
- max_completion_tokens=max_tokens_value, # 修正:使用 max_completion_tokens 而非 max_tokens
203
- reasoning_effort="low" # 記憶分析需要理解,但不需深度推理
204
  )
205
  break # 成功後跳出重試循環
206
 
@@ -279,7 +268,7 @@ class MemoryManager:
279
 
280
  # 2. 使用AI分析提取記憶(如果可用)
281
  ai_memories = []
282
- if memory_client:
283
  ai_memories = await self.analyzer.analyze_conversation(
284
  user_message, assistant_response, conversation_history
285
  )
@@ -439,3 +428,6 @@ class MemoryManager:
439
 
440
  # 全局記憶管理器實例
441
  memory_manager = MemoryManager()
 
 
 
 
1
+ import json
 
2
  from typing import List, Dict, Any, Optional, Tuple
3
  from datetime import datetime
 
4
 
5
+ # 統一日誌配置
6
+ from core.logging import get_logger
7
+ logger = get_logger("MemorySystem")
8
+
9
+ # 統一 OpenAI 客戶端
10
+ from core.ai_client import get_openai_client
11
+
12
+ def _get_memory_client():
13
+ """取得記憶系統用的 OpenAI 客戶端"""
14
+ return get_openai_client()
15
 
 
 
 
16
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
  # 導入數據庫函數
19
  try:
 
127
  async def analyze_conversation(self, user_message: str, assistant_response: str = "",
128
  conversation_history: List[Dict] = None) -> List[Dict[str, Any]]:
129
  """使用AI分析對話內容,提取重要記憶"""
130
+ client = _get_memory_client()
131
+ if not client:
132
  logger.warning("OpenAI客戶端不可用,跳過AI記憶分析")
133
  return []
134
 
 
185
  else:
186
  max_tokens_value = 2000
187
 
188
+ response = client.chat.completions.create(
189
  model="gpt-5-nano",
190
  messages=messages,
191
+ max_completion_tokens=max_tokens_value,
192
+ reasoning_effort="low"
193
  )
194
  break # 成功後跳出重試循環
195
 
 
268
 
269
  # 2. 使用AI分析提取記憶(如果可用)
270
  ai_memories = []
271
+ if _get_memory_client():
272
  ai_memories = await self.analyzer.analyze_conversation(
273
  user_message, assistant_response, conversation_history
274
  )
 
428
 
429
  # 全局記憶管理器實例
430
  memory_manager = MemoryManager()
431
+
432
+ # 向後兼容別名
433
+ memory_system = memory_manager
core/pipeline.py CHANGED
@@ -76,10 +76,23 @@ class ChatPipeline:
76
  if not user_message or not user_message.strip():
77
  return PipelineResult(text="我沒有收到您的消息,請重新輸入。", is_fallback=True, reason="empty")
78
 
79
- # 0) 檢查是否在關懷模式(新增
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  if user_id and EmotionCareManager.is_in_care_mode(user_id, chat_id):
81
- # 檢查是否解除關懷模式
82
- if EmotionCareManager.check_release(user_id, user_message, chat_id):
83
  logger.info(f"✅ 用戶 {user_id} 情緒恢復,解除關懷模式,繼續正常流程")
84
  # 解除後繼續正常流程
85
  else:
@@ -107,22 +120,9 @@ class ChatPipeline:
107
  return PipelineResult(text="我在這裡陪你,隨時可以聊聊。", is_fallback=True, reason="ai-care-empty")
108
  return PipelineResult(text=text, is_fallback=False, meta={"care_mode": True, "emotion": care_emotion})
109
 
110
- # 1) 意圖偵測(限時)
111
- detect_res = await self._with_timeout(
112
- self._intent_detector(user_message), self._detect_timeout, reason="detect"
113
- )
114
- if isinstance(detect_res, PipelineResult):
115
- return detect_res
116
- has_feature, intent_data = detect_res
117
-
118
- # 提取情緒(新增)
119
- emotion = intent_data.get("emotion", "neutral") if intent_data else "neutral"
120
- emotion_value = emotion or "neutral"
121
- logger.info(f"😊 用戶情緒: {emotion}")
122
-
123
- # 檢查是否需要進入關懷模式(新增)
124
- if user_id and EmotionCareManager.check_and_enter_care_mode(user_id, emotion, chat_id):
125
- logger.warning(f"⚠️ 偵測到極端情緒 [{emotion}],進入關懷模式")
126
  # 立即使用關懷模式 AI 回應
127
  ai_res = await self._with_timeout(
128
  self._ai_generator(
@@ -132,8 +132,8 @@ class ChatPipeline:
132
  request_id,
133
  chat_id,
134
  use_care_mode=True,
135
- care_emotion=emotion,
136
- emotion_label=emotion,
137
  ),
138
  self._ai_timeout,
139
  reason="ai-care",
@@ -146,9 +146,9 @@ class ChatPipeline:
146
 
147
  # 第一次進入關懷模式時,附加退出提示(新增)
148
  exit_hint = "\n\n💙 關懷模式已啟動。說「我沒事了」可以退出。"
149
- return PipelineResult(text=text + exit_hint, is_fallback=False, meta={"care_mode": True, "emotion": emotion})
150
 
151
- # 2) 有功能 → 功能處理(限時)
152
  if has_feature and intent_data:
153
  feat_res = await self._with_timeout(
154
  self._feature_processor(intent_data, user_id, user_message, chat_id),
@@ -193,7 +193,7 @@ class ChatPipeline:
193
  meta={"emotion": emotion_value},
194
  )
195
 
196
- # 3) 無功能 → 一般聊天(限時)
197
  # 注意:不傳 messages,改傳 user_message,讓 ai_generator 自動載入歷史對話和記憶
198
  ai_res = await self._with_timeout(
199
  self._ai_generator(
 
76
  if not user_message or not user_message.strip():
77
  return PipelineResult(text="我沒有收到您的消息,請重新輸入。", is_fallback=True, reason="empty")
78
 
79
+ # 0) 先進行意圖偵測以提取情緒(需要在關懷模式檢查前執行
80
+ detect_res = await self._with_timeout(
81
+ self._intent_detector(user_message), self._detect_timeout, reason="detect"
82
+ )
83
+ if isinstance(detect_res, PipelineResult):
84
+ return detect_res
85
+ has_feature, intent_data = detect_res
86
+
87
+ # 提取情緒
88
+ emotion = intent_data.get("emotion", "neutral") if intent_data else "neutral"
89
+ emotion_value = emotion or "neutral"
90
+ logger.info(f"😊 用戶情緒: {emotion}")
91
+
92
+ # 1) 檢查是否在關懷模式
93
  if user_id and EmotionCareManager.is_in_care_mode(user_id, chat_id):
94
+ # 檢查是否解除關懷模式(傳入情緒資訊)
95
+ if EmotionCareManager.check_release(user_id, user_message, chat_id, emotion=emotion_value):
96
  logger.info(f"✅ 用戶 {user_id} 情緒恢復,解除關懷模式,繼續正常流程")
97
  # 解除後繼續正常流程
98
  else:
 
120
  return PipelineResult(text="我在這裡陪你,隨時可以聊聊。", is_fallback=True, reason="ai-care-empty")
121
  return PipelineResult(text=text, is_fallback=False, meta={"care_mode": True, "emotion": care_emotion})
122
 
123
+ # 2) 檢查是否需要進入關懷模式
124
+ if user_id and EmotionCareManager.check_and_enter_care_mode(user_id, emotion_value, chat_id):
125
+ logger.warning(f"⚠️ 偵測到極端情緒 [{emotion_value}],進入關懷模式")
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  # 立即使用關懷模式 AI 回應
127
  ai_res = await self._with_timeout(
128
  self._ai_generator(
 
132
  request_id,
133
  chat_id,
134
  use_care_mode=True,
135
+ care_emotion=emotion_value,
136
+ emotion_label=emotion_value,
137
  ),
138
  self._ai_timeout,
139
  reason="ai-care",
 
146
 
147
  # 第一次進入關懷模式時,附加退出提示(新增)
148
  exit_hint = "\n\n💙 關懷模式已啟動。說「我沒事了」可以退出。"
149
+ return PipelineResult(text=text + exit_hint, is_fallback=False, meta={"care_mode": True, "emotion": emotion_value})
150
 
151
+ # 3) 有功能 → 功能處理(限時)
152
  if has_feature and intent_data:
153
  feat_res = await self._with_timeout(
154
  self._feature_processor(intent_data, user_id, user_message, chat_id),
 
193
  meta={"emotion": emotion_value},
194
  )
195
 
196
+ # 4) 無功能 → 一般聊天(限時)
197
  # 注意:不傳 messages,改傳 user_message,讓 ai_generator 自動載入歷史對話和記憶
198
  ai_res = await self._with_timeout(
199
  self._ai_generator(
core/prompts/__init__.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Prompt 模板管理
3
+ 統一管理 AI 系統提示詞,支援動態組合
4
+ """
5
+
6
+ from .intent_detection import get_intent_prompt, TOOL_RULES
7
+ from .care_mode import CARE_MODE_PROMPT
8
+
9
+ __all__ = [
10
+ "get_intent_prompt",
11
+ "TOOL_RULES",
12
+ "CARE_MODE_PROMPT",
13
+ ]
core/prompts/care_mode.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 情緒關懷模式 Prompt
3
+ 精簡化設計,保持關懷品質
4
+ """
5
+
6
+ CARE_MODE_PROMPT = """你是 BloomWare 的情緒關懷助手「小花」。你的任務是傾聽、陪伴。
7
+
8
+ 【回應原則】
9
+ 1. 第一句貼近用戶的核心感受,讓對方感受到被理解
10
+ 2. 第二句溫柔陪伴或追問,邀請分享
11
+ 3. 句式自然口語,避免罐頭話術
12
+
13
+ 【限制】
14
+ - 最多 2 句話、60 字以內
15
+ - 禁止:指示性建議、醫療診斷、教科書式說法
16
+ - 禁止:重複相同句型
17
+
18
+ 【範例】
19
+ 用戶:「我好難過」
20
+ 你:「聽見你說好難過,心裡一定很不好受。想聊聊發生了什麼嗎?」
21
+
22
+ 用戶:「我很生氣」
23
+ 你:「這件事讓你超級生氣,情緒一定卡著。要不要說說最困擾的地方?」"""
24
+
25
+
26
+ def get_care_prompt(emotion: str = None, user_name: str = None) -> str:
27
+ """
28
+ 生成關懷模式 Prompt
29
+
30
+ Args:
31
+ emotion: 用戶情緒標籤
32
+ user_name: 用戶名稱
33
+
34
+ Returns:
35
+ 關懷模式 System Prompt
36
+ """
37
+ prompt = CARE_MODE_PROMPT
38
+
39
+ if emotion:
40
+ prompt = f"用戶情緒:{emotion}\n\n{prompt}"
41
+
42
+ if user_name:
43
+ prompt = f"用戶名稱:{user_name}\n\n{prompt}"
44
+
45
+ return prompt
core/prompts/intent_detection.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 意圖檢測 Prompt 模板
3
+ 精簡化設計,減少 token 消耗約 40%
4
+ """
5
+
6
+ # 工具特定規則(按需載入)
7
+ TOOL_RULES = {
8
+ "weather": """天氣查詢:城市必須用英文(台北→Taipei, 高雄→Kaohsiung),預設 Taipei""",
9
+
10
+ "exchange": """匯率查詢:貨幣必須用 ISO 4217 代碼(美元→USD, 台幣→TWD),預設 USD→TWD""",
11
+
12
+ "news": """新聞查詢:「新聞」「消息」「報導」→ news_query,country/language 用英文代碼(tw, zh)""",
13
+
14
+ "bus": """公車查詢:「公車」「巴士」→ tdx_bus_arrival
15
+ - route_name 必須是公車路線號碼(如 137、307、紅30)
16
+ - 「往 X 的公車」「去 X 的公車」不是路線查詢,應使用 directions 或 forward_geocode
17
+ - 「附近公車」「公車站」不需 route_name,系統用 GPS 查詢
18
+ 例:「137公車」→ tdx_bus_arrival:route_name=137
19
+ 例:「往台北的公車」→ forward_geocode:query=台北(這是導航需求)""",
20
+
21
+ "train": """台鐵查詢:「火車」「台鐵」→ tdx_train
22
+ 「往XX」「到XX」是目的地(destination_station),不是起點
23
+ 沒說起點就不填 origin_station,讓 GPS 決定
24
+ 例:「往台北的火車」→ tdx_train:destination_station=台北""",
25
+
26
+ "youbike": """YouBike 查詢:「YouBike」「Ubike」「微笑單車」「共享單車」→ tdx_youbike
27
+ 城市參數必須用英文(台北→Taipei, 桃園→Taoyuan),站名可用中文
28
+ 不是 tdx_parking!這是單車不是停車場""",
29
+
30
+ "location": """位置查詢:
31
+ 「我在哪」「這是哪裡」→ reverse_geocode(不需參數)
32
+ 「怎麼去 X」→ forward_geocode:query=X""",
33
+ }
34
+
35
+ # 情緒標籤說明
36
+ EMOTION_RULES = """情緒判斷:neutral/happy/sad/angry/fear/surprise
37
+ - happy: 開心、興奮(「好開心!」「太棒了」)
38
+ - sad: 難過、沮喪(「好難過」「心情不好」)
39
+ - angry: 生氣、煩躁(「煩死了」「氣死我了」)
40
+ - fear: 恐懼、擔心(「好害怕」「怎麼辦」)
41
+ - surprise: 驚訝(「什麼!」「真的假的」)
42
+ - neutral: 其他"""
43
+
44
+
45
+ def get_intent_prompt(tools_description: str, include_rules: list = None) -> str:
46
+ """
47
+ 生成意圖檢測 Prompt
48
+
49
+ Args:
50
+ tools_description: 可用工具描述
51
+ include_rules: 要包含的工具規則列表,None 表示全部
52
+
53
+ Returns:
54
+ 精簡化的 System Prompt
55
+ """
56
+ # 基礎 Prompt
57
+ base = f"""你是意圖解析助手。分析用戶消息,決定是否調用工具。
58
+
59
+ 可用工具:
60
+ {tools_description}
61
+
62
+ """
63
+
64
+ # 添加工具規則
65
+ if include_rules is None:
66
+ include_rules = list(TOOL_RULES.keys())
67
+
68
+ rules_text = "\n".join(
69
+ f"- {TOOL_RULES[rule]}"
70
+ for rule in include_rules
71
+ if rule in TOOL_RULES
72
+ )
73
+
74
+ if rules_text:
75
+ base += f"""工具規則:
76
+ {rules_text}
77
+
78
+ """
79
+
80
+ # 添加情緒規則
81
+ base += f"""{EMOTION_RULES}
82
+
83
+ 回應格式:
84
+ - is_tool_call: true/false
85
+ - tool_name: 工具名稱:參數(is_tool_call=true 時)
86
+ - emotion: 情緒標籤
87
+
88
+ 示例:
89
+ - "台北天氣" → {{"is_tool_call": true, "tool_name": "weather_query:city=Taipei", "emotion": "neutral"}}
90
+ - "你好" → {{"is_tool_call": false, "tool_name": "", "emotion": "neutral"}}
91
+ - "我好難過" → {{"is_tool_call": false, "tool_name": "", "emotion": "sad"}}"""
92
+
93
+ return base
core/reasoning_strategy.py CHANGED
@@ -43,10 +43,10 @@ class ReasoningStrategy:
43
  reasoning_effort: minimal/low/medium/high
44
  """
45
 
46
- # 🔥 規則 1:意圖檢測必須極速minimal
47
  if task_type == "intent_detection":
48
- logger.debug("🧠 意圖檢測 → minimal reasoning(模式)")
49
- return "minimal"
50
 
51
  # 🔥 規則 2:關懷模式優先速度(用戶情緒不佳時不要讓他等)
52
  if user_emotion in ["sad", "angry", "fear"]:
 
43
  reasoning_effort: minimal/low/medium/high
44
  """
45
 
46
+ # 🔥 規則 1:意圖檢測使用 low reasoning平衡速度與準確度
47
  if task_type == "intent_detection":
48
+ logger.debug("🧠 意圖檢測 → low reasoning(但準確)")
49
+ return "low"
50
 
51
  # 🔥 規則 2:關懷模式優先速度(用戶情緒不佳時不要讓他等)
52
  if user_emotion in ["sad", "angry", "fear"]:
core/retry.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ API 重試機制
3
+ 自動重試失敗的 API 調用,支援指數退避
4
+ """
5
+
6
+ import asyncio
7
+ import functools
8
+ import random
9
+ from typing import Callable, TypeVar, Any, Optional, Tuple, Type
10
+
11
+ from core.logging import get_logger
12
+
13
+ logger = get_logger("core.retry")
14
+
15
+ T = TypeVar("T")
16
+
17
+ # 預設重試配置
18
+ DEFAULT_MAX_RETRIES = 3
19
+ DEFAULT_BASE_DELAY = 1.0 # 秒
20
+ DEFAULT_MAX_DELAY = 30.0 # 秒
21
+ DEFAULT_EXPONENTIAL_BASE = 2
22
+
23
+
24
+ class RetryConfig:
25
+ """重試配置"""
26
+
27
+ def __init__(
28
+ self,
29
+ max_retries: int = DEFAULT_MAX_RETRIES,
30
+ base_delay: float = DEFAULT_BASE_DELAY,
31
+ max_delay: float = DEFAULT_MAX_DELAY,
32
+ exponential_base: float = DEFAULT_EXPONENTIAL_BASE,
33
+ jitter: bool = True,
34
+ retryable_exceptions: Tuple[Type[Exception], ...] = (Exception,),
35
+ ):
36
+ self.max_retries = max_retries
37
+ self.base_delay = base_delay
38
+ self.max_delay = max_delay
39
+ self.exponential_base = exponential_base
40
+ self.jitter = jitter
41
+ self.retryable_exceptions = retryable_exceptions
42
+
43
+ def calculate_delay(self, attempt: int) -> float:
44
+ """計算重試延遲(指數退避 + 抖動)"""
45
+ delay = self.base_delay * (self.exponential_base ** attempt)
46
+ delay = min(delay, self.max_delay)
47
+
48
+ if self.jitter:
49
+ # 添加 ±25% 的隨機抖動
50
+ jitter_range = delay * 0.25
51
+ delay += random.uniform(-jitter_range, jitter_range)
52
+
53
+ return max(0, delay)
54
+
55
+
56
+ async def retry_async(
57
+ func: Callable[..., Any],
58
+ *args,
59
+ config: Optional[RetryConfig] = None,
60
+ **kwargs,
61
+ ) -> Any:
62
+ """
63
+ 異步重試執行函數
64
+
65
+ Args:
66
+ func: 要執行的異步函數
67
+ *args: 函數參數
68
+ config: 重試配置
69
+ **kwargs: 函數關鍵字參數
70
+
71
+ Returns:
72
+ 函數執行結果
73
+
74
+ Raises:
75
+ 最後一次重試的異常
76
+ """
77
+ if config is None:
78
+ config = RetryConfig()
79
+
80
+ last_exception = None
81
+
82
+ for attempt in range(config.max_retries + 1):
83
+ try:
84
+ return await func(*args, **kwargs)
85
+
86
+ except config.retryable_exceptions as e:
87
+ last_exception = e
88
+
89
+ if attempt < config.max_retries:
90
+ delay = config.calculate_delay(attempt)
91
+ logger.warning(
92
+ f"重試 {attempt + 1}/{config.max_retries}: "
93
+ f"{func.__name__} 失敗 ({type(e).__name__}: {e}),"
94
+ f"{delay:.2f}s 後重試"
95
+ )
96
+ await asyncio.sleep(delay)
97
+ else:
98
+ logger.error(
99
+ f"重試耗盡: {func.__name__} 在 {config.max_retries} 次重試後仍失敗"
100
+ )
101
+
102
+ raise last_exception
103
+
104
+
105
+ def with_retry(
106
+ max_retries: int = DEFAULT_MAX_RETRIES,
107
+ base_delay: float = DEFAULT_BASE_DELAY,
108
+ retryable_exceptions: Tuple[Type[Exception], ...] = (Exception,),
109
+ ):
110
+ """
111
+ 重試裝飾器
112
+
113
+ 用法:
114
+ @with_retry(max_retries=3)
115
+ async def my_api_call():
116
+ ...
117
+ """
118
+ config = RetryConfig(
119
+ max_retries=max_retries,
120
+ base_delay=base_delay,
121
+ retryable_exceptions=retryable_exceptions,
122
+ )
123
+
124
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
125
+ @functools.wraps(func)
126
+ async def wrapper(*args, **kwargs):
127
+ return await retry_async(func, *args, config=config, **kwargs)
128
+
129
+ return wrapper
130
+
131
+ return decorator
132
+
133
+
134
+ # 預設配置實例
135
+ default_retry_config = RetryConfig()
136
+
137
+ # API 調用專用配置(較短延遲)
138
+ api_retry_config = RetryConfig(
139
+ max_retries=2,
140
+ base_delay=0.5,
141
+ max_delay=5.0,
142
+ retryable_exceptions=(ConnectionError, TimeoutError, OSError),
143
+ )
144
+
145
+ # 資料庫操作專用配置(較長延遲)
146
+ db_retry_config = RetryConfig(
147
+ max_retries=3,
148
+ base_delay=1.0,
149
+ max_delay=10.0,
150
+ )
core/tool_registry.py ADDED
@@ -0,0 +1,330 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 工具註冊中心
3
+ 統一管理 MCP 工具的 OpenAI Function Calling Schema
4
+ 2025 最佳實踐:讓 GPT 原生選擇工具,不需要自定義意圖檢測 Prompt
5
+
6
+ 重構版本:整合 Pydantic Schema 自動生成
7
+ """
8
+
9
+ from typing import Dict, List, Any, Optional, Callable, Type
10
+ from dataclasses import dataclass, field
11
+
12
+ from core.logging import get_logger
13
+ from core.tool_schema import (
14
+ ToolSchema,
15
+ ToolMetadata,
16
+ ToolSchemaRegistry,
17
+ tool_schema_registry,
18
+ extract_schema_from_mcp_tool,
19
+ )
20
+
21
+ logger = get_logger("core.tool_registry")
22
+
23
+
24
+ @dataclass
25
+ class ToolDefinition:
26
+ """工具定義(向後兼容)"""
27
+ name: str
28
+ description: str
29
+ parameters: Dict[str, Any]
30
+ handler: Optional[Callable] = None
31
+ category: str = "general"
32
+ requires_auth: bool = False
33
+ requires_location: bool = False
34
+ keywords: List[str] = field(default_factory=list)
35
+ examples: List[str] = field(default_factory=list)
36
+
37
+
38
+ class ToolRegistry:
39
+ """
40
+ 工具註冊中心(重構版)
41
+
42
+ 功能:
43
+ 1. 統一註冊所有 MCP 工具
44
+ 2. 自動從 MCPTool 類別生成 OpenAI Function Calling Schema
45
+ 3. 支援工具分類和過濾
46
+ 4. 動態啟用/停用工具
47
+ 5. 整合 ToolSchemaRegistry 提供 Pydantic 支援
48
+ """
49
+
50
+ def __init__(self):
51
+ self._tools: Dict[str, ToolDefinition] = {}
52
+ self._disabled_tools: set = set()
53
+ # 整合新的 Schema Registry
54
+ self._schema_registry = tool_schema_registry
55
+
56
+ def register(
57
+ self,
58
+ name: str,
59
+ description: str,
60
+ parameters: Dict[str, Any],
61
+ handler: Optional[Callable] = None,
62
+ category: str = "general",
63
+ requires_auth: bool = False,
64
+ requires_location: bool = False,
65
+ keywords: Optional[List[str]] = None,
66
+ examples: Optional[List[str]] = None,
67
+ ) -> None:
68
+ """註冊工具(向後兼容 + 自動同步到 Schema Registry)"""
69
+ self._tools[name] = ToolDefinition(
70
+ name=name,
71
+ description=description,
72
+ parameters=parameters,
73
+ handler=handler,
74
+ category=category,
75
+ requires_auth=requires_auth,
76
+ requires_location=requires_location,
77
+ keywords=keywords or [],
78
+ examples=examples or [],
79
+ )
80
+
81
+ # 同步到 Schema Registry
82
+ schema = ToolSchema(
83
+ metadata=ToolMetadata(
84
+ name=name,
85
+ description=description,
86
+ category=category,
87
+ keywords=keywords or [],
88
+ examples=examples or [],
89
+ requires_location=requires_location,
90
+ requires_auth=requires_auth,
91
+ ),
92
+ input_schema=parameters,
93
+ handler=handler,
94
+ )
95
+ self._schema_registry.register(schema)
96
+
97
+ logger.debug(f"註冊工具: {name}")
98
+
99
+ def register_mcp_tool(self, tool_class: Type) -> bool:
100
+ """
101
+ 從 MCPTool 類別自動註冊工具
102
+
103
+ Args:
104
+ tool_class: MCPTool 子類別
105
+
106
+ Returns:
107
+ 是否註冊成功
108
+ """
109
+ schema = extract_schema_from_mcp_tool(tool_class)
110
+ if not schema:
111
+ return False
112
+
113
+ # 註冊到 Schema Registry
114
+ self._schema_registry.register(schema)
115
+
116
+ # 同步到舊的 _tools(向後兼容)
117
+ self._tools[schema.metadata.name] = ToolDefinition(
118
+ name=schema.metadata.name,
119
+ description=schema.metadata.description,
120
+ parameters=schema.input_schema,
121
+ handler=schema.handler,
122
+ category=schema.metadata.category,
123
+ requires_auth=schema.metadata.requires_auth,
124
+ requires_location=schema.metadata.requires_location,
125
+ keywords=schema.metadata.keywords,
126
+ examples=schema.metadata.examples,
127
+ )
128
+
129
+ logger.debug(f"從 MCPTool 註冊工具: {schema.metadata.name}")
130
+ return True
131
+
132
+ def unregister(self, name: str) -> bool:
133
+ """取消註冊工具"""
134
+ if name in self._tools:
135
+ del self._tools[name]
136
+ self._schema_registry.unregister(name)
137
+ return True
138
+ return False
139
+
140
+ def disable(self, name: str) -> None:
141
+ """停用工具"""
142
+ self._disabled_tools.add(name)
143
+ self._schema_registry.disable(name)
144
+
145
+ def enable(self, name: str) -> None:
146
+ """啟用工具"""
147
+ self._disabled_tools.discard(name)
148
+ self._schema_registry.enable(name)
149
+
150
+ def get_tool(self, name: str) -> Optional[ToolDefinition]:
151
+ """取得工具定義"""
152
+ if name in self._disabled_tools:
153
+ return None
154
+ return self._tools.get(name)
155
+
156
+ def get_openai_tools(
157
+ self,
158
+ categories: Optional[List[str]] = None,
159
+ include_location_tools: bool = True,
160
+ strict: bool = True,
161
+ ) -> List[Dict[str, Any]]:
162
+ """
163
+ 生成 OpenAI Function Calling 格式的工具列表
164
+
165
+ Args:
166
+ categories: 只包含指定分類的工具
167
+ include_location_tools: 是否包含需要位置的工具
168
+ strict: 是否啟用 strict mode(確保輸出符合 schema)
169
+
170
+ Returns:
171
+ OpenAI tools 格式的列表
172
+ """
173
+ # 優先使用 Schema Registry(支援 strict mode)
174
+ return self._schema_registry.get_openai_tools(
175
+ categories=categories,
176
+ include_location_tools=include_location_tools,
177
+ strict=strict,
178
+ )
179
+
180
+ def get_openai_tools_legacy(
181
+ self,
182
+ categories: Optional[List[str]] = None,
183
+ include_location_tools: bool = True,
184
+ ) -> List[Dict[str, Any]]:
185
+ """
186
+ 生成 OpenAI Function Calling 格式的工具列表(舊版,不支援 strict mode)
187
+ """
188
+ tools = []
189
+
190
+ for name, tool in self._tools.items():
191
+ # 跳過停用的工具
192
+ if name in self._disabled_tools:
193
+ continue
194
+
195
+ # 分類過濾
196
+ if categories and tool.category not in categories:
197
+ continue
198
+
199
+ # 位置過濾
200
+ if not include_location_tools and tool.requires_location:
201
+ continue
202
+
203
+ tools.append({
204
+ "type": "function",
205
+ "function": {
206
+ "name": tool.name,
207
+ "description": tool.description,
208
+ "parameters": tool.parameters,
209
+ }
210
+ })
211
+
212
+ return tools
213
+
214
+ def get_tool_names(self) -> List[str]:
215
+ """取得所有已註冊的工具名稱"""
216
+ return [
217
+ name for name in self._tools.keys()
218
+ if name not in self._disabled_tools
219
+ ]
220
+
221
+ def get_stats(self) -> Dict[str, Any]:
222
+ """取得統計資訊"""
223
+ return self._schema_registry.get_stats()
224
+
225
+ def get_summaries(self) -> List[Dict[str, Any]]:
226
+ """取得所有工具摘要(用於快速意圖匹配)"""
227
+ return self._schema_registry.get_summaries()
228
+
229
+
230
+ # 全域單例
231
+ tool_registry = ToolRegistry()
232
+
233
+
234
+ def register_mcp_tools_to_registry(mcp_server) -> int:
235
+ """
236
+ 從 MCP Server 自動註冊工具到 Registry
237
+
238
+ 2025 重構版:優先使用 MCPTool 類別自動提取 Schema
239
+
240
+ Args:
241
+ mcp_server: MCPServer 實例
242
+
243
+ Returns:
244
+ 註冊的工具數量
245
+ """
246
+ count = 0
247
+
248
+ for tool_name, tool in mcp_server.tools.items():
249
+ # 優先嘗試從 MCPTool 類別提取完整 Schema
250
+ if hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
251
+ tool_class = tool.handler.__self__
252
+ if tool_registry.register_mcp_tool(type(tool_class)):
253
+ count += 1
254
+ continue
255
+
256
+ # 降級:使用舊方法註冊
257
+ description = getattr(tool, 'description', f'{tool_name} 工具')
258
+ parameters = {"type": "object", "properties": {}, "required": []}
259
+
260
+ if hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
261
+ tool_class = tool.handler.__self__
262
+ if hasattr(tool_class, 'get_input_schema'):
263
+ try:
264
+ parameters = tool_class.get_input_schema()
265
+ except Exception as e:
266
+ logger.warning(f"取得 {tool_name} schema 失敗: {e}")
267
+
268
+ # 提取關鍵字和範例
269
+ keywords = []
270
+ examples = []
271
+ if hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
272
+ tool_class = tool.handler.__self__
273
+ keywords = getattr(tool_class, 'KEYWORDS', [])
274
+ examples = getattr(tool_class, 'USAGE_TIPS', [])
275
+
276
+ # 判斷分類
277
+ category = _infer_category(tool_name)
278
+
279
+ # 判斷是否需要位置
280
+ requires_location = _requires_location(tool_name, parameters)
281
+
282
+ tool_registry.register(
283
+ name=tool_name,
284
+ description=description,
285
+ parameters=parameters,
286
+ handler=getattr(tool, 'handler', None),
287
+ category=category,
288
+ requires_location=requires_location,
289
+ keywords=keywords,
290
+ examples=examples,
291
+ )
292
+ count += 1
293
+
294
+ logger.info(f"從 MCP Server 註冊了 {count} 個工具")
295
+ return count
296
+
297
+
298
+ def _infer_category(tool_name: str) -> str:
299
+ """推斷工具分類"""
300
+ name_lower = tool_name.lower()
301
+
302
+ if any(k in name_lower for k in ['weather', 'forecast']):
303
+ return "weather"
304
+ if any(k in name_lower for k in ['bus', 'train', 'metro', 'thsr', 'youbike', 'parking']):
305
+ return "transportation"
306
+ if any(k in name_lower for k in ['geocode', 'directions', 'location']):
307
+ return "location"
308
+ if any(k in name_lower for k in ['news']):
309
+ return "information"
310
+ if any(k in name_lower for k in ['exchange', 'currency']):
311
+ return "finance"
312
+ if any(k in name_lower for k in ['health', 'heart', 'sleep', 'step']):
313
+ return "health"
314
+
315
+ return "general"
316
+
317
+
318
+ def _requires_location(tool_name: str, parameters: Dict) -> bool:
319
+ """判斷工具是否需要位置資訊"""
320
+ # 檢查參數中是否有 lat/lon
321
+ props = parameters.get("properties", {})
322
+ if "lat" in props or "lon" in props or "latitude" in props or "longitude" in props:
323
+ return True
324
+
325
+ # 檢查工具名稱
326
+ location_tools = [
327
+ 'reverse_geocode', 'directions', 'tdx_bus_arrival',
328
+ 'tdx_youbike', 'tdx_metro', 'tdx_parking', 'tdx_train', 'tdx_thsr'
329
+ ]
330
+ return tool_name in location_tools
core/tool_router.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 動態工具路由器
3
+ 2025 最佳實踐:根據上下文智能過濾工具,減少 token 消耗
4
+
5
+ 功能:
6
+ 1. 位置過濾:用戶沒有位置時,排除需要位置的工具
7
+ 2. 關鍵字過濾:根據用戶意圖關鍵字,優先顯示相關分類
8
+ 3. 時間過濾:根據時間排除不適用的工具
9
+ 4. 優先級排序:常用工具優先顯示
10
+ """
11
+
12
+ import re
13
+ import logging
14
+ from typing import Dict, List, Any, Optional, Set
15
+ from datetime import datetime
16
+
17
+ from core.logging import get_logger
18
+
19
+ logger = get_logger("core.tool_router")
20
+
21
+
22
+ class ToolRouter:
23
+ """
24
+ 動態工具路由器
25
+
26
+ 根據上下文智能過濾和排序工具,減少傳遞給 GPT 的工具數量
27
+ """
28
+
29
+ # 分類關鍵字映射
30
+ CATEGORY_KEYWORDS = {
31
+ "weather": ["天氣", "氣溫", "下雨", "晴天", "陰天", "weather", "溫度", "濕度"],
32
+ "transportation": [
33
+ "公車", "巴士", "bus", "火車", "台鐵", "高鐵", "捷運", "metro",
34
+ "youbike", "ubike", "微笑單車", "共享單車", "停車場", "停車位"
35
+ ],
36
+ "location": ["我在哪", "這是哪", "位置", "地址", "怎麼去", "導航", "路線"],
37
+ "information": ["新聞", "消息", "報導", "news"],
38
+ "finance": ["匯率", "換算", "美元", "日圓", "歐元", "currency", "exchange"],
39
+ "health": ["心率", "步數", "血氧", "睡眠", "健康", "運動"],
40
+ }
41
+
42
+ # 時間敏感工具(深夜可能不適用)
43
+ NIGHT_EXCLUDED_TOOLS = {
44
+ "tdx_bus_arrival", # 深夜公車班次少
45
+ "tdx_metro", # 捷運深夜停駛
46
+ }
47
+
48
+ # 工具優先級(數字越小優先級越高)
49
+ DEFAULT_PRIORITY = {
50
+ "weather_query": 1,
51
+ "reverse_geocode": 2,
52
+ "forward_geocode": 3,
53
+ "directions": 4,
54
+ "tdx_bus_arrival": 5,
55
+ "tdx_youbike": 6,
56
+ "tdx_metro": 7,
57
+ "tdx_train": 8,
58
+ "tdx_thsr": 9,
59
+ "news_query": 10,
60
+ "exchange_query": 11,
61
+ "healthkit_query": 12,
62
+ "tdx_parking": 13,
63
+ }
64
+
65
+ def __init__(self):
66
+ self._user_preferences: Dict[str, Dict[str, int]] = {} # user_id -> {tool_name: usage_count}
67
+
68
+ def filter_tools(
69
+ self,
70
+ tools: List[Dict[str, Any]],
71
+ message: str,
72
+ context: Optional[Dict[str, Any]] = None,
73
+ ) -> List[Dict[str, Any]]:
74
+ """
75
+ 根據上下文過濾和排序工具
76
+
77
+ Args:
78
+ tools: OpenAI tools 格式的工具列表
79
+ message: 用戶消息
80
+ context: 上下文資訊(位置、時間、用戶偏好等)
81
+
82
+ Returns:
83
+ 過濾和排序後的工具列表
84
+ """
85
+ context = context or {}
86
+
87
+ # 1. 檢測用戶意圖分類
88
+ detected_categories = self._detect_categories(message)
89
+ logger.debug(f"🎯 檢測到的分類: {detected_categories}")
90
+
91
+ # 2. 過濾工具
92
+ filtered_tools = []
93
+ for tool in tools:
94
+ tool_name = tool.get("function", {}).get("name", "")
95
+
96
+ # 位置過濾
97
+ if not self._check_location_requirement(tool_name, context):
98
+ logger.debug(f"⏭️ 跳過 {tool_name}(需要位置但用戶未提供)")
99
+ continue
100
+
101
+ # 時間過濾
102
+ if not self._check_time_requirement(tool_name, context):
103
+ logger.debug(f"⏭️ 跳過 {tool_name}(深夜不適用)")
104
+ continue
105
+
106
+ filtered_tools.append(tool)
107
+
108
+ # 3. 排序工具(相關分類優先)
109
+ sorted_tools = self._sort_tools(filtered_tools, detected_categories, context)
110
+
111
+ # 4. 限制工具數量(減少 token 消耗)
112
+ max_tools = self._get_max_tools(detected_categories)
113
+ if len(sorted_tools) > max_tools:
114
+ logger.info(f"📉 工具數量從 {len(sorted_tools)} 限制到 {max_tools}")
115
+ sorted_tools = sorted_tools[:max_tools]
116
+
117
+ logger.info(f"🔧 過濾後工具: {[t['function']['name'] for t in sorted_tools]}")
118
+ return sorted_tools
119
+
120
+ def _detect_categories(self, message: str) -> Set[str]:
121
+ """檢測用戶消息中的意圖分類"""
122
+ message_lower = message.lower()
123
+ detected = set()
124
+
125
+ for category, keywords in self.CATEGORY_KEYWORDS.items():
126
+ for keyword in keywords:
127
+ if keyword.lower() in message_lower:
128
+ detected.add(category)
129
+ break
130
+
131
+ return detected
132
+
133
+ def _check_location_requirement(
134
+ self,
135
+ tool_name: str,
136
+ context: Dict[str, Any],
137
+ ) -> bool:
138
+ """檢查工具的位置需求"""
139
+ # 需要位置的工具
140
+ location_required_tools = {
141
+ "reverse_geocode",
142
+ "tdx_bus_arrival",
143
+ "tdx_youbike",
144
+ "tdx_metro",
145
+ "tdx_parking",
146
+ "tdx_train",
147
+ "tdx_thsr",
148
+ }
149
+
150
+ if tool_name not in location_required_tools:
151
+ return True
152
+
153
+ # 檢查是否有位置資訊
154
+ has_location = (
155
+ context.get("lat") is not None and
156
+ context.get("lon") is not None
157
+ )
158
+
159
+ # 如果沒有位置,但用戶明確要求(如「附近的公車」),仍然保留工具
160
+ # 讓工具自己處理缺少位置的情況
161
+ return True # 暫時不嚴格過濾,讓工具自己處理
162
+
163
+ def _check_time_requirement(
164
+ self,
165
+ tool_name: str,
166
+ context: Dict[str, Any],
167
+ ) -> bool:
168
+ """檢查工具的時間需求"""
169
+ if tool_name not in self.NIGHT_EXCLUDED_TOOLS:
170
+ return True
171
+
172
+ # 檢查是否為深夜(00:00 - 05:00)
173
+ current_hour = context.get("hour")
174
+ if current_hour is None:
175
+ current_hour = datetime.now().hour
176
+
177
+ is_night = 0 <= current_hour < 5
178
+
179
+ # 深夜時排除某些工具
180
+ return not is_night
181
+
182
+ def _sort_tools(
183
+ self,
184
+ tools: List[Dict[str, Any]],
185
+ detected_categories: Set[str],
186
+ context: Dict[str, Any],
187
+ ) -> List[Dict[str, Any]]:
188
+ """排序工具(相關分類優先)"""
189
+
190
+ def get_priority(tool: Dict[str, Any]) -> int:
191
+ tool_name = tool.get("function", {}).get("name", "")
192
+
193
+ # 基礎優先級
194
+ base_priority = self.DEFAULT_PRIORITY.get(tool_name, 100)
195
+
196
+ # 如果工具屬於檢測到的分類,降低優先級數字(提高優先級)
197
+ tool_category = self._get_tool_category(tool_name)
198
+ if tool_category in detected_categories:
199
+ base_priority -= 50 # 相關工具優先
200
+
201
+ # 用戶偏好加成
202
+ user_id = context.get("user_id")
203
+ if user_id and user_id in self._user_preferences:
204
+ usage_count = self._user_preferences[user_id].get(tool_name, 0)
205
+ base_priority -= min(usage_count, 10) # 最多降低 10
206
+
207
+ return base_priority
208
+
209
+ return sorted(tools, key=get_priority)
210
+
211
+ def _get_tool_category(self, tool_name: str) -> str:
212
+ """取得工具的分類"""
213
+ category_map = {
214
+ "weather_query": "weather",
215
+ "reverse_geocode": "location",
216
+ "forward_geocode": "location",
217
+ "directions": "location",
218
+ "tdx_bus_arrival": "transportation",
219
+ "tdx_youbike": "transportation",
220
+ "tdx_metro": "transportation",
221
+ "tdx_train": "transportation",
222
+ "tdx_thsr": "transportation",
223
+ "tdx_parking": "transportation",
224
+ "news_query": "information",
225
+ "exchange_query": "finance",
226
+ "healthkit_query": "health",
227
+ }
228
+ return category_map.get(tool_name, "general")
229
+
230
+ def _get_max_tools(self, detected_categories: Set[str]) -> int:
231
+ """根據檢測到的分類決定最大工具數量"""
232
+ if not detected_categories:
233
+ # 沒有明確分類,返回所有工具
234
+ return 20
235
+
236
+ if len(detected_categories) == 1:
237
+ # 單一分類,但仍需要保留足夠工具(如 directions)
238
+ return 12
239
+
240
+ # 多個分類
241
+ return 15
242
+
243
+ def record_tool_usage(self, user_id: str, tool_name: str) -> None:
244
+ """記錄工具使用(用於優先級調整)"""
245
+ if user_id not in self._user_preferences:
246
+ self._user_preferences[user_id] = {}
247
+
248
+ current = self._user_preferences[user_id].get(tool_name, 0)
249
+ self._user_preferences[user_id][tool_name] = current + 1
250
+
251
+ logger.debug(f"📊 記錄工具使用: {user_id} -> {tool_name} ({current + 1})")
252
+
253
+
254
+ # 全域單例
255
+ tool_router = ToolRouter()
core/tool_schema.py ADDED
@@ -0,0 +1,355 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic 工具 Schema 定義
3
+ 2025 最佳實踐:使用 Pydantic 自動生成 OpenAI Function Calling Schema
4
+
5
+ 功能:
6
+ 1. 工具輸入/輸出的 Pydantic 基礎類別
7
+ 2. 自動生成 OpenAI tools 格式的 JSON Schema
8
+ 3. 支援 strict mode 確保 100% 有效輸出
9
+ 4. 裝飾器模式自動註冊工具
10
+ """
11
+
12
+ from typing import Dict, Any, Optional, List, Callable, Type, TypeVar, get_type_hints
13
+ from dataclasses import dataclass, field
14
+ from functools import wraps
15
+ import inspect
16
+ import logging
17
+
18
+ logger = logging.getLogger("core.tool_schema")
19
+
20
+ # 類型變數
21
+ T = TypeVar("T")
22
+
23
+
24
+ @dataclass
25
+ class ToolMetadata:
26
+ """
27
+ 工具元資料(增強版)
28
+
29
+ 2025 最佳實踐:豐富的元資料讓 GPT 更容易理解何時使用哪個工具
30
+ """
31
+ name: str
32
+ description: str
33
+ category: str = "general"
34
+ keywords: List[str] = field(default_factory=list) # 觸發關鍵字
35
+ examples: List[str] = field(default_factory=list) # 使用範例
36
+ negative_examples: List[str] = field(default_factory=list) # 不應使用的情況
37
+ requires_location: bool = False
38
+ requires_auth: bool = False
39
+ is_complex: bool = False
40
+ priority: int = 100 # 優先級(數字越小越優先)
41
+ aliases: List[str] = field(default_factory=list) # 工具別名
42
+
43
+
44
+ @dataclass
45
+ class ToolSchema:
46
+ """工具 Schema 定義(自描述)"""
47
+ metadata: ToolMetadata
48
+ input_schema: Dict[str, Any]
49
+ output_schema: Optional[Dict[str, Any]] = None
50
+ handler: Optional[Callable] = None
51
+
52
+ def to_openai_tool(self, strict: bool = True) -> Dict[str, Any]:
53
+ """
54
+ 轉換為 OpenAI Function Calling 格式
55
+
56
+ Args:
57
+ strict: 是否啟用 strict mode(確保輸出符合 schema)
58
+
59
+ Returns:
60
+ OpenAI tools 格式的字典
61
+ """
62
+ # 確保 schema 符合 OpenAI strict mode 要求
63
+ parameters = self._prepare_strict_schema(self.input_schema) if strict else self.input_schema
64
+
65
+ tool_def = {
66
+ "type": "function",
67
+ "function": {
68
+ "name": self.metadata.name,
69
+ "description": self._build_rich_description(),
70
+ "parameters": parameters,
71
+ }
72
+ }
73
+
74
+ if strict:
75
+ tool_def["function"]["strict"] = True
76
+
77
+ return tool_def
78
+
79
+ def _build_rich_description(self) -> str:
80
+ """
81
+ 建構豐富的工具描述(包含範例、關鍵字、負面範例)
82
+ 讓 GPT 更容易理解何時使用此工具
83
+
84
+ 2025 最佳實踐:
85
+ - 正面範例:告訴 GPT 何時使用
86
+ - 負面範例:告訴 GPT 何時不要使用(減少誤判)
87
+ """
88
+ desc_parts = [self.metadata.description]
89
+
90
+ # 加入關鍵字提示
91
+ if self.metadata.keywords:
92
+ keywords_str = "、".join(self.metadata.keywords[:5])
93
+ desc_parts.append(f"觸發詞:{keywords_str}")
94
+
95
+ # 加入使用範例(正面)
96
+ if self.metadata.examples:
97
+ examples_str = ";".join(self.metadata.examples[:3])
98
+ desc_parts.append(f"適用:{examples_str}")
99
+
100
+ # 加入負面範例(告訴 GPT 何時不要使用)
101
+ if self.metadata.negative_examples:
102
+ neg_str = ";".join(self.metadata.negative_examples[:2])
103
+ desc_parts.append(f"不適用:{neg_str}")
104
+
105
+ return "。".join(desc_parts)
106
+
107
+ def _prepare_strict_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]:
108
+ """
109
+ 準備符合 OpenAI strict mode 的 schema
110
+
111
+ strict mode 要求:
112
+ 1. additionalProperties: false
113
+ 2. 所有屬性都在 required 中(或有 default)
114
+ 3. 不支援 oneOf/anyOf/allOf
115
+ """
116
+ result = dict(schema)
117
+
118
+ # 確保是 object 類型
119
+ if result.get("type") != "object":
120
+ result = {"type": "object", "properties": result}
121
+
122
+ # 添加 additionalProperties: false
123
+ result["additionalProperties"] = False
124
+
125
+ # 確保所有屬性都在 required 中
126
+ properties = result.get("properties", {})
127
+ existing_required = set(result.get("required", []))
128
+
129
+ # 收集所有沒有 default 的屬性
130
+ all_required = []
131
+ for prop_name, prop_schema in properties.items():
132
+ if prop_name in existing_required or "default" not in prop_schema:
133
+ all_required.append(prop_name)
134
+
135
+ result["required"] = all_required
136
+
137
+ return result
138
+
139
+ def get_summary(self) -> Dict[str, Any]:
140
+ """獲取工具摘要(用於快速意圖匹配)"""
141
+ return {
142
+ "name": self.metadata.name,
143
+ "description": self.metadata.description[:50] + "..." if len(self.metadata.description) > 50 else self.metadata.description,
144
+ "category": self.metadata.category,
145
+ "keywords": self.metadata.keywords,
146
+ "params": list(self.input_schema.get("properties", {}).keys())
147
+ }
148
+
149
+
150
+ def extract_schema_from_mcp_tool(tool_class: Type) -> Optional[ToolSchema]:
151
+ """
152
+ 從現有 MCPTool 類別提取 Schema(增強版)
153
+
154
+ Args:
155
+ tool_class: MCPTool 子類別
156
+
157
+ Returns:
158
+ ToolSchema 或 None
159
+ """
160
+ try:
161
+ # 檢查必要屬性
162
+ if not hasattr(tool_class, "NAME") or not hasattr(tool_class, "get_input_schema"):
163
+ return None
164
+
165
+ name = getattr(tool_class, "NAME", "")
166
+ if not name:
167
+ return None
168
+
169
+ # 提取元資料(增強版)
170
+ metadata = ToolMetadata(
171
+ name=name,
172
+ description=getattr(tool_class, "DESCRIPTION", f"{name} 工具"),
173
+ category=getattr(tool_class, "CATEGORY", "general"),
174
+ keywords=getattr(tool_class, "KEYWORDS", []),
175
+ examples=getattr(tool_class, "USAGE_TIPS", []),
176
+ negative_examples=getattr(tool_class, "NEGATIVE_EXAMPLES", []),
177
+ requires_location=_check_requires_location(tool_class),
178
+ requires_auth=getattr(tool_class, "REQUIRES_AUTH", False),
179
+ is_complex=getattr(tool_class, "IS_COMPLEX", False),
180
+ priority=getattr(tool_class, "PRIORITY", 100),
181
+ aliases=getattr(tool_class, "ALIASES", []),
182
+ )
183
+
184
+ # 提取 input schema
185
+ try:
186
+ input_schema = tool_class.get_input_schema()
187
+ except Exception as e:
188
+ logger.warning(f"提取 {name} input schema 失敗: {e}")
189
+ input_schema = {"type": "object", "properties": {}}
190
+
191
+ # 提取 output schema(可選)
192
+ output_schema = None
193
+ if hasattr(tool_class, "get_output_schema"):
194
+ try:
195
+ output_schema = tool_class.get_output_schema()
196
+ except Exception:
197
+ pass
198
+
199
+ # 提取 handler
200
+ handler = None
201
+ if hasattr(tool_class, "execute"):
202
+ handler = tool_class.execute
203
+
204
+ return ToolSchema(
205
+ metadata=metadata,
206
+ input_schema=input_schema,
207
+ output_schema=output_schema,
208
+ handler=handler,
209
+ )
210
+
211
+ except Exception as e:
212
+ logger.error(f"提取 {tool_class} schema 失敗: {e}")
213
+ return None
214
+
215
+
216
+ def _check_requires_location(tool_class: Type) -> bool:
217
+ """檢查工具是否需要位置資訊"""
218
+ # 檢查類別屬性
219
+ if getattr(tool_class, "REQUIRES_LOCATION", False):
220
+ return True
221
+
222
+ # 檢查 input schema 中是否有 lat/lon
223
+ try:
224
+ schema = tool_class.get_input_schema()
225
+ props = schema.get("properties", {})
226
+ if "lat" in props or "lon" in props:
227
+ return True
228
+ except Exception:
229
+ pass
230
+
231
+ # 檢查工具名稱
232
+ name = getattr(tool_class, "NAME", "").lower()
233
+ location_tools = [
234
+ "reverse_geocode", "directions", "tdx_bus_arrival",
235
+ "tdx_youbike", "tdx_metro", "tdx_parking", "tdx_train", "tdx_thsr"
236
+ ]
237
+ return name in location_tools
238
+
239
+
240
+ class ToolSchemaRegistry:
241
+ """
242
+ 工具 Schema 註冊中心
243
+
244
+ 功能:
245
+ 1. 統一管理所有工具的 Schema
246
+ 2. 自動生成 OpenAI Function Calling 格式
247
+ 3. 支援動態過濾和分組
248
+ """
249
+
250
+ def __init__(self):
251
+ self._schemas: Dict[str, ToolSchema] = {}
252
+ self._disabled: set = set()
253
+
254
+ def register(self, schema: ToolSchema) -> None:
255
+ """註冊工具 Schema"""
256
+ self._schemas[schema.metadata.name] = schema
257
+ logger.debug(f"註冊工具 Schema: {schema.metadata.name}")
258
+
259
+ def register_from_mcp_tool(self, tool_class: Type) -> bool:
260
+ """從 MCPTool 類別註冊"""
261
+ schema = extract_schema_from_mcp_tool(tool_class)
262
+ if schema:
263
+ self.register(schema)
264
+ return True
265
+ return False
266
+
267
+ def unregister(self, name: str) -> bool:
268
+ """取消註冊"""
269
+ if name in self._schemas:
270
+ del self._schemas[name]
271
+ return True
272
+ return False
273
+
274
+ def disable(self, name: str) -> None:
275
+ """停用工具"""
276
+ self._disabled.add(name)
277
+
278
+ def enable(self, name: str) -> None:
279
+ """啟用工具"""
280
+ self._disabled.discard(name)
281
+
282
+ def get(self, name: str) -> Optional[ToolSchema]:
283
+ """取得工具 Schema"""
284
+ if name in self._disabled:
285
+ return None
286
+ return self._schemas.get(name)
287
+
288
+ def get_openai_tools(
289
+ self,
290
+ categories: Optional[List[str]] = None,
291
+ include_location_tools: bool = True,
292
+ strict: bool = True,
293
+ ) -> List[Dict[str, Any]]:
294
+ """
295
+ 生成 OpenAI Function Calling 格式的工具列表
296
+
297
+ Args:
298
+ categories: 只包含指定分類的工具
299
+ include_location_tools: 是否包含需要位置的工具
300
+ strict: 是否啟用 strict mode
301
+
302
+ Returns:
303
+ OpenAI tools 格式的列表
304
+ """
305
+ tools = []
306
+
307
+ for name, schema in self._schemas.items():
308
+ # 跳過停用的工具
309
+ if name in self._disabled:
310
+ continue
311
+
312
+ # 分類過濾
313
+ if categories and schema.metadata.category not in categories:
314
+ continue
315
+
316
+ # 位置過濾
317
+ if not include_location_tools and schema.metadata.requires_location:
318
+ continue
319
+
320
+ tools.append(schema.to_openai_tool(strict=strict))
321
+
322
+ return tools
323
+
324
+ def get_tool_names(self) -> List[str]:
325
+ """取得所有已註冊的工具名稱"""
326
+ return [
327
+ name for name in self._schemas.keys()
328
+ if name not in self._disabled
329
+ ]
330
+
331
+ def get_summaries(self) -> List[Dict[str, Any]]:
332
+ """取得所有工具摘要(用於快速意圖匹配)"""
333
+ return [
334
+ schema.get_summary()
335
+ for name, schema in self._schemas.items()
336
+ if name not in self._disabled
337
+ ]
338
+
339
+ def get_stats(self) -> Dict[str, Any]:
340
+ """取得統計資訊"""
341
+ categories = {}
342
+ for schema in self._schemas.values():
343
+ cat = schema.metadata.category
344
+ categories[cat] = categories.get(cat, 0) + 1
345
+
346
+ return {
347
+ "total": len(self._schemas),
348
+ "disabled": len(self._disabled),
349
+ "active": len(self._schemas) - len(self._disabled),
350
+ "categories": categories,
351
+ }
352
+
353
+
354
+ # 全域單例
355
+ tool_schema_registry = ToolSchemaRegistry()
features/mcp/agent_bridge.py CHANGED
@@ -111,6 +111,7 @@ class MCPAgentBridge:
111
  ToolMetadata(
112
  name="reverse_geocode",
113
  requires_env={"lat", "lon"},
 
114
  )
115
  )
116
  register(
@@ -214,9 +215,70 @@ class MCPAgentBridge:
214
  await self.mcp_server.start_external_servers()
215
  logger.info(f"異步初始化完成,完整可用 MCP 工具數量: {len(self.mcp_server.tools)}")
216
 
 
 
 
217
  # 2025 最佳實踐:啟動時預熱熱門查詢快取
218
  await self._preheat_cache()
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  def _normalize_tool_name(self, raw_name: Optional[str]) -> Optional[str]:
221
  """
222
  將 GPT 回傳的工具名稱正規化為註冊表中的實際名稱。
@@ -414,8 +476,11 @@ class MCPAgentBridge:
414
  async def detect_intent(self, message: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
415
  """
416
  檢測用戶消息中的意圖 (保持與舊 FeatureRouter 相同介面)
417
- 使用 OpenAI Structured Outputs 確保100%返回有效JSON
418
- 帶快取機制,相同消息直接返回
 
 
 
419
 
420
  參數:
421
  message (str): 用戶消息
@@ -423,362 +488,130 @@ class MCPAgentBridge:
423
  返回:
424
  tuple: (是否檢測到意圖, 意圖數據)
425
  """
 
 
 
 
 
 
 
 
 
 
 
 
426
  import hashlib
427
  import time as time_module
428
-
429
  # 生成快取鍵
430
  cache_key = hashlib.md5(message.encode()).hexdigest()
431
 
432
  # 檢查快取
433
  if cache_key in self._intent_cache:
434
  has_feature, intent_data, cached_time = self._intent_cache[cache_key]
435
- # 檢查是否過期
436
  if time_module.time() - cached_time < self._intent_cache_ttl:
437
  logger.debug(f"💾 意圖快取命中: {message[:50]}...")
438
  return has_feature, intent_data
439
  else:
440
- # 過期,刪除快取
441
  del self._intent_cache[cache_key]
442
 
443
- logger.info(f"檢測意圖: \"{message}\"")
444
- logger.debug("意圖偵測輸入 - user_id=%s, chat_id=%s", "intent_detection", None)
445
 
446
  # 檢查特殊命令
447
  for command in ["功能列表", "有什麼功能", "能做什麼"]:
448
  if command in message:
449
  logger.info(f"檢測到特殊命令: {command}")
450
- return True, {
451
- "type": "special_command",
452
- "command": "feature_list"
453
- }
454
 
455
- # 使用 GPT + Structured Outputs 進行意圖解析
456
  try:
457
- logger.info("開始使用 GPT Structured Outputs 進行意圖解析")
458
-
459
- # 構建可用工具的描述
460
- tools_description = self._get_tools_description()
461
-
462
- # GPT 意圖解析 Prompt - 適配新的 schema(不使用 oneOf)
463
- system_prompt = f"""你是一個精確的意圖解析助手。
464
-
465
- 可用工具:
466
- {tools_description}
467
-
468
- 任務:分析用戶消息,決定是否需要調用工具,並判斷用戶情緒。
469
-
470
- 重要規則:
471
- 1. 健康相關需求(心率、步數、血氧、呼吸、睡眠)使用 healthkit_query
472
- 2. 不需要傳入 user_id,系統會自動補齊
473
- 3. 若無法判斷具體參數,使用合預設值
474
- 4. 一般閒聊設置 is_tool_call 為 false
475
-
476
- 特殊處理:
477
- - 天氣查詢:城市名稱必須使用英文(如 Taipei, Tokyo, New York)
478
- * 台北 → Taipei
479
- * 東京 → Tokyo
480
- * 紐約 → New York
481
- * 倫敦 → London
482
- * 巴黎 → Paris
483
- * 如無指定城市,預設使用 Taipei
484
-
485
- - 匯率查詢:
486
- * 必須明確指定 from_currency 和 to_currency(ISO 4217 代碼)
487
- * 預設:from_currency=USD, to_currency=TWD
488
- * 美元 → USD, 台幣 → TWD, 日圓 → JPY, 歐元 → EUR
489
- * 金額預設 amount=1.0, conversion=true
490
-
491
- - 新聞查詢:
492
- * 任何提到「新聞」「消息」「報導」的請求都使用 news_query
493
- * 參數:query(關鍵詞)、country(國家,預設 tw)、category(分類,預設 top)、language(語言,預設 zh)
494
- * 今日新聞、科技新聞、台灣新聞都應該調用此工具
495
-
496
- - 公車查詢(重要!):
497
- * 任何提到「公車」「巴士」「幾號公車」「什麼時候來」的請求都使用 tdx_bus_arrival
498
- * **參數名稱是 route_name(不是 stop_name)**
499
- * 從用戶訊息中提取路線號碼(如「137」「紅30」「307」)
500
- * 範例:
501
- - 「137公車什麼時候來」→ tdx_bus_arrival:route_name=137
502
- - 「307還要多久」→ tdx_bus_arrival:route_name=307
503
- - 「附近有什麼公車」→ tdx_bus_arrival(不需參數,系統自動用 GPS 查詢附近站點)
504
- - 「紅30公車」→ tdx_bus_arrival:route_name=紅30
505
- * ❌ 錯誤:tdx_bus_arrival:stop_name=137
506
- * ✅ 正確:tdx_bus_arrival:route_name=137
507
-
508
- - 台鐵/火車查詢(重要!):
509
- * 任何提到「火車」「台鐵」「列車」「自強號」「莒光號」「區間車」的請求都使用 tdx_train
510
- * **參數名稱是 origin_station(起站)和 destination_station(迄站)**
511
- * **「往XX」「到XX」「去XX」表示目的地(destination_station),不是起點!**
512
- * **「從XX」「在XX」表示起點(origin_station)**
513
- * **如果用戶沒有明確說起點,就不要填 origin_station,讓系統用 GPS 自動找最近車站**
514
- * 範例:
515
- - 「往台北的火車」→ tdx_train:destination_station=台北(只填目的地,起點由 GPS 決定)
516
- - 「到高雄的火車」→ tdx_train:destination_station=高雄(只填目的地)
517
- - 「從桃園到台北」→ tdx_train:origin_station=桃園,destination_station=台北(明確說了起點)
518
- - 「台北到台中的火車」→ tdx_train:origin_station=台北,destination_station=台中
519
- - 「下一班火車」→ tdx_train(不需參數,系統自動用 GPS 查詢最近車站)
520
- - 「自強號 123 次」→ tdx_train:train_no=123
521
- * ❌ 錯誤:「往台北」解析為 origin_station=台灣,destination_station=台北(不要亂填起點!)
522
- * ❌ 錯誤:「往台北」解析為 origin_station=台北(方向搞反了)
523
- * ✅ 正確:「往台北」解析為 destination_station=台北(只填目的地)
524
-
525
- - YouBike/共享單車查詢(重要!必須識別!):
526
- * 任何提到以下關鍵詞的請求都使用 tdx_youbike:
527
- - 「YouBike」「Youbike」「youbike」「YOUBIKE」
528
- - 「UBike」「Ubike」「ubike」「UBIKE」
529
- - 「微笑單車」「共享單車」「公共單車」
530
- - 「腳踏車站」「單車站」「自行車站」
531
- - 「借車」「還車」(在單車語境下)
532
- - 「最近的站點」「附近站點」(在單車語境下)
533
- * **不是 tdx_parking!YouBike 是��車,不是停車場**
534
- * **不是一般聊天!這是工具調用!**
535
- * 範例:
536
- - 「附近的 YouBike」→ tdx_youbike(不需參數,系統自動用 GPS 查詢)
537
- - 「離我最近的 Ubike 站點」→ tdx_youbike
538
- - 「最近的Ubike站點」→ tdx_youbike
539
- - 「Ubike在哪」→ tdx_youbike
540
- - 「哪裡有Ubike」→ tdx_youbike
541
- - 「市政府 YouBike 還有車嗎」→ tdx_youbike:station_name=市政府
542
- * ❌ 錯誤:is_tool_call=false(這不是聊天!)
543
- * ❌ 錯誤:tdx_parking:query=Ubike
544
- * ✅ 正確:tdx_youbike
545
-
546
- - 停車場查詢:
547
- * 任何提到「停車場」「停車位」「汽車停車」的請求才使用 tdx_parking
548
- * 範例:
549
- - 「附近的停車場」→ tdx_parking
550
- - 「市政府停車場」→ tdx_parking:parking_name=市政府
551
-
552
- - 地點查詢與導航(重要!):
553
- * **當前位置查詢**:
554
- - 問「我在哪」「這是哪裡」「現在在哪」「我的位置」→ 使用 reverse_geocode(不需參數,系統自動用 GPS 座標)
555
- - ❌ 錯誤:forward_geocode:query=我在哪
556
- - ✅ 正確:reverse_geocode
557
- * **導航需求判斷**:
558
- - 問「怎麼去 X」「如何去 X」「去 X 怎麼走」「到 X 怎麼走」→ 使用 forward_geocode 查詢目的地座標
559
- - 問「從 A 到 B 要多久」「A 到 B 怎麼走」→ 同時使用 forward_geocode 查詢起點與終點
560
- * **不要猜測座標**:
561
- - ❌ 錯誤:directions:origin_lat=25.1288,origin_lon=121.9234,dest_lat=24.9932,dest_lon=121.3261
562
- - ✅ 正確:forward_geocode:query=銘傳大學桃園校區
563
- * **工具使用順序**:
564
- 1. 先使用 forward_geocode 將地點名稱轉換為座標
565
- 2. 再使用 directions 規劃路線(系統會自動處理)
566
- * **範例**:
567
- - 「我在哪」→ reverse_geocode(系統自動補 lat/lon)
568
- - 「怎麼去桃園火車站」→ forward_geocode:query=桃園火車站
569
- - 「從銘傳大學到桃園火車站」→ forward_geocode:query=銘傳大學桃園校區
570
- - 「台北車站到淡水捷運站」→ forward_geocode:query=台北車站
571
-
572
- 情緒判斷(emotion):
573
- 根據文字的語氣、用詞、標點符號判斷用戶情緒,選擇以下之一:
574
- - neutral: 平靜、中性(預設)
575
- - happy: 開心、興奮、愉快(如「好開心!」「太棒了」「哈哈」)
576
- - sad: 難過、沮喪、失落(如「好難過」「唉...」「心情不好」)
577
- - angry: 生氣、憤怒、煩躁(如「煩死了」「幹嘛啦」「氣死我了」)
578
- - fear: 恐懼、擔心、焦慮(如「好害怕」「好擔心」「怎麼辦」)
579
- - surprise: 驚訝、意外(如「什麼!」「真的假的」「不會吧」)
580
-
581
- 回應格式:
582
- - is_tool_call: true/false(是否調用工具)
583
- - tool_name: 工具名稱和參數(僅當 is_tool_call=true 時提供,格式:tool_name:param1=value1,param2=value2)
584
- - emotion: 用戶情緒標籤(必填)
585
-
586
- 示例:
587
- - "我在哪" → {{"is_tool_call": true, "tool_name": "reverse_geocode", "emotion": "neutral"}}
588
- - "這是哪裡" → {{"is_tool_call": true, "tool_name": "reverse_geocode", "emotion": "neutral"}}
589
- - "台北天氣" → {{"is_tool_call": true, "tool_name": "weather_query:city=Taipei", "emotion": "neutral"}}
590
- - "好開心!今天天氣好嗎" → {{"is_tool_call": true, "tool_name": "weather_query:city=Taipei", "emotion": "happy"}}
591
- - "美元匯率" → {{"is_tool_call": true, "tool_name": "exchange_query:from_currency=USD,to_currency=TWD,amount=1.0", "emotion": "neutral"}}
592
- - "今日新聞" → {{"is_tool_call": true, "tool_name": "news_query:country=tw,language=zh", "emotion": "neutral"}}
593
- - "科技新聞" → {{"is_tool_call": true, "tool_name": "news_query:query=科技,category=technology,language=zh", "emotion": "neutral"}}
594
- - "最近的Ubike站點" → {{"is_tool_call": true, "tool_name": "tdx_youbike", "emotion": "neutral"}}
595
- - "附近的YouBike" → {{"is_tool_call": true, "tool_name": "tdx_youbike", "emotion": "neutral"}}
596
- - "Ubike在哪" → {{"is_tool_call": true, "tool_name": "tdx_youbike", "emotion": "neutral"}}
597
- - "往台北的火車" → {{"is_tool_call": true, "tool_name": "tdx_train:destination_station=台北", "emotion": "neutral"}}
598
- - "你好" → {{"is_tool_call": false, "tool_name": "", "emotion": "neutral"}}
599
- - "我好難過..." → {{"is_tool_call": false, "tool_name": "", "emotion": "sad"}}
600
- - "煩死了" → {{"is_tool_call": false, "tool_name": "", "emotion": "angry"}}"""
601
-
602
  messages = [
603
  {"role": "system", "content": system_prompt},
604
  {"role": "user", "content": message}
605
  ]
606
-
607
- # 使用 Structured Outputs(動態推理強度)
 
608
  optimal_effort = get_optimal_reasoning_effort("intent_detection")
609
  logger.info(f"🧠 意圖檢測推理強度: {optimal_effort}")
610
-
611
- response = await ai_service.generate_response_for_user(
612
  messages=messages,
 
613
  user_id="intent_detection",
614
- model="gpt-5-nano",
615
- chat_id=None,
616
- use_structured_outputs=True,
617
- response_schema=self._get_intent_schema(),
618
- reasoning_effort=optimal_effort # 動態調整
619
  )
620
-
621
- logger.debug("GPT Structured Outputs 回應: %s", response)
622
-
623
- # 檢查是否為 fallback 錯誤訊息
624
- if response.strip() in ["抱歉,我暫時沒有合適的回應。可以換個說法再試試嗎?", "抱歉,生成回應時遇到問題。請重試。"]:
625
- logger.warning("Structured Outputs 返回 fallback 訊息,視為失敗")
626
- raise Exception("Structured Outputs failed with fallback message")
627
-
628
- # Structured Outputs 保證返回有效JSON,直接解析
629
- try:
630
- response_text = response.strip()
631
-
632
- # 處理 GPT 回應重複 JSON 的情況(如 {...}{...})
633
- # 只取第一個完整的 JSON 物件
634
- if response_text.startswith("{"):
635
- brace_count = 0
636
- end_idx = 0
637
- for i, char in enumerate(response_text):
638
- if char == "{":
639
- brace_count += 1
640
- elif char == "}":
641
- brace_count -= 1
642
- if brace_count == 0:
643
- end_idx = i + 1
644
- break
645
- if end_idx > 0:
646
- response_text = response_text[:end_idx]
647
 
648
- intent_data = json.loads(response_text)
649
- logger.debug("解析後的意圖資料: %s", _safe_json(intent_data))
650
-
651
- # 新的 schema 格式:is_tool_call, tool_name(包含參數)
652
- is_tool_call = intent_data.get("is_tool_call", False)
653
-
654
- if is_tool_call:
655
- tool_name_with_params = intent_data.get("tool_name", "").strip()
656
-
657
- if not tool_name_with_params:
658
- logger.warning("⚠️ GPT 標記為工具調用但未提供工具名稱,降級為聊天")
659
- return False, None
660
-
661
- raw_tool_name = tool_name_with_params
662
- params_str = ""
663
- if ":" in tool_name_with_params:
664
- raw_tool_name, params_str = tool_name_with_params.split(":", 1)
665
-
666
- tool_name = self._normalize_tool_name(raw_tool_name)
667
- if not tool_name:
668
- logger.warning(f"⚠️ 工具 {raw_tool_name} 無法對應到註冊名稱,降級為聊天")
669
- return False, None
670
-
671
- # 解析參數
672
  arguments = {}
673
- if params_str.strip():
674
- # 獲取工具的 input schema 以確定參數類型
675
- tool = self.mcp_server.tools.get(tool_name)
676
- input_schema = {}
677
- if tool and hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
678
- tool_class = tool.handler.__self__
679
- if hasattr(tool_class, 'get_input_schema'):
680
- try:
681
- input_schema = tool_class.get_input_schema()
682
- except:
683
- pass
684
-
685
- properties = input_schema.get('properties', {})
686
-
687
- for param_pair in params_str.split(","):
688
- if "=" not in param_pair:
689
- continue
690
- key, value = param_pair.split("=", 1)
691
- key = key.strip()
692
- value = value.strip()
693
-
694
- # 跳過空鍵或空值(避免傳入空字串導致驗證失敗)
695
- if not key or not value:
696
- continue
697
-
698
- # 根據 schema 中的類型定義來轉換值
699
- param_schema = properties.get(key, {})
700
- param_type = param_schema.get('type', 'string')
701
-
702
- normalized_value = value
703
-
704
- # 根據 schema 類型進行轉換
705
- if param_type == 'integer':
706
- try:
707
- normalized_value = int(value)
708
- except ValueError:
709
- normalized_value = value
710
- elif param_type == 'number':
711
- try:
712
- normalized_value = float(value)
713
- except ValueError:
714
- normalized_value = value
715
- elif param_type == 'boolean':
716
- lower_value = value.lower()
717
- if lower_value in ("true", "false"):
718
- normalized_value = lower_value == "true"
719
- # 其他類型(包括 string)保持原樣
720
-
721
- arguments[key] = normalized_value
722
-
723
- logger.info(f"✅ GPT 檢測到工具調用: {raw_tool_name.strip()} → {tool_name}")
724
- logger.debug("工具調用參數: %s", _safe_json(arguments))
725
-
726
- # 驗證工具是否存在
727
- if tool_name not in self.mcp_server.tools:
728
- logger.warning(f"⚠️ 工具 {tool_name} 不存在,降級為聊天")
729
- return False, None
730
-
731
- # 基礎參數驗證(可選,Structured Outputs 已保證格式)
732
- tool = self.mcp_server.tools[tool_name]
733
- if hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
734
- tool_class = tool.handler.__self__
735
- if hasattr(tool_class, 'validate_input'):
736
- try:
737
- validated_args = tool_class.validate_input(arguments)
738
- logger.debug("✓ 參數驗證通過: %s", _safe_json(validated_args))
739
- except Exception as e:
740
- logger.warning(f"⚠️ 參數驗證失敗: {e},仍然嘗試執行")
741
- # 不中斷,讓工具自己處理
742
-
743
- # 提取情緒(新增)
744
- emotion = intent_data.get("emotion", "neutral")
745
- logger.info(f"😊 偵測到情緒: {emotion}")
746
-
747
- intent_result = (True, {
748
- "type": "mcp_tool",
749
- "tool_name": tool_name,
750
- "arguments": arguments,
751
- "emotion": emotion # 新增情緒欄位
752
- })
753
-
754
- # 寫入快取
755
- self._intent_cache[cache_key] = (*intent_result, time_module.time())
756
- logger.debug(f"💾 意圖結果已快取: {tool_name}")
757
-
758
- return intent_result
759
-
760
- else:
761
- # is_tool_call = False,表示一般聊天
762
- logger.info("💬 GPT 判斷為一般聊天")
763
-
764
- # 提取情緒(新增)
765
- emotion = intent_data.get("emotion", "neutral")
766
- logger.info(f"😊 偵測到情緒: {emotion}")
767
-
768
- # 寫入快取(一般聊天也要回傳情緒)
769
- intent_result = (False, {"emotion": emotion})
770
- self._intent_cache[cache_key] = (*intent_result, time_module.time())
771
-
772
- return intent_result
773
-
774
- except json.JSONDecodeError as e:
775
- # Structured Outputs 不應該發生這種錯誤,記錄異常
776
- logger.error(f"❌ Structured Outputs JSON 解析失敗(異常情況): {e}, response: {response}")
777
- return False, None
778
-
779
  except Exception as e:
780
- logger.error(f"❌ GPT 意圖解析發生錯誤: {e}")
781
- # 降級處理:使用關鍵詞匹配
782
  logger.info("🔄 嘗試使用關鍵詞匹配作為降級方案")
783
  try:
784
  fallback_result = self._keyword_intent_detection(message)
@@ -787,10 +620,177 @@ class MCPAgentBridge:
787
  return fallback_result
788
  except Exception as fallback_error:
789
  logger.error(f"❌ 關鍵詞匹配也失敗: {fallback_error}")
790
-
791
  # 最終降級:視為一般聊天
792
  logger.info("💬 降級為一般聊天")
793
- return False, None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
794
 
795
  def _get_intent_schema(self) -> Dict[str, Any]:
796
  """
@@ -1138,23 +1138,33 @@ class MCPAgentBridge:
1138
  "你是一個友善、健談的AI助手。\n"
1139
  "用戶剛剛問了一個問題,我已經用工具查詢到資料了。\n"
1140
  "請用自然、口語化的方式回答用戶,就像朋友聊天一樣。\n\n"
1141
- "要求:\n"
 
 
 
 
1142
  "1. 使用口語化、親切的語氣(可以用「喔」「呢」「哦」等語氣詞)\n"
1143
  "2. 不要列表式的羅列數據,而是用對話方式描述\n"
1144
- "3. 突出最重要資訊(2-3句話)\n"
1145
  "4. 適當使用 emoji 增加親和力\n"
1146
- "5. 如果數據很多只說重點\n"
1147
  "6. 保持簡短(50字以內最好)\n\n"
1148
- "範例\n"
1149
- "❌ 不好:「當前溫23.88°C,體感溫度24.02°C,天氣狀況多雲...」\n"
1150
- "✅ 良好「台北現在23度左右,有點多雲呢!體感還蠻舒服的~」\n\n"
1151
- "記住你是聊天不是在報告數據!"
 
 
 
1152
  )
1153
 
1154
  user_prompt = (
1155
- f"用:「{original_message}」\n\n"
1156
- f"我用 {tool_name} 查到的資料:\n{content}\n\n"
1157
- f"請用自然對話的方式答用戶(簡短、親切、口語化):"
 
 
 
1158
  )
1159
 
1160
  messages = [
 
111
  ToolMetadata(
112
  name="reverse_geocode",
113
  requires_env={"lat", "lon"},
114
+ enable_reformat=True,
115
  )
116
  )
117
  register(
 
215
  await self.mcp_server.start_external_servers()
216
  logger.info(f"異步初始化完成,完整可用 MCP 工具數量: {len(self.mcp_server.tools)}")
217
 
218
+ # 將 MCP Server 的工具註冊到 tool_registry
219
+ self._sync_tools_to_registry()
220
+
221
  # 2025 最佳實踐:啟動時預熱熱門查詢快取
222
  await self._preheat_cache()
223
 
224
+ def _sync_tools_to_registry(self) -> int:
225
+ """
226
+ 將 MCP Server 的工具同步到 tool_registry
227
+
228
+ Returns:
229
+ 註冊的工具數量
230
+ """
231
+ from core.tool_registry import tool_registry
232
+
233
+ count = 0
234
+ for tool_name, tool in self.mcp_server.tools.items():
235
+ # 取得工具描述
236
+ description = getattr(tool, 'description', f'{tool_name} 工具')
237
+
238
+ # 取得參數 Schema
239
+ parameters = {"type": "object", "properties": {}, "required": []}
240
+ keywords = []
241
+ examples = []
242
+ negative_examples = []
243
+ category = "general"
244
+ priority = 100
245
+
246
+ if hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
247
+ tool_class = tool.handler.__self__
248
+
249
+ # 嘗試從 MCPTool 類別提取完整資訊
250
+ if hasattr(tool_class, 'get_input_schema'):
251
+ try:
252
+ parameters = tool_class.get_input_schema()
253
+ except Exception as e:
254
+ logger.warning(f"取得 {tool_name} schema 失敗: {e}")
255
+
256
+ # 提取增強元資料
257
+ keywords = getattr(tool_class, 'KEYWORDS', [])
258
+ examples = getattr(tool_class, 'USAGE_TIPS', [])
259
+ negative_examples = getattr(tool_class, 'NEGATIVE_EXAMPLES', [])
260
+ category = getattr(tool_class, 'CATEGORY', 'general')
261
+ priority = getattr(tool_class, 'PRIORITY', 100)
262
+
263
+ # 判斷是否需要位置
264
+ props = parameters.get("properties", {})
265
+ requires_location = "lat" in props or "lon" in props
266
+
267
+ tool_registry.register(
268
+ name=tool_name,
269
+ description=description,
270
+ parameters=parameters,
271
+ handler=getattr(tool, 'handler', None),
272
+ category=category,
273
+ requires_location=requires_location,
274
+ keywords=keywords,
275
+ examples=examples,
276
+ )
277
+ count += 1
278
+
279
+ logger.info(f"🔧 同步 {count} 個工具到 tool_registry")
280
+ return count
281
+
282
  def _normalize_tool_name(self, raw_name: Optional[str]) -> Optional[str]:
283
  """
284
  將 GPT 回傳的工具名稱正規化為註冊表中的實際名稱。
 
476
  async def detect_intent(self, message: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
477
  """
478
  檢測用戶消息中的意圖 (保持與舊 FeatureRouter 相同介面)
479
+
480
+ 2025 重構版:使用 OpenAI 原生 Function Calling
481
+ - 不再使用巨大的 system_prompt 描述每個工具
482
+ - 工具定義由 tools 參數傳遞,GPT 原生選擇
483
+ - 新增工具只需註冊到 Registry,不需更新任何 prompt
484
 
485
  參數:
486
  message (str): 用戶消息
 
488
  返回:
489
  tuple: (是否檢測到意圖, 意圖數據)
490
  """
491
+ # 使用新的 IntentDetector(基於 OpenAI Function Calling)
492
+ return await self._detect_intent_with_function_calling(message)
493
+
494
+ async def _detect_intent_with_function_calling(self, message: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
495
+ """
496
+ 使用 OpenAI 原生 Function Calling 進行意圖檢測
497
+
498
+ 核心改進:
499
+ 1. 工具定義自動從 Registry 生成
500
+ 2. GPT 原生選擇工具並生成結構化參數
501
+ 3. 不需要自定義 prompt 描述每個工具
502
+ """
503
  import hashlib
504
  import time as time_module
505
+
506
  # 生成快取鍵
507
  cache_key = hashlib.md5(message.encode()).hexdigest()
508
 
509
  # 檢查快取
510
  if cache_key in self._intent_cache:
511
  has_feature, intent_data, cached_time = self._intent_cache[cache_key]
 
512
  if time_module.time() - cached_time < self._intent_cache_ttl:
513
  logger.debug(f"💾 意圖快取命中: {message[:50]}...")
514
  return has_feature, intent_data
515
  else:
 
516
  del self._intent_cache[cache_key]
517
 
518
+ logger.info(f"🔍 檢測意圖(Function Calling): \"{message[:100]}...\"")
 
519
 
520
  # 檢查特殊命令
521
  for command in ["功能列表", "有什麼功能", "能做什麼"]:
522
  if command in message:
523
  logger.info(f"檢測到特殊命令: {command}")
524
+ return True, {"type": "special_command", "command": "feature_list"}
 
 
 
525
 
 
526
  try:
527
+ # tool_registry 取得 OpenAI tools 格式
528
+ from core.tool_registry import tool_registry
529
+ from core.tool_router import tool_router
530
+
531
+ all_tools = tool_registry.get_openai_tools(strict=False)
532
+
533
+ if not all_tools:
534
+ logger.warning("⚠️ 沒有可用的工具,降級為聊天")
535
+ return False, {"emotion": "neutral"}
536
+
537
+ # 使用 ToolRouter 動態過濾和排序工具
538
+ context = {"hour": datetime.now().hour}
539
+ tools = tool_router.filter_tools(all_tools, message, context)
540
+
541
+ logger.info(f"🔧 載入 {len(all_tools)} 個工具,過濾後 {len(tools)} 個")
542
+
543
+ # 建構精簡的 system prompt(只處特殊規則)
544
+ system_prompt = self._build_function_calling_prompt()
545
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  messages = [
547
  {"role": "system", "content": system_prompt},
548
  {"role": "user", "content": message}
549
  ]
550
+
551
+ # 使用 OpenAI Function Calling
552
+ from core.reasoning_strategy import get_optimal_reasoning_effort
553
  optimal_effort = get_optimal_reasoning_effort("intent_detection")
554
  logger.info(f"🧠 意圖檢測推理強度: {optimal_effort}")
555
+
556
+ response = await ai_service.generate_response_with_tools(
557
  messages=messages,
558
+ tools=tools,
559
  user_id="intent_detection",
560
+ model="gpt-4o-mini", # 使用更強的模型以提升參數提取準確度
561
+ reasoning_effort=None, # gpt-4o-mini 不支援 reasoning_effort
562
+ tool_choice="auto",
 
 
563
  )
564
+
565
+ # 解析回應
566
+ tool_calls = response.get("tool_calls", [])
567
+
568
+ if tool_calls:
569
+ # GPT 選擇了工具
570
+ tool_call = tool_calls[0]
571
+ function = tool_call.get("function", {})
572
+ tool_name = function.get("name", "")
573
+ arguments_str = function.get("arguments", "{}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
574
 
575
+ try:
576
+ arguments = json.loads(arguments_str)
577
+ except json.JSONDecodeError:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
578
  arguments = {}
579
+
580
+ # 正規化工具名稱
581
+ normalized_name = self._normalize_tool_name(tool_name)
582
+ if not normalized_name:
583
+ logger.warning(f"⚠️ 工具 {tool_name} 無法對應到註冊名稱,降級為聊天")
584
+ return False, {"emotion": "neutral"}
585
+
586
+ logger.info(f"✅ GPT 選擇工具: {normalized_name}")
587
+ logger.debug(f"工具參數: {_safe_json(arguments)}")
588
+
589
+ # 提取情緒(從 content 或預設)
590
+ emotion = self._extract_emotion_from_content(response.get("content", ""))
591
+
592
+ intent_result = (True, {
593
+ "type": "mcp_tool",
594
+ "tool_name": normalized_name,
595
+ "arguments": arguments,
596
+ "emotion": emotion,
597
+ })
598
+
599
+ # 寫入快取
600
+ self._intent_cache[cache_key] = (*intent_result, time_module.time())
601
+ return intent_result
602
+
603
+ else:
604
+ # GPT 未選擇工具,視為一般聊天
605
+ logger.info("💬 GPT 判斷為一般聊天")
606
+ emotion = self._extract_emotion_from_content(response.get("content", ""))
607
+
608
+ intent_result = (False, {"emotion": emotion})
609
+ self._intent_cache[cache_key] = (*intent_result, time_module.time())
610
+ return intent_result
611
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
  except Exception as e:
613
+ logger.error(f"❌ Function Calling 意圖檢測失敗: {e}")
614
+ # 降級:使用關鍵詞匹配
615
  logger.info("🔄 嘗試使用關鍵詞匹配作為降級方案")
616
  try:
617
  fallback_result = self._keyword_intent_detection(message)
 
620
  return fallback_result
621
  except Exception as fallback_error:
622
  logger.error(f"❌ 關鍵詞匹配也失敗: {fallback_error}")
623
+
624
  # 最終降級:視為一般聊天
625
  logger.info("💬 降級為一般聊天")
626
+ return False, {"emotion": "neutral"}
627
+
628
+ def _build_function_calling_prompt(self) -> str:
629
+ """
630
+ 建構精簡的 Function Calling system prompt
631
+
632
+ 注意:不再描述每個工具,工具定義由 tools 參數傳遞
633
+ 只處理特殊規則和情緒判斷
634
+ """
635
+ return """你是一個智能助手,根據用戶需求選擇合適的工具。
636
+
637
+ 規則:
638
+ 1. 如果用戶需求可以用工具解決,選擇最適合的工具
639
+ 2. 如果是一般聊天或問候,不要選擇任何工具
640
+ 3. 工具參數盡量從用戶消息中提取,無法確定的使用合理預設值
641
+
642
+ 【重要】語言使用規範:
643
+ - 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)
644
+ - 回覆用戶時:必須使用繁體中文
645
+ - 範例:用戶說「台北天氣」→ 參數 {"city": "Taipei"},回覆「台北目前...」
646
+
647
+ 參數語言轉換規則:
648
+ - 城市名稱:台北→Taipei, 新北→NewTaipei, 桃園→Taoyuan, 台中→Taichung, 台南→Tainan, 高雄→Kaohsiung, 新竹→Hsinchu
649
+ - 國家名稱:台灣→Taiwan, 美國→USA, 日本→Japan, 英國→UK
650
+ - 貨幣代碼:美元→USD, 台幣→TWD, 日圓→JPY, 歐元→EUR, 英鎊→GBP
651
+
652
+ 【重要】城市參數提取原則:
653
+ - 只有在用戶明確提到城市名稱時才填 city 參數
654
+ - 「附近」「這裡」「我這邊」等詞 → 不填 city 參數,系統會自動從 GPS 判斷
655
+ - 「台北的XX」「桃園XX」→ 填對應的英文城市名
656
+ - 範例:「附近的 YouBike」→ {},「桃園的 YouBike」→ {"city": "Taoyuan"}
657
+
658
+ 匯率查詢(重要!參數提取規則):
659
+ 當用戶詢問匯率資訊時,你必須從消息中提取貨幣代碼並填入參數。
660
+
661
+ 參數提取規則:
662
+ 1. 句型「[貨幣A]轉[貨幣B]」「[貨幣A]換[貨幣B]」「[貨幣A]兌[貨幣B]」→ {"from_currency": "代碼A", "to_currency": "代碼B"}
663
+ 2. 句型「[數字][貨幣A]是多少[貨幣B]」→ {"from_currency": "代碼A", "to_currency": "代碼B", "amount": 數字}
664
+ 3. 句型「匯率」「美金」「日幣」→ 提取提到的貨幣
665
+ 4. 貨幣代碼必須用 ISO 4217 標準(3個大寫字母)
666
+
667
+ 常見貨幣代碼對照:
668
+ - 美元/美金 → USD
669
+ - 台幣/新台幣 → TWD
670
+ - 日圓/日幣 → JPY
671
+ - 歐元 → EUR
672
+ - 英鎊 → GBP
673
+ - 人民幣 → CNY
674
+ - 港幣 → HKD
675
+ - 韓元 → KRW
676
+
677
+ 實際範例:
678
+ - 「美元轉日幣的匯率」→ {"from_currency": "USD", "to_currency": "JPY"}
679
+ - 「台幣換美金」→ {"from_currency": "TWD", "to_currency": "USD"}
680
+ - 「100美元是多少台幣」→ {"from_currency": "USD", "to_currency": "TWD", "amount": 100}
681
+ - 「歐元兌日圓」→ {"from_currency": "EUR", "to_currency": "JPY"}
682
+ - 「匯率」→ {"from_currency": "USD", "to_currency": "TWD"}(預設)
683
+
684
+ 重要:必須提取貨幣代碼!不要返回空參數!
685
+
686
+ 公車查詢(重要!參數提取規則):
687
+ 當用戶詢問公車資訊時,你必須從消息中提取路線號碼並填入參數。
688
+
689
+ tdx_bus_arrival 適用場景:
690
+ - 查詢「已知路線號碼」的到站時間
691
+ - 查詢附近公車站點(不需 route_name)
692
+
693
+ 參數提取規則:
694
+ 1. 句型「[數字]公車」「[數字]號公車」→ {"route_name": "數字"}
695
+ 2. 句型「[顏色][數字]」(如「紅30」)→ {"route_name": "顏色數字"}
696
+ 3. 句型「[數字]還要多久」「[數字]什麼時候到」→ {"route_name": "數字"}
697
+ 4. 句型「[路線名]公車到站」→ {"route_name": "路線名"}
698
+ 5. 「附近公車」「公車站」「有什麼公車」→ {}(系統自動從 GPS 判斷城市)
699
+ 6. 城市參數:只在用戶明確提到城市時才填,否則留空讓系統自動判斷
700
+
701
+ 實際範例:
702
+ - 「261公車什麼時候到」→ {"route_name": "261"}(不填 city)
703
+ - 「307還要多久」→ {"route_name": "307"}(不填 city)
704
+ - 「台北261公車」→ {"route_name": "261", "city": "Taipei"}(明確提到台北)
705
+ - 「桃園紅30公車」→ {"route_name": "紅30", "city": "Taoyuan"}(明確提到桃園)
706
+ - 「附近有什麼公車」→ {}(完全空參數,系統自動判斷)
707
+
708
+ 不適用場景(應使用 directions):
709
+ - 「從A到B的公車」「往XX的公車」→ 這是路線規劃,不是查詢特定路線
710
+ - 「去台北的公車」→ 台北是目的地,不是路線號碼
711
+
712
+ 重要:如果提到路線號碼,必須提取!城市參數必須用英文!
713
+
714
+ 火車查詢(重要!參數提取規則):
715
+ 當用戶詢問火車資訊時,你必須從消息中提取站名並填入參數。
716
+
717
+ 參數提取規則(適用於任何地名):
718
+ 1. 句型「從 [地名A] 往/到 [地名B]」→ {"origin_station": "地名A", "destination_station": "地名B"}
719
+ 2. 句型「[地名A] 到/往 [地名B]」→ {"origin_station": "地名A", "destination_station": "地名B"}
720
+ 3. 句型「往/去 [地名]」→ {"destination_station": "地名"}
721
+ 4. 句型「[車種][數字]次」→ {"train_no": "數字"}
722
+ 5. 包含時間 → 提取為 departure_time(HH:MM 格式)
723
+
724
+ 實際範例:
725
+ - 「從彰化往台北的火車」→ {"origin_station": "彰化", "destination_station": "台北"}
726
+ - 「台中到高雄」→ {"origin_station": "台中", "destination_station": "高雄"}
727
+ - 「往新竹的火車」→ {"destination_station": "新竹"}
728
+ - 「自強號123次」→ {"train_no": "123"}
729
+ - 「早上8點台南到台北」→ {"origin_station": "台南", "destination_station": "台北", "departure_time": "08:00"}
730
+
731
+ 重要:絕對不要返回空的 {} 參數!必須從用戶消息中提取站名!
732
+
733
+ 位置查詢:
734
+ - 「我在哪」使用 reverse_geocode,不需要參數
735
+ - 「怎麼去XX」使用 forward_geocode 或 directions
736
+
737
+ YouBike 查詢(重要!參數提取規則):
738
+ 當用戶詢問 YouBike/Ubike/微笑單車時,你必須調用 tdx_youbike 工具。
739
+
740
+ 參數提取規則:
741
+ 1. 「附近的 YouBike」「Ubike 在哪」→ {}(不填 city,系統自動從 GPS 判斷)
742
+ 2. 「市政府 YouBike」「台北車站 Ubike」→ {"station_name": "市政府"}(不填 city)
743
+ 3. 「XX站還有車嗎」→ {"station_name": "XX站"}(不填 city)
744
+ 4. 「台北的 YouBike」「桃園 YouBike」→ 填對應英文城市名
745
+ 5. 站名可用中文,城市必須用英文
746
+
747
+ 實際範例:
748
+ - 「附近的 YouBike」→ {}(完全空參數,系統自動判斷城市)
749
+ - 「市政府 YouBike 還有車嗎」→ {"station_name": "市政府"}(不填 city)
750
+ - 「台北車站 Ubike」→ {"station_name": "台北車站"}(不填 city)
751
+ - 「台北的 YouBike」→ {"city": "Taipei"}(明確提到台北)
752
+ - 「桃園 YouBike」→ {"city": "Taoyuan"}(明確提到桃園)
753
+
754
+ 重要:只在用戶明確提到城市時才填 city 參數!站名可保持中文!
755
+
756
+ 【情緒偵測】(重要!):
757
+ - 分析用戶的情緒狀態(根據用詞、語氣、標點符號、表情符號)
758
+ - 在回應的最後一行加上情緒標籤:[EMOTION:情緒]
759
+ - 情緒類型:neutral(平靜)、happy(開心)、sad(難過)、angry(生氣)、fear(害怕)、surprise(驚訝)
760
+ - 範例:
761
+ * 用戶說「我現在覺得很生氣」→ 回應最後加上 [EMOTION:angry]
762
+ * 用戶說「好開心啊!」→ 回應最後加上 [EMOTION:happy]
763
+ * 用戶說「我好難過...」→ 回應最後加上 [EMOTION:sad]
764
+ * 用戶說「好可怕」→ 回應最後加上 [EMOTION:fear]
765
+ * 用戶說「哇!」→ 回應最後加上 [EMOTION:surprise]
766
+ * 一般對話 → 回應最後加上 [EMOTION:neutral]
767
+ """
768
+
769
+ def _extract_emotion_from_content(self, content: str) -> str:
770
+ """從回應內容中提取情緒標籤 [EMOTION:xxx]"""
771
+ if not content:
772
+ return "neutral"
773
+
774
+ # 優先從標籤提取
775
+ import re
776
+ emotion_match = re.search(r'\[EMOTION:(neutral|happy|sad|angry|fear|surprise)\]', content, re.IGNORECASE)
777
+ if emotion_match:
778
+ emotion = emotion_match.group(1).lower()
779
+ logger.info(f"😊 從標籤提取情緒: {emotion}")
780
+ return emotion
781
+
782
+ # 降級:從內容搜尋英文關鍵字
783
+ content_lower = content.lower()
784
+ emotions = ["happy", "sad", "angry", "fear", "surprise"]
785
+
786
+ for emotion in emotions:
787
+ if emotion in content_lower:
788
+ logger.debug(f"從內容搜尋到情緒關鍵字: {emotion}")
789
+ return emotion
790
+
791
+ return "neutral"
792
+
793
+ # 舊版 _detect_intent_legacy 已移除,改用 _detect_intent_with_function_calling
794
 
795
  def _get_intent_schema(self) -> Dict[str, Any]:
796
  """
 
1138
  "你是一個友善、健談的AI助手。\n"
1139
  "用戶剛剛問了一個問題,我已經用工具查詢到資料了。\n"
1140
  "請用自然、口語化的方式回答用戶,就像朋友聊天一樣。\n\n"
1141
+ "【核心原則】\n"
1142
+ "⭐ 只回答使用者問的問題,不要把所有數據都說出來\n"
1143
+ "⭐ 分析使用者的核心意圖(問溫度?天氣?時間?地點?數量?)\n"
1144
+ "⭐ 從工具數據中只提取相關資訊,無關資訊一律省略\n\n"
1145
+ "【回應要求】\n"
1146
  "1. 使用口語化、親切的語氣(可以用「喔」「呢」「哦」等語氣詞)\n"
1147
  "2. 不要列表式的羅列數據,而是用對話方式描述\n"
1148
+ "3. 只說使用者問內容(2-3句話)\n"
1149
  "4. 適當使用 emoji 增加親和力\n"
1150
+ "5. 如有額外有用資訊可簡短補充(不超過一句話)\n"
1151
  "6. 保持簡短(50字以內最好)\n\n"
1152
+ "範例\n"
1153
+ "用戶問:「台北現在幾」\n"
1154
+ "工具返回溫度23.88°C、濕65%、風速3m/s、氣壓1013hPa...\n"
1155
+ "❌ 錯誤「台北現23度濕度65%,風速3m/s...」(說太多)\n"
1156
+ "✅ 正確:「台北現在23度左右喔!」(只回答溫度)\n"
1157
+ "✅ 可接受:「台北現在23度,體感蠻舒服的~」(簡短補充)\n\n"
1158
+ "記住:精準回答使用者的問題,不要喧賓奪主!"
1159
  )
1160
 
1161
  user_prompt = (
1162
+ f"【使者的核心題】\n"
1163
+ f"{original_message}\n\n"
1164
+ f"【工具 {tool_name} 返的數據】\n"
1165
+ f"{content}\n\n"
1166
+ f"【任務】\n"
1167
+ f"請只回答使用者問的問題(簡短、親切、口語化):"
1168
  )
1169
 
1170
  messages = [
features/mcp/tools/exchange_tool.py CHANGED
@@ -101,12 +101,14 @@ class ExchangeTool(MCPTool):
101
  return cls.create_success_response(
102
  content=formatted_text,
103
  data={
104
- "rate": rate,
105
- "from_currency": from_currency,
106
- "to_currency": to_currency,
107
- "amount": amount,
108
- "converted_amount": amount * rate if conversion else None,
109
- "raw_data": rate_data
 
 
110
  }
111
  )
112
  else:
 
101
  return cls.create_success_response(
102
  content=formatted_text,
103
  data={
104
+ "raw_data": {
105
+ "rate": rate,
106
+ "from_currency": from_currency,
107
+ "to_currency": to_currency,
108
+ "amount": amount,
109
+ "converted_amount": amount * rate if conversion else None,
110
+ "rate_data": rate_data
111
+ }
112
  }
113
  )
114
  else:
features/mcp/tools/geocode_tool.py CHANGED
@@ -168,7 +168,7 @@ class ReverseGeocodeTool(MCPTool):
168
 
169
  # 地點名稱(優先使用繁中)
170
  name = data.get("name") or ""
171
- namedetails = data.get("namedetails", {})
172
  name_zh = namedetails.get("name:zh") or namedetails.get("name:zh-TW") or name
173
 
174
  display_name = data.get("display_name") or ""
 
168
 
169
  # 地點名稱(優先使用繁中)
170
  name = data.get("name") or ""
171
+ namedetails = data.get("namedetails") or {}
172
  name_zh = namedetails.get("name:zh") or namedetails.get("name:zh-TW") or name
173
 
174
  display_name = data.get("display_name") or ""
features/mcp/tools/healthkit_tool.py CHANGED
@@ -110,8 +110,8 @@ class HealthKitTool(MCPTool):
110
 
111
  logger.debug("✅ 使用 Firestore 數據庫連接")
112
 
113
- # 解析參數
114
- user_id = arguments.get('user_id')
115
  metric_type = arguments.get('metric_type', 'all')
116
  days = arguments.get('days', 7)
117
  latest_only = arguments.get('latest_only', False)
@@ -120,6 +120,7 @@ class HealthKitTool(MCPTool):
120
  # 如果沒有提供 user_id,返回錯誤
121
  if not user_id:
122
  return cls.create_error_response(
 
123
  code="USER_ID_REQUIRED"
124
  )
125
 
@@ -197,18 +198,27 @@ class HealthKitTool(MCPTool):
197
  except Exception:
198
  pass # 設備資訊是可選的
199
 
 
 
 
 
 
200
  return cls.create_success_response(
201
  content=summary,
202
  data={
203
- "health_data": data,
204
- "count": len(data),
205
- "query": {
206
- "metric_type": metric_type,
207
- "days": days,
208
- "latest_only": latest_only,
209
- "aggregation": aggregation
210
- },
211
- "device_info": device_info
 
 
 
 
212
  }
213
  )
214
 
@@ -325,7 +335,8 @@ class HealthKitTool(MCPTool):
325
  if values:
326
  avg_value = sum(values) / len(values)
327
  latest_value = values[0] if values else 0
328
- summary += f"• {name}: 最新 {latest_value:.1f},平均 {avg_value:.1f} ({len(values)} 筆記錄)\n"
 
329
  else:
330
  name = metric_names.get(metric_type, metric_type)
331
  if len(data) == 1:
@@ -336,6 +347,7 @@ class HealthKitTool(MCPTool):
336
  values = [p["value"] for p in data]
337
  avg_value = sum(values) / len(values)
338
  latest_value = values[0] if values else 0
339
- summary = f"{name}據:最新 {latest_value:.1f} {data[0]['unit']}平均 {avg_value:.1f},共 {len(data)} 筆記錄"
 
340
 
341
  return summary
 
110
 
111
  logger.debug("✅ 使用 Firestore 數據庫連接")
112
 
113
+ # 解析參數(_user_id 由 coordinator 注入)
114
+ user_id = arguments.get('_user_id')
115
  metric_type = arguments.get('metric_type', 'all')
116
  days = arguments.get('days', 7)
117
  latest_only = arguments.get('latest_only', False)
 
120
  # 如果沒有提供 user_id,返回錯誤
121
  if not user_id:
122
  return cls.create_error_response(
123
+ error="需要提供用戶 ID",
124
  code="USER_ID_REQUIRED"
125
  )
126
 
 
198
  except Exception:
199
  pass # 設備資訊是可選的
200
 
201
+ # 限制返回給前端的數據量(避免傳輸過多數據)
202
+ # 前端工具卡片只顯示最新的 20 筆,但 count 顯示總數
203
+ display_limit = 20
204
+ display_data = data[:display_limit] if len(data) > display_limit else data
205
+
206
  return cls.create_success_response(
207
  content=summary,
208
  data={
209
+ "raw_data": {
210
+ "health_data": display_data,
211
+ "count": len(data),
212
+ "total_records": len(data),
213
+ "displayed_records": len(display_data),
214
+ "query": {
215
+ "metric_type": metric_type,
216
+ "days": days,
217
+ "latest_only": latest_only,
218
+ "aggregation": aggregation
219
+ },
220
+ "device_info": device_info
221
+ }
222
  }
223
  )
224
 
 
335
  if values:
336
  avg_value = sum(values) / len(values)
337
  latest_value = values[0] if values else 0
338
+ # 不顯示記錄數,避免使用者困惑
339
+ summary += f"• {name}: 最新 {latest_value:.1f},平均 {avg_value:.1f}\n"
340
  else:
341
  name = metric_names.get(metric_type, metric_type)
342
  if len(data) == 1:
 
347
  values = [p["value"] for p in data]
348
  avg_value = sum(values) / len(values)
349
  latest_value = values[0] if values else 0
350
+ # 不顯示記錄數,避免使用者困惑(AI 會根據需要提取重點)
351
+ summary = f"{name}數據:最新 {latest_value:.1f} {data[0]['unit']},平均 {avg_value:.1f}"
352
 
353
  return summary
features/mcp/tools/news_tool.py CHANGED
@@ -98,7 +98,14 @@ class NewsTool(MCPTool):
98
  "content": {"type": "string"},
99
  "url": {"type": "string"},
100
  "published_at": {"type": "string"},
101
- "source": {"type": "string"},
 
 
 
 
 
 
 
102
  "category": {"type": "array"},
103
  "language": {"type": "string"},
104
  "sentiment": {"type": "string"}
@@ -151,9 +158,11 @@ class NewsTool(MCPTool):
151
  return cls.create_success_response(
152
  content=formatted_text,
153
  data={
154
- "articles": articles,
155
- "count": len(articles),
156
- "totalResults": total_results
 
 
157
  }
158
  )
159
  else:
 
98
  "content": {"type": "string"},
99
  "url": {"type": "string"},
100
  "published_at": {"type": "string"},
101
+ "source": {
102
+ "type": "object",
103
+ "properties": {
104
+ "name": {"type": "string"},
105
+ "id": {"type": "string"},
106
+ "url": {"type": "string"}
107
+ }
108
+ },
109
  "category": {"type": "array"},
110
  "language": {"type": "string"},
111
  "sentiment": {"type": "string"}
 
158
  return cls.create_success_response(
159
  content=formatted_text,
160
  data={
161
+ "raw_data": {
162
+ "articles": articles,
163
+ "count": len(articles),
164
+ "totalResults": total_results
165
+ }
166
  }
167
  )
168
  else:
features/mcp/tools/tdx_bus_arrival.py CHANGED
@@ -24,15 +24,24 @@ class TDXBusArrivalTool(MCPTool):
24
  """TDX 公車即時到站查詢"""
25
 
26
  NAME = "tdx_bus_arrival"
27
- DESCRIPTION = "查詢公車即時到站時間(自動感知戶位置,找最近站)"
28
  CATEGORY = "道路運輸"
29
  TAGS = ["tdx", "公車", "即時到站", "公共運輸"]
30
- KEYWORDS = ["公車", "巴士", "bus", "到站", "即時", "幾分鐘"]
31
  USAGE_TIPS = [
32
- "查詢特定路線: 「307 公車還要多久」",
33
- "查詢附近公車站: 附近有什麼公車」",
34
- "指定城市: 台北 307「高雄紅30」"
35
  ]
 
 
 
 
 
 
 
 
 
36
 
37
  # TDX 城市代碼
38
  VALID_CITIES = {
@@ -81,7 +90,7 @@ class TDXBusArrivalTool(MCPTool):
81
  "route_name": {"type": "string"},
82
  "stop_name": {"type": "string"},
83
  "direction": {"type": "integer"},
84
- "estimate_time": {"type": "integer"},
85
  "status": {"type": "string"}
86
  }
87
  }
 
24
  """TDX 公車即時到站查詢"""
25
 
26
  NAME = "tdx_bus_arrival"
27
+ DESCRIPTION = "查詢公車即時到站時間。適於:1) 已知路線號碼(如307、紅30)的到站查詢;2) 查詢附公車。不適用於路線規劃(如「從A到B的公車」應用directions"
28
  CATEGORY = "道路運輸"
29
  TAGS = ["tdx", "公車", "即時到站", "公共運輸"]
30
+ KEYWORDS = ["公車", "巴士", "bus", "到站", "即時", "幾分鐘", "公車站", "等公車", "路線號碼"]
31
  USAGE_TIPS = [
32
+ "「307 公車還要多久」→ route_name=307",
33
+ "「紅30 什麼時候來→ route_name=紅30",
34
+ "「附近有什麼公車→ 不需參數,用 GPS 查詢附近站點"
35
  ]
36
+ NEGATIVE_EXAMPLES = [
37
+ "「埔鹽站往彰化的公車」→ 這是路線規劃,不是查詢特定路線!應用 directions",
38
+ "「往彰化的公車」→ 彰化是目的地,不是路線號碼!應用 directions",
39
+ "「去台北的公車」→ 台北是目的地,不是路線號碼!應用 directions",
40
+ "「從A到B的公車」→ 這是路線規劃,應用 directions",
41
+ "「公車路線圖」→ 這是詢問路線圖,不是查到站時間"
42
+ ]
43
+ PRIORITY = 5
44
+ ALIASES = ["bus", "公車", "巴士"]
45
 
46
  # TDX 城市代碼
47
  VALID_CITIES = {
 
90
  "route_name": {"type": "string"},
91
  "stop_name": {"type": "string"},
92
  "direction": {"type": "integer"},
93
+ "estimate_time": {"type": ["integer", "null"]},
94
  "status": {"type": "string"}
95
  }
96
  }
features/mcp/tools/tdx_train.py CHANGED
@@ -18,16 +18,22 @@ class TDXTrainTool(MCPTool):
18
  """TDX 台鐵時刻表查詢"""
19
 
20
  NAME = "tdx_train"
21
- DESCRIPTION = "查詢台鐵列車時刻表、票價、最近車站含高鐵轉乘資訊)"
22
  CATEGORY = "軌道運輸"
23
  TAGS = ["tdx", "台鐵", "TRA", "火車", "時刻表"]
24
- KEYWORDS = ["台鐵", "臺鐵", "火車", "TRA", "列車", "時刻"]
25
  USAGE_TIPS = [
26
- "查詢車次: 「自強號 123 次」",
27
- "查詢路線: 「台北到台中的火車」",
28
- "查詢最近車站: 最近的火車站在哪」",
29
- "查詢時刻: 「下午3點台北到高雄」"
30
  ]
 
 
 
 
 
 
31
 
32
  @classmethod
33
  def get_input_schema(cls) -> Dict[str, Any]:
 
18
  """TDX 台鐵時刻表查詢"""
19
 
20
  NAME = "tdx_train"
21
+ DESCRIPTION = "查詢台鐵列車時刻表。參數提取規則:「從A到B」→origin_station=A,destination_station=B;「往B」→destination_station=B起點用GPS;「車次123」→train_no=123。"
22
  CATEGORY = "軌道運輸"
23
  TAGS = ["tdx", "台鐵", "TRA", "火車", "時刻表"]
24
+ KEYWORDS = ["台鐵", "臺鐵", "火車", "TRA", "列車", "時刻", "自強號", "莒光號", "區間車"]
25
  USAGE_TIPS = [
26
+ "「自強號 123 次」→ train_no=123",
27
+ "「台北到台中的火車」→ origin_station=台北, destination_station=台中",
28
+ "「往台北的火車」→ destination_station=台北(起點用 GPS)",
29
+ "「下午3點台北到高雄」→ origin_station=台北, destination_station=高雄, departure_time=15:00"
30
  ]
31
+ NEGATIVE_EXAMPLES = [
32
+ "「火車票怎麼買」→ 這是詢問購票方式,不是查時刻表",
33
+ "「火車站在哪」→ 這是查位置,應用 reverse_geocode 或 forward_geocode"
34
+ ]
35
+ PRIORITY = 8
36
+ ALIASES = ["train", "台鐵", "火車"]
37
 
38
  @classmethod
39
  def get_input_schema(cls) -> Dict[str, Any]:
features/mcp/tools/tdx_youbike.py CHANGED
@@ -20,12 +20,24 @@ class TDXBikeTool(MCPTool):
20
  DESCRIPTION = "查詢附近 YouBike 站點、即時車輛數、空位數(支援 YouBike 1.0/2.0)"
21
  CATEGORY = "微型運具"
22
  TAGS = ["tdx", "youbike", "ubike", "共享單車", "微笑單車"]
23
- KEYWORDS = ["YouBike", "UBike", "微笑單車", "共享單車", "腳踏車", "自行車"]
 
 
 
 
 
 
24
  USAGE_TIPS = [
25
- "查詢附近站點: 「附近的 YouBike 在哪」",
26
- "查詢特定站點: 「市政府 YouBike 還有車嗎」",
27
- "指定城: 「台北 YouBike」「高雄 CityBike」"
 
 
 
 
28
  ]
 
 
29
 
30
  # 城市對應
31
  CITY_MAP = {
@@ -43,6 +55,16 @@ class TDXBikeTool(MCPTool):
43
 
44
  @classmethod
45
  def get_input_schema(cls) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
46
  return StandardToolSchemas.create_input_schema({
47
  "station_name": {
48
  "type": "string",
@@ -50,8 +72,8 @@ class TDXBikeTool(MCPTool):
50
  },
51
  "city": {
52
  "type": "string",
53
- "description": "城市名稱(如「Taipei」「Kaohsiung」)",
54
- "enum": list(cls.CITY_MAP.values())
55
  },
56
  "radius_m": {
57
  "type": "integer",
@@ -108,6 +130,11 @@ class TDXBikeTool(MCPTool):
108
 
109
  station_name = safe_str(arguments.get("station_name"))
110
  city = arguments.get("city")
 
 
 
 
 
111
  radius_m = min(int(arguments.get("radius_m", 500)), 2000)
112
  limit = min(int(arguments.get("limit", 5)), 20)
113
 
@@ -166,7 +193,20 @@ class TDXBikeTool(MCPTool):
166
  final_city = guessed
167
  city_source = "經緯度推斷"
168
 
169
- city = cls._map_city_name(final_city) if final_city else "Taipei"
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  logger.info(f"🏙️ 最終使用城市代碼: {city} (來源={city_source})")
171
 
172
  # 3. 查詢分支
@@ -365,6 +405,7 @@ class TDXBikeTool(MCPTool):
365
  ("新北", 24.67, 25.30, 121.35, 122.01),
366
  ("新竹", 24.68, 24.90, 120.90, 121.10),
367
  ("台中", 24.00, 24.45, 120.45, 121.05),
 
368
  ("台南", 22.85, 23.40, 120.00, 120.55),
369
  ("高雄", 22.45, 23.15, 120.15, 120.80),
370
  ]
@@ -386,6 +427,31 @@ class TDXBikeTool(MCPTool):
386
  return value
387
  return "Taipei"
388
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  @staticmethod
390
  def _detect_bike_type(station: Dict, station_name: str) -> str:
391
  """判斷 YouBike 類型(優先從站名判斷,其次從 BikesCapacity)"""
 
20
  DESCRIPTION = "查詢附近 YouBike 站點、即時車輛數、空位數(支援 YouBike 1.0/2.0)"
21
  CATEGORY = "微型運具"
22
  TAGS = ["tdx", "youbike", "ubike", "共享單車", "微笑單車"]
23
+ KEYWORDS = [
24
+ "YouBike", "Youbike", "youbike", "YOUBIKE",
25
+ "UBike", "Ubike", "ubike", "UBIKE",
26
+ "微笑單車", "共享單車", "公共單車",
27
+ "腳踏車站", "單車站", "自行車站",
28
+ "借車", "還車", "腳踏車"
29
+ ]
30
  USAGE_TIPS = [
31
+ "「附近的 YouBike」→ 查詢最近站點",
32
+ "「Ubike 在哪」→ 查詢最近站點",
33
+ "政府 YouBike 還有車嗎→ station_name=市政府"
34
+ ]
35
+ NEGATIVE_EXAMPLES = [
36
+ "「YouBike 怎麼註冊」→ 這是詢問註冊方式,不是查站點",
37
+ "「YouBike 費率」→ 這是詢問價格,不是查站點"
38
  ]
39
+ PRIORITY = 6
40
+ ALIASES = ["youbike", "ubike", "微笑單車", "共享單車"]
41
 
42
  # 城市對應
43
  CITY_MAP = {
 
55
 
56
  @classmethod
57
  def get_input_schema(cls) -> Dict[str, Any]:
58
+ # 建立包含中文和英文的城市列表
59
+ all_cities = list(cls.CITY_MAP.keys()) + list(cls.CITY_MAP.values())
60
+ # 去重並保持順序
61
+ unique_cities = []
62
+ seen = set()
63
+ for city in all_cities:
64
+ if city not in seen:
65
+ unique_cities.append(city)
66
+ seen.add(city)
67
+
68
  return StandardToolSchemas.create_input_schema({
69
  "station_name": {
70
  "type": "string",
 
72
  },
73
  "city": {
74
  "type": "string",
75
+ "description": "城市名稱(支援中文如「台北」「桃園」或英文如「Taipei」「Taoyuan」)",
76
+ "enum": unique_cities
77
  },
78
  "radius_m": {
79
  "type": "integer",
 
130
 
131
  station_name = safe_str(arguments.get("station_name"))
132
  city = arguments.get("city")
133
+
134
+ # 如果 city 是中文,轉換為英文
135
+ if city:
136
+ city = cls._map_city_name(city)
137
+
138
  radius_m = min(int(arguments.get("radius_m", 500)), 2000)
139
  limit = min(int(arguments.get("limit", 5)), 20)
140
 
 
193
  final_city = guessed
194
  city_source = "經緯度推斷"
195
 
196
+ # 檢查城市是否支援 YouBike
197
+ if final_city:
198
+ city = cls._map_city_name(final_city)
199
+ if city == "Taipei" and final_city not in cls.CITY_MAP:
200
+ # 城市不在支援列表中,提供友善錯誤訊息
201
+ nearest_city = cls._find_nearest_supported_city(user_lat, user_lon)
202
+ raise ExecutionError(
203
+ f"🚲 很抱歉,{final_city}目前沒有 YouBike 服務。\n\n"
204
+ f"最近有 YouBike 的城市是:{nearest_city}\n"
205
+ f"支援 YouBike 的城市:台北、新北、桃園、新竹、台中、台南、高雄"
206
+ )
207
+ else:
208
+ city = "Taipei"
209
+
210
  logger.info(f"🏙️ 最終使用城市代碼: {city} (來源={city_source})")
211
 
212
  # 3. 查詢分支
 
405
  ("新北", 24.67, 25.30, 121.35, 122.01),
406
  ("新竹", 24.68, 24.90, 120.90, 121.10),
407
  ("台中", 24.00, 24.45, 120.45, 121.05),
408
+ ("彰化", 23.85, 24.15, 120.35, 120.70), # 新增彰化範圍
409
  ("台南", 22.85, 23.40, 120.00, 120.55),
410
  ("高雄", 22.45, 23.15, 120.15, 120.80),
411
  ]
 
427
  return value
428
  return "Taipei"
429
 
430
+ @staticmethod
431
+ def _find_nearest_supported_city(lat: float, lon: float) -> str:
432
+ """找出最近的支援 YouBike 的城市"""
433
+ # 支援 YouBike 的城市中心點(大約位置)
434
+ city_centers = {
435
+ "台北": (25.033, 121.565),
436
+ "新北": (25.012, 121.466),
437
+ "桃園": (24.994, 121.301),
438
+ "新竹": (24.806, 120.968),
439
+ "台中": (24.148, 120.674),
440
+ "台南": (22.997, 120.213),
441
+ "高雄": (22.627, 120.301),
442
+ }
443
+
444
+ min_distance = float('inf')
445
+ nearest_city = "台北"
446
+
447
+ for city_name, (city_lat, city_lon) in city_centers.items():
448
+ distance = TDXBaseAPI.haversine_distance(lat, lon, city_lat, city_lon)
449
+ if distance < min_distance:
450
+ min_distance = distance
451
+ nearest_city = city_name
452
+
453
+ return nearest_city
454
+
455
  @staticmethod
456
  def _detect_bike_type(station: Dict, station_name: str) -> str:
457
  """判斷 YouBike 類型(優先從站名判斷,其次從 BikesCapacity)"""
features/mcp/tools/weather_tool.py CHANGED
@@ -33,12 +33,18 @@ class WeatherTool(MCPTool):
33
  DESCRIPTION = "查詢指定城市的即時天氣資訊(溫度、濕度、天氣狀況等)"
34
  CATEGORY = "生活資訊"
35
  TAGS = ["weather", "天氣", "氣象"]
36
- KEYWORDS = ["天氣", "氣溫", "下雨", "晴天", "陰天", "weather", "溫度"]
37
  USAGE_TIPS = [
38
- "提供城市名稱(英文)如 Taipei, Tokyo",
39
- "支援經緯度查詢",
40
- "可指定語言 (zh_tw, en, zh_cn)"
41
  ]
 
 
 
 
 
 
42
 
43
  @classmethod
44
  def get_input_schema(cls) -> Dict[str, Any]:
@@ -57,7 +63,7 @@ class WeatherTool(MCPTool):
57
  "default": "zh_tw",
58
  "enum": ["zh_tw", "en", "zh_cn"]
59
  }
60
- }, ["city"]) # city 留空 lat/lon 有值則忽略
61
 
62
  @classmethod
63
  def get_output_schema(cls) -> Dict[str, Any]:
 
33
  DESCRIPTION = "查詢指定城市的即時天氣資訊(溫度、濕度、天氣狀況等)"
34
  CATEGORY = "生活資訊"
35
  TAGS = ["weather", "天氣", "氣象"]
36
+ KEYWORDS = ["天氣", "氣溫", "下雨", "晴天", "陰天", "weather", "溫度", "濕度", "會不會下雨", "熱不熱", "冷不冷"]
37
  USAGE_TIPS = [
38
+ "「台北天氣」→ city=Taipei",
39
+ "「東京今天會下雨嗎」→ city=Tokyo",
40
+ "「現在幾度」→ 使用用戶位置"
41
  ]
42
+ NEGATIVE_EXAMPLES = [
43
+ "「天氣預報 App」→ 這是詢問 App,不是查天氣",
44
+ "「天氣好好」→ 這是感嘆,不是查詢"
45
+ ]
46
+ PRIORITY = 1 # 高優先級
47
+ ALIASES = ["weather", "氣象"]
48
 
49
  @classmethod
50
  def get_input_schema(cls) -> Dict[str, Any]:
 
63
  "default": "zh_tw",
64
  "enum": ["zh_tw", "en", "zh_cn"]
65
  }
66
+ }, []) # 所有參數都是選的 execute 方法內部邏輯判斷
67
 
68
  @classmethod
69
  def get_output_schema(cls) -> Dict[str, Any]:
middleware/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 中間件模組
3
+ 拆分自 app.py,提高可維護性
4
+ """
5
+
6
+ from .csp import CSPMiddleware
7
+ from .rate_limit import RateLimitMiddleware, rate_limiter
8
+ from .exception_handler import ExceptionHandlerMiddleware, RequestLoggingMiddleware
9
+ from .compression import GzipMiddleware
10
+
11
+ __all__ = [
12
+ "CSPMiddleware",
13
+ "RateLimitMiddleware",
14
+ "rate_limiter",
15
+ "ExceptionHandlerMiddleware",
16
+ "RequestLoggingMiddleware",
17
+ "GzipMiddleware",
18
+ ]
middleware/compression.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 回應壓縮中間件
3
+ 使用 gzip 壓縮 API 回應,減少傳輸大小
4
+ """
5
+
6
+ import gzip
7
+ from typing import Callable
8
+
9
+ from starlette.middleware.base import BaseHTTPMiddleware
10
+ from starlette.requests import Request
11
+ from starlette.responses import Response
12
+
13
+ from core.logging import get_logger
14
+
15
+ logger = get_logger("middleware.compression")
16
+
17
+ # 最小壓縮大小(bytes)
18
+ MIN_COMPRESS_SIZE = 500
19
+
20
+ # 可壓縮的 Content-Type
21
+ COMPRESSIBLE_TYPES = {
22
+ "application/json",
23
+ "text/html",
24
+ "text/plain",
25
+ "text/css",
26
+ "text/javascript",
27
+ "application/javascript",
28
+ }
29
+
30
+
31
+ class GzipMiddleware(BaseHTTPMiddleware):
32
+ """
33
+ Gzip 壓縮中間件
34
+
35
+ 功能:
36
+ 1. 檢查客戶端是否支援 gzip
37
+ 2. 壓縮大於閾值的回應
38
+ 3. 只壓縮可壓縮的 Content-Type
39
+ """
40
+
41
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
42
+ # 檢查客戶端是否支援 gzip
43
+ accept_encoding = request.headers.get("accept-encoding", "")
44
+ supports_gzip = "gzip" in accept_encoding.lower()
45
+
46
+ response = await call_next(request)
47
+
48
+ # 不支援 gzip 或已經壓縮,直接返回
49
+ if not supports_gzip:
50
+ return response
51
+
52
+ if response.headers.get("content-encoding"):
53
+ return response
54
+
55
+ # 檢查 Content-Type 是否可壓縮
56
+ content_type = response.headers.get("content-type", "")
57
+ base_type = content_type.split(";")[0].strip()
58
+
59
+ if base_type not in COMPRESSIBLE_TYPES:
60
+ return response
61
+
62
+ # 讀取回應內容
63
+ body = b""
64
+ async for chunk in response.body_iterator:
65
+ body += chunk
66
+
67
+ # 檢查大小是否值得壓縮
68
+ if len(body) < MIN_COMPRESS_SIZE:
69
+ # 重建回應
70
+ return Response(
71
+ content=body,
72
+ status_code=response.status_code,
73
+ headers=dict(response.headers),
74
+ media_type=response.media_type,
75
+ )
76
+
77
+ # 壓縮內容
78
+ compressed = gzip.compress(body, compresslevel=6)
79
+
80
+ # 只有壓縮後更小才使用
81
+ if len(compressed) >= len(body):
82
+ return Response(
83
+ content=body,
84
+ status_code=response.status_code,
85
+ headers=dict(response.headers),
86
+ media_type=response.media_type,
87
+ )
88
+
89
+ # 更新標頭
90
+ headers = dict(response.headers)
91
+ headers["content-encoding"] = "gzip"
92
+ headers["content-length"] = str(len(compressed))
93
+ # 移除可能衝突的標頭
94
+ headers.pop("transfer-encoding", None)
95
+
96
+ logger.debug(
97
+ f"壓縮回應: {len(body)} -> {len(compressed)} bytes "
98
+ f"({100 - len(compressed) * 100 // len(body)}% 減少)"
99
+ )
100
+
101
+ return Response(
102
+ content=compressed,
103
+ status_code=response.status_code,
104
+ headers=headers,
105
+ media_type=response.media_type,
106
+ )
middleware/csp.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Content Security Policy 中間件
3
+ 允許內嵌 script 用於語音沉浸式前端
4
+ """
5
+
6
+ from starlette.middleware.base import BaseHTTPMiddleware
7
+ from starlette.requests import Request as StarletteRequest
8
+
9
+
10
+ class CSPMiddleware(BaseHTTPMiddleware):
11
+ """CSP 中間件"""
12
+
13
+ async def dispatch(self, request: StarletteRequest, call_next):
14
+ response = await call_next(request)
15
+
16
+ # 對所有靜態檔案路徑添加寬鬆的 CSP header
17
+ if request.url.path.startswith("/static/"):
18
+ # 移除可能存在的嚴格 CSP
19
+ if "Content-Security-Policy" in response.headers:
20
+ del response.headers["Content-Security-Policy"]
21
+
22
+ # 設定寬鬆的 CSP 以允許內嵌 script
23
+ response.headers["Content-Security-Policy"] = (
24
+ "default-src 'self'; "
25
+ "script-src 'self' 'unsafe-inline' 'unsafe-eval' "
26
+ "https://accounts.google.com https://www.gstatic.com; "
27
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
28
+ "font-src 'self' https://fonts.gstatic.com data:; "
29
+ "connect-src 'self' ws: wss: https://accounts.google.com; "
30
+ "img-src 'self' data: https: blob:; "
31
+ "media-src 'self' blob: data:; "
32
+ "frame-src https://accounts.google.com; "
33
+ "base-uri 'self';"
34
+ )
35
+
36
+ return response
middleware/exception_handler.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 全域異常處理中間件
3
+ 統一處理所有未捕獲的異常,返回標準化錯誤回應
4
+ """
5
+
6
+ import logging
7
+ import traceback
8
+ from typing import Callable
9
+
10
+ from fastapi import Request, Response
11
+ from fastapi.responses import JSONResponse
12
+ from starlette.middleware.base import BaseHTTPMiddleware
13
+
14
+ from core.exceptions import BloomWareException, handle_exception
15
+ from core.logging import get_logger
16
+
17
+ logger = get_logger("middleware.exception_handler")
18
+
19
+
20
+ class ExceptionHandlerMiddleware(BaseHTTPMiddleware):
21
+ """
22
+ 全域異常處理中間件
23
+
24
+ 功能:
25
+ 1. 捕獲所有未處理的異常
26
+ 2. 轉換為標準化 JSON 錯誤回應
27
+ 3. 記錄錯誤日誌(生產環境隱藏堆疊)
28
+ """
29
+
30
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
31
+ try:
32
+ response = await call_next(request)
33
+ return response
34
+
35
+ except BloomWareException as e:
36
+ # 已知的業務異常
37
+ logger.warning(f"業務異常: {e.code} - {e.message}")
38
+ return e.to_response()
39
+
40
+ except Exception as e:
41
+ # 未知異常
42
+ logger.error(f"未處理的異常: {type(e).__name__}: {e}")
43
+ logger.debug(f"堆疊追蹤:\n{traceback.format_exc()}")
44
+
45
+ # 返回標準化錯誤回應
46
+ return JSONResponse(
47
+ status_code=500,
48
+ content={
49
+ "success": False,
50
+ "error": {
51
+ "code": "INTERNAL_ERROR",
52
+ "message": "內部伺服器錯誤",
53
+ "details": {}
54
+ }
55
+ }
56
+ )
57
+
58
+
59
+ class RequestLoggingMiddleware(BaseHTTPMiddleware):
60
+ """
61
+ 請求日誌中間件
62
+
63
+ 功能:
64
+ 1. 記錄所有 API 請求
65
+ 2. 計算請求處理時間
66
+ 3. 記錄回應狀態碼
67
+ """
68
+
69
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
70
+ import time
71
+
72
+ start_time = time.time()
73
+ method = request.method
74
+ path = request.url.path
75
+
76
+ # 跳過健康檢查和靜態資源的日誌
77
+ skip_paths = ["/health", "/static/", "/favicon.ico"]
78
+ should_log = not any(path.startswith(p) for p in skip_paths)
79
+
80
+ try:
81
+ response = await call_next(request)
82
+ process_time = (time.time() - start_time) * 1000
83
+
84
+ if should_log:
85
+ logger.info(
86
+ f"{method} {path} - {response.status_code} - {process_time:.2f}ms"
87
+ )
88
+
89
+ # 添加處理時間到回應標頭
90
+ response.headers["X-Process-Time"] = f"{process_time:.2f}ms"
91
+ return response
92
+
93
+ except Exception as e:
94
+ process_time = (time.time() - start_time) * 1000
95
+ if should_log:
96
+ logger.error(
97
+ f"{method} {path} - ERROR - {process_time:.2f}ms - {e}"
98
+ )
99
+ raise
middleware/rate_limit.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rate Limiting 中間件
3
+ 防止 API 濫用
4
+ """
5
+
6
+ import logging
7
+ import time
8
+ from typing import Dict, Tuple
9
+ from collections import defaultdict
10
+ from starlette.middleware.base import BaseHTTPMiddleware
11
+ from starlette.requests import Request as StarletteRequest
12
+ from starlette.responses import JSONResponse
13
+
14
+ logger = logging.getLogger("middleware.rate_limit")
15
+
16
+
17
+ class RateLimiter:
18
+ """
19
+ 簡易 Rate Limiter(記憶體實現)
20
+
21
+ 生產環境建議使用 Redis 實現
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ requests_per_minute: int = 60,
27
+ requests_per_hour: int = 1000,
28
+ ):
29
+ self.requests_per_minute = requests_per_minute
30
+ self.requests_per_hour = requests_per_hour
31
+
32
+ # 記錄請求:{ip: [(timestamp, count), ...]}
33
+ self._minute_requests: Dict[str, list] = defaultdict(list)
34
+ self._hour_requests: Dict[str, list] = defaultdict(list)
35
+
36
+ def _cleanup_old_requests(self, requests: list, window_seconds: int) -> list:
37
+ """清理過期的請求記錄"""
38
+ current_time = time.time()
39
+ return [
40
+ (ts, count) for ts, count in requests
41
+ if current_time - ts < window_seconds
42
+ ]
43
+
44
+ def is_allowed(self, client_ip: str) -> Tuple[bool, str]:
45
+ """
46
+ 檢查請求是否被允許
47
+
48
+ Returns:
49
+ (is_allowed, reason)
50
+ """
51
+ current_time = time.time()
52
+
53
+ # 清理過期記錄
54
+ self._minute_requests[client_ip] = self._cleanup_old_requests(
55
+ self._minute_requests[client_ip], 60
56
+ )
57
+ self._hour_requests[client_ip] = self._cleanup_old_requests(
58
+ self._hour_requests[client_ip], 3600
59
+ )
60
+
61
+ # 計算當前窗口內的請求數
62
+ minute_count = sum(count for _, count in self._minute_requests[client_ip])
63
+ hour_count = sum(count for _, count in self._hour_requests[client_ip])
64
+
65
+ # 檢查限制
66
+ if minute_count >= self.requests_per_minute:
67
+ return False, f"每分鐘請求數超過限制({self.requests_per_minute})"
68
+
69
+ if hour_count >= self.requests_per_hour:
70
+ return False, f"每小時請求數超過限制({self.requests_per_hour})"
71
+
72
+ # 記錄請求
73
+ self._minute_requests[client_ip].append((current_time, 1))
74
+ self._hour_requests[client_ip].append((current_time, 1))
75
+
76
+ return True, ""
77
+
78
+ def get_remaining(self, client_ip: str) -> Dict[str, int]:
79
+ """獲取剩餘請求數"""
80
+ # 清理過期記錄
81
+ self._minute_requests[client_ip] = self._cleanup_old_requests(
82
+ self._minute_requests[client_ip], 60
83
+ )
84
+ self._hour_requests[client_ip] = self._cleanup_old_requests(
85
+ self._hour_requests[client_ip], 3600
86
+ )
87
+
88
+ minute_count = sum(count for _, count in self._minute_requests[client_ip])
89
+ hour_count = sum(count for _, count in self._hour_requests[client_ip])
90
+
91
+ return {
92
+ "minute_remaining": max(0, self.requests_per_minute - minute_count),
93
+ "hour_remaining": max(0, self.requests_per_hour - hour_count),
94
+ }
95
+
96
+
97
+ # 全局 Rate Limiter 實例
98
+ rate_limiter = RateLimiter(
99
+ requests_per_minute=60,
100
+ requests_per_hour=1000,
101
+ )
102
+
103
+
104
+ def get_client_ip(request: StarletteRequest) -> str:
105
+ """獲取客戶端 IP"""
106
+ # 優先取 X-Forwarded-For
107
+ xff = request.headers.get("x-forwarded-for") or request.headers.get("X-Forwarded-For")
108
+ if xff:
109
+ ip = xff.split(",")[0].strip()
110
+ if ip:
111
+ return ip
112
+ return request.client.host if request.client else "unknown"
113
+
114
+
115
+ class RateLimitMiddleware(BaseHTTPMiddleware):
116
+ """Rate Limiting 中間件"""
117
+
118
+ # 不需要限制的路徑
119
+ EXEMPT_PATHS = {
120
+ "/",
121
+ "/health",
122
+ "/static",
123
+ "/login",
124
+ "/favicon.ico",
125
+ }
126
+
127
+ async def dispatch(self, request: StarletteRequest, call_next):
128
+ # 檢查是否豁免
129
+ path = request.url.path
130
+ if any(path.startswith(exempt) for exempt in self.EXEMPT_PATHS):
131
+ return await call_next(request)
132
+
133
+ # 獲取客戶端 IP
134
+ client_ip = get_client_ip(request)
135
+
136
+ # 檢查 Rate Limit
137
+ is_allowed, reason = rate_limiter.is_allowed(client_ip)
138
+
139
+ if not is_allowed:
140
+ logger.warning(f"Rate limit exceeded for {client_ip}: {reason}")
141
+ remaining = rate_limiter.get_remaining(client_ip)
142
+
143
+ return JSONResponse(
144
+ status_code=429,
145
+ content={
146
+ "error": "Too Many Requests",
147
+ "message": reason,
148
+ "retry_after": 60, # 建議等待時間(秒)
149
+ },
150
+ headers={
151
+ "Retry-After": "60",
152
+ "X-RateLimit-Remaining-Minute": str(remaining["minute_remaining"]),
153
+ "X-RateLimit-Remaining-Hour": str(remaining["hour_remaining"]),
154
+ }
155
+ )
156
+
157
+ # 繼續處理請求
158
+ response = await call_next(request)
159
+
160
+ # 添加 Rate Limit 頭
161
+ remaining = rate_limiter.get_remaining(client_ip)
162
+ response.headers["X-RateLimit-Remaining-Minute"] = str(remaining["minute_remaining"])
163
+ response.headers["X-RateLimit-Remaining-Hour"] = str(remaining["hour_remaining"])
164
+
165
+ return response
models/schemas.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic 模型定義
3
+ 統一管理 API 請求/回應的資料結構
4
+ """
5
+
6
+ from datetime import datetime
7
+ from typing import List, Optional
8
+ from pydantic import BaseModel, EmailStr, Field
9
+
10
+
11
+ # ===== 用戶相關 =====
12
+
13
+ class UserCreate(BaseModel):
14
+ """用戶註冊請求"""
15
+ name: str
16
+ email: EmailStr
17
+ password: str = Field(min_length=6)
18
+
19
+
20
+ class UserLogin(BaseModel):
21
+ """用戶登入請求"""
22
+ email: EmailStr
23
+ password: str
24
+
25
+
26
+ class UserInfo(BaseModel):
27
+ """用戶資訊"""
28
+ id: str
29
+ name: str
30
+ email: EmailStr
31
+ created_at: datetime
32
+
33
+
34
+ class UserPublic(BaseModel):
35
+ """用戶公開資訊回應"""
36
+ success: bool
37
+ user: UserInfo
38
+
39
+
40
+ class UserLoginPublicResponse(BaseModel):
41
+ """用戶登入回應"""
42
+ success: bool
43
+ user: UserInfo
44
+ token: Optional[str] = None
45
+
46
+
47
+ # ===== 對話相關 =====
48
+
49
+ class ChatCreateRequest(BaseModel):
50
+ """建立對話請求"""
51
+ user_id: str
52
+ title: Optional[str] = "新對話"
53
+
54
+
55
+ class ChatTitleUpdateRequest(BaseModel):
56
+ """更新對話標題請求"""
57
+ title: str
58
+
59
+
60
+ class ChatPublic(BaseModel):
61
+ """對話公開資訊"""
62
+ chat_id: str
63
+ user_id: str
64
+ title: str
65
+ created_at: datetime
66
+ updated_at: datetime
67
+
68
+
69
+ class ChatSummary(BaseModel):
70
+ """對話摘要"""
71
+ chat_id: str
72
+ title: str
73
+ updated_at: datetime
74
+
75
+
76
+ class ChatListResponse(BaseModel):
77
+ """對話列表回應"""
78
+ chats: List[ChatSummary]
79
+
80
+
81
+ # ===== 訊息相關 =====
82
+
83
+ class MessageCreateRequest(BaseModel):
84
+ """建立訊息請求"""
85
+ sender: str
86
+ content: str
87
+
88
+
89
+ class MessagePublic(BaseModel):
90
+ """訊息公開資訊"""
91
+ sender: str
92
+ content: str
93
+ timestamp: datetime
94
+
95
+
96
+ class ChatDetailResponse(ChatPublic):
97
+ """對話詳情回應(含訊息)"""
98
+ messages: List[MessagePublic]
99
+
100
+
101
+ # ===== 檔案分析 =====
102
+
103
+ class FileAnalysisRequest(BaseModel):
104
+ """檔案分析請求"""
105
+ filename: str
106
+ content: str
107
+ mime_type: str
108
+ user_prompt: Optional[str] = "請分析這個檔案的內容"
109
+
110
+
111
+ class FileAnalysisResponse(BaseModel):
112
+ """檔案分析回應"""
113
+ success: bool
114
+ filename: str
115
+ analysis: Optional[str] = None
116
+ error: Optional[str] = None
117
+
118
+
119
+ # ===== 語音相關 =====
120
+
121
+ class SpeakerLabelBindRequest(BaseModel):
122
+ """語音標籤綁定請求"""
123
+ speaker_label: str
models/speaker_identification/models_cnn/speaker_db.pkl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4377a566b92e5cca1db0f83274a7037169f371e47ca357e3c764c8908ef55e43
3
+ size 4157
models/speaker_identification/scripts/inference.py CHANGED
@@ -1,144 +1,91 @@
1
  import os
2
- import argparse
3
- import numpy as np
4
  import librosa
5
- import noisereduce as nr
6
- import pyaudio
7
  import wave
8
- import torch
9
- import torch.nn as nn
10
- import torchaudio
11
 
 
 
 
12
 
13
- def get_device():
14
- if torch.cuda.is_available():
15
- print("Using device: CUDA")
16
- return torch.device('cuda')
17
- if torch.backends.mps.is_available():
18
- print("Using device: MPS")
19
- return torch.device('mps')
20
- print("Using device: CPU")
21
- return torch.device('cpu')
22
 
23
 
24
- class Wav2Vec2SpeakerClassifier(nn.Module):
25
- def __init__(self, bundle, num_classes, freeze_encoder=True):
26
- super().__init__()
27
- self.encoder = bundle.get_model()
28
- for p in self.encoder.parameters():
29
- p.requires_grad = False
30
- hidden_dim = getattr(self.encoder, 'encoder_embed_dim', 768)
31
- self.classifier = nn.Sequential(
32
- nn.Dropout(0.0),
33
- nn.Linear(hidden_dim, num_classes)
34
  )
 
35
 
36
- def forward(self, waveforms, lengths=None):
37
- out = self.encoder(waveforms, lengths)
38
- if isinstance(out, tuple):
39
- features, lengths = out
40
- else:
41
- features, lengths = out, None
42
- if lengths is not None:
43
- valid_lengths = (lengths * features.size(1)).round().to(torch.long).clamp(min=1, max=features.size(1))
44
- mask = torch.arange(features.size(1), device=features.device).unsqueeze(0) < valid_lengths.unsqueeze(1)
45
- features = features * mask.unsqueeze(-1)
46
- pooled = features.sum(dim=1) / valid_lengths.unsqueeze(-1)
47
- else:
48
- pooled = features.mean(dim=1)
49
- return self.classifier(pooled)
50
 
51
-
52
- def load_classes(classes_path_or_dir):
53
- # 若提供文字檔則逐行讀取,否則從目錄列出子資料夾
54
- if os.path.isfile(classes_path_or_dir):
55
- with open(classes_path_or_dir, 'r', encoding='utf-8') as f:
56
- classes = [line.strip() for line in f if line.strip()]
57
- else:
58
- classes = sorted([d for d in os.listdir(classes_path_or_dir)
59
- if os.path.isdir(os.path.join(classes_path_or_dir, d))])
60
- return classes
61
 
62
 
63
- def load_audio(path, target_sr):
64
- wav, sr = torchaudio.load(path)
65
- if wav.size(0) > 1:
66
- wav = wav.mean(dim=0, keepdim=True)
67
- if sr != target_sr:
68
- resampler = torchaudio.transforms.Resample(sr, target_sr)
69
- wav = resampler(wav)
70
- sr = target_sr
71
- wav = wav.squeeze(0)
72
- length = torch.tensor([wav.shape[0]], dtype=torch.long)
73
- return wav.unsqueeze(0), length
74
 
75
 
76
- def softmax(x):
77
- e = torch.exp(x - x.max(dim=1, keepdim=True).values)
78
- return e / e.sum(dim=1, keepdim=True)
 
 
 
 
 
 
 
 
 
79
 
80
 
81
  def predict_files(model_dir, file_list, threshold=0.0):
82
  """
83
- 預測多個音訊檔案的說話者
84
 
85
  Args:
86
- model_dir: 模型目錄,包含 speaker_id_model.pth 和 classes.txt
87
  file_list: 檔案路徑列表
88
- threshold: 預測門檻(目前未使用
89
 
90
  Returns:
91
  結果列表,每個元素為字典,包含 'pred', 'score', 'top'
92
  """
93
- device = get_device()
94
- bundle = torchaudio.pipelines.WAV2VEC2_BASE
95
- target_sr = bundle.sample_rate
96
-
97
- model_path = os.path.join(model_dir, 'speaker_id_model.pth')
98
- classes_path = os.path.join(model_dir, 'classes.txt')
99
- processed_dir = os.path.join(model_dir, 'processed_audio')
100
-
101
- if os.path.isfile(classes_path):
102
- classes = load_classes(classes_path)
103
- elif os.path.isdir(processed_dir):
104
- classes = load_classes(processed_dir)
105
- else:
106
- raise FileNotFoundError(f"找不到類別定義:{classes_path} 或 {processed_dir}")
107
-
108
- num_classes = len(classes)
109
-
110
- model = Wav2Vec2SpeakerClassifier(bundle, num_classes)
111
- state = torch.load(model_path, map_location='cpu')
112
- model.load_state_dict(state)
113
- model.to(device).eval()
114
 
 
115
  results = []
 
116
  for file_path in file_list:
117
  try:
118
- # 前處理音訊
119
- y, sr = process_like_training(file_path)
120
-
121
- # 轉成模型輸入
122
- y_t = torch.tensor(y, dtype=torch.float32).unsqueeze(0)
123
- if sr != target_sr:
124
- resampler = torchaudio.transforms.Resample(sr, target_sr)
125
- y_t = resampler(y_t)
126
- length = torch.tensor([y_t.shape[1]], dtype=torch.long)
127
- waveforms, lengths = y_t.to(device), length.to(device)
128
 
129
- with torch.no_grad():
130
- logits = model(waveforms, lengths)
131
- probs = softmax(logits).squeeze(0).cpu()
132
- top_prob, top_idx = torch.max(probs, dim=0)
133
- pred = classes[top_idx.item()]
134
-
135
- # 獲取 top 候選
136
- topk = torch.topk(probs, k=min(3, num_classes))
137
- top = [(classes[i], float(p)) for p, i in zip(topk.values.tolist(), topk.indices.tolist())]
138
 
139
  result = {
140
- 'pred': pred,
141
- 'score': float(top_prob.item()),
142
  'top': top
143
  }
144
  results.append(result)
@@ -148,20 +95,18 @@ def predict_files(model_dir, file_list, threshold=0.0):
148
  return results
149
 
150
 
151
- # ============== 錄音與前處理(比照 process_audio.py) ==============
152
- REC_SR = 22050
153
- TARGET_RMS = 0.1
154
- VAD_TOP_DB = 30
155
-
156
-
157
- def record_audio(filename, seconds=3, sr=REC_SR):
158
  pa = pyaudio.PyAudio()
159
  stream = pa.open(format=pyaudio.paInt16, channels=1, rate=sr, input=True, frames_per_buffer=1024)
160
  print(f"開始錄製 {seconds}s...")
161
  frames = []
162
  for _ in range(int(sr / 1024 * seconds)):
163
  frames.append(stream.read(1024))
164
- stream.stop_stream(); stream.close(); pa.terminate()
 
 
165
  with wave.open(filename, 'wb') as wf:
166
  wf.setnchannels(1)
167
  wf.setsampwidth(pyaudio.PyAudio().get_sample_size(pyaudio.paInt16))
@@ -170,82 +115,37 @@ def record_audio(filename, seconds=3, sr=REC_SR):
170
  print("錄製結束。")
171
 
172
 
173
- def process_like_training(input_wav_path):
174
- # 與 process_audio.py 一致:librosa 載入、去噪、VAD、RMS 正規化(保留原始長度)
175
- y, sr = librosa.load(input_wav_path, sr=REC_SR)
176
- y = nr.reduce_noise(y=y, sr=sr)
177
- intervals = librosa.effects.split(y, top_db=VAD_TOP_DB)
178
- if len(intervals) > 0:
179
- y = np.concatenate([y[s:e] for s, e in intervals])
180
- # RMS 正規化
181
- rms = np.sqrt(np.mean(y ** 2)) if len(y) else 0.0
182
- if rms > 0:
183
- y = y * (TARGET_RMS / rms)
184
- return y, sr
185
-
186
-
187
  def main():
188
- parser = argparse.ArgumentParser(description='Speaker ID Inference (Wav2Vec2)')
189
- parser.add_argument('--audio', type=str, default=None, help='Path to wav file;若省略則使用麥克風錄音')
190
- parser.add_argument('--model', type=str, default='speaker_id_model.pth', help='Path to model .pth')
191
- parser.add_argument('--classes', type=str, default='processed_audio', help='Path to classes dir or classes.txt')
192
  parser.add_argument('--seconds', type=int, default=3, help='錄音秒數(麥克風模式)')
193
- parser.add_argument('--save-processed', action='store_true', help='輸出處理後音檔 processed_record.wav')
194
  args = parser.parse_args()
195
 
196
- device = get_device()
197
- bundle = torchaudio.pipelines.WAV2VEC2_BASE
198
- target_sr = bundle.sample_rate
199
 
200
- classes = load_classes(args.classes)
201
- num_classes = len(classes)
202
-
203
- model = Wav2Vec2SpeakerClassifier(bundle, num_classes)
204
- state = torch.load(args.model, map_location='cpu')
205
- model.load_state_dict(state)
206
- model.to(device).eval()
207
-
208
- # 準備音訊來源:檔案或錄音
209
  temp_path = None
210
  if args.audio is None:
211
  temp_path = 'temp_record.wav'
212
- record_audio(temp_path, seconds=args.seconds, sr=REC_SR)
213
- raw_path = temp_path
214
  else:
215
- raw_path = args.audio
216
-
217
- # 前處理(比照 process_audio.py)
218
- y, sr = process_like_training(raw_path)
219
- if args.save_processed:
220
- try:
221
- import soundfile as sf
222
- sf.write('processed_record.wav', y, sr)
223
- except Exception:
224
- pass
225
-
226
- # 轉成模型輸入:重採樣到 16k + 提供長度
227
- y_t = torch.tensor(y, dtype=torch.float32).unsqueeze(0)
228
- if sr != target_sr:
229
- resampler = torchaudio.transforms.Resample(sr, target_sr)
230
- y_t = resampler(y_t)
231
- sr = target_sr
232
- length = torch.tensor([y_t.shape[1]], dtype=torch.long)
233
- waveforms, lengths = y_t.to(device), length.to(device)
234
 
235
- with torch.no_grad():
236
- logits = model(waveforms, lengths)
237
- probs = softmax(logits).squeeze(0).cpu()
238
- top_prob, top_idx = torch.max(probs, dim=0)
239
- pred = classes[top_idx.item()]
240
 
241
- print(f'Predicted speaker: {pred} (prob={top_prob.item():.3f})')
242
- # 顯示前 3 名
243
- topk = torch.topk(probs, k=min(3, num_classes))
244
- print('Top candidates:')
245
- for p, i in zip(topk.values.tolist(), topk.indices.tolist()):
246
- print(f' {classes[i]}: {p:.3f}')
247
 
248
- if temp_path is not None and os.path.exists(temp_path):
 
249
  try:
250
  os.remove(temp_path)
251
  except Exception:
 
1
  import os
2
+ import torch
 
3
  import librosa
4
+ import numpy as np
5
+ import pickle
6
  import wave
7
+ import pyaudio
8
+ from speechbrain.pretrained import EncoderClassifier
9
+ from sklearn.metrics.pairwise import cosine_similarity
10
 
11
+ # 常數
12
+ SAMPLE_RATE = 16000
13
+ DB_FILE = "speaker_db.pkl"
14
 
15
+ # 全域 classifier(延遲載入)
16
+ _classifier = None
 
 
 
 
 
 
 
17
 
18
 
19
+ def get_classifier():
20
+ """取得 ECAPA-TDNN 分類器(單例模式)"""
21
+ global _classifier
22
+ if _classifier is None:
23
+ _classifier = EncoderClassifier.from_hparams(
24
+ source="speechbrain/spkrec-ecapa-voxceleb"
 
 
 
 
25
  )
26
+ return _classifier
27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
 
29
+ def get_embedding(file_path):
30
+ """從音訊檔案取得 ECAPA-TDNN 嵌入向量"""
31
+ signal, sr = librosa.load(file_path, sr=SAMPLE_RATE, mono=True)
32
+ signal_tensor = torch.tensor(signal).unsqueeze(0)
33
+ classifier = get_classifier()
34
+ embedding = classifier.encode_batch(signal_tensor)
35
+ return embedding.squeeze().detach().numpy()
 
 
 
36
 
37
 
38
+ def load_speaker_db(db_path):
39
+ """載入說話者嵌入資料庫"""
40
+ with open(db_path, "rb") as f:
41
+ speaker_embeddings = pickle.load(f)
42
+ return speaker_embeddings
 
 
 
 
 
 
43
 
44
 
45
+ def recognize_speaker(test_file, speaker_embeddings):
46
+ """
47
+ 辨識語音(與原始 ECAPA_TDNN.py 完全一致)
48
+ """
49
+ test_emb = get_embedding(test_file).reshape(1, -1)
50
+ scores = {}
51
+ for spk, emb in speaker_embeddings.items():
52
+ sim = cosine_similarity(test_emb, emb.reshape(1, -1))[0][0]
53
+ scores[spk] = sim
54
+ predicted = max(scores, key=scores.get)
55
+ scores[predicted] += 0.35
56
+ return predicted, scores
57
 
58
 
59
  def predict_files(model_dir, file_list, threshold=0.0):
60
  """
61
+ 預測多個音訊檔案的說話者
62
 
63
  Args:
64
+ model_dir: 模型目錄,包含 speaker_db.pkl
65
  file_list: 檔案路徑列表
66
+ threshold: 未使用,保留介面相容
67
 
68
  Returns:
69
  結果列表,每個元素為字典,包含 'pred', 'score', 'top'
70
  """
71
+ db_path = os.path.join(model_dir, DB_FILE)
72
+ if not os.path.exists(db_path):
73
+ raise FileNotFoundError(f"找不到說話者資料庫:{db_path}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
+ speaker_embeddings = load_speaker_db(db_path)
76
  results = []
77
+
78
  for file_path in file_list:
79
  try:
80
+ predicted, scores = recognize_speaker(file_path, speaker_embeddings)
 
 
 
 
 
 
 
 
 
81
 
82
+ # 排序取 top 候選
83
+ sorted_scores = sorted(scores.items(), key=lambda x: x[1], reverse=True)
84
+ top = [(spk, float(score)) for spk, score in sorted_scores[:3]]
 
 
 
 
 
 
85
 
86
  result = {
87
+ 'pred': predicted,
88
+ 'score': float(scores[predicted]),
89
  'top': top
90
  }
91
  results.append(result)
 
95
  return results
96
 
97
 
98
+ # ============== 錄音功能 ==============
99
+ def record_audio(filename, seconds=3, sr=SAMPLE_RATE):
100
+ """從麥克風錄製音訊"""
 
 
 
 
101
  pa = pyaudio.PyAudio()
102
  stream = pa.open(format=pyaudio.paInt16, channels=1, rate=sr, input=True, frames_per_buffer=1024)
103
  print(f"開始錄製 {seconds}s...")
104
  frames = []
105
  for _ in range(int(sr / 1024 * seconds)):
106
  frames.append(stream.read(1024))
107
+ stream.stop_stream()
108
+ stream.close()
109
+ pa.terminate()
110
  with wave.open(filename, 'wb') as wf:
111
  wf.setnchannels(1)
112
  wf.setsampwidth(pyaudio.PyAudio().get_sample_size(pyaudio.paInt16))
 
115
  print("錄製結束。")
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  def main():
119
+ import argparse
120
+ parser = argparse.ArgumentParser(description='Speaker ID Inference (ECAPA-TDNN)')
121
+ parser.add_argument('--audio', type=str, default=None, help='音訊檔案路徑;若省略則使用麥克風錄音')
122
+ parser.add_argument('--db', type=str, default='speaker_db.pkl', help='說話者資料庫路徑')
123
  parser.add_argument('--seconds', type=int, default=3, help='錄音秒數(麥克風模式)')
 
124
  args = parser.parse_args()
125
 
126
+ # 載入資料庫
127
+ speaker_embeddings = load_speaker_db(args.db)
128
+ print(f"已載入 {len(speaker_embeddings)} 位說話者:{list(speaker_embeddings.keys())}")
129
 
130
+ # 準備音訊
 
 
 
 
 
 
 
 
131
  temp_path = None
132
  if args.audio is None:
133
  temp_path = 'temp_record.wav'
134
+ record_audio(temp_path, seconds=args.seconds)
135
+ audio_path = temp_path
136
  else:
137
+ audio_path = args.audio
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
 
139
+ # 辨識
140
+ predicted, scores = recognize_speaker(audio_path, speaker_embeddings)
 
 
 
141
 
142
+ print(f'\n辨識結果:{predicted}')
143
+ print('辨識機率:')
144
+ for spk, score in sorted(scores.items(), key=lambda x: x[1], reverse=True):
145
+ print(f' {spk}: {score:.4f}')
 
 
146
 
147
+ # 清理暫存檔
148
+ if temp_path and os.path.exists(temp_path):
149
  try:
150
  os.remove(temp_path)
151
  except Exception:
models/speaker_identification/scripts/process_audio.py DELETED
@@ -1,52 +0,0 @@
1
- import os
2
- import librosa
3
- import numpy as np
4
- import noisereduce as nr
5
- import soundfile as sf
6
-
7
- # 參考採樣率與音量標準化
8
- sr = 22050 # 假設採樣率
9
- target_rms = 0.1 # 目標 RMS 水平
10
- vad_top_db = 30 # VAD 門檻,值越小越容易刪除靜音
11
-
12
- # 輸出目錄
13
- output_dir = 'processed_audio'
14
- os.makedirs(output_dir, exist_ok=True)
15
-
16
- # 用於記錄每個子目錄的當前編號
17
- counter = {}
18
-
19
- # 遍歷 voice_data 目錄下的所有 .wav 和 .mp3 文件
20
- for root, dirs, files in os.walk('voice_data'):
21
- for file in files:
22
- if file.endswith(('.wav', '.mp3')):
23
- file_path = os.path.join(root, file)
24
- # 獲取相對路徑,用於創建輸出子目錄
25
- rel_path = os.path.relpath(root, 'voice_data')
26
- sub_output_dir = os.path.join(output_dir, rel_path)
27
- os.makedirs(sub_output_dir, exist_ok=True)
28
-
29
- if rel_path not in counter:
30
- counter[rel_path] = 1
31
-
32
- y, sr = librosa.load(file_path, sr=sr)
33
- # 去噪
34
- y = nr.reduce_noise(y=y, sr=sr)
35
-
36
- # 語音活動偵測 (VAD),移除靜音區段
37
- intervals = librosa.effects.split(y, top_db=vad_top_db)
38
- if len(intervals) == 0:
39
- # 無語音內容,跳過此檔
40
- continue
41
- y = np.concatenate([y[start:end] for start, end in intervals])
42
-
43
- # 保留原始長度並直接保存
44
- rms = np.sqrt(np.mean(y**2))
45
- if rms > 0:
46
- y = y * (target_rms / rms)
47
-
48
- output_path = os.path.join(sub_output_dir, f'voice_{counter[rel_path]:02d}.wav')
49
- sf.write(output_path, y, sr)
50
- counter[rel_path] += 1
51
-
52
- print("音頻處理完成。")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
models/speaker_identification/scripts/train_speaker_id.py DELETED
@@ -1,306 +0,0 @@
1
- import os
2
- import random
3
-
4
- import matplotlib.pyplot as plt
5
- import numpy as np
6
- import seaborn as sns
7
- import torch
8
- import torch.nn as nn
9
- import torch.optim as optim
10
- import torchaudio
11
- import torchaudio.functional as F
12
- from sklearn.metrics import confusion_matrix
13
- from sklearn.preprocessing import LabelEncoder
14
- from sklearn.utils.class_weight import compute_class_weight
15
- from torch.utils.data import DataLoader, Dataset
16
-
17
-
18
- # 基本參數
19
- data_dir = '/kaggle/input/voice-identity/processed_audio'
20
- batch_size = 16
21
- num_epochs = 50
22
-
23
- # 遷移學習設定(不使用 HuggingFace,改用 torchaudio 內建預訓練)
24
- bundle = torchaudio.pipelines.WAV2VEC2_BASE
25
- target_sr = bundle.sample_rate
26
- freeze_encoder = True # 先凍結整個 encoder,只訓練分類頭
27
- unfreeze_last_n = 2 # 若要微調,可調整解凍的 Transformer 層數
28
- head_learning_rate = 1e-3
29
- encoder_learning_rate = 1e-5
30
- encoder_warmup_epochs = 10 # 預設 10 epoch 後解凍最後幾層(若 unfreeze_last_n > 0)
31
- pitch_shift_semitones = [0, -4, 4] # 每筆資料的音調擴增組合
32
-
33
-
34
- def waveform_augment(waveform, sr, max_shift_ratio=0.02, noise_factor=0.002):
35
- """時間平移 + 微量噪音;針對短音訊使用較小位移"""
36
- if max_shift_ratio > 0:
37
- shift = int(sr * max_shift_ratio)
38
- if shift > 0:
39
- offset = random.randint(-shift, shift)
40
- waveform = torch.roll(waveform, offset, dims=1)
41
- if noise_factor > 0:
42
- noise = torch.randn_like(waveform) * noise_factor
43
- waveform = waveform + noise
44
- return waveform
45
-
46
-
47
- class SpeakerDataset(Dataset):
48
- def __init__(self, data_dir, classes, transform=None, target_sr=16000, pitch_shifts=None):
49
- self.data_dir = data_dir
50
- self.classes = classes
51
- self.transform = transform
52
- self.target_sr = target_sr
53
- self.resamplers = {}
54
- self.pitch_shifts = sorted(set(pitch_shifts or [0]))
55
- self.data = []
56
-
57
- for label, cls in enumerate(classes):
58
- cls_dir = os.path.join(data_dir, cls)
59
- for file in os.listdir(cls_dir):
60
- if file.endswith('.wav'):
61
- file_path = os.path.join(cls_dir, file)
62
- for shift in self.pitch_shifts:
63
- self.data.append((file_path, label, shift))
64
-
65
- def __len__(self):
66
- return len(self.data)
67
-
68
- def __getitem__(self, idx):
69
- file_path, label, semitone = self.data[idx]
70
- waveform, sr = torchaudio.load(file_path)
71
-
72
- if waveform.size(0) > 1:
73
- waveform = waveform.mean(dim=0, keepdim=True)
74
-
75
- if sr != self.target_sr:
76
- if sr not in self.resamplers:
77
- self.resamplers[sr] = torchaudio.transforms.Resample(sr, self.target_sr)
78
- waveform = self.resamplers[sr](waveform)
79
- sr = self.target_sr
80
-
81
- if semitone != 0:
82
- waveform = F.pitch_shift(waveform, sr, n_steps=semitone)
83
-
84
- if self.transform:
85
- waveform = self.transform(waveform, sr)
86
-
87
- return waveform.squeeze(0), label
88
-
89
-
90
- def collate_waveforms(batch):
91
- waveforms, labels = zip(*batch)
92
- lengths = torch.tensor([waveform.shape[0] for waveform in waveforms], dtype=torch.long)
93
- max_len = lengths.max().item()
94
- padded = torch.zeros(len(waveforms), max_len)
95
- for idx, waveform in enumerate(waveforms):
96
- padded[idx, : waveform.shape[0]] = waveform
97
- labels_tensor = torch.tensor(labels, dtype=torch.long)
98
- return padded, lengths, labels_tensor
99
-
100
-
101
- class Wav2Vec2SpeakerClassifier(nn.Module):
102
- def __init__(self, bundle, num_classes, freeze_encoder=True, unfreeze_last_n=0):
103
- super().__init__()
104
- self.encoder = bundle.get_model()
105
- self.freeze_encoder = freeze_encoder
106
- self.unfreeze_last_n = unfreeze_last_n
107
-
108
- # 預設凍結整個 encoder
109
- for param in self.encoder.parameters():
110
- param.requires_grad = False
111
-
112
- if not freeze_encoder:
113
- # 先全部鎖住,再選擇性解凍最後 N 層;若無法部分解凍則退而求其次為完全解凍
114
- transformer_layers = getattr(self.encoder, 'encoder', None)
115
- if transformer_layers is not None and hasattr(transformer_layers, 'layers') and unfreeze_last_n > 0:
116
- for layer in transformer_layers.layers[-unfreeze_last_n:]:
117
- for param in layer.parameters():
118
- param.requires_grad = True
119
- elif not freeze_encoder:
120
- for param in self.encoder.parameters():
121
- param.requires_grad = True
122
-
123
- self.encoder_frozen = freeze_encoder
124
-
125
- hidden_dim = getattr(self.encoder, 'encoder_embed_dim', 768)
126
- self.classifier = nn.Sequential(
127
- nn.Dropout(0.3),
128
- nn.Linear(hidden_dim, num_classes)
129
- )
130
-
131
- def unfreeze_last_layers(self, n_layers=None):
132
- """解凍最後 n 層 Transformer block,回傳剛解凍的參數列表。"""
133
- n_layers = n_layers or self.unfreeze_last_n
134
- unfrozen_params = []
135
- transformer_layers = getattr(self.encoder, 'encoder', None)
136
- if transformer_layers is not None and hasattr(transformer_layers, 'layers') and n_layers > 0:
137
- target_layers = transformer_layers.layers[-n_layers:]
138
- for layer in target_layers:
139
- for param in layer.parameters():
140
- if not param.requires_grad:
141
- param.requires_grad = True
142
- unfrozen_params.append(param)
143
- elif n_layers > 0:
144
- for param in self.encoder.parameters():
145
- if not param.requires_grad:
146
- param.requires_grad = True
147
- unfrozen_params.append(param)
148
- if unfrozen_params:
149
- self.encoder_frozen = False
150
- return unfrozen_params
151
-
152
- def forward(self, waveforms, lengths=None):
153
- if waveforms.dim() != 2:
154
- raise ValueError("音訊張量應為 [batch, time] 形式")
155
-
156
- encoder_out = self.encoder(waveforms, lengths)
157
- if isinstance(encoder_out, tuple):
158
- features, lengths = encoder_out
159
- else:
160
- features, lengths = encoder_out, None
161
-
162
- if lengths is not None:
163
- if torch.is_floating_point(lengths):
164
- valid_lengths = (lengths * features.size(1)).round().to(torch.long)
165
- else:
166
- valid_lengths = lengths.to(torch.long)
167
- valid_lengths = valid_lengths.clamp(min=1, max=features.size(1))
168
- mask = torch.arange(features.size(1), device=features.device).unsqueeze(0) < valid_lengths.unsqueeze(1)
169
- masked_features = features * mask.unsqueeze(-1)
170
- pooled = masked_features.sum(dim=1) / valid_lengths.unsqueeze(-1)
171
- else:
172
- pooled = features.mean(dim=1)
173
-
174
- logits = self.classifier(pooled)
175
- return logits
176
-
177
-
178
- classes = sorted([d for d in os.listdir(data_dir) if os.path.isdir(os.path.join(data_dir, d))])
179
- num_classes = len(classes)
180
- label_encoder = LabelEncoder()
181
- label_encoder.fit(classes)
182
-
183
- dataset = SpeakerDataset(
184
- data_dir=data_dir,
185
- classes=classes,
186
- transform=waveform_augment,
187
- target_sr=target_sr,
188
- pitch_shifts=pitch_shift_semitones
189
- )
190
- train_loader = DataLoader(
191
- dataset,
192
- batch_size=batch_size,
193
- shuffle=True,
194
- num_workers=0,
195
- pin_memory=False,
196
- collate_fn=collate_waveforms
197
- )
198
-
199
- labels = [label for _, label, _ in dataset.data]
200
- class_weights = compute_class_weight('balanced', classes=np.unique(labels), y=labels)
201
- class_weights = torch.tensor(class_weights, dtype=torch.float)
202
-
203
- if torch.cuda.is_available():
204
- device = torch.device('cuda')
205
- print("Using device: CUDA")
206
- elif torch.backends.mps.is_available():
207
- device = torch.device('mps')
208
- print("Using device: MPS")
209
- else:
210
- device = torch.device('cpu')
211
- print("Using device: CPU")
212
-
213
- model = Wav2Vec2SpeakerClassifier(
214
- bundle=bundle,
215
- num_classes=num_classes,
216
- freeze_encoder=freeze_encoder,
217
- unfreeze_last_n=unfreeze_last_n
218
- ).to(device)
219
- criterion = nn.CrossEntropyLoss(weight=class_weights.to(device))
220
-
221
- encoder_params = [p for p in model.encoder.parameters() if p.requires_grad]
222
- head_params = list(model.classifier.parameters())
223
- if encoder_params:
224
- optimizer = optim.Adam([
225
- {'params': head_params, 'lr': head_learning_rate},
226
- {'params': encoder_params, 'lr': encoder_learning_rate}
227
- ])
228
- else:
229
- optimizer = optim.Adam(head_params, lr=head_learning_rate)
230
-
231
- use_amp = torch.cuda.is_available()
232
- scaler = torch.amp.GradScaler() if use_amp else None
233
-
234
- def add_encoder_params_to_optimizer(model, optimizer, lr):
235
- existing_params = set()
236
- for group in optimizer.param_groups:
237
- existing_params.update(id(p) for p in group['params'])
238
- new_params = [p for p in model.encoder.parameters() if p.requires_grad and id(p) not in existing_params]
239
- if new_params:
240
- optimizer.add_param_group({'params': new_params, 'lr': lr})
241
-
242
- for epoch in range(num_epochs):
243
- # 動態解凍策略:達到指定 epoch 後解凍最後幾層
244
- if model.encoder_frozen and unfreeze_last_n > 0 and (epoch + 1) == encoder_warmup_epochs:
245
- newly_unfrozen = model.unfreeze_last_layers(unfreeze_last_n)
246
- if newly_unfrozen:
247
- add_encoder_params_to_optimizer(model, optimizer, encoder_learning_rate)
248
- print(f'Unfroze last {unfreeze_last_n} encoder layer(s) at epoch {epoch + 1}.')
249
-
250
- model.train()
251
- if model.encoder_frozen:
252
- model.encoder.eval()
253
- else:
254
- model.encoder.train()
255
-
256
- running_loss = 0.0
257
- for waveforms, lengths, labels_batch in train_loader:
258
- waveforms = waveforms.to(device)
259
- lengths = lengths.to(device)
260
- labels_batch = labels_batch.to(device)
261
- optimizer.zero_grad()
262
-
263
- if use_amp:
264
- with torch.amp.autocast(device_type='cuda'):
265
- logits = model(waveforms, lengths)
266
- loss = criterion(logits, labels_batch)
267
- scaler.scale(loss).backward()
268
- scaler.step(optimizer)
269
- scaler.update()
270
- else:
271
- logits = model(waveforms, lengths)
272
- loss = criterion(logits, labels_batch)
273
- loss.backward()
274
- optimizer.step()
275
-
276
- running_loss += loss.item()
277
-
278
- avg_loss = running_loss / len(train_loader)
279
- print(f'Epoch {epoch + 1}/{num_epochs}, Loss: {avg_loss:.4f}')
280
-
281
- model.eval()
282
- all_preds = []
283
- all_labels = []
284
- with torch.no_grad():
285
- for waveforms, lengths, labels_batch in train_loader:
286
- waveforms = waveforms.to(device)
287
- lengths = lengths.to(device)
288
- logits = model(waveforms, lengths)
289
- preds = torch.argmax(logits, dim=1)
290
- all_preds.extend(preds.cpu().numpy())
291
- all_labels.extend(labels_batch.numpy())
292
-
293
- cm = confusion_matrix(all_labels, all_preds)
294
- print("Confusion Matrix:")
295
- print(cm)
296
-
297
- plt.figure(figsize=(8, 6))
298
- sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', xticklabels=classes, yticklabels=classes)
299
- plt.title('Confusion Matrix')
300
- plt.xlabel('Predicted')
301
- plt.ylabel('True')
302
- plt.savefig('confusion_matrix.png')
303
- plt.close()
304
-
305
- torch.save(model.state_dict(), 'speaker_id_model.pth')
306
- print("訓練完成,模型已保存。")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
render.yaml DELETED
@@ -1,28 +0,0 @@
1
- services:
2
- - type: web
3
- name: bloom-ware
4
- runtime: python
5
- region: singapore # 或 oregon(美西)
6
- plan: Hobby # 免費方案
7
- buildCommand: pip install -r requirements.txt
8
- startCommand: python3 app.py
9
- envVars:
10
- - key: ENVIRONMENT
11
- value: production
12
- - key: PYTHON_VERSION
13
- value: 3.12.4
14
- - key: HOST
15
- value: 0.0.0.0
16
- - key: PORT
17
- value: 10000 # Render 固定使用 10000 端口
18
- # 其他敏感環境變數請在 Render Dashboard 手動設定:
19
- # - FIREBASE_PROJECT_ID
20
- # - FIREBASE_CREDENTIALS_JSON(完整JSON字串)
21
- # - OPENAI_API_KEY
22
- # - GOOGLE_CLIENT_ID
23
- # - GOOGLE_CLIENT_SECRET
24
- # - GOOGLE_REDIRECT_URI(https://your-app.onrender.com/auth/google/callback)
25
- # - WEATHER_API_KEY
26
- # - NEWSDATA_API_KEY
27
- # - EXCHANGE_API_KEY
28
- # - JWT_SECRET_KEY(使用 Python secrets.token_urlsafe(32) 生成新的)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
routers/__init__.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Bloom Ware API 路由模組
3
+ 拆分自 app.py,提高可維護性
4
+ """
5
+
6
+ from .auth import router as auth_router
7
+ from .chat import router as chat_router
8
+ from .voice import router as voice_router
9
+ from .health import router as health_router
10
+ from .files import router as files_router
11
+ from .system import router as system_router
12
+
13
+ __all__ = [
14
+ "auth_router",
15
+ "chat_router",
16
+ "voice_router",
17
+ "health_router",
18
+ "files_router",
19
+ "system_router",
20
+ ]
routers/auth.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 認證相關 API 路由
3
+ 包含 Google OAuth、JWT 認證等
4
+ """
5
+
6
+ import logging
7
+ from datetime import datetime
8
+ from typing import Dict, Optional
9
+ from fastapi import APIRouter, Request, HTTPException, Depends
10
+ from fastapi.responses import JSONResponse, RedirectResponse
11
+ from pydantic import BaseModel, EmailStr
12
+
13
+ from core.config import settings
14
+ from core.auth import jwt_auth, get_current_user_optional, require_auth
15
+ from core.auth.google_oauth import GoogleOAuth
16
+ from core.database import create_or_login_google_user
17
+
18
+ logger = logging.getLogger("routers.auth")
19
+
20
+ router = APIRouter(prefix="/auth", tags=["認證"])
21
+
22
+ # Google OAuth 實例
23
+ google_oauth = GoogleOAuth()
24
+
25
+
26
+ class GoogleAuthRequest(BaseModel):
27
+ """Google 認證請求"""
28
+ credential: str
29
+
30
+
31
+ class TokenResponse(BaseModel):
32
+ """Token 響應"""
33
+ success: bool
34
+ token: Optional[str] = None
35
+ user: Optional[dict] = None
36
+ error: Optional[str] = None
37
+
38
+
39
+ @router.get("/google/login")
40
+ async def google_login():
41
+ """
42
+ Google OAuth 登入入口
43
+ 重定向到 Google 授權頁面
44
+ """
45
+ auth_url = google_oauth.get_authorization_url()
46
+ return RedirectResponse(url=auth_url)
47
+
48
+
49
+ @router.get("/google/callback")
50
+ async def google_callback(code: str = None, error: str = None):
51
+ """
52
+ Google OAuth 回調處理
53
+ """
54
+ if error:
55
+ logger.error(f"Google OAuth 錯誤: {error}")
56
+ return RedirectResponse(url=f"/login?error={error}")
57
+
58
+ if not code:
59
+ logger.error("Google OAuth 回調缺少 code 參數")
60
+ return RedirectResponse(url="/login?error=missing_code")
61
+
62
+ try:
63
+ # 交換 code 獲取 token
64
+ token_info = await google_oauth.exchange_code(code)
65
+ if not token_info:
66
+ return RedirectResponse(url="/login?error=token_exchange_failed")
67
+
68
+ # 獲取用戶信息
69
+ user_info = await google_oauth.get_user_info(token_info.get("access_token"))
70
+ if not user_info:
71
+ return RedirectResponse(url="/login?error=user_info_failed")
72
+
73
+ # 創建或登入用戶
74
+ result = await create_or_login_google_user(user_info)
75
+ if not result.get("success"):
76
+ error_msg = result.get("error", "unknown_error")
77
+ return RedirectResponse(url=f"/login?error={error_msg}")
78
+
79
+ # 生成 JWT token
80
+ user = result.get("user", {})
81
+ jwt_token = jwt_auth.create_access_token({
82
+ "sub": user.get("id"),
83
+ "email": user.get("email"),
84
+ "name": user.get("name"),
85
+ })
86
+
87
+ # 重定向到前端,帶上 token
88
+ return RedirectResponse(url=f"/static/frontend/index.html?token={jwt_token}")
89
+
90
+ except Exception as e:
91
+ logger.exception(f"Google OAuth 回調處理失敗: {e}")
92
+ return RedirectResponse(url=f"/login?error=callback_failed")
93
+
94
+
95
+ @router.post("/google/verify", response_model=TokenResponse)
96
+ async def google_verify(request: GoogleAuthRequest):
97
+ """
98
+ 驗證 Google ID Token(前端 One Tap 登入)
99
+ """
100
+ try:
101
+ # 驗證 Google credential
102
+ user_info = await google_oauth.verify_id_token(request.credential)
103
+ if not user_info:
104
+ return TokenResponse(success=False, error="invalid_credential")
105
+
106
+ # 創建或登入用戶
107
+ result = await create_or_login_google_user(user_info)
108
+ if not result.get("success"):
109
+ return TokenResponse(success=False, error=result.get("error"))
110
+
111
+ # 生成 JWT token
112
+ user = result.get("user", {})
113
+ jwt_token = jwt_auth.create_access_token({
114
+ "sub": user.get("id"),
115
+ "email": user.get("email"),
116
+ "name": user.get("name"),
117
+ })
118
+
119
+ return TokenResponse(
120
+ success=True,
121
+ token=jwt_token,
122
+ user=user,
123
+ )
124
+
125
+ except Exception as e:
126
+ logger.exception(f"Google 驗證失敗: {e}")
127
+ return TokenResponse(success=False, error=str(e))
128
+
129
+
130
+ @router.get("/me")
131
+ async def get_current_user(user: dict = Depends(require_auth)):
132
+ """
133
+ 獲取當前登入用戶信息
134
+ """
135
+ return {
136
+ "success": True,
137
+ "user": {
138
+ "id": user.get("sub"),
139
+ "email": user.get("email"),
140
+ "name": user.get("name"),
141
+ }
142
+ }
143
+
144
+
145
+ @router.post("/refresh")
146
+ async def refresh_token(user: dict = Depends(require_auth)):
147
+ """
148
+ 刷新 JWT Token
149
+ """
150
+ new_token = jwt_auth.create_access_token({
151
+ "sub": user.get("sub"),
152
+ "email": user.get("email"),
153
+ "name": user.get("name"),
154
+ })
155
+
156
+ return {
157
+ "success": True,
158
+ "token": new_token,
159
+ }
routers/chat.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 對話相關 API 路由
3
+ 包含對話管理、消息歷史等
4
+ """
5
+
6
+ import logging
7
+ from datetime import datetime
8
+ from typing import List, Optional
9
+ from fastapi import APIRouter, HTTPException, Depends
10
+ from pydantic import BaseModel
11
+
12
+ from core.auth import require_auth
13
+ from core.database import (
14
+ create_chat,
15
+ get_user_chats,
16
+ get_chat,
17
+ update_chat_title,
18
+ delete_chat,
19
+ get_chat_messages,
20
+ )
21
+ from core.database.optimized import (
22
+ get_chat as get_chat_optimized,
23
+ get_user_chats as get_user_chats_optimized,
24
+ )
25
+
26
+ logger = logging.getLogger("routers.chat")
27
+
28
+ router = APIRouter(prefix="/api/chats", tags=["對話"])
29
+
30
+
31
+ class ChatCreateRequest(BaseModel):
32
+ """創建對話請求"""
33
+ title: Optional[str] = "新對話"
34
+
35
+
36
+ class ChatTitleUpdateRequest(BaseModel):
37
+ """更新對話標題請求"""
38
+ title: str
39
+
40
+
41
+ class ChatSummary(BaseModel):
42
+ """對話摘要"""
43
+ chat_id: str
44
+ title: str
45
+ updated_at: datetime
46
+
47
+
48
+ class ChatListResponse(BaseModel):
49
+ """對話列表響應"""
50
+ success: bool
51
+ chats: List[ChatSummary]
52
+
53
+
54
+ @router.post("")
55
+ async def create_new_chat(
56
+ request: ChatCreateRequest,
57
+ user: dict = Depends(require_auth)
58
+ ):
59
+ """
60
+ 創建新對話
61
+ """
62
+ user_id = user.get("sub")
63
+ if not user_id:
64
+ raise HTTPException(status_code=401, detail="無效的用戶")
65
+
66
+ result = await create_chat(user_id, request.title)
67
+ if not result.get("success"):
68
+ raise HTTPException(status_code=500, detail=result.get("error"))
69
+
70
+ return result
71
+
72
+
73
+ @router.get("")
74
+ async def list_user_chats(user: dict = Depends(require_auth)):
75
+ """
76
+ 獲取用戶的所有對話
77
+ """
78
+ user_id = user.get("sub")
79
+ if not user_id:
80
+ raise HTTPException(status_code=401, detail="無效的用戶")
81
+
82
+ result = await get_user_chats_optimized(user_id)
83
+ if not result.get("success"):
84
+ raise HTTPException(status_code=500, detail=result.get("error"))
85
+
86
+ return result
87
+
88
+
89
+ @router.get("/{chat_id}")
90
+ async def get_chat_detail(
91
+ chat_id: str,
92
+ user: dict = Depends(require_auth)
93
+ ):
94
+ """
95
+ 獲取對話詳情(包含消息)
96
+ """
97
+ user_id = user.get("sub")
98
+ if not user_id:
99
+ raise HTTPException(status_code=401, detail="無效的用戶")
100
+
101
+ result = await get_chat_optimized(chat_id)
102
+ if not result.get("success"):
103
+ raise HTTPException(status_code=404, detail="對話不存在")
104
+
105
+ chat = result.get("chat", {})
106
+
107
+ # 驗證對話所有權
108
+ if chat.get("user_id") != user_id:
109
+ raise HTTPException(status_code=403, detail="無權訪問此對話")
110
+
111
+ return result
112
+
113
+
114
+ @router.get("/{chat_id}/messages")
115
+ async def get_chat_messages_api(
116
+ chat_id: str,
117
+ limit: int = 50,
118
+ user: dict = Depends(require_auth)
119
+ ):
120
+ """
121
+ 獲取對話消息歷史
122
+ """
123
+ user_id = user.get("sub")
124
+ if not user_id:
125
+ raise HTTPException(status_code=401, detail="無效的用戶")
126
+
127
+ # 先驗證對話所有權
128
+ chat_result = await get_chat_optimized(chat_id)
129
+ if not chat_result.get("success"):
130
+ raise HTTPException(status_code=404, detail="對話不存在")
131
+
132
+ chat = chat_result.get("chat", {})
133
+ if chat.get("user_id") != user_id:
134
+ raise HTTPException(status_code=403, detail="無權訪問此對話")
135
+
136
+ # 獲取消息
137
+ messages = await get_chat_messages(chat_id, limit=limit)
138
+
139
+ return {
140
+ "success": True,
141
+ "chat_id": chat_id,
142
+ "messages": messages,
143
+ }
144
+
145
+
146
+ @router.put("/{chat_id}/title")
147
+ async def update_chat_title_api(
148
+ chat_id: str,
149
+ request: ChatTitleUpdateRequest,
150
+ user: dict = Depends(require_auth)
151
+ ):
152
+ """
153
+ 更新對話標題
154
+ """
155
+ user_id = user.get("sub")
156
+ if not user_id:
157
+ raise HTTPException(status_code=401, detail="無效的用戶")
158
+
159
+ # 先驗證對話所有權
160
+ chat_result = await get_chat_optimized(chat_id)
161
+ if not chat_result.get("success"):
162
+ raise HTTPException(status_code=404, detail="對話不存在")
163
+
164
+ chat = chat_result.get("chat", {})
165
+ if chat.get("user_id") != user_id:
166
+ raise HTTPException(status_code=403, detail="無權修改此對話")
167
+
168
+ result = await update_chat_title(chat_id, request.title)
169
+ if not result.get("success"):
170
+ raise HTTPException(status_code=500, detail=result.get("error"))
171
+
172
+ return result
173
+
174
+
175
+ @router.delete("/{chat_id}")
176
+ async def delete_chat_api(
177
+ chat_id: str,
178
+ user: dict = Depends(require_auth)
179
+ ):
180
+ """
181
+ 刪除對話
182
+ """
183
+ user_id = user.get("sub")
184
+ if not user_id:
185
+ raise HTTPException(status_code=401, detail="無效的用戶")
186
+
187
+ # 先驗證對話所有權
188
+ chat_result = await get_chat_optimized(chat_id)
189
+ if not chat_result.get("success"):
190
+ raise HTTPException(status_code=404, detail="對話不存在")
191
+
192
+ chat = chat_result.get("chat", {})
193
+ if chat.get("user_id") != user_id:
194
+ raise HTTPException(status_code=403, detail="無權刪除此對話")
195
+
196
+ result = await delete_chat(chat_id)
197
+ if not result.get("success"):
198
+ raise HTTPException(status_code=500, detail=result.get("error"))
199
+
200
+ return result
routers/files.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 文件處理相關 API 路由
3
+ 包含文件上傳、分析等
4
+ """
5
+
6
+ import logging
7
+ import base64
8
+ import mimetypes
9
+ from typing import Optional
10
+ from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
11
+ from pydantic import BaseModel
12
+
13
+ from core.auth import require_auth
14
+
15
+ logger = logging.getLogger("routers.files")
16
+
17
+ router = APIRouter(prefix="/api/files", tags=["文件"])
18
+
19
+
20
+ class FileAnalysisRequest(BaseModel):
21
+ """文件分析請求"""
22
+ filename: str
23
+ content: str # base64 編碼
24
+ mime_type: str
25
+ user_prompt: Optional[str] = "請分析這個檔案的內容"
26
+
27
+
28
+ class FileAnalysisResponse(BaseModel):
29
+ """文件分析響應"""
30
+ success: bool
31
+ filename: str
32
+ analysis: Optional[str] = None
33
+ error: Optional[str] = None
34
+
35
+
36
+ @router.post("/analyze", response_model=FileAnalysisResponse)
37
+ async def analyze_file(
38
+ request: FileAnalysisRequest,
39
+ user: dict = Depends(require_auth)
40
+ ):
41
+ """
42
+ 分析上傳的文件內容
43
+ 支援 PDF、圖片、文字文件等
44
+ """
45
+ user_id = user.get("sub")
46
+ if not user_id:
47
+ raise HTTPException(status_code=401, detail="無效的用戶")
48
+
49
+ try:
50
+ # 解碼文件內容
51
+ try:
52
+ file_content = base64.b64decode(request.content)
53
+ except Exception:
54
+ return FileAnalysisResponse(
55
+ success=False,
56
+ filename=request.filename,
57
+ error="無法解碼文件內容",
58
+ )
59
+
60
+ # 根據 MIME 類型處理
61
+ mime_type = request.mime_type.lower()
62
+ extracted_text = ""
63
+
64
+ if mime_type.startswith("text/"):
65
+ # 文字文件
66
+ try:
67
+ extracted_text = file_content.decode("utf-8")
68
+ except UnicodeDecodeError:
69
+ extracted_text = file_content.decode("latin-1")
70
+
71
+ elif mime_type == "application/pdf":
72
+ # PDF 文件
73
+ try:
74
+ import pdfplumber
75
+ import io
76
+
77
+ with pdfplumber.open(io.BytesIO(file_content)) as pdf:
78
+ pages_text = []
79
+ for page in pdf.pages:
80
+ text = page.extract_text()
81
+ if text:
82
+ pages_text.append(text)
83
+ extracted_text = "\n\n".join(pages_text)
84
+ except ImportError:
85
+ return FileAnalysisResponse(
86
+ success=False,
87
+ filename=request.filename,
88
+ error="PDF 處理模組不可用",
89
+ )
90
+ except Exception as e:
91
+ return FileAnalysisResponse(
92
+ success=False,
93
+ filename=request.filename,
94
+ error=f"PDF 解析失敗: {str(e)}",
95
+ )
96
+
97
+ elif mime_type.startswith("image/"):
98
+ # 圖片文件 - 使用 GPT-4 Vision
99
+ try:
100
+ import services.ai_service as ai_service
101
+
102
+ # 構建 Vision API 請求
103
+ image_base64 = request.content
104
+ messages = [
105
+ {
106
+ "role": "user",
107
+ "content": [
108
+ {"type": "text", "text": request.user_prompt},
109
+ {
110
+ "type": "image_url",
111
+ "image_url": {
112
+ "url": f"data:{mime_type};base64,{image_base64}"
113
+ }
114
+ }
115
+ ]
116
+ }
117
+ ]
118
+
119
+ analysis = await ai_service.generate_response_async(
120
+ messages,
121
+ model="gpt-4o-mini", # 使用支援 Vision 的模型
122
+ )
123
+
124
+ return FileAnalysisResponse(
125
+ success=True,
126
+ filename=request.filename,
127
+ analysis=analysis,
128
+ )
129
+
130
+ except Exception as e:
131
+ return FileAnalysisResponse(
132
+ success=False,
133
+ filename=request.filename,
134
+ error=f"圖片分析失敗: {str(e)}",
135
+ )
136
+
137
+ else:
138
+ return FileAnalysisResponse(
139
+ success=False,
140
+ filename=request.filename,
141
+ error=f"不支援的文件類型: {mime_type}",
142
+ )
143
+
144
+ # 使用 AI 分析提取的文字
145
+ if extracted_text:
146
+ try:
147
+ import services.ai_service as ai_service
148
+
149
+ messages = [
150
+ {
151
+ "role": "system",
152
+ "content": "你是一個專業的文件分析助手。請根據用戶的要求分析以下文件內容。"
153
+ },
154
+ {
155
+ "role": "user",
156
+ "content": f"{request.user_prompt}\n\n文件內容:\n{extracted_text[:10000]}" # 限制長度
157
+ }
158
+ ]
159
+
160
+ analysis = await ai_service.generate_response_async(messages)
161
+
162
+ return FileAnalysisResponse(
163
+ success=True,
164
+ filename=request.filename,
165
+ analysis=analysis,
166
+ )
167
+
168
+ except Exception as e:
169
+ return FileAnalysisResponse(
170
+ success=False,
171
+ filename=request.filename,
172
+ error=f"AI 分析失敗: {str(e)}",
173
+ )
174
+
175
+ return FileAnalysisResponse(
176
+ success=False,
177
+ filename=request.filename,
178
+ error="無法提取文件內容",
179
+ )
180
+
181
+ except Exception as e:
182
+ logger.exception(f"文件分析失敗: {e}")
183
+ return FileAnalysisResponse(
184
+ success=False,
185
+ filename=request.filename,
186
+ error=str(e),
187
+ )
188
+
189
+
190
+ @router.post("/upload")
191
+ async def upload_file(
192
+ file: UploadFile = File(...),
193
+ user: dict = Depends(require_auth)
194
+ ):
195
+ """
196
+ 上傳文件(返回 base64 編碼)
197
+ """
198
+ user_id = user.get("sub")
199
+ if not user_id:
200
+ raise HTTPException(status_code=401, detail="無效的用戶")
201
+
202
+ try:
203
+ # 讀取文件內容
204
+ content = await file.read()
205
+
206
+ # 檢查文件大小(限制 10MB)
207
+ if len(content) > 10 * 1024 * 1024:
208
+ raise HTTPException(status_code=413, detail="文件大小超過限制(10MB)")
209
+
210
+ # 編碼為 base64
211
+ content_base64 = base64.b64encode(content).decode("utf-8")
212
+
213
+ # 獲取 MIME 類型
214
+ mime_type = file.content_type or mimetypes.guess_type(file.filename)[0] or "application/octet-stream"
215
+
216
+ return {
217
+ "success": True,
218
+ "filename": file.filename,
219
+ "mime_type": mime_type,
220
+ "size": len(content),
221
+ "content": content_base64,
222
+ }
223
+
224
+ except HTTPException:
225
+ raise
226
+ except Exception as e:
227
+ logger.exception(f"文件上傳失敗: {e}")
228
+ raise HTTPException(status_code=500, detail=str(e))
routers/health.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 健康數據相關 API 路由
3
+ 包含 HealthKit 數據同步等
4
+ """
5
+
6
+ import logging
7
+ from datetime import datetime
8
+ from typing import List, Optional, Dict, Any
9
+ from fastapi import APIRouter, HTTPException, Depends
10
+ from pydantic import BaseModel
11
+
12
+ from core.auth import require_auth
13
+
14
+ logger = logging.getLogger("routers.health")
15
+
16
+ router = APIRouter(prefix="/api/health", tags=["健康數據"])
17
+
18
+
19
+ class HealthDataPoint(BaseModel):
20
+ """健康數據點"""
21
+ type: str # heart_rate, steps, sleep, etc.
22
+ value: float
23
+ unit: str
24
+ timestamp: datetime
25
+ source: Optional[str] = None
26
+
27
+
28
+ class HealthDataSyncRequest(BaseModel):
29
+ """健康數據同步請求"""
30
+ data: List[HealthDataPoint]
31
+
32
+
33
+ class HealthQueryRequest(BaseModel):
34
+ """健康數據查詢請求"""
35
+ types: List[str]
36
+ start_date: Optional[datetime] = None
37
+ end_date: Optional[datetime] = None
38
+
39
+
40
+ @router.post("/sync")
41
+ async def sync_health_data(
42
+ request: HealthDataSyncRequest,
43
+ user: dict = Depends(require_auth)
44
+ ):
45
+ """
46
+ 同步健康數據(從 HealthKit/Google Fit)
47
+ """
48
+ user_id = user.get("sub")
49
+ if not user_id:
50
+ raise HTTPException(status_code=401, detail="無效的用戶")
51
+
52
+ try:
53
+ from core.database import firestore_db
54
+
55
+ if not firestore_db:
56
+ raise HTTPException(status_code=503, detail="數據庫不可用")
57
+
58
+ # 批量寫入健康數據
59
+ batch = firestore_db.batch()
60
+ health_collection = firestore_db.collection("health_data")
61
+
62
+ for data_point in request.data:
63
+ doc_ref = health_collection.document()
64
+ batch.set(doc_ref, {
65
+ "user_id": user_id,
66
+ "type": data_point.type,
67
+ "value": data_point.value,
68
+ "unit": data_point.unit,
69
+ "timestamp": data_point.timestamp,
70
+ "source": data_point.source,
71
+ "synced_at": datetime.now(),
72
+ })
73
+
74
+ # 執行批量寫入
75
+ import asyncio
76
+ await asyncio.to_thread(batch.commit)
77
+
78
+ logger.info(f"用戶 {user_id} 同步了 {len(request.data)} 條健康數據")
79
+
80
+ return {
81
+ "success": True,
82
+ "synced_count": len(request.data),
83
+ }
84
+
85
+ except Exception as e:
86
+ logger.exception(f"健康數據同步失敗: {e}")
87
+ raise HTTPException(status_code=500, detail=str(e))
88
+
89
+
90
+ @router.post("/query")
91
+ async def query_health_data(
92
+ request: HealthQueryRequest,
93
+ user: dict = Depends(require_auth)
94
+ ):
95
+ """
96
+ 查詢健康數據
97
+ """
98
+ user_id = user.get("sub")
99
+ if not user_id:
100
+ raise HTTPException(status_code=401, detail="無效的用戶")
101
+
102
+ try:
103
+ from core.database import firestore_db
104
+ from google.cloud.firestore import FieldFilter
105
+
106
+ if not firestore_db:
107
+ raise HTTPException(status_code=503, detail="數據庫不可用")
108
+
109
+ health_collection = firestore_db.collection("health_data")
110
+
111
+ # 構建查詢
112
+ query = health_collection.where(
113
+ filter=FieldFilter("user_id", "==", user_id)
114
+ )
115
+
116
+ # 時間範圍過濾
117
+ if request.start_date:
118
+ query = query.where(
119
+ filter=FieldFilter("timestamp", ">=", request.start_date)
120
+ )
121
+ if request.end_date:
122
+ query = query.where(
123
+ filter=FieldFilter("timestamp", "<=", request.end_date)
124
+ )
125
+
126
+ # 執行查詢
127
+ import asyncio
128
+ docs = await asyncio.to_thread(lambda: list(query.stream()))
129
+
130
+ # 過濾類型並格式化結果
131
+ results: Dict[str, List[Dict[str, Any]]] = {t: [] for t in request.types}
132
+
133
+ for doc in docs:
134
+ data = doc.to_dict()
135
+ data_type = data.get("type")
136
+ if data_type in request.types:
137
+ results[data_type].append({
138
+ "value": data.get("value"),
139
+ "unit": data.get("unit"),
140
+ "timestamp": data.get("timestamp"),
141
+ "source": data.get("source"),
142
+ })
143
+
144
+ return {
145
+ "success": True,
146
+ "data": results,
147
+ }
148
+
149
+ except Exception as e:
150
+ logger.exception(f"健康數據查詢失敗: {e}")
151
+ raise HTTPException(status_code=500, detail=str(e))
152
+
153
+
154
+ @router.get("/summary")
155
+ async def get_health_summary(user: dict = Depends(require_auth)):
156
+ """
157
+ 獲取健康數據摘要(今日)
158
+ """
159
+ user_id = user.get("sub")
160
+ if not user_id:
161
+ raise HTTPException(status_code=401, detail="無效的用戶")
162
+
163
+ try:
164
+ from core.database import firestore_db
165
+ from google.cloud.firestore import FieldFilter
166
+
167
+ if not firestore_db:
168
+ raise HTTPException(status_code=503, detail="數據庫不可用")
169
+
170
+ # 今日開始時間
171
+ today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
172
+
173
+ health_collection = firestore_db.collection("health_data")
174
+
175
+ query = health_collection.where(
176
+ filter=FieldFilter("user_id", "==", user_id)
177
+ ).where(
178
+ filter=FieldFilter("timestamp", ">=", today_start)
179
+ )
180
+
181
+ import asyncio
182
+ docs = await asyncio.to_thread(lambda: list(query.stream()))
183
+
184
+ # 計算摘要
185
+ summary = {
186
+ "steps": 0,
187
+ "heart_rate_avg": 0,
188
+ "heart_rate_readings": [],
189
+ "sleep_hours": 0,
190
+ "active_calories": 0,
191
+ }
192
+
193
+ for doc in docs:
194
+ data = doc.to_dict()
195
+ data_type = data.get("type")
196
+ value = data.get("value", 0)
197
+
198
+ if data_type == "steps":
199
+ summary["steps"] += value
200
+ elif data_type == "heart_rate":
201
+ summary["heart_rate_readings"].append(value)
202
+ elif data_type == "sleep":
203
+ summary["sleep_hours"] += value
204
+ elif data_type == "active_calories":
205
+ summary["active_calories"] += value
206
+
207
+ # 計算心率平均值
208
+ if summary["heart_rate_readings"]:
209
+ summary["heart_rate_avg"] = sum(summary["heart_rate_readings"]) / len(summary["heart_rate_readings"])
210
+ del summary["heart_rate_readings"]
211
+
212
+ return {
213
+ "success": True,
214
+ "date": today_start.date().isoformat(),
215
+ "summary": summary,
216
+ }
217
+
218
+ except Exception as e:
219
+ logger.exception(f"健康摘要獲取失敗: {e}")
220
+ raise HTTPException(status_code=500, detail=str(e))
routers/system.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 系統相關 API 路由
3
+ 包含狀態檢查、效能統計、MCP 工具列表等
4
+ """
5
+
6
+ from typing import Optional, Dict, Any
7
+ from fastapi import APIRouter, Depends
8
+ from fastapi.responses import RedirectResponse
9
+
10
+ from core.auth import get_current_user_optional
11
+ from core.logging import get_logger
12
+ from core.config import settings
13
+
14
+ logger = get_logger("routers.system")
15
+
16
+ router = APIRouter(tags=["系統"])
17
+
18
+
19
+ @router.get("/")
20
+ async def root():
21
+ """根路徑導向登入頁面"""
22
+ return RedirectResponse(url="/login/")
23
+
24
+
25
+ @router.get("/status")
26
+ async def get_status():
27
+ """系統狀態檢查"""
28
+ return {
29
+ "status": "running",
30
+ "version": "2.0.0",
31
+ "environment": settings.ENVIRONMENT,
32
+ }
33
+
34
+
35
+ @router.get("/api/mcp/tools")
36
+ async def list_mcp_tools(current_user: dict = Depends(get_current_user_optional)):
37
+ """
38
+ 列出所有可用的 MCP 工具
39
+
40
+ Returns:
41
+ 工具列表,包含名稱、描述、參數等
42
+ """
43
+ try:
44
+ # 延遲導入避免循環依賴
45
+ from app import app
46
+
47
+ if not hasattr(app.state, 'feature_router'):
48
+ return {
49
+ "success": False,
50
+ "error": "MCP 服務尚未初始化",
51
+ "tools": []
52
+ }
53
+
54
+ feature_router = app.state.feature_router
55
+ tools_info = []
56
+
57
+ for tool_name, tool in feature_router.mcp_server.tools.items():
58
+ tool_info = {
59
+ "name": tool_name,
60
+ "description": getattr(tool, 'description', '無描述'),
61
+ }
62
+
63
+ # 嘗試獲取參數 schema
64
+ if hasattr(tool, 'handler') and hasattr(tool.handler, '__self__'):
65
+ tool_class = tool.handler.__self__
66
+ if hasattr(tool_class, 'get_input_schema'):
67
+ try:
68
+ tool_info["parameters"] = tool_class.get_input_schema()
69
+ except Exception:
70
+ pass
71
+
72
+ tools_info.append(tool_info)
73
+
74
+ return {
75
+ "success": True,
76
+ "count": len(tools_info),
77
+ "tools": tools_info
78
+ }
79
+
80
+ except Exception as e:
81
+ logger.error(f"獲取 MCP 工具列表失敗: {e}")
82
+ return {
83
+ "success": False,
84
+ "error": str(e),
85
+ "tools": []
86
+ }
87
+
88
+
89
+ @router.get("/api/performance/stats")
90
+ async def get_performance_stats(current_user: dict = Depends(get_current_user_optional)):
91
+ """
92
+ 獲取系統效能統計
93
+
94
+ Returns:
95
+ 快取命中率、查詢統計等
96
+ """
97
+ try:
98
+ from core.database.cache import db_cache
99
+ from core.database.optimized import query_optimizer
100
+
101
+ cache_stats = db_cache.get_all_stats()
102
+ query_stats = query_optimizer.get_stats()
103
+
104
+ return {
105
+ "success": True,
106
+ "cache": cache_stats,
107
+ "queries": query_stats,
108
+ }
109
+
110
+ except Exception as e:
111
+ logger.error(f"獲取效能統計失敗: {e}")
112
+ return {
113
+ "success": False,
114
+ "error": str(e)
115
+ }
routers/voice.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 語音相關 API 路由
3
+ 包含語音登入、TTS、STT 等
4
+ """
5
+
6
+ import logging
7
+ from typing import Optional
8
+ from fastapi import APIRouter, HTTPException, Depends
9
+ from pydantic import BaseModel
10
+
11
+ from core.auth import require_auth
12
+ from core.database import set_user_speaker_label, get_user_by_speaker_label
13
+
14
+ logger = logging.getLogger("routers.voice")
15
+
16
+ router = APIRouter(prefix="/api/voice", tags=["語音"])
17
+
18
+
19
+ class SpeakerLabelBindRequest(BaseModel):
20
+ """綁定語音標籤請求"""
21
+ speaker_label: str
22
+
23
+
24
+ class TTSRequest(BaseModel):
25
+ """TTS 請求"""
26
+ text: str
27
+ voice: str = "nova"
28
+ speed: float = 1.0
29
+
30
+
31
+ @router.post("/bind-speaker")
32
+ async def bind_speaker_label(
33
+ request: SpeakerLabelBindRequest,
34
+ user: dict = Depends(require_auth)
35
+ ):
36
+ """
37
+ 綁定語音標籤到用戶帳號
38
+ """
39
+ user_id = user.get("sub")
40
+ if not user_id:
41
+ raise HTTPException(status_code=401, detail="無效的用戶")
42
+
43
+ result = await set_user_speaker_label(user_id, request.speaker_label)
44
+
45
+ if not result.get("success"):
46
+ error = result.get("error")
47
+ if error == "SPEAKER_LABEL_TAKEN":
48
+ raise HTTPException(status_code=409, detail="此語音標籤已被其他用戶綁定")
49
+ elif error == "USER_NOT_FOUND":
50
+ raise HTTPException(status_code=404, detail="用戶不存在")
51
+ else:
52
+ raise HTTPException(status_code=500, detail=error)
53
+
54
+ return {"success": True, "message": "語音標籤綁定成功"}
55
+
56
+
57
+ @router.get("/lookup-speaker/{speaker_label}")
58
+ async def lookup_speaker(speaker_label: str):
59
+ """
60
+ 根據語音標籤查找用戶(用於語音登入)
61
+ """
62
+ user = await get_user_by_speaker_label(speaker_label)
63
+
64
+ if not user:
65
+ raise HTTPException(status_code=404, detail="找不到對應的用戶")
66
+
67
+ return {
68
+ "success": True,
69
+ "user": {
70
+ "id": user.get("id"),
71
+ "name": user.get("name"),
72
+ }
73
+ }
74
+
75
+
76
+ @router.post("/tts")
77
+ async def text_to_speech(
78
+ request: TTSRequest,
79
+ user: dict = Depends(require_auth)
80
+ ):
81
+ """
82
+ 文字轉語音
83
+ """
84
+ try:
85
+ from services.tts_service import tts_service
86
+
87
+ result = await tts_service.synthesize(
88
+ text=request.text,
89
+ voice=request.voice,
90
+ speed=request.speed,
91
+ )
92
+
93
+ if not result.get("success"):
94
+ raise HTTPException(status_code=500, detail=result.get("error"))
95
+
96
+ # 返回 base64 編碼的音頻
97
+ import base64
98
+ audio_base64 = base64.b64encode(result["audio_data"]).decode("utf-8")
99
+
100
+ return {
101
+ "success": True,
102
+ "audio": audio_base64,
103
+ "voice": result.get("voice"),
104
+ }
105
+
106
+ except ImportError:
107
+ raise HTTPException(status_code=503, detail="TTS 服務不可用")
108
+ except Exception as e:
109
+ logger.exception(f"TTS 失敗: {e}")
110
+ raise HTTPException(status_code=500, detail=str(e))
111
+
112
+
113
+ class VoiceLoginRequest(BaseModel):
114
+ """語音登入請求"""
115
+ audio_base64: str # base64 編碼的 PCM16 音訊
116
+ sample_rate: int = 16000
117
+
118
+
119
+ class VoiceLoginResponse(BaseModel):
120
+ """語音登入回應"""
121
+ success: bool
122
+ access_token: str = None
123
+ user: dict = None
124
+ emotion: str = None
125
+ error: str = None
126
+
127
+
128
+ @router.post("/login", response_model=VoiceLoginResponse)
129
+ async def voice_login(request: VoiceLoginRequest):
130
+ """
131
+ 語音登入 API
132
+
133
+ 流程:
134
+ 1. 接收 base64 編碼的音訊
135
+ 2. 執行身份辨識 + 情緒辨識
136
+ 3. 查詢 speaker_label 對應的用戶
137
+ 4. 生成 JWT token
138
+ 5. 回傳 token + 情緒
139
+ """
140
+ import base64
141
+ import jwt
142
+ from datetime import datetime, timedelta
143
+ from core.config import settings
144
+
145
+ try:
146
+ # 取得 VoiceAuthService 實例
147
+ from fastapi import Request
148
+ from main import app
149
+
150
+ voice_auth = getattr(app.state, "voice_auth", None)
151
+ if not voice_auth:
152
+ # 嘗試動態建立
153
+ from services.voice_login import VoiceAuthService, VoiceLoginConfig
154
+ voice_auth = VoiceAuthService(config=VoiceLoginConfig(
155
+ window_seconds=3,
156
+ required_windows=1,
157
+ ))
158
+
159
+ # 解碼音訊
160
+ audio_bytes = base64.b64decode(request.audio_base64)
161
+
162
+ # 建立臨時 session 並處理音訊
163
+ temp_user_id = f"voice_login_{datetime.now().timestamp()}"
164
+ voice_auth.start_session(temp_user_id, request.sample_rate)
165
+ voice_auth._buffers[temp_user_id] = bytearray(audio_bytes)
166
+
167
+ # 執行辨識
168
+ result = voice_auth.stop_and_authenticate(temp_user_id)
169
+
170
+ # 清理 session
171
+ voice_auth.clear_session(temp_user_id)
172
+
173
+ if not result.get("success"):
174
+ error_code = result.get("error", "UNKNOWN_ERROR")
175
+ error_messages = {
176
+ "NO_AUDIO": "沒有收到音訊資料",
177
+ "AUDIO_TOO_SHORT": "音訊太短,請錄製至少 3 秒",
178
+ "LOW_SNR": "環境太吵,請在安靜的地方重試",
179
+ "INCONSISTENT_WINDOWS": "無法確認身份,請重試",
180
+ "THRESHOLD_NOT_MET": "無法確認身份,請重試",
181
+ "MODEL_ERROR": "辨識系統錯誤,請稍後重試",
182
+ }
183
+ return VoiceLoginResponse(
184
+ success=False,
185
+ error=error_messages.get(error_code, f"辨識失敗:{error_code}")
186
+ )
187
+
188
+ # 取得辨識結果
189
+ speaker_label = result.get("label")
190
+ emotion = result.get("emotion", {})
191
+ emotion_label = emotion.get("label", "neutral") if isinstance(emotion, dict) else "neutral"
192
+
193
+ logger.info(f"🎙️ 語音辨識成功: speaker={speaker_label}, emotion={emotion_label}")
194
+
195
+ # 查詢對應的用戶
196
+ user = await get_user_by_speaker_label(speaker_label)
197
+
198
+ if not user:
199
+ return VoiceLoginResponse(
200
+ success=False,
201
+ error=f"找不到綁定的帳號。請先使用 Google 登入並綁定語音。"
202
+ )
203
+
204
+ # 生成 JWT token
205
+ user_id = user.get("id")
206
+ user_name = user.get("name", "用戶")
207
+ user_email = user.get("email", "")
208
+
209
+ payload = {
210
+ "sub": user_id,
211
+ "name": user_name,
212
+ "email": user_email,
213
+ "iat": datetime.utcnow(),
214
+ "exp": datetime.utcnow() + timedelta(days=7),
215
+ "login_method": "voice",
216
+ "emotion": emotion_label,
217
+ }
218
+
219
+ token = jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
220
+
221
+ logger.info(f"✅ 語音登入成功: user={user_name}, emotion={emotion_label}")
222
+
223
+ return VoiceLoginResponse(
224
+ success=True,
225
+ access_token=token,
226
+ user={
227
+ "id": user_id,
228
+ "name": user_name,
229
+ "email": user_email,
230
+ },
231
+ emotion=emotion_label,
232
+ )
233
+
234
+ except Exception as e:
235
+ logger.exception(f"❌ 語音登入失敗: {e}")
236
+ return VoiceLoginResponse(
237
+ success=False,
238
+ error=f"系統錯誤:{str(e)}"
239
+ )
services/ai_service.py CHANGED
@@ -1,39 +1,21 @@
1
- import os
2
- import sys
3
- import logging
4
  import asyncio
5
- from dotenv import load_dotenv
6
  from datetime import datetime, timezone, timedelta
7
  import time
8
  import json
9
  from typing import Dict, List, Any, Optional
10
 
11
- # 設置日誌
12
- # 設定日誌等級:預設 WARNING,可透過 BLOOMWARE_LOG_LEVEL 覆寫
13
- LOG_LEVEL_NAME = os.getenv("BLOOMWARE_LOG_LEVEL", "WARNING").upper()
14
- LOG_LEVEL = getattr(logging, LOG_LEVEL_NAME, logging.WARNING)
15
- logging.basicConfig(
16
- level=LOG_LEVEL,
17
- format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
18
- )
19
- logger = logging.getLogger("AI_Service")
20
- # 將終端日誌級別設置為 ERROR(保留重要訊息)
21
- console_handler = logging.StreamHandler()
22
- console_handler.setLevel(logging.ERROR)
23
- formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
24
- console_handler.setFormatter(formatter)
25
- logger.addHandler(console_handler)
26
- logger.propagate = False # 防止日誌重複輸出
27
- logger.setLevel(LOG_LEVEL)
28
-
29
- # 載入環境變數
30
- load_dotenv()
31
 
32
  # 統一配置管理
33
  from core.config import settings
34
 
 
 
 
35
  # 超時設定(秒)
36
- OPENAI_TIMEOUT = settings.OPENAI_TIMEOUT # 關懷模式 reasoning model 需要更長時間
37
 
38
  # 情緒關懷模式 System Prompt(新增)
39
  CARE_MODE_SYSTEM_PROMPT = """你是 BloomWare 的情緒關懷助手「小花」,由銘傳大學人工智慧應用學系槓上開發團隊打造。你不是 GPT,也不要自稱 GPT;你的任務是在情緒低落時傾聽、陪伴。
@@ -55,21 +37,13 @@ CARE_MODE_SYSTEM_PROMPT = """你是 BloomWare 的情緒關懷助手「小花」
55
  用戶:「我很生氣」 → 你:「這件事讓你超級生氣,情緒一定卡著。要不要跟我說說最困擾你的地方?」
56
  用戶:「講笑話給我聽」 → 你:「你想聽點輕鬆的,我當然可以陪你。想先聽小笑話還是先聊聊怎麼了?」"""
57
 
58
- # 導入時間服務模組
59
- # from features.daily_life.time_service import get_current_time_data, format_time_for_messages # 已整合到 MCPAgentBridge
 
 
60
 
61
- # 嘗試導入 OpenAI
62
- try:
63
- import openai
64
- from openai import OpenAI
65
- client = OpenAI(
66
- api_key=os.getenv("OPENAI_API_KEY"),
67
- timeout=30.0, # 增加超時時間
68
- max_retries=3 # 添加重試次數
69
- )
70
- except Exception as e:
71
- logger.error(f"初始化 OpenAI 客戶端失敗: {e}")
72
- client = None
73
 
74
  # 導入DB函數
75
  try:
@@ -486,22 +460,9 @@ def _extract_text_from_message_obj(message: Any) -> str:
486
  return ""
487
 
488
  def initialize_openai():
489
- """初始化OpenAI客戶端"""
490
- global client
491
- api_key = settings.OPENAI_API_KEY
492
- if not api_key:
493
- logger.error("OpenAI API密鑰未設置,請在.env文件中設置OPENAI_API_KEY環境變數")
494
- print("\n❌ 錯誤: OpenAI API密鑰未設置!請在.env文件中設置OPENAI_API_KEY\n")
495
- return False
496
- try:
497
- logger.info("正在初始化OpenAI客戶端...")
498
- client = OpenAI(api_key=api_key)
499
- logger.info("OpenAI 客戶端初始化完成")
500
- return True
501
- except Exception as e:
502
- logger.error(f"初始化OpenAI客戶端失敗: {e}")
503
- print(f"\n❌ OpenAI API連接失敗: {e}\n")
504
- return False
505
 
506
  ## 已移除內部測試函式 test_openai_response,避免干擾正式流程
507
 
@@ -532,7 +493,8 @@ async def generate_response_async(
532
  stream: 是否啟用串流模式(2025 最佳實踐)
533
  on_chunk: 串流 chunk 回調函數(async callable)
534
  """
535
- if client is None and not initialize_openai():
 
536
  return "抱歉,AI服務暫時不可用。系統無法連接到OpenAI服務。"
537
  try:
538
  start_time = time.time()
@@ -579,7 +541,7 @@ async def generate_response_async(
579
  full_response = ""
580
  stream_obj = await loop.run_in_executor(
581
  None,
582
- lambda: client.chat.completions.create(**request_kwargs)
583
  )
584
 
585
  # 逐塊處理
@@ -602,7 +564,7 @@ async def generate_response_async(
602
  response = await asyncio.wait_for(
603
  loop.run_in_executor(
604
  None,
605
- lambda: client.chat.completions.create(**request_kwargs),
606
  ),
607
  timeout=OPENAI_TIMEOUT,
608
  )
@@ -779,7 +741,11 @@ async def _generate_response_with_chat_db(
779
  system_prompt = (
780
  "你是 BloomWare 的個人化助理 小花,由銘傳大學人工智慧應用學系 槓上開發 團隊開發。"
781
  "你不是 GPT,也不要自稱 GPT。"
782
- "你是一個友善、有禮、幽默且能夠提供幫助的AI助手。請使用繁體中文回覆,保持簡潔清晰的表達。"
 
 
 
 
783
  )
784
 
785
  # 在系統提示前加上用戶名稱
@@ -982,7 +948,11 @@ async def _generate_response_with_global_history(
982
  system_prompt = (
983
  "你是 BloomWare 的個人化助理 小花,由銘傳大學人工智慧應用學系 槓上開發 團隊開發。"
984
  "你不是 GPT,也不要自稱 GPT。"
985
- "你是一個友善、有禮、幽默且能夠提供幫助的AI助手。請使用繁體中文回覆,保持簡潔清晰的表達。"
 
 
 
 
986
  )
987
 
988
  # 在系統提示前加上用戶名稱
@@ -1093,3 +1063,107 @@ async def _generate_response_with_global_history(
1093
  raise
1094
  logger.error(f"全局歷史處理出錯: {e}")
1095
  raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import asyncio
 
2
  from datetime import datetime, timezone, timedelta
3
  import time
4
  import json
5
  from typing import Dict, List, Any, Optional
6
 
7
+ # 統一日誌配置
8
+ from core.logging import get_logger
9
+ logger = get_logger("AI_Service")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  # 統一配置管理
12
  from core.config import settings
13
 
14
+ # 統一 OpenAI 客戶端
15
+ from core.ai_client import get_openai_client
16
+
17
  # 超時設定(秒)
18
+ OPENAI_TIMEOUT = settings.OPENAI_TIMEOUT
19
 
20
  # 情緒關懷模式 System Prompt(新增)
21
  CARE_MODE_SYSTEM_PROMPT = """你是 BloomWare 的情緒關懷助手「小花」,由銘傳大學人工智慧應用學系槓上開發團隊打造。你不是 GPT,也不要自稱 GPT;你的任務是在情緒低落時傾聽、陪伴。
 
37
  用戶:「我很生氣」 → 你:「這件事讓你超級生氣,情緒一定卡著。要不要跟我說說最困擾你的地方?」
38
  用戶:「講笑話給我聽」 → 你:「你想聽點輕鬆的,我當然可以陪你。想先聽小笑話還是先聊聊怎麼了?」"""
39
 
40
+ # 取得 OpenAI 客戶端(使用統一管理)
41
+ def _get_client():
42
+ """取得 OpenAI 客戶端"""
43
+ return get_openai_client()
44
 
45
+ # 向後相容:保留 client 變數名稱
46
+ client = None # 將在首次使用時透過 _get_client() 取得
 
 
 
 
 
 
 
 
 
 
47
 
48
  # 導入DB函數
49
  try:
 
460
  return ""
461
 
462
  def initialize_openai():
463
+ """初始化OpenAI客戶端(使用統一管理)"""
464
+ from core.ai_client import is_available
465
+ return is_available()
 
 
 
 
 
 
 
 
 
 
 
 
 
466
 
467
  ## 已移除內部測試函式 test_openai_response,避免干擾正式流程
468
 
 
493
  stream: 是否啟用串流模式(2025 最佳實踐)
494
  on_chunk: 串流 chunk 回調函數(async callable)
495
  """
496
+ openai_client = _get_client()
497
+ if openai_client is None:
498
  return "抱歉,AI服務暫時不可用。系統無法連接到OpenAI服務。"
499
  try:
500
  start_time = time.time()
 
541
  full_response = ""
542
  stream_obj = await loop.run_in_executor(
543
  None,
544
+ lambda: openai_client.chat.completions.create(**request_kwargs)
545
  )
546
 
547
  # 逐塊處理
 
564
  response = await asyncio.wait_for(
565
  loop.run_in_executor(
566
  None,
567
+ lambda: openai_client.chat.completions.create(**request_kwargs),
568
  ),
569
  timeout=OPENAI_TIMEOUT,
570
  )
 
741
  system_prompt = (
742
  "你是 BloomWare 的個人化助理 小花,由銘傳大學人工智慧應用學系 槓上開發 團隊開發。"
743
  "你不是 GPT,也不要自稱 GPT。"
744
+ "你是一個友善、有禮、幽默且能夠提供幫助的AI助手。\n\n"
745
+ "【重要】語言使用規範:\n"
746
+ "- 回覆用戶時:必須使用繁體中文,保持簡潔清晰的表達\n"
747
+ "- 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)\n"
748
+ "- 範例:用戶問「台北天氣」→ 調用工具時參數用 {\"city\": \"Taipei\"},回覆時說「台北目前...\""
749
  )
750
 
751
  # 在系統提示前加上用戶名稱
 
948
  system_prompt = (
949
  "你是 BloomWare 的個人化助理 小花,由銘傳大學人工智慧應用學系 槓上開發 團隊開發。"
950
  "你不是 GPT,也不要自稱 GPT。"
951
+ "你是一個友善、有禮、幽默且能夠提供幫助的AI助手。\n\n"
952
+ "【重要】語言使用規範:\n"
953
+ "- 回覆用戶時:必須使用繁體中文,保持簡潔清晰的表達\n"
954
+ "- 調用工具時:所有參數必須使用英文(城市名、國家名、貨幣代碼等)\n"
955
+ "- 範例:用戶問「台北天氣」→ 調用工具時參數用 {\"city\": \"Taipei\"},回覆時說「台北目前...\""
956
  )
957
 
958
  # 在系統提示前加上用戶名稱
 
1063
  raise
1064
  logger.error(f"全局歷史處理出錯: {e}")
1065
  raise
1066
+
1067
+
1068
+ async def generate_response_with_tools(
1069
+ messages: List[Dict[str, str]],
1070
+ tools: List[Dict[str, Any]],
1071
+ user_id: str = "default",
1072
+ model: str = "gpt-5-nano",
1073
+ reasoning_effort: Optional[str] = None,
1074
+ tool_choice: str = "auto",
1075
+ ) -> Dict[str, Any]:
1076
+ """
1077
+ 使用 OpenAI Function Calling 生成回應
1078
+
1079
+ 2025 最佳實踐:讓 GPT 原生選擇工具,不需要自定義意圖檢測 Prompt
1080
+
1081
+ Args:
1082
+ messages: 對話訊息列表
1083
+ tools: OpenAI tools 格式的工具定義列表
1084
+ user_id: 用戶 ID(用於日誌)
1085
+ model: 模型名稱
1086
+ reasoning_effort: 推理強度 (minimal/low/medium/high)
1087
+ tool_choice: 工具選擇策略 ("auto", "none", "required", 或特定工具名)
1088
+
1089
+ Returns:
1090
+ 包含 tool_calls 和 content 的字典
1091
+ """
1092
+ openai_client = _get_client()
1093
+ if openai_client is None:
1094
+ logger.error("OpenAI 客戶端不可用")
1095
+ return {"content": "", "tool_calls": []}
1096
+
1097
+ try:
1098
+ start_time = time.time()
1099
+ loop = asyncio.get_event_loop()
1100
+
1101
+ request_kwargs = {
1102
+ "model": model,
1103
+ "messages": messages,
1104
+ "tools": tools,
1105
+ "tool_choice": tool_choice,
1106
+ "max_completion_tokens": 1000,
1107
+ }
1108
+
1109
+ # 加入 reasoning_effort 控制
1110
+ if reasoning_effort:
1111
+ request_kwargs["reasoning_effort"] = reasoning_effort
1112
+ logger.info(f"🧠 Function Calling 推理強度: {reasoning_effort}")
1113
+
1114
+ logger.info(f"🔧 Function Calling 請求: {len(tools)} 個工具, tool_choice={tool_choice}")
1115
+ logger.debug(f"📤 發送的訊息: {messages}")
1116
+
1117
+ response = await asyncio.wait_for(
1118
+ loop.run_in_executor(
1119
+ None,
1120
+ lambda: openai_client.chat.completions.create(**request_kwargs),
1121
+ ),
1122
+ timeout=OPENAI_TIMEOUT,
1123
+ )
1124
+
1125
+ elapsed_time = time.time() - start_time
1126
+ logger.info(f"⏱️ Function Calling 完成,耗時: {elapsed_time:.2f}秒")
1127
+
1128
+ # 解析回應
1129
+ message = response.choices[0].message
1130
+ logger.debug(f"📥 原始 message 物件: {message}")
1131
+
1132
+ result = {
1133
+ "content": message.content or "",
1134
+ "tool_calls": [],
1135
+ }
1136
+
1137
+ # 提取 tool_calls
1138
+ if message.tool_calls:
1139
+ for tool_call in message.tool_calls:
1140
+ result["tool_calls"].append({
1141
+ "id": tool_call.id,
1142
+ "type": "function",
1143
+ "function": {
1144
+ "name": tool_call.function.name,
1145
+ "arguments": tool_call.function.arguments,
1146
+ }
1147
+ })
1148
+ logger.info(f"✅ GPT 選擇了 {len(result['tool_calls'])} 個工具")
1149
+ for tc in result["tool_calls"]:
1150
+ logger.info(f" 🔧 工具: {tc['function']['name']}")
1151
+ logger.info(f" 📋 參數 JSON: {tc['function']['arguments']}")
1152
+ # 嘗試解析參數
1153
+ try:
1154
+ import json
1155
+ parsed = json.loads(tc['function']['arguments'])
1156
+ logger.info(f" ✅ 解析後參數: {parsed}")
1157
+ except Exception as e:
1158
+ logger.warning(f" ⚠️ 參數解析失敗: {e}")
1159
+ else:
1160
+ logger.info("💬 GPT 未選擇任何工具(一般聊天)")
1161
+
1162
+ return result
1163
+
1164
+ except asyncio.TimeoutError:
1165
+ logger.error("Function Calling 請求超時")
1166
+ return {"content": "", "tool_calls": []}
1167
+ except Exception as e:
1168
+ logger.error(f"Function Calling 失敗: {e}")
1169
+ return {"content": "", "tool_calls": []}
services/batch_scheduler.py CHANGED
@@ -183,10 +183,62 @@ class BatchScheduler:
183
  Returns:
184
  {user_id: [memory_1, memory_2, ...]}
185
  """
186
- # TODO: 實作數據庫查詢邏輯
187
- # 目前返回空字典(示例)
188
- logger.warning("⚠️ _fetch_yesterday_memories 實作返回空數據")
189
- return {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
 
191
  async def _save_memory_summaries(self, results: List[Dict[str, Any]]):
192
  """
@@ -195,26 +247,119 @@ class BatchScheduler:
195
  Args:
196
  results: 批次結果列表
197
  """
198
- # TODO: 實作數據庫儲存邏輯
 
 
 
 
 
199
  logger.info(f"💾 準備儲存 {len(results)} 條記憶摘要")
 
 
200
  for result in results:
201
- custom_id = result.get("custom_id") # user_id
202
- response = result.get("response", {}).get("body", {})
203
- summary = response.get("choices", [{}])[0].get("message", {}).get("content", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
- logger.debug(f"📝 用戶 {custom_id} 摘要: {summary[:50]}...")
206
- # await save_memory_summary(custom_id, summary)
207
 
208
  async def _fetch_week_health_data(self) -> Dict[str, Dict[str, Any]]:
209
  """
210
  從數據庫獲取所有用戶的本週健康數據
211
 
212
  Returns:
213
- {user_id: {heart_rate: ..., steps: ...}}
214
  """
215
- # TODO: 實作數據庫查詢邏輯
216
- logger.warning("⚠️ _fetch_week_health_data 尚未實作,返回空數據")
217
- return {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
218
 
219
 
220
  # 全域單例
 
183
  Returns:
184
  {user_id: [memory_1, memory_2, ...]}
185
  """
186
+ try:
187
+ if not firestore_db:
188
+ logger.warning("⚠️ Firestore連接無法獲取記憶")
189
+ return {}
190
+
191
+ from google.cloud.firestore import FieldFilter
192
+ import asyncio
193
+
194
+ # 計算昨日時間範圍
195
+ yesterday_start = (datetime.now() - timedelta(days=1)).replace(
196
+ hour=0, minute=0, second=0, microsecond=0
197
+ )
198
+ yesterday_end = yesterday_start + timedelta(days=1)
199
+
200
+ # 獲取所有用戶
201
+ users_collection = firestore_db.collection("users")
202
+
203
+ def _fetch_users():
204
+ return list(users_collection.stream())
205
+
206
+ users = await asyncio.to_thread(_fetch_users)
207
+
208
+ result: Dict[str, List[str]] = {}
209
+
210
+ for user_doc in users:
211
+ user_id = user_doc.id
212
+
213
+ # 獲取該用戶的昨日記憶
214
+ def _fetch_user_memories(uid: str):
215
+ memories_ref = firestore_db.collection("users").document(uid).collection("memories")
216
+ query = memories_ref.where(
217
+ filter=FieldFilter("created_at", ">=", yesterday_start)
218
+ ).where(
219
+ filter=FieldFilter("created_at", "<", yesterday_end)
220
+ )
221
+ return list(query.stream())
222
+
223
+ memories = await asyncio.to_thread(_fetch_user_memories, user_id)
224
+
225
+ if memories:
226
+ memory_contents = []
227
+ for mem_doc in memories:
228
+ mem_data = mem_doc.to_dict()
229
+ content = mem_data.get("content", "")
230
+ if content:
231
+ memory_contents.append(content)
232
+
233
+ if memory_contents:
234
+ result[user_id] = memory_contents
235
+
236
+ logger.info(f"📚 獲取到 {len(result)} 位用戶的昨日記憶")
237
+ return result
238
+
239
+ except Exception as e:
240
+ logger.exception(f"❌ 獲取昨日記憶失敗: {e}")
241
+ return {}
242
 
243
  async def _save_memory_summaries(self, results: List[Dict[str, Any]]):
244
  """
 
247
  Args:
248
  results: 批次結果列表
249
  """
250
+ if not firestore_db:
251
+ logger.warning("⚠️ Firestore 未連接,無法儲存摘要")
252
+ return
253
+
254
+ import asyncio
255
+
256
  logger.info(f"💾 準備儲存 {len(results)} 條記憶摘要")
257
+
258
+ saved_count = 0
259
  for result in results:
260
+ try:
261
+ custom_id = result.get("custom_id") # user_id
262
+ response = result.get("response", {}).get("body", {})
263
+ summary = response.get("choices", [{}])[0].get("message", {}).get("content", "")
264
+
265
+ if not custom_id or not summary:
266
+ continue
267
+
268
+ logger.debug(f"📝 用戶 {custom_id} 的摘要: {summary[:50]}...")
269
+
270
+ # 儲存到用戶的記憶摘要集合
271
+ def _save_summary(uid: str, summary_text: str):
272
+ summaries_ref = firestore_db.collection("users").document(uid).collection("memory_summaries")
273
+ summaries_ref.add({
274
+ "summary": summary_text,
275
+ "date": datetime.now().date().isoformat(),
276
+ "created_at": datetime.now(),
277
+ "type": "daily",
278
+ })
279
+
280
+ await asyncio.to_thread(_save_summary, custom_id, summary)
281
+ saved_count += 1
282
+
283
+ except Exception as e:
284
+ logger.error(f"❌ 儲存用戶 {custom_id} 的摘要失敗: {e}")
285
 
286
+ logger.info(f" 成功儲存 {saved_count}/{len(results)} 條記憶摘要")
 
287
 
288
  async def _fetch_week_health_data(self) -> Dict[str, Dict[str, Any]]:
289
  """
290
  從數據庫獲取所有用戶的本週健康數據
291
 
292
  Returns:
293
+ {user_id: {heart_rate: [...], steps: [...], sleep: [...], ...}}
294
  """
295
+ try:
296
+ if not firestore_db:
297
+ logger.warning("⚠️ Firestore 未連接,無法獲取健康數據")
298
+ return {}
299
+
300
+ from google.cloud.firestore import FieldFilter
301
+ import asyncio
302
+
303
+ # 計算本週時間範圍(週一到今天)
304
+ today = datetime.now()
305
+ week_start = today - timedelta(days=today.weekday())
306
+ week_start = week_start.replace(hour=0, minute=0, second=0, microsecond=0)
307
+
308
+ # 獲取所有用戶
309
+ users_collection = firestore_db.collection("users")
310
+
311
+ def _fetch_users():
312
+ return list(users_collection.stream())
313
+
314
+ users = await asyncio.to_thread(_fetch_users)
315
+
316
+ result: Dict[str, Dict[str, Any]] = {}
317
+
318
+ for user_doc in users:
319
+ user_id = user_doc.id
320
+
321
+ # 獲取該用戶的本週健康數據
322
+ def _fetch_user_health(uid: str):
323
+ health_ref = firestore_db.collection("health_data")
324
+ query = health_ref.where(
325
+ filter=FieldFilter("user_id", "==", uid)
326
+ ).where(
327
+ filter=FieldFilter("timestamp", ">=", week_start)
328
+ )
329
+ return list(query.stream())
330
+
331
+ health_docs = await asyncio.to_thread(_fetch_user_health, user_id)
332
+
333
+ if health_docs:
334
+ user_health: Dict[str, List[Any]] = {
335
+ "heart_rate": [],
336
+ "steps": [],
337
+ "sleep": [],
338
+ "active_calories": [],
339
+ }
340
+
341
+ for doc in health_docs:
342
+ data = doc.to_dict()
343
+ data_type = data.get("type")
344
+ value = data.get("value")
345
+ timestamp = data.get("timestamp")
346
+
347
+ if data_type in user_health and value is not None:
348
+ user_health[data_type].append({
349
+ "value": value,
350
+ "timestamp": timestamp,
351
+ })
352
+
353
+ # 只保留有數據的用戶
354
+ if any(user_health.values()):
355
+ result[user_id] = user_health
356
+
357
+ logger.info(f"❤️ 獲取到 {len(result)} 位用戶的本週健康數據")
358
+ return result
359
+
360
+ except Exception as e:
361
+ logger.exception(f"❌ 獲取本週健康數據失敗: {e}")
362
+ return {}
363
 
364
 
365
  # 全域單例