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)