| import gradio as gr |
| import json |
| import os |
| import pandas as pd |
| from dotenv import load_dotenv |
| from services import GeminiService |
|
|
| |
| load_dotenv() |
| SAVE_FILE = os.getenv("SAVE_FILE_NAME", "saved_professors.json") |
|
|
| |
| try: |
| gemini_service = GeminiService() |
| except Exception as e: |
| print(f"Service Error: {e}") |
| gemini_service = None |
|
|
| |
|
|
| def get_key(p): |
| return f"{p['name']}-{p['university']}" |
|
|
| def load_data(): |
| if os.path.exists(SAVE_FILE): |
| try: |
| with open(SAVE_FILE, 'r', encoding='utf-8') as f: |
| return json.load(f) |
| except: |
| return [] |
| return [] |
|
|
| def save_data(data): |
| try: |
| with open(SAVE_FILE, 'w', encoding='utf-8') as f: |
| json.dump(data, f, ensure_ascii=False, indent=2) |
| except Exception as e: |
| print(f"Save Error: {e}") |
|
|
| def format_df(source_list, saved_list): |
| if not source_list: |
| return pd.DataFrame(columns=["狀態", "姓名", "大學", "系所", "標籤"]) |
| |
| saved_map = {get_key(p): p for p in saved_list} |
| |
| data = [] |
| for p in source_list: |
| display_p = saved_map.get(get_key(p), p) |
| |
| status_map = {'match': '✅', 'mismatch': '❌', 'pending': '❓'} |
| status_icon = status_map.get(display_p.get('status'), '') |
| has_detail = "📄" if display_p.get('details') else "" |
| |
| tags = ", ".join(display_p.get('tags', [])) |
| |
| data.append([ |
| f"{status_icon} {has_detail}", |
| display_p['name'], |
| display_p['university'], |
| display_p['department'], |
| tags |
| ]) |
| return pd.DataFrame(data, columns=["狀態", "姓名", "大學", "系所", "標籤"]) |
|
|
| def get_tags_text(prof): |
| if not prof or not prof.get('tags'): |
| return "目前標籤: (無)" |
| return "🏷️ " + ", ".join([f"`{t}`" for t in prof['tags']]) |
|
|
| def get_tags_choices(prof): |
| if not prof: return [] |
| return prof.get('tags', []) |
|
|
| |
|
|
| def search_professors(query, current_saved): |
| if not query: return gr.update(), current_saved, gr.update() |
| |
| try: |
| results = gemini_service.search_professors(query) |
| return format_df(results, current_saved), results, gr.update(visible=True) |
| except Exception as e: |
| raise gr.Error(f"搜尋失敗: {e}") |
|
|
| def load_more(query, current_search_results, current_saved): |
| if not query: return gr.update(), current_search_results |
| |
| current_names = [p['name'] for p in current_search_results] |
| try: |
| new_results = gemini_service.search_professors(query, exclude_names=current_names) |
| |
| existing_keys = set(get_key(p) for p in current_search_results) |
| for p in new_results: |
| if get_key(p) not in existing_keys: |
| current_search_results.append(p) |
| |
| return format_df(current_search_results, current_saved), current_search_results |
| except Exception as e: |
| raise gr.Error(f"載入失敗: {e}") |
|
|
| def select_professor_from_df(evt: gr.SelectData, search_results, saved_data, view_mode): |
| if not evt: return [gr.update()] * 8 |
| index = evt.index[0] |
| |
| target_list = saved_data if view_mode == "追蹤清單" else search_results |
| if not target_list or index >= len(target_list): |
| return gr.update(), gr.update(), gr.update(), None, None, gr.update(), gr.update(), gr.update() |
| |
| prof = target_list[index] |
| |
| key = get_key(prof) |
| saved_prof = next((p for p in saved_data if get_key(p) == key), None) |
| current_prof = saved_prof if saved_prof else prof |
| |
| details_md = "" |
| |
| if current_prof.get('details') and len(current_prof.get('details')) > 10: |
| details_md = current_prof['details'] |
| if not saved_prof: |
| saved_data.insert(0, current_prof) |
| save_data(saved_data) |
| else: |
| gr.Info(f"正在調查 {current_prof['name']}...") |
| try: |
| res = gemini_service.get_professor_details(current_prof) |
| current_prof['details'] = res['text'] |
| current_prof['sources'] = res['sources'] |
| details_md = res['text'] |
| |
| if saved_prof: |
| saved_prof.update(current_prof) |
| else: |
| saved_data.insert(0, current_prof) |
| save_data(saved_data) |
| except Exception as e: |
| raise gr.Error(f"調查失敗: {e}") |
|
|
| if current_prof.get('sources'): |
| details_md += "\n\n### 📚 參考來源\n" |
| for s in current_prof['sources']: |
| details_md += f"- [{s['title']}]({s['uri']})\n" |
|
|
| return ( |
| gr.update(visible=True), |
| details_md, |
| [], |
| current_prof, |
| saved_data, |
| get_tags_text(current_prof), |
| gr.update(choices=get_tags_choices(current_prof), value=None), |
| gr.update(visible=True) |
| ) |
|
|
| def add_tag(new_tag, selected_prof, saved_data, view_mode, search_results): |
| if not selected_prof or not new_tag: |
| return gr.update(), gr.update(), gr.update(), saved_data, gr.update() |
|
|
| if 'tags' not in selected_prof: selected_prof['tags'] = [] |
| |
| if new_tag not in selected_prof['tags']: |
| selected_prof['tags'].append(new_tag) |
| |
| key = get_key(selected_prof) |
| found = False |
| for i, p in enumerate(saved_data): |
| if get_key(p) == key: |
| saved_data[i] = selected_prof |
| found = True |
| break |
| if not found: |
| saved_data.insert(0, selected_prof) |
| |
| save_data(saved_data) |
| gr.Info(f"已新增標籤: {new_tag}") |
| |
| target_list = saved_data if view_mode == "追蹤清單" else search_results |
| new_df = format_df(target_list, saved_data) |
|
|
| return ( |
| gr.update(value=""), |
| get_tags_text(selected_prof), |
| gr.update(choices=selected_prof['tags']), |
| saved_data, |
| new_df |
| ) |
|
|
| def remove_tag(tag_to_remove, selected_prof, saved_data, view_mode, search_results): |
| if not selected_prof or not tag_to_remove: |
| return gr.update(), gr.update(), saved_data, gr.update() |
| |
| if 'tags' in selected_prof and tag_to_remove in selected_prof['tags']: |
| selected_prof['tags'].remove(tag_to_remove) |
| |
| key = get_key(selected_prof) |
| for i, p in enumerate(saved_data): |
| if get_key(p) == key: |
| saved_data[i] = selected_prof |
| break |
| save_data(saved_data) |
| gr.Info(f"已移除標籤: {tag_to_remove}") |
|
|
| target_list = saved_data if view_mode == "追蹤清單" else search_results |
| new_df = format_df(target_list, saved_data) |
| |
| return ( |
| get_tags_text(selected_prof), |
| gr.update(choices=selected_prof['tags'], value=None), |
| saved_data, |
| new_df |
| ) |
|
|
| def chat_response(history, message, selected_prof): |
| if not selected_prof: return history, "" |
| context = selected_prof.get('details', '') |
| if not context: return history, "" |
| |
| service_history = [] |
| for h in history: |
| service_history.append({"role": "user", "content": h[0]}) |
| if h[1]: service_history.append({"role": "model", "content": h[1]}) |
| |
| try: |
| reply = gemini_service.chat_with_ai(service_history, message, context) |
| history.append((message, reply)) |
| except Exception as e: |
| history.append((message, f"Error: {e}")) |
| return history, "" |
|
|
| def update_status(status, selected_prof, saved_data, view_mode, search_results): |
| if not selected_prof: return gr.update(), saved_data |
| |
| selected_prof['status'] = status if selected_prof.get('status') != status else None |
| |
| key = get_key(selected_prof) |
| for i, p in enumerate(saved_data): |
| if get_key(p) == key: |
| saved_data[i] = selected_prof |
| break |
| save_data(saved_data) |
| |
| target_list = saved_data if view_mode == "追蹤清單" else search_results |
| return format_df(target_list, saved_data), saved_data |
|
|
| def remove_prof(selected_prof, saved_data, view_mode, search_results): |
| if not selected_prof: return gr.update(), gr.update(value=None), saved_data, gr.update(visible=False) |
| |
| key = get_key(selected_prof) |
| new_saved = [p for p in saved_data if get_key(p) != key] |
| save_data(new_saved) |
| |
| target_list = new_saved if view_mode == "追蹤清單" else search_results |
| |
| return ( |
| gr.Info("已移除"), |
| format_df(target_list, new_saved), |
| new_saved, |
| gr.update(visible=False) |
| ) |
|
|
| def toggle_view(mode, search_res, saved_data): |
| if mode == "搜尋結果": |
| return format_df(search_res, saved_data), gr.update(visible=True) |
| else: |
| return format_df(saved_data, saved_data), gr.update(visible=False) |
|
|
| |
|
|
| |
| with gr.Blocks(title="Prof.404 開箱教授去哪兒?", theme=gr.themes.Soft()) as demo: |
| |
| |
| saved_state = gr.State(load_data()) |
| search_res_state = gr.State([]) |
| selected_prof_state = gr.State(None) |
| |
| |
| gr.Markdown("# Prof.404 🎓 開箱教授去哪兒? (Gradio Edition)") |
| |
| with gr.Row(): |
| search_input = gr.Textbox(label="搜尋研究領域", placeholder="例如: LLM, 量子計算...", scale=4) |
| search_btn = gr.Button("🔍 搜尋", variant="primary", scale=1) |
| |
| with gr.Row(): |
| view_radio = gr.Radio(["搜尋結果", "追蹤清單"], label="顯示模式", value="搜尋結果") |
| |
| with gr.Row(): |
| |
| with gr.Column(scale=1): |
| prof_df = gr.Dataframe( |
| headers=["狀態", "姓名", "大學", "系所", "標籤"], |
| datatype=["str", "str", "str", "str", "str"], |
| interactive=False, |
| label="教授列表 (點擊查看詳情)" |
| ) |
| load_more_btn = gr.Button("載入更多", visible=False) |
| |
| |
| with gr.Column(scale=2, visible=False) as details_col: |
| detail_md = gr.Markdown("詳細資料...") |
| |
| |
| with gr.Row(): |
| btn_match = gr.Button("✅ 符合") |
| btn_mismatch = gr.Button("❌ 不符") |
| btn_pending = gr.Button("❓ 待觀察") |
| btn_remove = gr.Button("🗑️ 移除", variant="stop") |
| |
| gr.Markdown("---") |
| |
| |
| with gr.Column(visible=False) as tags_row: |
| tags_display = gr.Markdown("目前標籤: (無)") |
| with gr.Row(): |
| tag_input = gr.Textbox(label="新增標籤", placeholder="輸入後按新增...", scale=3) |
| tag_add_btn = gr.Button("➕ 新增", scale=1) |
| |
| with gr.Accordion("刪除標籤", open=False): |
| with gr.Row(): |
| tag_dropdown = gr.Dropdown(label="選擇標籤", choices=[], scale=3) |
| tag_del_btn = gr.Button("🗑️ 刪除", scale=1, variant="secondary") |
|
|
| gr.Markdown("---") |
| gr.Markdown("### 💬 AI 助手") |
| chatbot = gr.Chatbot(height=300) |
| msg = gr.Textbox(label="提問") |
| send_btn = gr.Button("送出") |
|
|
| |
| |
| search_btn.click( |
| search_professors, |
| inputs=[search_input, saved_state], |
| outputs=[prof_df, search_res_state, load_more_btn] |
| ) |
| |
| load_more_btn.click( |
| load_more, |
| inputs=[search_input, search_res_state, saved_state], |
| outputs=[prof_df, search_res_state] |
| ) |
| |
| view_radio.change( |
| toggle_view, |
| inputs=[view_radio, search_res_state, saved_state], |
| outputs=[prof_df, load_more_btn] |
| ) |
| |
| prof_df.select( |
| select_professor_from_df, |
| inputs=[search_res_state, saved_state, view_radio], |
| outputs=[ |
| details_col, detail_md, chatbot, selected_prof_state, saved_state, |
| tags_display, tag_dropdown, tags_row |
| ] |
| ) |
| |
| send_btn.click(chat_response, inputs=[chatbot, msg, selected_prof_state], outputs=[chatbot, msg]) |
| msg.submit(chat_response, inputs=[chatbot, msg, selected_prof_state], outputs=[chatbot, msg]) |
| |
| tag_add_btn.click( |
| add_tag, |
| inputs=[tag_input, selected_prof_state, saved_state, view_radio, search_res_state], |
| outputs=[tag_input, tags_display, tag_dropdown, saved_state, prof_df] |
| ) |
| |
| tag_del_btn.click( |
| remove_tag, |
| inputs=[tag_dropdown, selected_prof_state, saved_state, view_radio, search_res_state], |
| outputs=[tags_display, tag_dropdown, saved_state, prof_df] |
| ) |
|
|
| for btn, status in [(btn_match, 'match'), (btn_mismatch, 'mismatch'), (btn_pending, 'pending')]: |
| btn.click( |
| update_status, |
| inputs=[gr.State(status), selected_prof_state, saved_state, view_radio, search_res_state], |
| outputs=[prof_df, saved_state] |
| ) |
|
|
| btn_remove.click( |
| remove_prof, |
| inputs=[selected_prof_state, saved_state, view_radio, search_res_state], |
| outputs=[gr.State(None), prof_df, saved_state, details_col] |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch() |