Update app.py
Browse files
app.py
CHANGED
|
@@ -7,14 +7,10 @@ from linebot.exceptions import InvalidSignatureError
|
|
| 7 |
import io
|
| 8 |
import json
|
| 9 |
from PIL import Image
|
| 10 |
-
from linebot.models import
|
| 11 |
-
MessageEvent, TextMessage, TextSendMessage, QuickReply,
|
| 12 |
-
QuickReplyButton, MessageAction, ImageMessage, FlexSendMessage,
|
| 13 |
-
LocationMessage, LocationAction
|
| 14 |
-
)
|
| 15 |
import urllib.parse
|
| 16 |
import math
|
| 17 |
-
|
| 18 |
|
| 19 |
app = Flask(__name__)
|
| 20 |
|
|
@@ -24,7 +20,7 @@ handler = WebhookHandler(os.environ.get('LINE_CHANNEL_SECRET'))
|
|
| 24 |
genai.configure(api_key=os.environ.get('GEMINI_API_KEY'))
|
| 25 |
model = genai.GenerativeModel('gemini-2.5-flash')
|
| 26 |
|
| 27 |
-
# 建立虛擬藥妝資料庫 (Mock Database)
|
| 28 |
fake_database = {
|
| 29 |
"CeraVe適樂膚 長效潤澤修護霜 340g": [
|
| 30 |
{"store": "屈臣氏", "name": "CeraVe適樂膚 長效潤澤修護霜 340g", "price": "619","link": "https://www.watsons.com.tw/cerave-cerave%E9%81%A9%E6%A8%82%E8%86%9A%E9%95%B7%E6%95%88%E6%BD%A4%E6%BE%A4%E4%BF%AE%E8%AD%B7%E9%9C%9C-340g/p/BP_153671","logo": "https://openhouse.osa.nycu.edu.tw/media/data/company_logos/3._%E5%B1%88%E8%87%A3%E6%B0%8Flogo.png"},
|
|
@@ -38,7 +34,8 @@ fake_database = {
|
|
| 38 |
]
|
| 39 |
}
|
| 40 |
|
| 41 |
-
|
|
|
|
| 42 |
user_states = {}
|
| 43 |
|
| 44 |
@app.route("/callback", methods=['POST'])
|
|
@@ -51,38 +48,48 @@ def callback():
|
|
| 51 |
abort(400)
|
| 52 |
return 'OK'
|
| 53 |
|
| 54 |
-
# =================
|
| 55 |
@handler.add(MessageEvent, message=ImageMessage)
|
| 56 |
def handle_image(event):
|
| 57 |
user_id = event.source.user_id
|
| 58 |
state = user_states.get(user_id)
|
| 59 |
|
|
|
|
| 60 |
if state and state.get("step") == "ASK_PHOTO_ITEM":
|
|
|
|
| 61 |
line_bot_api.reply_message(
|
| 62 |
event.reply_token,
|
| 63 |
TextSendMessage(text="✨ 收到照片!精靈正在用魔法辨識這款產品,請稍候...")
|
| 64 |
)
|
| 65 |
|
| 66 |
try:
|
|
|
|
| 67 |
message_content = line_bot_api.get_message_content(event.message.id)
|
| 68 |
image_bytes = io.BytesIO(message_content.content)
|
| 69 |
pil_image = Image.open(image_bytes)
|
| 70 |
|
|
|
|
| 71 |
vision_prompt = """
|
| 72 |
請辨識這張圖片中的保養品。
|
| 73 |
如果是 CeraVe (適樂膚) 的產品,請直接回答:CeraVe適樂膚 長效潤澤修護霜 340g
|
| 74 |
如果是 Avene (雅漾) 活泉水,請直接回答:Avene 雅漾舒護活泉水150ml
|
| 75 |
如果都不是,請發揮你的專業,判斷它是什麼真實的品牌與品名,並「直接回答品牌與品名」。
|
| 76 |
|
| 77 |
-
注意:非常重要!無論如何,你只能輸出「商品名稱」,絕對不要輸出任何其他廢話、標點符號、價格
|
| 78 |
"""
|
| 79 |
|
|
|
|
| 80 |
response = model.generate_content([vision_prompt, pil_image])
|
| 81 |
product_name = response.text.strip()
|
| 82 |
|
|
|
|
| 83 |
if product_name in fake_database:
|
|
|
|
| 84 |
data_list = fake_database[product_name]
|
|
|
|
| 85 |
else:
|
|
|
|
|
|
|
| 86 |
estimate_prompt = f"""
|
| 87 |
請估算「{product_name}」這款產品在台灣屈臣氏、康是美、寶雅的合理售價。
|
| 88 |
請務必「只回傳」一個標準的 JSON 陣列,不要有任何額外的說明文字。格式嚴格如下:
|
|
@@ -95,118 +102,227 @@ def handle_image(event):
|
|
| 95 |
est_response = model.generate_content(estimate_prompt)
|
| 96 |
clean_text = est_response.text.replace("```json", "").replace("```", "").strip()
|
| 97 |
data_list = json.loads(clean_text)
|
|
|
|
| 98 |
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
"type": "box",
|
| 118 |
"layout": "vertical",
|
| 119 |
-
"spacing": "sm",
|
| 120 |
-
"margin": "lg",
|
| 121 |
"contents": [
|
|
|
|
| 122 |
{
|
| 123 |
"type": "box",
|
| 124 |
-
"layout": "
|
| 125 |
-
"
|
| 126 |
-
|
| 127 |
-
{"type": "text", "text": safe_store, "size": "md", "color": "#111111", "weight": "bold", "flex": 2}
|
| 128 |
-
]
|
| 129 |
-
},
|
| 130 |
-
{
|
| 131 |
-
"type": "box",
|
| 132 |
-
"layout": "horizontal",
|
| 133 |
-
"contents": [
|
| 134 |
-
{"type": "text", "text": "商品售價", "size": "md", "color": "#8c8c8c", "flex": 1},
|
| 135 |
-
{"type": "text", "text": f"${safe_price}", "size": "md", "color": "#111111", "weight": "bold", "flex": 2}
|
| 136 |
-
]
|
| 137 |
-
}
|
| 138 |
-
]
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
if safe_discount:
|
| 142 |
-
specs_box["contents"].append(
|
| 143 |
-
{
|
| 144 |
-
"type": "box",
|
| 145 |
-
"layout": "horizontal",
|
| 146 |
"contents": [
|
| 147 |
-
{
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
]
|
| 150 |
}
|
| 151 |
-
)
|
| 152 |
-
|
| 153 |
-
card = {
|
| 154 |
-
"type": "bubble",
|
| 155 |
-
"size": "mega",
|
| 156 |
-
"hero": {
|
| 157 |
-
"type": "image",
|
| 158 |
-
"url": safe_logo,
|
| 159 |
-
"size": "full",
|
| 160 |
-
"aspectRatio": "2:1",
|
| 161 |
-
"aspectMode": "fit",
|
| 162 |
-
"backgroundColor": "#FFFFFF"
|
| 163 |
-
},
|
| 164 |
-
"body": {
|
| 165 |
-
"type": "box",
|
| 166 |
-
"layout": "vertical",
|
| 167 |
-
"contents": [
|
| 168 |
-
{"type": "text", "text": safe_name, "weight": "bold", "size": "xl", "wrap": True},
|
| 169 |
-
specs_box
|
| 170 |
]
|
| 171 |
-
|
| 172 |
-
|
| 173 |
"type": "box",
|
| 174 |
"layout": "vertical",
|
| 175 |
"contents": [
|
| 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 |
@handler.add(MessageEvent, message=LocationMessage)
|
| 211 |
def handle_location(event):
|
| 212 |
user_id = event.source.user_id
|
|
@@ -216,14 +332,14 @@ def handle_location(event):
|
|
| 216 |
state = user_states.get(user_id, {})
|
| 217 |
brand_preference = state.get("search_brand", "全都要")
|
| 218 |
|
| 219 |
-
|
| 220 |
-
google_api_key = os.environ.get('GOOGLE_MAPS_API_KEY')
|
| 221 |
if not google_api_key:
|
| 222 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="❌ 系統尚未設定 Google Maps API 金鑰。"))
|
| 223 |
return
|
| 224 |
|
| 225 |
-
# 計算距離的數學
|
| 226 |
def calculate_distance(lat1, lon1, lat2, lon2):
|
|
|
|
| 227 |
R = 6371.0
|
| 228 |
lat1_rad, lon1_rad = math.radians(lat1), math.radians(lon1)
|
| 229 |
lat2_rad, lon2_rad = math.radians(lat2), math.radians(lon2)
|
|
@@ -233,7 +349,7 @@ def handle_location(event):
|
|
| 233 |
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
| 234 |
return int(R * c * 1000)
|
| 235 |
|
| 236 |
-
# 查詢「今天確切營業時間」的
|
| 237 |
def get_exact_hours(place_id, api_key):
|
| 238 |
url = f"https://maps.googleapis.com/maps/api/place/details/json?place_id={place_id}&fields=opening_hours&language=zh-TW&key={api_key}"
|
| 239 |
try:
|
|
@@ -241,6 +357,7 @@ def handle_location(event):
|
|
| 241 |
if 'result' in res and 'opening_hours' in res['result']:
|
| 242 |
weekday_text = res['result']['opening_hours'].get('weekday_text', [])
|
| 243 |
if weekday_text:
|
|
|
|
| 244 |
tw_tz = timezone(timedelta(hours=8))
|
| 245 |
today_idx = datetime.now(tw_tz).weekday()
|
| 246 |
today_str = weekday_text[today_idx]
|
|
@@ -250,14 +367,16 @@ def handle_location(event):
|
|
| 250 |
pass
|
| 251 |
return "詳情請洽門市"
|
| 252 |
|
|
|
|
| 253 |
brands_to_search = ["屈臣氏", "康是美", "寶雅"] if brand_preference == "全都要" else [brand_preference]
|
| 254 |
-
search_radius = 3000
|
| 255 |
|
| 256 |
try:
|
| 257 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🧚♀️ 精靈正在為您精準定位各品牌門市,請稍候..."))
|
| 258 |
|
| 259 |
nearest_stores = []
|
| 260 |
|
|
|
|
| 261 |
for brand in brands_to_search:
|
| 262 |
url = f"https://maps.googleapis.com/maps/api/place/textsearch/json?query={brand}&location={user_lat},{user_lon}&radius={search_radius}&language=zh-TW&key={google_api_key}"
|
| 263 |
response = requests.get(url).json()
|
|
@@ -293,12 +412,14 @@ def handle_location(event):
|
|
| 293 |
"photo_ref": photo_ref
|
| 294 |
})
|
| 295 |
|
|
|
|
| 296 |
sorted_brand_stores = sorted(brand_stores, key=lambda x: x['distance'])
|
| 297 |
|
| 298 |
if brand_preference == "全都要":
|
| 299 |
-
|
| 300 |
-
|
| 301 |
else:
|
|
|
|
| 302 |
nearest_stores.extend(sorted_brand_stores[:3])
|
| 303 |
|
| 304 |
if not nearest_stores:
|
|
@@ -306,8 +427,10 @@ def handle_location(event):
|
|
| 306 |
if user_id in user_states: del user_states[user_id]
|
| 307 |
return
|
| 308 |
|
|
|
|
| 309 |
nearest_stores = sorted(nearest_stores, key=lambda x: x['distance'])
|
| 310 |
|
|
|
|
| 311 |
bubbles = []
|
| 312 |
for store in nearest_stores:
|
| 313 |
exact_hours = get_exact_hours(store['place_id'], google_api_key)
|
|
@@ -321,6 +444,7 @@ def handle_location(event):
|
|
| 321 |
else:
|
| 322 |
image_url = "https://images.unsplash.com/photo-1576426863848-c21f53c60b19?q=80&w=600&auto=format&fit=crop"
|
| 323 |
|
|
|
|
| 324 |
card = {
|
| 325 |
"type": "bubble",
|
| 326 |
"size": "mega",
|
|
@@ -401,23 +525,16 @@ def handle_location(event):
|
|
| 401 |
"contents": bubbles
|
| 402 |
}
|
| 403 |
})
|
| 404 |
-
|
| 405 |
-
line_bot_api.push_message(user_id, flex_message)
|
| 406 |
-
if user_id in user_states: del user_states[user_id]
|
| 407 |
-
|
| 408 |
-
except Exception as e:
|
| 409 |
-
line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 搜尋過程發生錯誤:{e}"))
|
| 410 |
-
if user_id in user_states: del user_states[user_id]
|
| 411 |
|
| 412 |
-
# =================
|
| 413 |
@handler.add(MessageEvent, message=TextMessage)
|
| 414 |
def handle_message(event):
|
| 415 |
user_id = event.source.user_id
|
| 416 |
user_msg = event.message.text.strip()
|
| 417 |
|
| 418 |
-
# 附近店家 - 步驟 1:選擇品牌
|
| 419 |
if user_msg == "附近店家":
|
| 420 |
-
user_states[user_id] = {"step": "ASK_BRAND"}
|
| 421 |
|
| 422 |
brand_buttons = QuickReply(items=[
|
| 423 |
QuickReplyButton(action=MessageAction(label="屈臣氏", text="屈臣氏")),
|
|
@@ -428,16 +545,19 @@ def handle_message(event):
|
|
| 428 |
|
| 429 |
line_bot_api.reply_message(
|
| 430 |
event.reply_token,
|
| 431 |
-
TextSendMessage(
|
|
|
|
|
|
|
|
|
|
| 432 |
)
|
| 433 |
return
|
| 434 |
|
| 435 |
-
# 附近店家 - 步驟 2:要求定位
|
| 436 |
state = user_states.get(user_id)
|
| 437 |
if state and state.get("step") == "ASK_BRAND":
|
| 438 |
if user_msg in ["屈臣氏", "康是美", "寶雅", "全都要"]:
|
| 439 |
-
state["search_brand"] = user_msg
|
| 440 |
-
state["step"] = "ASK_LOCATION"
|
| 441 |
|
| 442 |
location_button = QuickReply(items=[
|
| 443 |
QuickReplyButton(action=LocationAction(label="📍 點我分享位置"))
|
|
@@ -445,7 +565,10 @@ def handle_message(event):
|
|
| 445 |
|
| 446 |
line_bot_api.reply_message(
|
| 447 |
event.reply_token,
|
| 448 |
-
TextSendMessage(
|
|
|
|
|
|
|
|
|
|
| 449 |
)
|
| 450 |
else:
|
| 451 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請點擊下方的按鈕選擇品牌喔!"))
|
|
@@ -455,6 +578,7 @@ def handle_message(event):
|
|
| 455 |
if user_msg == "精靈顧問" or user_msg == "重新開始":
|
| 456 |
user_states[user_id] = {"step": "ASK_SKIN"}
|
| 457 |
|
|
|
|
| 458 |
skin_buttons = QuickReply(items=[
|
| 459 |
QuickReplyButton(action=MessageAction(label="乾性肌", text="乾性肌")),
|
| 460 |
QuickReplyButton(action=MessageAction(label="油性肌", text="油性肌")),
|
|
@@ -476,7 +600,10 @@ def handle_message(event):
|
|
| 476 |
)
|
| 477 |
return
|
| 478 |
|
|
|
|
| 479 |
state = user_states.get(user_id)
|
|
|
|
|
|
|
| 480 |
if not state:
|
| 481 |
line_bot_api.reply_message(
|
| 482 |
event.reply_token,
|
|
@@ -486,12 +613,13 @@ def handle_message(event):
|
|
| 486 |
|
| 487 |
current_step = state.get("step")
|
| 488 |
|
| 489 |
-
# 【步驟一】處理膚質選擇
|
| 490 |
if current_step == "ASK_SKIN":
|
| 491 |
if user_msg in ["乾性肌", "油性肌", "混合肌", "敏感肌"]:
|
| 492 |
-
state["skin_type"] = user_msg
|
| 493 |
-
state["step"] = "ASK_ISSUE"
|
| 494 |
|
|
|
|
| 495 |
issue_buttons = QuickReply(items=[
|
| 496 |
QuickReplyButton(action=MessageAction(label="嚴重脫皮", text="嚴重脫皮")),
|
| 497 |
QuickReplyButton(action=MessageAction(label="暗沉無光", text="暗沉無光")),
|
|
@@ -506,12 +634,13 @@ def handle_message(event):
|
|
| 506 |
else:
|
| 507 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的膚質選項,或是輸入「重新開始」喔!"))
|
| 508 |
|
| 509 |
-
# 【步驟二】處理困擾選擇
|
| 510 |
elif current_step == "ASK_ISSUE":
|
| 511 |
if user_msg in ["嚴重脫皮", "暗沉無光", "痘痘粉刺", "泛紅乾癢"]:
|
| 512 |
-
state["issue"] = user_msg
|
| 513 |
-
state["step"] = "ASK_PREFERENCE"
|
| 514 |
|
|
|
|
| 515 |
pref_buttons = QuickReply(items=[
|
| 516 |
QuickReplyButton(action=MessageAction(label="偏好清爽凝露", text="偏好清爽凝露")),
|
| 517 |
QuickReplyButton(action=MessageAction(label="偏好滋潤乳霜", text="偏好滋潤乳霜")),
|
|
@@ -525,47 +654,184 @@ def handle_message(event):
|
|
| 525 |
else:
|
| 526 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的肌膚問題選項,或是輸入「重新開始」喔!"))
|
| 527 |
|
| 528 |
-
# 【步驟三】處理偏好選擇 -> 產生建議
|
| 529 |
elif current_step == "ASK_PREFERENCE":
|
| 530 |
if user_msg in ["偏好清爽凝露", "偏好滋潤乳霜", "都可以"]:
|
| 531 |
skin_type = state["skin_type"]
|
| 532 |
issue = state["issue"]
|
| 533 |
preference = user_msg
|
| 534 |
|
|
|
|
| 535 |
line_bot_api.reply_message(
|
| 536 |
event.reply_token,
|
| 537 |
TextSendMessage(text="🧚♀️ 精靈正在為您翻閱保養圖鑑,請稍候幾秒鐘...")
|
| 538 |
)
|
| 539 |
|
|
|
|
| 540 |
prompt = f"""
|
| 541 |
你現在是一位「台灣藥妝店皮膚科精靈顧問🧚♀️」,擁有10年以上保養諮詢經驗。
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 548 |
"""
|
|
|
|
| 549 |
try:
|
|
|
|
| 550 |
response = model.generate_content(prompt)
|
| 551 |
ai_reply = response.text
|
|
|
|
|
|
|
| 552 |
line_bot_api.push_message(user_id, TextSendMessage(text=ai_reply))
|
|
|
|
| 553 |
except Exception as e:
|
| 554 |
-
|
|
|
|
|
|
|
| 555 |
|
|
|
|
| 556 |
del user_states[user_id]
|
| 557 |
else:
|
| 558 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的質地偏好選項,或是輸入「重新開始」喔!"))
|
| 559 |
|
| 560 |
-
|
|
|
|
| 561 |
elif current_step == "ASK_PHOTO_ITEM":
|
|
|
|
| 562 |
text_prompt = f"請幫我查詢「{user_msg}」這款產品在台灣藥妝店的合理售價範圍與推薦購買商家。請簡單溫柔地回答,並條列出品名、價格、商家。"
|
| 563 |
try:
|
|
|
|
| 564 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="✨ 收到文字!精靈正在幫您查詢這款產品,請稍候..."))
|
|
|
|
|
|
|
| 565 |
response = model.generate_content(text_prompt)
|
|
|
|
|
|
|
| 566 |
line_bot_api.push_message(user_id, TextSendMessage(text=f"🧚♀️ 查詢成功!\n\n{response.text}"))
|
|
|
|
| 567 |
except Exception as e:
|
| 568 |
line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 查詢失敗,錯誤原因:\n{e}"))
|
|
|
|
|
|
|
| 569 |
del user_states[user_id]
|
| 570 |
|
| 571 |
if __name__ == "__main__":
|
|
|
|
| 7 |
import io
|
| 8 |
import json
|
| 9 |
from PIL import Image
|
| 10 |
+
from linebot.models import MessageEvent, TextMessage, TextSendMessage, QuickReply, QuickReplyButton, MessageAction, ImageMessage, FlexSendMessage, LocationMessage, LocationAction
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
import urllib.parse
|
| 12 |
import math
|
| 13 |
+
import urllib.parse
|
| 14 |
|
| 15 |
app = Flask(__name__)
|
| 16 |
|
|
|
|
| 20 |
genai.configure(api_key=os.environ.get('GEMINI_API_KEY'))
|
| 21 |
model = genai.GenerativeModel('gemini-2.5-flash')
|
| 22 |
|
| 23 |
+
# 建立我們的虛擬藥妝資料庫 (Mock Database)
|
| 24 |
fake_database = {
|
| 25 |
"CeraVe適樂膚 長效潤澤修護霜 340g": [
|
| 26 |
{"store": "屈臣氏", "name": "CeraVe適樂膚 長效潤澤修護霜 340g", "price": "619","link": "https://www.watsons.com.tw/cerave-cerave%E9%81%A9%E6%A8%82%E8%86%9A%E9%95%B7%E6%95%88%E6%BD%A4%E6%BE%A4%E4%BF%AE%E8%AD%B7%E9%9C%9C-340g/p/BP_153671","logo": "https://openhouse.osa.nycu.edu.tw/media/data/company_logos/3._%E5%B1%88%E8%87%A3%E6%B0%8Flogo.png"},
|
|
|
|
| 34 |
]
|
| 35 |
}
|
| 36 |
|
| 37 |
+
|
| 38 |
+
# ================= 2. 建立狀態儲存區(暫存每個用戶的進度) =================
|
| 39 |
user_states = {}
|
| 40 |
|
| 41 |
@app.route("/callback", methods=['POST'])
|
|
|
|
| 48 |
abort(400)
|
| 49 |
return 'OK'
|
| 50 |
|
| 51 |
+
# ================= 【新增】專門處理使用者傳送照片的邏輯 =================
|
| 52 |
@handler.add(MessageEvent, message=ImageMessage)
|
| 53 |
def handle_image(event):
|
| 54 |
user_id = event.source.user_id
|
| 55 |
state = user_states.get(user_id)
|
| 56 |
|
| 57 |
+
# 檢查使用者是否處於拍照精靈的等待狀態
|
| 58 |
if state and state.get("step") == "ASK_PHOTO_ITEM":
|
| 59 |
+
# 先給個回覆讓使用者知道精靈在看了
|
| 60 |
line_bot_api.reply_message(
|
| 61 |
event.reply_token,
|
| 62 |
TextSendMessage(text="✨ 收到照片!精靈正在用魔法辨識這款產品,請稍候...")
|
| 63 |
)
|
| 64 |
|
| 65 |
try:
|
| 66 |
+
# 1. 從 LINE 伺服器下載使用者傳的照片
|
| 67 |
message_content = line_bot_api.get_message_content(event.message.id)
|
| 68 |
image_bytes = io.BytesIO(message_content.content)
|
| 69 |
pil_image = Image.open(image_bytes)
|
| 70 |
|
| 71 |
+
# 2. 設定 Prompt,要求 Gemini 直接分析這張圖片
|
| 72 |
vision_prompt = """
|
| 73 |
請辨識這張圖片中的保養品。
|
| 74 |
如果是 CeraVe (適樂膚) 的產品,請直接回答:CeraVe適樂膚 長效潤澤修護霜 340g
|
| 75 |
如果是 Avene (雅漾) 活泉水,請直接回答:Avene 雅漾舒護活泉水150ml
|
| 76 |
如果都不是,請發揮你的專業,判斷它是什麼真實的品牌與品名,並「直接回答品牌與品名」。
|
| 77 |
|
| 78 |
+
注意:非常重要!無論如何,你只能輸出「商品名稱」,絕對不要輸出任何其他廢話、標點符號、價格或商家資訊。
|
| 79 |
"""
|
| 80 |
|
| 81 |
+
# 3. 把圖片和 Prompt 一起丟給 Gemini (支援多模態)
|
| 82 |
response = model.generate_content([vision_prompt, pil_image])
|
| 83 |
product_name = response.text.strip()
|
| 84 |
|
| 85 |
+
# --- 第 3 步:雙軌制 (假資料庫 vs AI估價) ---
|
| 86 |
if product_name in fake_database:
|
| 87 |
+
# 🌟 路線 A:命中資料庫,使用妳設定的真實數據!
|
| 88 |
data_list = fake_database[product_name]
|
| 89 |
+
price_tag = "資料庫真實價" # 讓圖卡顯示這是真實數據
|
| 90 |
else:
|
| 91 |
+
# 🤖 路線 B:資料庫沒有,呼叫 AI 進行現場估價!
|
| 92 |
+
|
| 93 |
estimate_prompt = f"""
|
| 94 |
請估算「{product_name}」這款產品在台灣屈臣氏、康是美、寶雅的合理售價。
|
| 95 |
請務必「只回傳」一個標準的 JSON 陣列,不要有任何額外的說明文字。格式嚴格如下:
|
|
|
|
| 102 |
est_response = model.generate_content(estimate_prompt)
|
| 103 |
clean_text = est_response.text.replace("```json", "").replace("```", "").strip()
|
| 104 |
data_list = json.loads(clean_text)
|
| 105 |
+
price_tag = "AI 估計價" # 讓圖卡顯示這是 AI 猜的
|
| 106 |
|
| 107 |
+
# --- 第 4 步:將資料塞進橫向圖卡 (不管哪條路線,最後都會來到這裡) ---
|
| 108 |
+
# --- 第 4 步:將資料塞進橫向圖卡 ---
|
| 109 |
+
# --- 第 4 步:將資料塞進 Toyota 風格的旗艦版圖卡 ---
|
| 110 |
+
# --- 第 4 步:將資料塞進 Toyota 風格的旗艦版圖卡 ---
|
| 111 |
+
# --- 第 4 步:將資料塞進 Toyota 風格的旗艦版圖卡 (終極防彈版) ---
|
| 112 |
+
# --- 第 4 步:將資料塞進 Toyota 風格 (店家 Logo 版) ---
|
| 113 |
+
# ================= 【霸氣點名版】保證全品牌上榜 + 曜石黑完美排版 =================
|
| 114 |
+
@handler.add(MessageEvent, message=LocationMessage)
|
| 115 |
+
def handle_location(event):
|
| 116 |
+
user_id = event.source.user_id
|
| 117 |
+
user_lat = event.message.latitude
|
| 118 |
+
user_lon = event.message.longitude
|
| 119 |
+
|
| 120 |
+
state = user_states.get(user_id, {})
|
| 121 |
+
brand_preference = state.get("search_brand", "全都要")
|
| 122 |
+
|
| 123 |
+
# ✅ 修正一:對齊正確的金鑰變數名稱
|
| 124 |
+
google_api_key = os.environ.get('Maps_API_KEY')
|
| 125 |
+
if not google_api_key:
|
| 126 |
+
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="❌ 系統尚未設定 Google Maps API 金鑰。"))
|
| 127 |
+
return
|
| 128 |
+
|
| 129 |
+
# 計算距離的數學魔法
|
| 130 |
+
def calculate_distance(lat1, lon1, lat2, lon2):
|
| 131 |
+
import math
|
| 132 |
+
R = 6371.0
|
| 133 |
+
lat1_rad, lon1_rad = math.radians(lat1), math.radians(lon1)
|
| 134 |
+
lat2_rad, lon2_rad = math.radians(lat2), math.radians(lon2)
|
| 135 |
+
dlon = lon2_rad - lon1_rad
|
| 136 |
+
dlat = lat2_rad - lat1_rad
|
| 137 |
+
a = math.sin(dlat / 2)**2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlon / 2)**2
|
| 138 |
+
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
| 139 |
+
return int(R * c * 1000)
|
| 140 |
+
|
| 141 |
+
# 查詢「今天確切營業時間」的魔法
|
| 142 |
+
def get_exact_hours(place_id, api_key):
|
| 143 |
+
url = f"https://maps.googleapis.com/maps/api/place/details/json?place_id={place_id}&fields=opening_hours&language=zh-TW&key={api_key}"
|
| 144 |
+
try:
|
| 145 |
+
res = requests.get(url).json()
|
| 146 |
+
if 'result' in res and 'opening_hours' in res['result']:
|
| 147 |
+
weekday_text = res['result']['opening_hours'].get('weekday_text', [])
|
| 148 |
+
if weekday_text:
|
| 149 |
+
from datetime import datetime, timezone, timedelta
|
| 150 |
+
tw_tz = timezone(timedelta(hours=8))
|
| 151 |
+
today_idx = datetime.now(tw_tz).weekday()
|
| 152 |
+
today_str = weekday_text[today_idx]
|
| 153 |
+
if ": " in today_str:
|
| 154 |
+
return today_str.split(": ", 1)[1]
|
| 155 |
+
except Exception:
|
| 156 |
+
pass
|
| 157 |
+
return "詳情請洽門市"
|
| 158 |
+
|
| 159 |
+
# 決定要搜尋的名單
|
| 160 |
+
brands_to_search = ["屈臣氏", "康是美", "寶雅"] if brand_preference == "全都要" else [brand_preference]
|
| 161 |
+
search_radius = 3000
|
| 162 |
+
|
| 163 |
+
try:
|
| 164 |
+
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🧚♀️ 精靈正在為您精準定位各品牌門市,請稍候..."))
|
| 165 |
+
|
| 166 |
+
nearest_stores = []
|
| 167 |
+
|
| 168 |
+
for brand in brands_to_search:
|
| 169 |
+
url = f"https://maps.googleapis.com/maps/api/place/textsearch/json?query={brand}&location={user_lat},{user_lon}&radius={search_radius}&language=zh-TW&key={google_api_key}"
|
| 170 |
+
response = requests.get(url).json()
|
| 171 |
+
|
| 172 |
+
if response['status'] == 'OK' and response.get('results'):
|
| 173 |
+
brand_stores = []
|
| 174 |
+
for place in response['results']:
|
| 175 |
+
store_lat = place['geometry']['location']['lat']
|
| 176 |
+
store_lon = place['geometry']['location']['lng']
|
| 177 |
+
distance = calculate_distance(user_lat, user_lon, store_lat, store_lon)
|
| 178 |
+
|
| 179 |
+
photo_ref = ""
|
| 180 |
+
if 'photos' in place and len(place['photos']) > 0:
|
| 181 |
+
photo_ref = place['photos'][0]['photo_reference']
|
| 182 |
+
|
| 183 |
+
open_now = place.get('opening_hours', {}).get('open_now')
|
| 184 |
+
if open_now is True:
|
| 185 |
+
status_light = "🟢 營業中"
|
| 186 |
+
elif open_now is False:
|
| 187 |
+
status_light = "🔴 休息中"
|
| 188 |
+
else:
|
| 189 |
+
status_light = "🕒 營業狀態"
|
| 190 |
+
|
| 191 |
+
brand_stores.append({
|
| 192 |
+
"place_id": place.get('place_id'),
|
| 193 |
+
"name": place.get('name', '未知名稱'),
|
| 194 |
+
"rating": place.get('rating', '無評分'),
|
| 195 |
+
"lat": store_lat,
|
| 196 |
+
"lon": store_lon,
|
| 197 |
+
"distance": distance,
|
| 198 |
+
"address": place.get('formatted_address', ''),
|
| 199 |
+
"status_light": status_light,
|
| 200 |
+
"photo_ref": photo_ref
|
| 201 |
+
})
|
| 202 |
+
|
| 203 |
+
sorted_brand_stores = sorted(brand_stores, key=lambda x: x['distance'])
|
| 204 |
|
| 205 |
+
if brand_preference == "全都要":
|
| 206 |
+
if sorted_brand_stores:
|
| 207 |
+
nearest_stores.append(sorted_brand_stores[0])
|
| 208 |
+
else:
|
| 209 |
+
nearest_stores.extend(sorted_brand_stores[:3])
|
| 210 |
+
|
| 211 |
+
if not nearest_stores:
|
| 212 |
+
line_bot_api.push_message(user_id, TextSendMessage(text="🥺 哎呀,精靈在您附近 3 公里內都找不到藥妝店呢..."))
|
| 213 |
+
if user_id in user_states: del user_states[user_id]
|
| 214 |
+
return
|
| 215 |
+
|
| 216 |
+
nearest_stores = sorted(nearest_stores, key=lambda x: x['distance'])
|
| 217 |
+
|
| 218 |
+
# 準備製作符合高級風格的 Flex Message 卡片
|
| 219 |
+
bubbles = []
|
| 220 |
+
for store in nearest_stores:
|
| 221 |
+
exact_hours = get_exact_hours(store['place_id'], google_api_key)
|
| 222 |
+
display_hours = f"{store['status_light']} ({exact_hours})"
|
| 223 |
+
|
| 224 |
+
map_query = urllib.parse.quote(f"{store['name']} {store['address']}")
|
| 225 |
+
map_url = f"https://www.google.com/maps/search/?api=1&query={map_query}"
|
| 226 |
+
|
| 227 |
+
if store['photo_ref']:
|
| 228 |
+
image_url = f"https://maps.googleapis.com/maps/api/place/photo?maxwidth=600&photo_reference={store['photo_ref']}&key={google_api_key}"
|
| 229 |
+
else:
|
| 230 |
+
image_url = "https://images.unsplash.com/photo-1576426863848-c21f53c60b19?q=80&w=600&auto=format&fit=crop"
|
| 231 |
+
|
| 232 |
+
# ✅ 修正二:乾淨、排版漂亮且結構完全正確的 card 字典
|
| 233 |
+
card = {
|
| 234 |
+
"type": "bubble",
|
| 235 |
+
"size": "mega",
|
| 236 |
+
"hero": {
|
| 237 |
+
"type": "image",
|
| 238 |
+
"url": image_url,
|
| 239 |
+
"size": "full",
|
| 240 |
+
"aspectRatio": "16:9",
|
| 241 |
+
"aspectMode": "cover"
|
| 242 |
+
},
|
| 243 |
+
"body": {
|
| 244 |
"type": "box",
|
| 245 |
"layout": "vertical",
|
|
|
|
|
|
|
| 246 |
"contents": [
|
| 247 |
+
{"type": "text", "text": store['name'], "weight": "bold", "size": "xl", "wrap": True, "color": "#111111"},
|
| 248 |
{
|
| 249 |
"type": "box",
|
| 250 |
+
"layout": "vertical",
|
| 251 |
+
"spacing": "sm",
|
| 252 |
+
"margin": "lg",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
"contents": [
|
| 254 |
+
{
|
| 255 |
+
"type": "box",
|
| 256 |
+
"layout": "baseline",
|
| 257 |
+
"spacing": "sm",
|
| 258 |
+
"contents": [
|
| 259 |
+
{"type": "text", "text": "目前距離", "size": "sm", "color": "#8c8c8c", "flex": 3},
|
| 260 |
+
{"type": "text", "text": f"{store['distance']} 公尺", "size": "sm", "color": "#111111", "weight": "bold", "flex": 5}
|
| 261 |
+
]
|
| 262 |
+
},
|
| 263 |
+
{
|
| 264 |
+
"type": "box",
|
| 265 |
+
"layout": "baseline",
|
| 266 |
+
"spacing": "sm",
|
| 267 |
+
"contents": [
|
| 268 |
+
{"type": "text", "text": "營業時間", "size": "sm", "color": "#8c8c8c", "flex": 3},
|
| 269 |
+
{"type": "text", "text": display_hours, "size": "sm", "color": "#111111", "weight": "bold", "flex": 5, "wrap": True}
|
| 270 |
+
]
|
| 271 |
+
},
|
| 272 |
+
{
|
| 273 |
+
"type": "box",
|
| 274 |
+
"layout": "baseline",
|
| 275 |
+
"spacing": "sm",
|
| 276 |
+
"contents": [
|
| 277 |
+
{"type": "text", "text": "店家評分", "size": "sm", "color": "#8c8c8c", "flex": 3},
|
| 278 |
+
{"type": "text", "text": f"⭐ {store['rating']}", "size": "sm", "color": "#fbbc04", "weight": "bold", "flex": 5}
|
| 279 |
+
]
|
| 280 |
+
}
|
| 281 |
]
|
| 282 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
]
|
| 284 |
+
},
|
| 285 |
+
"footer": {
|
| 286 |
"type": "box",
|
| 287 |
"layout": "vertical",
|
| 288 |
"contents": [
|
| 289 |
+
{
|
| 290 |
+
"type": "button",
|
| 291 |
+
"style": "primary",
|
| 292 |
+
"color": "#000000",
|
| 293 |
+
"height": "md",
|
| 294 |
+
"action": {
|
| 295 |
+
"type": "uri",
|
| 296 |
+
"label": "開啟地圖導航",
|
| 297 |
+
"uri": map_url
|
| 298 |
+
}
|
| 299 |
}
|
|
|
|
| 300 |
]
|
|
|
|
| 301 |
}
|
| 302 |
+
}
|
| 303 |
+
bubbles.append(card)
|
| 304 |
+
|
| 305 |
+
flex_message = FlexSendMessage.new_from_json_dict({
|
| 306 |
+
"type": "flex",
|
| 307 |
+
"alt_text": "🔍 附近的藥妝店地圖出爐囉!",
|
| 308 |
+
"contents": {
|
| 309 |
+
"type": "carousel",
|
| 310 |
+
"contents": bubbles
|
| 311 |
+
}
|
| 312 |
+
})
|
| 313 |
+
|
| 314 |
+
line_bot_api.push_message(user_id, flex_message)
|
| 315 |
+
if user_id in user_states: del user_states[user_id]
|
| 316 |
+
|
| 317 |
+
except Exception as e:
|
| 318 |
+
line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 搜尋過程發生錯誤:{e}"))
|
| 319 |
+
if user_id in user_states: del user_states[user_id]
|
| 320 |
|
| 321 |
+
# ================= 【新增】專門處理使用者傳送位置的邏輯 =================
|
| 322 |
+
# ================= 【升級版】全台通用的真實附近店家搜尋 =================
|
| 323 |
+
# ================= 【終極版】客製化品牌 + 營業時間 + Flex 卡片 =================
|
| 324 |
+
# ================= 【極致美學版】客製化品牌 + 店家美照 + 精確時間 + 曜石黑圖卡 =================
|
| 325 |
+
# ================= 【霸氣點名版】保證全品牌上榜 + 曜石黑完美排版 =================
|
| 326 |
@handler.add(MessageEvent, message=LocationMessage)
|
| 327 |
def handle_location(event):
|
| 328 |
user_id = event.source.user_id
|
|
|
|
| 332 |
state = user_states.get(user_id, {})
|
| 333 |
brand_preference = state.get("search_brand", "全都要")
|
| 334 |
|
| 335 |
+
google_api_key = os.environ.get('Maps_API_KEY')
|
|
|
|
| 336 |
if not google_api_key:
|
| 337 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="❌ 系統尚未設定 Google Maps API 金鑰。"))
|
| 338 |
return
|
| 339 |
|
| 340 |
+
# 計算距離的數學魔法
|
| 341 |
def calculate_distance(lat1, lon1, lat2, lon2):
|
| 342 |
+
import math
|
| 343 |
R = 6371.0
|
| 344 |
lat1_rad, lon1_rad = math.radians(lat1), math.radians(lon1)
|
| 345 |
lat2_rad, lon2_rad = math.radians(lat2), math.radians(lon2)
|
|
|
|
| 349 |
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
| 350 |
return int(R * c * 1000)
|
| 351 |
|
| 352 |
+
# 查詢「今天確切營業時間」的魔法
|
| 353 |
def get_exact_hours(place_id, api_key):
|
| 354 |
url = f"https://maps.googleapis.com/maps/api/place/details/json?place_id={place_id}&fields=opening_hours&language=zh-TW&key={api_key}"
|
| 355 |
try:
|
|
|
|
| 357 |
if 'result' in res and 'opening_hours' in res['result']:
|
| 358 |
weekday_text = res['result']['opening_hours'].get('weekday_text', [])
|
| 359 |
if weekday_text:
|
| 360 |
+
from datetime import datetime, timezone, timedelta
|
| 361 |
tw_tz = timezone(timedelta(hours=8))
|
| 362 |
today_idx = datetime.now(tw_tz).weekday()
|
| 363 |
today_str = weekday_text[today_idx]
|
|
|
|
| 367 |
pass
|
| 368 |
return "詳情請洽門市"
|
| 369 |
|
| 370 |
+
# 🌟 核心改寫:決定要搜尋的名單。全都要就分開查 3 次!
|
| 371 |
brands_to_search = ["屈臣氏", "康是美", "寶雅"] if brand_preference == "全都要" else [brand_preference]
|
| 372 |
+
search_radius = 3000 # 稍微擴大到 3 公里,確保偏遠的寶雅也能抓到
|
| 373 |
|
| 374 |
try:
|
| 375 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="🧚♀️ 精靈正在為您精準定位各品牌門市,請稍候..."))
|
| 376 |
|
| 377 |
nearest_stores = []
|
| 378 |
|
| 379 |
+
# 🌟 針對每個品牌,獨立逼迫 Google 交出名單
|
| 380 |
for brand in brands_to_search:
|
| 381 |
url = f"https://maps.googleapis.com/maps/api/place/textsearch/json?query={brand}&location={user_lat},{user_lon}&radius={search_radius}&language=zh-TW&key={google_api_key}"
|
| 382 |
response = requests.get(url).json()
|
|
|
|
| 412 |
"photo_ref": photo_ref
|
| 413 |
})
|
| 414 |
|
| 415 |
+
# 依距離排序這個品牌的所有門市
|
| 416 |
sorted_brand_stores = sorted(brand_stores, key=lambda x: x['distance'])
|
| 417 |
|
| 418 |
if brand_preference == "全都要":
|
| 419 |
+
# 只抓該品牌離妳最近的「唯一代表」加入總榜單
|
| 420 |
+
nearest_stores.append(sorted_brand_stores[0])
|
| 421 |
else:
|
| 422 |
+
# 如果只搜一家,就給最近的 3 個選項
|
| 423 |
nearest_stores.extend(sorted_brand_stores[:3])
|
| 424 |
|
| 425 |
if not nearest_stores:
|
|
|
|
| 427 |
if user_id in user_states: del user_states[user_id]
|
| 428 |
return
|
| 429 |
|
| 430 |
+
# 🌟 總榜單出爐後,依照這幾家店的距離再排序一次
|
| 431 |
nearest_stores = sorted(nearest_stores, key=lambda x: x['distance'])
|
| 432 |
|
| 433 |
+
# 準備製作符合 Toyota 高級風格的 Flex Message 卡片
|
| 434 |
bubbles = []
|
| 435 |
for store in nearest_stores:
|
| 436 |
exact_hours = get_exact_hours(store['place_id'], google_api_key)
|
|
|
|
| 444 |
else:
|
| 445 |
image_url = "https://images.unsplash.com/photo-1576426863848-c21f53c60b19?q=80&w=600&auto=format&fit=crop"
|
| 446 |
|
| 447 |
+
# ✅ 修正 2:乾淨俐落、不重複嵌套的規格表!
|
| 448 |
card = {
|
| 449 |
"type": "bubble",
|
| 450 |
"size": "mega",
|
|
|
|
| 525 |
"contents": bubbles
|
| 526 |
}
|
| 527 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
|
| 529 |
+
# ================= 3. 多輪對話核心邏輯 =================
|
| 530 |
@handler.add(MessageEvent, message=TextMessage)
|
| 531 |
def handle_message(event):
|
| 532 |
user_id = event.source.user_id
|
| 533 |
user_msg = event.message.text.strip()
|
| 534 |
|
| 535 |
+
# ================= 【升級】附近店家 - 步驟 1:選擇品牌 =================
|
| 536 |
if user_msg == "附近店家":
|
| 537 |
+
user_states[user_id] = {"step": "ASK_BRAND"} # 記錄用戶進入找店流程
|
| 538 |
|
| 539 |
brand_buttons = QuickReply(items=[
|
| 540 |
QuickReplyButton(action=MessageAction(label="屈臣氏", text="屈臣氏")),
|
|
|
|
| 545 |
|
| 546 |
line_bot_api.reply_message(
|
| 547 |
event.reply_token,
|
| 548 |
+
TextSendMessage(
|
| 549 |
+
text="請問你想尋找哪一間藥妝店呢?",
|
| 550 |
+
quick_reply=brand_buttons
|
| 551 |
+
)
|
| 552 |
)
|
| 553 |
return
|
| 554 |
|
| 555 |
+
# ================= 【升級】附近店家 - 步驟 2:要求定位 =================
|
| 556 |
state = user_states.get(user_id)
|
| 557 |
if state and state.get("step") == "ASK_BRAND":
|
| 558 |
if user_msg in ["屈臣氏", "康是美", "寶雅", "全都要"]:
|
| 559 |
+
state["search_brand"] = user_msg # 把使用者選的品牌存起來
|
| 560 |
+
state["step"] = "ASK_LOCATION" # 推進到下一步
|
| 561 |
|
| 562 |
location_button = QuickReply(items=[
|
| 563 |
QuickReplyButton(action=LocationAction(label="📍 點我分享位置"))
|
|
|
|
| 565 |
|
| 566 |
line_bot_api.reply_message(
|
| 567 |
event.reply_token,
|
| 568 |
+
TextSendMessage(
|
| 569 |
+
text=f"沒問題!請分享您的位置,精靈來幫您找最近的【{user_msg}】!",
|
| 570 |
+
quick_reply=location_button
|
| 571 |
+
)
|
| 572 |
)
|
| 573 |
else:
|
| 574 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請點擊下方的按鈕選擇品牌喔!"))
|
|
|
|
| 578 |
if user_msg == "精靈顧問" or user_msg == "重新開始":
|
| 579 |
user_states[user_id] = {"step": "ASK_SKIN"}
|
| 580 |
|
| 581 |
+
# 建立第一步:膚質按鈕
|
| 582 |
skin_buttons = QuickReply(items=[
|
| 583 |
QuickReplyButton(action=MessageAction(label="乾性肌", text="乾性肌")),
|
| 584 |
QuickReplyButton(action=MessageAction(label="油性肌", text="油性肌")),
|
|
|
|
| 600 |
)
|
| 601 |
return
|
| 602 |
|
| 603 |
+
# 取得該用戶當前的對話狀態
|
| 604 |
state = user_states.get(user_id)
|
| 605 |
+
|
| 606 |
+
# 如果用戶不在諮詢流程中,輸入其他字則做基本回應
|
| 607 |
if not state:
|
| 608 |
line_bot_api.reply_message(
|
| 609 |
event.reply_token,
|
|
|
|
| 613 |
|
| 614 |
current_step = state.get("step")
|
| 615 |
|
| 616 |
+
# 【步驟一】處理膚質選擇 -> 進入詢問困擾
|
| 617 |
if current_step == "ASK_SKIN":
|
| 618 |
if user_msg in ["乾性肌", "油性肌", "混合肌", "敏感肌"]:
|
| 619 |
+
state["skin_type"] = user_msg # 記錄膚質
|
| 620 |
+
state["step"] = "ASK_ISSUE" # 進到下一步
|
| 621 |
|
| 622 |
+
# 建立第二步:困擾按鈕
|
| 623 |
issue_buttons = QuickReply(items=[
|
| 624 |
QuickReplyButton(action=MessageAction(label="嚴重脫皮", text="嚴重脫皮")),
|
| 625 |
QuickReplyButton(action=MessageAction(label="暗沉無光", text="暗沉無光")),
|
|
|
|
| 634 |
else:
|
| 635 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的膚質選項,或是輸入「重新開始」喔!"))
|
| 636 |
|
| 637 |
+
# 【步驟二】處理困擾選擇 -> 進入詢問偏好
|
| 638 |
elif current_step == "ASK_ISSUE":
|
| 639 |
if user_msg in ["嚴重脫皮", "暗沉無光", "痘痘粉刺", "泛紅乾癢"]:
|
| 640 |
+
state["issue"] = user_msg # 記錄困擾
|
| 641 |
+
state["step"] = "ASK_PREFERENCE" # 進到下一步
|
| 642 |
|
| 643 |
+
# 建立第三步:偏好按鈕
|
| 644 |
pref_buttons = QuickReply(items=[
|
| 645 |
QuickReplyButton(action=MessageAction(label="偏好清爽凝露", text="偏好清爽凝露")),
|
| 646 |
QuickReplyButton(action=MessageAction(label="偏好滋潤乳霜", text="偏好滋潤乳霜")),
|
|
|
|
| 654 |
else:
|
| 655 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的肌膚問題選項,或是輸入「重新開始」喔!"))
|
| 656 |
|
| 657 |
+
# 【步驟三】處理偏好選擇 -> 產生最終建議並結束
|
| 658 |
elif current_step == "ASK_PREFERENCE":
|
| 659 |
if user_msg in ["偏好清爽凝露", "偏好滋潤乳霜", "都可以"]:
|
| 660 |
skin_type = state["skin_type"]
|
| 661 |
issue = state["issue"]
|
| 662 |
preference = user_msg
|
| 663 |
|
| 664 |
+
# 先回傳一個「思考中」的提示,因為呼叫 AI 需要 1~3 秒
|
| 665 |
line_bot_api.reply_message(
|
| 666 |
event.reply_token,
|
| 667 |
TextSendMessage(text="🧚♀️ 精靈正在為您翻閱保養圖鑑,請稍候幾秒鐘...")
|
| 668 |
)
|
| 669 |
|
| 670 |
+
# --- 開始呼叫 AI 產生客製化建議 ---
|
| 671 |
prompt = f"""
|
| 672 |
你現在是一位「台灣藥妝店皮膚科精靈顧問🧚♀️」,擁有10年以上保養諮詢經驗。
|
| 673 |
+
|
| 674 |
+
使用者資料:
|
| 675 |
+
|
| 676 |
+
* 膚質:{skin_type}
|
| 677 |
+
* 困擾:{issue}
|
| 678 |
+
* 偏好:{preference}
|
| 679 |
+
|
| 680 |
+
請根據以上資訊提供個人化保養建議。
|
| 681 |
+
|
| 682 |
+
【輸出規則】
|
| 683 |
+
|
| 684 |
+
1. 回覆控制在 180~250 字。
|
| 685 |
+
2. 語氣溫柔、親切,像專業美容顧問。
|
| 686 |
+
3. 使用繁體中文。
|
| 687 |
+
4. 不要使用表格。
|
| 688 |
+
5. 不要輸出任何前言、結語、免責聲明。
|
| 689 |
+
6. 不要出現「以下是分析報告」、「分析結果」等字樣。
|
| 690 |
+
7. 不要使用分隔線(────)。
|
| 691 |
+
8. 必須完全依照下列格式輸出:
|
| 692 |
+
|
| 693 |
+
🧚♀️ 哈囉親愛的!
|
| 694 |
+
|
| 695 |
+
🩺 精靈診斷
|
| 696 |
+
(用1~2句話分析目前膚況,需根據使用者的膚質與困擾客製化)
|
| 697 |
+
|
| 698 |
+
💡 保養建議
|
| 699 |
+
• (具體建議1)
|
| 700 |
+
• (具體建議2)
|
| 701 |
+
• (具體建議3)
|
| 702 |
+
|
| 703 |
+
🛍️ 推薦產品
|
| 704 |
+
|
| 705 |
+
① (完整品牌+產品名稱)
|
| 706 |
+
⭐ (20字內推薦原因)
|
| 707 |
+
|
| 708 |
+
② (完整品牌+產品名稱)
|
| 709 |
+
⭐ (20字內推薦原因)
|
| 710 |
+
|
| 711 |
+
③ (完整品牌+產品名稱)
|
| 712 |
+
⭐ (20字內推薦原因)
|
| 713 |
+
|
| 714 |
+
【產品規則】
|
| 715 |
+
【精靈診斷輸出規則】
|
| 716 |
+
|
| 717 |
+
* 用 1~2 句話描述即可。
|
| 718 |
+
* 必須根據「膚質 + 困擾」客製化,不可套話。
|
| 719 |
+
* 要說明目前肌膚「正在發生的狀況」或「核心問題」。
|
| 720 |
+
* 可以包含簡單原因(例如:缺水、屏障受損、油水平衡失調)。
|
| 721 |
+
* 語氣溫柔、像專業皮膚顧問,但不要太長。
|
| 722 |
+
|
| 723 |
+
✅ 範例風格:
|
| 724 |
+
你的肌膚目前偏乾且屏障較弱,因此出現脫皮與緊繃感。
|
| 725 |
+
油脂分泌較旺盛,毛孔堵塞導致粉刺與痘痘問題。
|
| 726 |
+
肌膚處於敏感狀態,容易泛紅與乾癢。
|
| 727 |
+
|
| 728 |
+
【保養建議輸出規則】
|
| 729 |
+
|
| 730 |
+
* 輸出 3 點條列(•)
|
| 731 |
+
* 每點格式固定:
|
| 732 |
+
* 主動作要具體(如:溫和清潔、加強保濕、避免去角質)
|
| 733 |
+
* 原因控制在 1 句,不要太長
|
| 734 |
+
* 可提及膚況關鍵字(缺水、油脂、屏障、泛紅等)
|
| 735 |
+
* 語氣專業但簡潔,不要教科書感
|
| 736 |
+
|
| 737 |
+
✅ 範例格式:
|
| 738 |
+
|
| 739 |
+
• 溫和清潔: 使用低刺激洗面乳,避免過度清潔導致乾燥與刺激
|
| 740 |
+
• 立即保濕:洗臉後立刻補水,幫助鎖住水分改善脫皮
|
| 741 |
+
• 避免去角質: 減少刺激性保養,讓肌膚屏障有時間修復
|
| 742 |
+
|
| 743 |
+
❌ 不要太長解釋
|
| 744 |
+
❌ 不要超過 1 句原因
|
| 745 |
+
❌ 不要使用專業論文語氣
|
| 746 |
+
|
| 747 |
+
|
| 748 |
+
【產品推薦規則】
|
| 749 |
+
|
| 750 |
+
* 推薦 3 項產品即可。
|
| 751 |
+
* 必須是台灣藥妝店可購買的真實商品。
|
| 752 |
+
* 每項產品需包含:
|
| 753 |
+
1. 完整品牌與品名
|
| 754 |
+
2. 1句推薦原因
|
| 755 |
+
* 推薦原因控制在 25~50 字。
|
| 756 |
+
* 可簡單提及 1~2 個關鍵成分(例如:玻尿酸、神經醯胺、積雪草、維他命B5、菸鹼醯胺等)。
|
| 757 |
+
* 說明該成分對使用者膚況的幫助。
|
| 758 |
+
* 不要介紹品牌歷史。
|
| 759 |
+
* 不要描述過多技術名詞。
|
| 760 |
+
* 不要超過五句話。
|
| 761 |
+
|
| 762 |
+
|
| 763 |
+
【範例風格】
|
| 764 |
+
|
| 765 |
+
🧚♀️ 哈囉親愛的!
|
| 766 |
+
|
| 767 |
+
🩺 精靈診斷
|
| 768 |
+
你的肌膚目前有明顯缺水現象,因此出現脫皮狀況。
|
| 769 |
+
|
| 770 |
+
💡 保養建議
|
| 771 |
+
• 洗臉後立即補充保濕產品
|
| 772 |
+
• 暫停去角質與酸類保養
|
| 773 |
+
• 選擇溫和產品加強修護
|
| 774 |
+
|
| 775 |
+
🛍️ 推薦產品
|
| 776 |
+
|
| 777 |
+
推薦原因請採用以下風格:
|
| 778 |
+
|
| 779 |
+
❌ 過短:
|
| 780 |
+
⭐ 保濕效果佳
|
| 781 |
+
|
| 782 |
+
❌ 過長:
|
| 783 |
+
⭐ 採用獨家智慧型玻尿酸水循環科技...
|
| 784 |
+
|
| 785 |
+
✅ 理想格式:
|
| 786 |
+
⭐ 含多重玻尿酸,能加強補水並改善乾燥脫皮。
|
| 787 |
+
|
| 788 |
+
✅ 理想格式:
|
| 789 |
+
⭐ 添加神經醯胺,有助修護肌膚屏障並減少乾燥。
|
| 790 |
+
|
| 791 |
+
✅ 理想格式:
|
| 792 |
+
⭐ 含維他命B5與積雪草,可舒緩泛紅與不適感。
|
| 793 |
+
|
| 794 |
+
|
| 795 |
+
|
| 796 |
"""
|
| 797 |
+
|
| 798 |
try:
|
| 799 |
+
# 傳送 Prompt 給 Gemini
|
| 800 |
response = model.generate_content(prompt)
|
| 801 |
ai_reply = response.text
|
| 802 |
+
|
| 803 |
+
# 改用 push_message 主動推播結果
|
| 804 |
line_bot_api.push_message(user_id, TextSendMessage(text=ai_reply))
|
| 805 |
+
|
| 806 |
except Exception as e:
|
| 807 |
+
# 🛠️ 這裡做了修改:如果發生錯誤,直接把真實的錯誤訊息(e)用 LINE 傳給你!
|
| 808 |
+
error_msg = f"❌ 精靈魔法發動失敗!詳細錯誤原因如下:\n\n{e}"
|
| 809 |
+
line_bot_api.push_message(user_id, TextSendMessage(text=error_msg))
|
| 810 |
|
| 811 |
+
# 階段性任務完成,清除狀態
|
| 812 |
del user_states[user_id]
|
| 813 |
else:
|
| 814 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="請選擇下方的質地偏好選項,或是輸入「重新開始」喔!"))
|
| 815 |
|
| 816 |
+
|
| 817 |
+
|
| 818 |
elif current_step == "ASK_PHOTO_ITEM":
|
| 819 |
+
# 如果使用者沒傳照片,而是傳純文字,就走這條路
|
| 820 |
text_prompt = f"請幫我查詢「{user_msg}」這款產品在台灣藥妝店的合理售價範圍與推薦購買商家。請簡單溫柔地回答,並條列出品名、價格、商家。"
|
| 821 |
try:
|
| 822 |
+
# 先回傳提示
|
| 823 |
line_bot_api.reply_message(event.reply_token, TextSendMessage(text="✨ 收到文字!精靈正在幫您查詢這款產品,請稍候..."))
|
| 824 |
+
|
| 825 |
+
# 呼叫 AI
|
| 826 |
response = model.generate_content(text_prompt)
|
| 827 |
+
|
| 828 |
+
# 用 push_message 推播結果
|
| 829 |
line_bot_api.push_message(user_id, TextSendMessage(text=f"🧚♀️ 查詢成功!\n\n{response.text}"))
|
| 830 |
+
|
| 831 |
except Exception as e:
|
| 832 |
line_bot_api.push_message(user_id, TextSendMessage(text=f"❌ 查詢失敗,錯誤原因:\n{e}"))
|
| 833 |
+
|
| 834 |
+
# 任務完成,清除狀態
|
| 835 |
del user_states[user_id]
|
| 836 |
|
| 837 |
if __name__ == "__main__":
|