Spaces:
Sleeping
Sleeping
Commit ·
26b9faa
1
Parent(s): 522e4b6
Latest
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- app.py +380 -315
- bloom-ware-login/app/layout.tsx +3 -0
- bloom-ware-login/components/login-form.tsx +168 -29
- bloom-ware-login/components/offline-indicator.tsx +63 -0
- core/ai_client.py +83 -0
- core/config.py +44 -3
- core/database/base.py +60 -12
- core/emotion_care_manager.py +17 -4
- core/exceptions.py +267 -0
- core/intent_detector.py +272 -0
- core/logging.py +105 -0
- core/memory_system.py +20 -28
- core/pipeline.py +24 -24
- core/prompts/__init__.py +13 -0
- core/prompts/care_mode.py +45 -0
- core/prompts/intent_detection.py +93 -0
- core/reasoning_strategy.py +3 -3
- core/retry.py +150 -0
- core/tool_registry.py +330 -0
- core/tool_router.py +255 -0
- core/tool_schema.py +355 -0
- features/mcp/agent_bridge.py +347 -337
- features/mcp/tools/exchange_tool.py +8 -6
- features/mcp/tools/geocode_tool.py +1 -1
- features/mcp/tools/healthkit_tool.py +25 -13
- features/mcp/tools/news_tool.py +13 -4
- features/mcp/tools/tdx_bus_arrival.py +15 -6
- features/mcp/tools/tdx_train.py +12 -6
- features/mcp/tools/tdx_youbike.py +73 -7
- features/mcp/tools/weather_tool.py +11 -5
- middleware/__init__.py +18 -0
- middleware/compression.py +106 -0
- middleware/csp.py +36 -0
- middleware/exception_handler.py +99 -0
- middleware/rate_limit.py +165 -0
- models/schemas.py +123 -0
- models/speaker_identification/models_cnn/speaker_db.pkl +3 -0
- models/speaker_identification/scripts/inference.py +81 -181
- models/speaker_identification/scripts/process_audio.py +0 -52
- models/speaker_identification/scripts/train_speaker_id.py +0 -306
- render.yaml +0 -28
- routers/__init__.py +20 -0
- routers/auth.py +159 -0
- routers/chat.py +200 -0
- routers/files.py +228 -0
- routers/health.py +220 -0
- routers/system.py +115 -0
- routers/voice.py +239 -0
- services/ai_service.py +134 -60
- 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 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 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.
|
| 282 |
-
margin_threshold=0.
|
| 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 |
-
#
|
| 355 |
-
await asyncio.sleep(
|
| 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("
|
| 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("⚠️ 未找到
|
| 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 連線管理(
|
| 467 |
# -----------------------------
|
| 468 |
-
|
| 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 |
-
# 語音綁定狀態管理器(
|
| 536 |
# -----------------------------
|
| 537 |
-
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 987 |
|
| 988 |
elif message_type == "audio_chunk":
|
| 989 |
try:
|
| 990 |
b64 = message_data.get("pcm16_base64", "")
|
| 991 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 216 |
}
|
| 217 |
}
|
| 218 |
|
| 219 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
console.log('🎤 開始語音登入...');
|
| 221 |
-
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 250 |
variant="outline"
|
| 251 |
>
|
| 252 |
-
|
| 253 |
-
<
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
</Button>
|
| 273 |
|
| 274 |
{/* Voice Login */}
|
| 275 |
<Button
|
|
|
|
| 276 |
onClick={handleVoiceLogin}
|
| 277 |
-
|
|
|
|
| 278 |
variant="outline"
|
| 279 |
>
|
| 280 |
-
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) <
|
| 161 |
-
print("
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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'})情緒恢復({
|
| 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
|
| 2 |
-
import logging
|
| 3 |
from typing import List, Dict, Any, Optional, Tuple
|
| 4 |
from datetime import datetime
|
| 5 |
-
import json
|
| 6 |
|
| 7 |
-
#
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 143 |
logger.warning("OpenAI客戶端不可用,跳過AI記憶分析")
|
| 144 |
return []
|
| 145 |
|
|
@@ -196,11 +185,11 @@ class MemoryAnalyzer:
|
|
| 196 |
else:
|
| 197 |
max_tokens_value = 2000
|
| 198 |
|
| 199 |
-
response =
|
| 200 |
model="gpt-5-nano",
|
| 201 |
messages=messages,
|
| 202 |
-
max_completion_tokens=max_tokens_value,
|
| 203 |
-
reasoning_effort="low"
|
| 204 |
)
|
| 205 |
break # 成功後跳出重試循環
|
| 206 |
|
|
@@ -279,7 +268,7 @@ class MemoryManager:
|
|
| 279 |
|
| 280 |
# 2. 使用AI分析提取記憶(如果可用)
|
| 281 |
ai_memories = []
|
| 282 |
-
if
|
| 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 |
-
#
|
| 111 |
-
|
| 112 |
-
|
| 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=
|
| 136 |
-
emotion_label=
|
| 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":
|
| 150 |
|
| 151 |
-
#
|
| 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 |
-
#
|
| 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:意圖檢測
|
| 47 |
if task_type == "intent_detection":
|
| 48 |
-
logger.debug("🧠 意圖檢測 →
|
| 49 |
-
return "
|
| 50 |
|
| 51 |
# 🔥 規則 2:關懷模式優先速度(用戶情緒不佳時不要讓他等)
|
| 52 |
if user_emotion in ["sad", "angry", "fear"]:
|
|
|
|
| 43 |
reasoning_effort: minimal/low/medium/high
|
| 44 |
"""
|
| 45 |
|
| 46 |
+
# 🔥 規則 1:意圖檢測使用 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 |
-
|
| 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 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 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 |
-
# 使用
|
|
|
|
| 608 |
optimal_effort = get_optimal_reasoning_effort("intent_detection")
|
| 609 |
logger.info(f"🧠 意圖檢測推理強度: {optimal_effort}")
|
| 610 |
-
|
| 611 |
-
response = await ai_service.
|
| 612 |
messages=messages,
|
|
|
|
| 613 |
user_id="intent_detection",
|
| 614 |
-
model="gpt-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
response_schema=self._get_intent_schema(),
|
| 618 |
-
reasoning_effort=optimal_effort # 動態調整
|
| 619 |
)
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
if
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 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 |
-
|
| 649 |
-
|
| 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 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 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"❌
|
| 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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1142 |
"1. 使用口語化、親切的語氣(可以用「喔」「呢」「哦」等語氣詞)\n"
|
| 1143 |
"2. 不要列表式的羅列數據,而是用對話方式描述\n"
|
| 1144 |
-
"3.
|
| 1145 |
"4. 適當使用 emoji 增加親和力\n"
|
| 1146 |
-
"5. 如
|
| 1147 |
"6. 保持簡短(50字以內最好)\n\n"
|
| 1148 |
-
"範例
|
| 1149 |
-
"
|
| 1150 |
-
"
|
| 1151 |
-
"
|
|
|
|
|
|
|
|
|
|
| 1152 |
)
|
| 1153 |
|
| 1154 |
user_prompt = (
|
| 1155 |
-
f"用
|
| 1156 |
-
f"
|
| 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 |
-
"
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
|
|
|
|
|
|
| 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('
|
| 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 |
-
"
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
"
|
| 207 |
-
"
|
| 208 |
-
"
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 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": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 155 |
-
|
| 156 |
-
|
|
|
|
|
|
|
| 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 |
-
"
|
| 33 |
-
"
|
| 34 |
-
"
|
| 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 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
| 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 = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
USAGE_TIPS = [
|
| 25 |
-
"
|
| 26 |
-
"查詢
|
| 27 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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」「
|
| 54 |
-
"enum":
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 39 |
-
"
|
| 40 |
-
"
|
| 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 |
-
}, [
|
| 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
|
| 3 |
-
import numpy as np
|
| 4 |
import librosa
|
| 5 |
-
import
|
| 6 |
-
import
|
| 7 |
import wave
|
| 8 |
-
import
|
| 9 |
-
|
| 10 |
-
import
|
| 11 |
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 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 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 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 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 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
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 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
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
def predict_files(model_dir, file_list, threshold=0.0):
|
| 82 |
"""
|
| 83 |
-
預測多個音訊檔案的說話者
|
| 84 |
|
| 85 |
Args:
|
| 86 |
-
model_dir: 模型目錄,包含
|
| 87 |
file_list: 檔案路徑列表
|
| 88 |
-
threshold:
|
| 89 |
|
| 90 |
Returns:
|
| 91 |
結果列表,每個元素為字典,包含 'pred', 'score', 'top'
|
| 92 |
"""
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 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 |
-
|
| 130 |
-
|
| 131 |
-
|
| 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':
|
| 141 |
-
'score': float(
|
| 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 |
-
# ============== 錄音
|
| 152 |
-
|
| 153 |
-
|
| 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()
|
|
|
|
|
|
|
| 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 |
-
|
| 189 |
-
parser
|
| 190 |
-
parser.add_argument('--
|
| 191 |
-
parser.add_argument('--
|
| 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 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
|
| 200 |
-
|
| 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
|
| 213 |
-
|
| 214 |
else:
|
| 215 |
-
|
| 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 |
-
|
| 236 |
-
|
| 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'
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
for p, i in zip(topk.values.tolist(), topk.indices.tolist()):
|
| 246 |
-
print(f' {classes[i]}: {p:.3f}')
|
| 247 |
|
| 248 |
-
|
|
|
|
| 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 |
-
|
| 13 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
#
|
| 62 |
-
|
| 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 |
-
|
| 491 |
-
|
| 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 |
-
|
|
|
|
| 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:
|
| 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:
|
| 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 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
logger.info(f"💾 準備儲存 {len(results)} 條記憶摘要")
|
|
|
|
|
|
|
| 200 |
for result in results:
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
|
| 205 |
-
|
| 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 |
-
|
| 216 |
-
|
| 217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
# 全域單例
|