File size: 17,873 Bytes
a578f9e | 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 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 | import gradio as gr
from openai import OpenAI
from huggingface_hub import InferenceClient
import os
import sys
import subprocess
import time
import requests
import urllib.parse
import pandas as pd
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
from PIL import Image
from dotenv import load_dotenv
# ==========================================
# Step 0: 環境安裝檢查 (僅保留 Playwright)
# ==========================================
def install_playwright():
"""確保 Playwright 瀏覽器核心有安裝"""
try:
import playwright
except ImportError:
print("⚠️ 偵測到缺少 playwright,正在強制安裝...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "playwright"])
# 設定瀏覽器路徑
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = os.path.join(os.getcwd(), "playwright_browsers")
print("🔄 檢查 Chromium 瀏覽器...")
try:
# 檢查是否已安裝,若無則安裝
if not os.path.exists(os.environ["PLAYWRIGHT_BROWSERS_PATH"]):
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True)
except Exception as e:
print(f"⚠️ 瀏覽器安裝警告: {e}")
# 執行安裝
install_playwright()
# 延遲匯入
from playwright.sync_api import sync_playwright
load_dotenv()
# ==========================================
# 模組載入 (emotion 模組)
# ==========================================
import emotion
# ==========================================
# 0. 環境變數 & 1. 資料初始化
# ==========================================
GROQ_API_KEY = os.getenv("GROQ_API_KEY")
HF_TOKEN = os.getenv("HF_TOKEN")
global_df = None
global_mood_df = None
global_retriever = None
rag_initialized = False
def init_static_data():
"""初始化靜態的心情資料:讀取外部 CSV"""
global global_mood_df
if global_mood_df is None:
csv_path = 'mood_food_guide.csv'
if os.path.exists(csv_path):
try:
# 讀取 CSV,確保欄位正確
global_mood_df = pd.read_csv(csv_path)
print(f"✅ 成功載入心情指南:{csv_path}")
except Exception as e:
print(f"❌ 讀取 CSV 失敗: {e}")
global_mood_df = pd.DataFrame() # 建立空表防止後續報錯
else:
print(f"⚠️ 警告:找不到 {csv_path},請確認檔案已上傳至 Space。")
global_mood_df = pd.DataFrame()
# ==========================================
# API 金鑰檢查工具函式
# ==========================================
def check_api_key_status(name, key):
"""檢查 API Key 是否存在,並回傳部分內容以供辨識"""
if not key:
return "❌ 未設定 (Not Set)"
# 遮罩處理,只顯示前後幾碼
if len(key) > 8:
masked = f"{key[:4]}...{key[-4:]}"
else:
masked = "******"
return f"✅ 已設定 ({masked})"
# ==========================================
# 2. Google Maps 爬蟲功能
# ==========================================
def sync_google_maps(url):
global global_df, global_retriever, rag_initialized
clean_url = url.strip() if url else ""
if not clean_url or "http" not in clean_url.lower():
yield "❌ 請輸入有效的 Google Maps 分享連結。"
return
try:
yield "🚀 [1/4] 啟動瀏覽器..."
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
yield "🌐 [2/4] 連線中..."
page.goto(clean_url, wait_until="domcontentloaded", timeout=60000)
yield "⏳ [3/4] 等待列表加載 (約 10 秒)..."
time.sleep(10)
yield "📄 [4/4] 解析餐廳資訊..."
titles = page.locator('div.fontHeadlineSmall').all_inner_texts()
details = page.locator('div.fontBodyMedium').all_inner_texts()
restaurant_list = []
for i, name in enumerate(titles):
name = name.strip()
if name:
addr = details[i].strip() if i < len(details) else ""
search_query = f"{name} {addr}"
restaurant_list.append({
"Name": name,
"Address": addr,
"Category": "未分類",
"RAG_Content": f"餐廳:{name},資訊:{addr}",
"URL": f"https://www.google.com/maps/search/?api=1&query={urllib.parse.quote(search_query)}"
})
browser.close()
if not restaurant_list:
yield "⚠️ 找不到餐廳。請確認連結格式正確且已公開。"
return
global_df = pd.DataFrame(restaurant_list)
try:
embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-MiniLM-L6-v2")
vectorstore = FAISS.from_texts(global_df['RAG_Content'].tolist(), embeddings, metadatas=global_df.to_dict('records'))
global_retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
except Exception as e:
print(f"RAG 初始化警告: {e}")
rag_initialized = True
yield f"✅ 同步成功!已載入 {len(restaurant_list)} 間餐廳。"
except Exception as e:
yield f"❌ 系統錯誤:{str(e)}"
# ==========================================
# 3. 核心功能 (AI Agent)
# ==========================================
def get_restaurant_data(mood_score_str, food_choice):
init_static_data() # 確保心情指南已載入
if global_df is None or global_df.empty:
return None, True, "", "資料庫未載入,無建議"
# 解析分數 (例如 "3 (普通)" -> 3)
try: score = int(str(mood_score_str).split(' ')[0])
except: score = 3
# 從 CSV 資料中查找對應的推薦類別與原因
rec_categories = ""
mood_reason = "隨意探索"
if global_mood_df is not None and not global_mood_df.empty:
mood_info = global_mood_df[global_mood_df['分數'] == score]
if not mood_info.empty:
rec_categories = mood_info.iloc[0]['推薦料理類別']
mood_reason = mood_info.iloc[0]['原因']
candidates = global_df.copy()
food_keyword = "飯" if food_choice == "吃飯" else "麵" if food_choice == "吃麵" else ""
is_random = False
if food_keyword:
filtered = candidates[candidates['Name'].str.contains(food_keyword, case=False, na=False) | candidates['RAG_Content'].str.contains(food_keyword, case=False, na=False)]
if not filtered.empty:
candidates = filtered
else:
is_random = True
if candidates.empty:
result = global_df.sample(1).iloc[0]; is_random = True
else:
result = candidates.sample(1).iloc[0]
return result, is_random, rec_categories, mood_reason
def generate_content_with_groq(restaurant_name, restaurant_detail, user_diary, mood_score, mood_guide_reason, debug_mode=False):
if not GROQ_API_KEY: return "⚠️ 請設定 GROQ_API_KEY", ""
client = OpenAI(api_key=GROQ_API_KEY, base_url="https://api.groq.com/openai/v1")
system_prompt = "你是一個幽默、懂吃且善解人意的 AI 朋友。請根據使用者的日記、心情以及「心情美食指南」來推薦餐廳。"
user_msg = f"""
【狀態】心情分數:{mood_score},日記:{user_diary}
【心情美食指南建議】
因為分數是 {mood_score},建議吃這類食物的原因是:「{mood_guide_reason}」。
【推薦餐廳】
名稱:{restaurant_name}
資料:{restaurant_detail}
任務:
請用繁體中文寫一段溫暖有趣的回覆:
1. 先回應他的日記與測驗人設。
2. 引用「心情美食指南」的原因,告訴他為什麼現在適合吃這家餐廳。
3. 介紹這家餐廳的特色。
"""
debug_log = ""
if debug_mode:
debug_log = f"### 🔧 Groq Prompt Debug\n**System:** {system_prompt}\n**User:** {user_msg}"
try:
response = client.chat.completions.create(model="llama-3.3-70b-versatile", messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_msg}])
return response.choices[0].message.content, debug_log
except Exception as e:
return f"Groq Error: {str(e)}", debug_log
def generate_image_huggingface(prompt):
if not HF_TOKEN:
return None, "HF_TOKEN 未設定"
try:
hf_client = InferenceClient(token=HF_TOKEN)
image = hf_client.text_to_image(
prompt=prompt,
negative_prompt="blurry, low quality, distortion, text, watermark",
model="stabilityai/stable-diffusion-xl-base-1.0"
)
return image, None # 成功時,錯誤訊息為 None
except Exception as e:
# 回傳具體錯誤訊息
return None, str(e)
def mood_agent_logic(score_input, food_input, diary_input, debug_mode):
if not rag_initialized:
yield "⚠️ 請先同步地圖清單!", None, "", gr.update()
return
restaurant, is_random, rec_categories, mood_reason = get_restaurant_data(score_input, food_input)
if restaurant is None:
yield "資料庫讀取錯誤", None, "", gr.update()
return
name = restaurant['Name']; url = restaurant['URL']
info = restaurant.get('RAG_Content', '')
rag_info = str(restaurant.get('RAG_Content', ''))
img_prompt = f"Delicious food photography of {name}, {info}. high quality, photorealistic, 8k, cinematic lighting, appetizing, restaurant atmosphere, 50mm lens"
ai_text, groq_debug_log = generate_content_with_groq(name, rag_info, diary_input, score_input, mood_reason, debug_mode)
prefix = ""
if is_random and food_input != "隨便":
prefix = f"> 💡 **溫馨提示**:清單中暫無『{food_input}』,已從現有名單挑選最適合的店!\n\n"
# 組合完整的除錯資訊,包含 API Key 狀態與錯誤訊息
full_debug_log = ""
if debug_mode:
# 1. 檢查 Key 狀態
groq_status = check_api_key_status("GROQ_API_KEY", GROQ_API_KEY)
hf_status = check_api_key_status("HF_TOKEN", HF_TOKEN)
api_debug_block = f"""
### 🔑 API 金鑰與系統狀態
- **GROQ_API_KEY**: {groq_status}
- **HF_TOKEN**: {hf_status}
- **RAG 狀態**: {"✅ 已初始化" if rag_initialized else "❌ 未初始化"}
"""
full_debug_log = api_debug_block + "\n" + groq_debug_log + f"\n\n### 🎨 Image Prompt Debug\n{img_prompt}"
debug_output_update = gr.update(value=full_debug_log, visible=debug_mode)
final_response = f"{prefix}### 🍽️ 推薦:{name}\n\n{ai_text}"
map_html = f'<div style="text-align:center"><a href="{url}" target="_blank" style="background:#4CAF50;color:white;padding:8px 16px;border-radius:20px;text-decoration:none">🗺️ Google Map 導航</a></div>'
# 先回傳文字
yield final_response, None, map_html, debug_output_update
# 呼叫圖片生成並捕捉錯誤
image_output, img_error = generate_image_huggingface(img_prompt)
# 如果有圖片錯誤且在除錯模式,追加錯誤訊息到 log
if img_error and debug_mode:
full_debug_log += f"\n\n⚠️ **Hugging Face 圖片生成失敗:**\n{img_error}"
debug_output_update = gr.update(value=full_debug_log)
yield final_response, image_output, map_html, debug_output_update
# ==========================================
# 4. 橋接邏輯
# ==========================================
def _score_to_radio_value(score):
mapping = {1: "1 (心情差)", 2: "2 (不太好)", 3: "3 (普通)", 4: "4 (不錯)", 5: "5 (超棒)"}
try: score = int(score)
except: score = 3
return mapping.get(score, "3 (普通)")
def bridge_start_click(st):
try:
res = emotion.on_restart(st)
return res[1], res[0], gr.update(visible=True), res[5], res[4], gr.update(visible=False)
except: return gr.update(), gr.update(), gr.update(), gr.update(), st, gr.update()
def bridge_stop_click(st):
try:
res = emotion.on_stop(st)
return res[1], res[0], gr.update(visible=False), res[5], res[4]
except: return gr.update(), gr.update(), gr.update(), gr.update(), st
def bridge_predict_frame(frame, st):
try:
res = emotion.on_stream(frame, st)
out_cam = res[0]; out_result = res[1]; out_st = res[4]; out_btn_start = res[5]
score_update = gr.update(); btn_go_visible = gr.update(visible=False)
out_btn_stop = gr.update()
if hasattr(out_st, 'finished') and out_st.finished and hasattr(out_st, 'final_score'):
new_val = _score_to_radio_value(out_st.final_score)
score_update = gr.update(value=new_val)
out_btn_stop = gr.update(visible=False)
btn_go_visible = gr.update(visible=True)
return out_cam, out_result, out_st, out_btn_stop, out_btn_start, score_update, btn_go_visible
except Exception as e:
return frame, gr.update(), st, gr.update(), gr.update(), gr.update(), gr.update()
def bridge_predict_upload(img, st):
try:
res = emotion.on_upload(img, st)
out_result, out_st = res[0], res[2]
score_update = gr.update()
if hasattr(out_st, 'finished') and out_st.finished and hasattr(out_st, 'final_score'):
new_val = _score_to_radio_value(out_st.final_score)
score_update = gr.update(value=new_val)
return out_result, score_update, gr.Tabs(selected=1), out_st
except: return gr.update(), gr.update(), gr.Tabs(), st
# ==========================================
# 5. Gradio 介面
# ==========================================
css_ = "#app_container { max-width: 960px; margin: 0 auto; }"
if hasattr(emotion, 'css'): css_ += "\n" + emotion.css
with gr.Blocks(title="AI 心情食堂", css=css_) as demo:
st_state = gr.State(emotion.AppState())
with gr.Tabs() as tabs:
# Tab 1
with gr.TabItem("😊 情緒辨識 (Step 1)", id=0):
with gr.Column(elem_id="app_container"):
gr.Markdown("### 第一步:測測你的心情能量\n讓 AI 看看你的表情,自動幫你決定心情分數!")
with gr.Row():
btn_start = gr.Button("📸 開啟攝影機辨識", variant="primary")
btn_stop = gr.Button("⏹️ 停止", variant="secondary", visible=False)
cam = gr.Image(sources=["webcam"], streaming=True, type="numpy", label="攝影機畫面", visible=False)
result_markdown = gr.Markdown(emotion._hint_html("請按「開啟攝影機辨識」並允許瀏覽器使用相機。"))
btn_go_dining = gr.Button("🚀 確定心情,來找餐廳!", variant="primary", visible=False, size="lg")
# Tab 2
with gr.TabItem("🍽️ AI 心情食堂 (Step 2)", id=1):
with gr.Column():
gr.Markdown(f"## 🔗 載入你的口袋名單")
with gr.Row():
map_url = gr.Textbox(label="Google Maps Saved Lists 連結", placeholder="請貼上清單的分享連結...", scale=3)
sync_btn = gr.Button("🔄 同步清單", variant="secondary", scale=1)
sync_msg = gr.Markdown("ℹ️ 尚未同步資料庫")
gr.Markdown("---")
gr.Markdown(f"## 🍱 今天想吃點什麼?")
with gr.Row():
with gr.Column(scale=1):
score_input = gr.Radio(["1 (心情差)", "2 (不太好)", "3 (普通)", "4 (不錯)", "5 (超棒)"], label="1. 心情分數 (由 Tab 1 自動填入)", value="3 (普通)")
food_input = gr.Radio(["吃飯", "吃麵", "隨便"], label="2. 想吃什麼", value="隨便")
diary_input = gr.Textbox(lines=4, label="3. 心情日記", placeholder="寫下今天發生的事...")
debug_mode_btn = gr.Checkbox(label="🔧 開啟除錯模式", value=False)
submit_btn = gr.Button("🍱 送出給 Agent", variant="primary")
debug_output = gr.Markdown(label="除錯資訊 (Debug Log)", visible=False)
with gr.Column(scale=1):
agent_output = gr.Markdown(label="AI 回應")
image_output = gr.Image(label="AI 推薦美食圖", type="pil", width=400)
map_output = gr.HTML(label="地圖導航")
# 事件
sync_btn.click(fn=sync_google_maps, inputs=[map_url], outputs=[sync_msg])
btn_start.click(fn=bridge_start_click, inputs=[st_state], outputs=[result_markdown, cam, btn_stop, btn_start, st_state, btn_go_dining])
btn_stop.click(fn=bridge_stop_click, inputs=[st_state], outputs=[result_markdown, cam, btn_stop, btn_start, st_state])
cam.stream(fn=bridge_predict_frame, inputs=[cam, st_state], outputs=[cam, result_markdown, st_state, btn_stop, btn_start, score_input, btn_go_dining])
btn_go_dining.click(fn=lambda: gr.Tabs(selected=1), inputs=None, outputs=tabs)
# debug_mode
submit_btn.click(
fn=mood_agent_logic,
inputs=[score_input, food_input, diary_input, debug_mode_btn],
outputs=[agent_output, image_output, map_output, debug_output]
)
if __name__ == "__main__":
demo.launch(ssr_mode=False) |