Spaces:
Running
Running
File size: 9,585 Bytes
69fb140 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 |
"""
語音綁定狀態機
處理語音帳號綁定流程(關鍵字匹配,無 GPT)
"""
import logging
import time
from datetime import datetime
from pathlib import Path
from typing import Dict, Any, Optional, List
from fastapi import WebSocket
from core.logging import get_logger
logger = get_logger("services.voice_binding")
def get_available_speaker_labels() -> List[str]:
"""讀取可用的 speaker label 列表"""
classes_file = Path("models/speaker_identification/models_cnn/classes.txt")
if classes_file.exists():
with open(classes_file, "r", encoding="utf-8") as f:
return [line.strip() for line in f if line.strip()]
return []
class VoiceBindingStateMachine:
"""
語音帳號綁定狀態機(硬編碼關鍵字匹配)
流程:
1. 用戶說「綁定語音登入」
2. 系統詢問「你要綁定哪個帳號?」並顯示可用的 label 列表
3. 用戶輸入帳號名稱(label)
4. 系統綁定 speaker_label 到用戶帳號
5. 系統回應「綁定成功囉!」
"""
# 觸發關鍵字
TRIGGER_KEYWORDS = [
"綁定語音登入",
"語音登入綁定",
"綁定語音",
"設定語音登入",
]
# 狀態超時時間(秒)
STATE_TIMEOUT = 300 # 5 分鐘
def __init__(self):
# 用戶狀態:{user_id: {state: str, timestamp: datetime}}
self.user_states: Dict[str, Dict[str, Any]] = {}
def check_binding_trigger(self, user_id: str, message: str) -> Optional[str]:
"""
檢查是否觸發綁定流程
Returns:
- "TRIGGER": 觸發綁定流程
- "AWAITING_LABEL": 等待用戶輸入帳號名稱
- None: 不是綁定相關訊息
"""
message_lower = message.lower().replace(" ", "")
# 檢測觸發關鍵字
for keyword in self.TRIGGER_KEYWORDS:
if keyword.replace(" ", "") in message_lower:
self.user_states[user_id] = {
"state": "AWAITING_LABEL",
"timestamp": datetime.now()
}
return "TRIGGER"
# 檢查是否在等待輸入帳號名稱狀態
if user_id in self.user_states:
state_info = self.user_states[user_id]
if state_info.get("state") == "AWAITING_LABEL":
# 檢查是否超時
elapsed = (datetime.now() - state_info.get("timestamp")).total_seconds()
if elapsed > self.STATE_TIMEOUT:
del self.user_states[user_id]
return None
return "AWAITING_LABEL"
return None
async def handle_binding_flow(
self,
user_id: str,
message: str,
websocket: WebSocket,
voice_service: Optional[Any] = None,
get_user_by_id: Optional[Any] = None,
) -> bool:
"""
處理綁定流程
Args:
user_id: 用戶 ID
message: 用戶訊息
websocket: WebSocket 連線
voice_service: 語音認證服務(可選)
get_user_by_id: 取得用戶資料的函數(可選)
Returns:
True: 已處理(不要繼續到 Agent)
False: 未處理(繼續到 Agent)
"""
state = self.check_binding_trigger(user_id, message)
if state == "TRIGGER":
logger.info(f"🎙️ 用戶 {user_id} 觸發語音綁定流程")
# 檢查使用者是否已經綁定過 speaker_label
if get_user_by_id:
try:
user_data = await get_user_by_id(user_id)
if user_data and user_data.get("speaker_label"):
existing_label = user_data.get("speaker_label")
logger.info(f"⚠️ 用戶 {user_id} 已綁定 speaker_label: {existing_label}")
await websocket.send_json({
"type": "bot_message",
"message": (
f"你已經綁定過語音了!目前的聲紋標籤是:{existing_label}。"
"如果需要重新綁定,請聯繫管理員。"
),
"timestamp": time.time()
})
self.clear_state(user_id)
return True
except Exception as e:
logger.error(f"❌ 檢查使用者綁定狀態失敗: {e}")
await websocket.send_json({
"type": "error",
"message": "系統錯誤,無法檢查綁定狀態"
})
return True
# 未綁定,顯示可用的 label 列表
available_labels = get_available_speaker_labels()
labels_str = "、".join(available_labels) if available_labels else "(無可用帳號)"
logger.info(f"✅ 用戶 {user_id} 尚未綁定,詢問要綁定的帳號")
await websocket.send_json({
"type": "bot_message",
"message": f"好的,你要綁定哪個帳號呢?\n可用的帳號有:{labels_str}\n請輸入帳號名稱:",
"timestamp": time.time()
})
return True
elif state == "AWAITING_LABEL":
# 用戶輸入了帳號名稱
label_input = message.strip().lower()
available_labels = get_available_speaker_labels()
logger.info(f"🎙️ 用戶 {user_id} 輸入帳號名稱: {label_input}")
# 檢查輸入的 label 是否有效
if label_input not in [l.lower() for l in available_labels]:
labels_str = "、".join(available_labels)
await websocket.send_json({
"type": "bot_message",
"message": f"找不到這個帳號喔!可用的帳號有:{labels_str}\n請重新輸入:",
"timestamp": time.time()
})
return True
# 找到匹配的 label(保持原始大小寫)
matched_label = next((l for l in available_labels if l.lower() == label_input), label_input)
# 執行綁定
try:
from core.database import get_user_by_speaker_label, set_user_speaker_label
# 檢查這個 label 是否已被其他用戶綁定
existing_user = await get_user_by_speaker_label(matched_label)
if existing_user and existing_user.get("id") != user_id:
await websocket.send_json({
"type": "bot_message",
"message": f"這個帳號({matched_label})已經被其他人綁定了,請選擇其他帳號。",
"timestamp": time.time()
})
return True
# 綁定到當前用戶
bind_result = await set_user_speaker_label(user_id, matched_label)
if bind_result.get("success"):
logger.info(f"✅ 用戶 {user_id} 成功綁定 speaker_label: {matched_label}")
await websocket.send_json({
"type": "bot_message",
"message": f"綁定成功囉!🎉 你的語音帳號已設定為:{matched_label}",
"timestamp": time.time()
})
await websocket.send_json({
"type": "voice_binding_success",
"speaker_label": matched_label
})
else:
error_msg = bind_result.get("error", "未知錯誤")
await websocket.send_json({
"type": "bot_message",
"message": f"綁定失敗:{error_msg}",
"timestamp": time.time()
})
except Exception as e:
logger.error(f"❌ 綁定 speaker_label 失敗: {e}")
await websocket.send_json({
"type": "bot_message",
"message": "系統錯誤,綁定失敗,請稍後再試。",
"timestamp": time.time()
})
# 清理狀態,返回待機模式
self.clear_state(user_id)
return True
return False
def clear_state(self, user_id: str) -> None:
"""清理用戶狀態"""
self.user_states.pop(user_id, None)
def is_awaiting_label(self, user_id: str) -> bool:
"""檢查用戶是否在等待輸入帳號名稱狀態"""
if user_id not in self.user_states:
return False
state_info = self.user_states[user_id]
if state_info.get("state") != "AWAITING_LABEL":
return False
# 檢查是否超時
elapsed = (datetime.now() - state_info.get("timestamp")).total_seconds()
if elapsed > self.STATE_TIMEOUT:
del self.user_states[user_id]
return False
return True
# 保留舊方法名稱以保持相容性
def is_awaiting_voice(self, user_id: str) -> bool:
"""檢查用戶是否在等待語音錄製狀態(已棄用,保留相容性)"""
return self.is_awaiting_label(user_id)
# 全域單例
voice_binding_fsm = VoiceBindingStateMachine()
|