DeepLearning101 commited on
Commit
a54a192
·
verified ·
1 Parent(s): d823771

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +564 -257
app.py CHANGED
@@ -1,17 +1,18 @@
1
  import gradio as gr
2
- import google.generativeai as genai
3
- import os
4
  import json
 
5
  import pandas as pd
6
  import tempfile
7
- from pdf2image import convert_from_path
8
- from pptx import Presentation
9
- from pptx.util import Inches, Pt
10
- from pptx.dml.color import RGBColor
11
- from huggingface_hub import HfApi, hf_hub_download
12
  from dotenv import load_dotenv
 
 
 
 
 
13
 
14
- # --- 設定與常數 ---
15
  load_dotenv()
16
  PROF_SAVE_FILE = "saved_professors.json"
17
  COMP_SAVE_FILE = "saved_companies.json"
@@ -19,296 +20,602 @@ HF_TOKEN = os.getenv("HF_TOKEN")
19
  DATASET_REPO_ID = os.getenv("DATASET_REPO_ID")
20
 
21
  # ==========================================
22
- # 🧠 核心服務層 (The Logic / Chef)
23
  # ==========================================
24
  class UnifiedService:
25
- def __init__(self, api_key_input=None):
26
- self.api_key = self._get_api_key(api_key_input)
27
  if self.api_key:
28
  genai.configure(api_key=self.api_key)
29
- # 使用支援 Google Search 的模型
30
- self.model_name = "gemini-2.0-flash-exp"
31
-
32
- def _get_api_key(self, user_key):
33
- if user_key and user_key.strip(): return user_key.strip()
34
- system_key = os.getenv("GEMINI_API_KEY")
35
- if system_key: return system_key
36
- return None # 允許初始化時無 Key,但在使用功能時會噴錯
37
-
38
- def _check_key(self):
39
- if not self.api_key: raise ValueError("請先輸入 API Key 或設定系統環境變數")
40
-
41
- # --- 1. PDF 轉 PPTX ---
42
- def analyze_pdf_to_pptx(self, pdf_file, progress):
43
- self._check_key()
44
- model = genai.GenerativeModel(self.model_name)
45
- prs = Presentation()
46
- prs.slide_width = Inches(16); prs.slide_height = Inches(9)
47
-
48
- progress(0.1, desc="轉檔中...")
49
- images = convert_from_path(pdf_file)
 
 
 
 
 
 
 
 
 
 
 
 
 
50
 
 
 
 
 
 
51
  for i, img in enumerate(images):
52
- progress(0.1 + (0.8 * (i / len(images))), desc=f"分析第 {i+1} 頁...")
53
- slide = prs.slides.add_slide(prs.slide_layouts[6])
54
 
55
- prompt = "Detect all text blocks. Return JSON: [{'text':..., 'box_2d':[ymin,xmin,ymax,xmax] (0-1000), 'font_size':int, 'is_bold':bool, 'color':hex}]"
56
  try:
57
- response = model.generate_content([prompt, img], generation_config={"response_mime_type": "application/json"})
58
- blocks = json.loads(response.text)
59
- for b in blocks:
60
- box = b.get("box_2d", [0,0,0,0])
61
- left, top = Inches((box[1]/1000)*16), Inches((box[0]/1000)*9)
62
- width, height = Inches(((box[3]-box[1])/1000)*16), Inches(((box[2]-box[0])/1000)*9)
63
- tx = slide.shapes.add_textbox(left, top, width, height)
64
- p = tx.text_frame.paragraphs[0]
65
- p.text = b.get("text",""); p.font.size = Pt(b.get("font_size", 12)); p.font.bold = b.get("is_bold", False)
66
- try: p.font.color.rgb = RGBColor.from_string(b.get("color", "#000000").replace("#",""))
67
- except: pass
68
- except Exception as e: print(f"Page {i} err: {e}")
69
 
70
- out = tempfile.mktemp(suffix=".pptx")
71
- prs.save(out)
72
- return out, "✅ 轉換完成"
73
-
74
- # --- 2. 圖片去字 ---
75
- def remove_text(self, image):
76
- self._check_key()
77
- model = genai.GenerativeModel(self.model_name)
78
- prompt = "Remove all text from this image, fill background naturally. Return image only."
79
- resp = model.generate_content([prompt, image]) # V1 SDK 通常回傳 multipart,這裡簡化處理
80
- # 注意: Gemini V1 SDK 在 Python 直接回傳 image 比較 tricky,若失敗建議檢查 SDK 版本
81
- # 這裡假設環境支援直接回圖,若否則需用 requests 操作 REST API
82
- try:
83
- return resp.parts[0].image
84
- except:
85
- return image # Fallback
86
-
87
- # --- 3. 搜尋 (教授/公司) 共用邏輯 ---
88
- def _search_with_google(self, query, prompt_template):
89
- self._check_key()
90
- # 這裡使用 Google Search Tool 設定
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  tools = [{"google_search": {}}]
92
- model = genai.GenerativeModel(self.model_name, tools=tools)
93
 
94
- # Step 1: Search
95
- resp1 = model.generate_content(prompt_template.format(query=query))
 
 
 
 
96
 
97
- # Step 2: Extract JSON (Pure Text Model)
98
- model_extract = genai.GenerativeModel(self.model_name) # No tools for extraction
99
- extract_prompt = f"Extract structured data from this text into JSON array: {resp1.text}"
100
- resp2 = model_extract.generate_content(extract_prompt, generation_config={"response_mime_type": "application/json"})
 
 
 
 
101
  try: return json.loads(resp2.text)
102
  except: return []
103
 
104
- def search_professors(self, query):
105
- p = "Find 10 prominent professors in Taiwan for '{query}'. Return name, university, department."
106
- return self._search_with_google(query, p)
 
 
 
 
107
 
108
- def search_companies(self, query):
109
- p = "Find 5-10 Taiwanese companies for '{query}'. Return name, industry."
110
- return self._search_with_google(query, p)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
 
112
- def get_details(self, data, role):
113
- self._check_key()
114
  tools = [{"google_search": {}}]
115
- model = genai.GenerativeModel(self.model_name, tools=tools)
116
- prompt = f"Act as {role}. Investigate: {json.dumps(data)}. Report in Traditional Chinese Markdown."
117
  resp = model.generate_content(prompt)
 
 
 
 
 
 
 
118
 
119
- # 處理來源引用 (V1 SDK)
120
- sources = []
121
- if hasattr(resp.candidates[0], 'grounding_metadata'):
122
- chunks = resp.candidates[0].grounding_metadata.grounding_chunks
123
- for c in chunks:
124
- if c.web: sources.append({"title": c.web.title, "uri": c.web.uri})
125
-
126
- # 去重
127
- unique_sources = list({v['uri']:v for v in sources}.values())
128
- return {"text": resp.text, "sources": unique_sources}
129
-
130
- def chat(self, hist, msg, context, role):
131
- self._check_key()
132
- model = genai.GenerativeModel(self.model_name)
133
- chat = model.start_chat(history=[
134
- {"role": "user" if h[0] else "model", "parts": [h[0] or h[1]]} for h in hist
135
- ])
136
- full_msg = f"Context: {context}\nInstruction: {role}\nUser: {msg}"
137
- resp = chat.send_message(full_msg)
138
  return resp.text
139
 
140
- # ==========================================
141
- # 💾 資料存取層 (Persistence)
142
- # ==========================================
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  def load_data(filename):
 
144
  if HF_TOKEN and DATASET_REPO_ID:
145
  try: hf_hub_download(repo_id=DATASET_REPO_ID, filename=filename, repo_type="dataset", token=HF_TOKEN, local_dir=".")
146
  except: pass
147
  if os.path.exists(filename):
148
  try:
149
- with open(filename, 'r', encoding='utf-8') as f: return json.load(f)
150
- except: pass
151
- return []
152
 
153
  def save_data(data, filename):
154
- with open(filename, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2)
 
 
155
  if HF_TOKEN and DATASET_REPO_ID:
156
  try:
157
  api = HfApi(token=HF_TOKEN)
158
- api.upload_file(path_or_fileobj=filename, path_in_repo=filename, repo_id=DATASET_REPO_ID, repo_type="dataset", commit_message="Sync")
159
  except: pass
160
 
161
- # ==========================================
162
- # 🖥️ 介面邏輯 (UI Helpers)
163
- # ==========================================
164
- def format_df(data_list, cols):
165
- if not data_list: return pd.DataFrame(columns=cols)
166
- res = []
167
- for d in data_list:
168
- icon = {'match':'✅','good':'✅','risk':'⚠️'}.get(d.get('status'),'')
169
- res.append([f"{icon} {d.get('name')}", d.get('university') or d.get('industry'), ", ".join(d.get('tags',[]))])
170
- return pd.DataFrame(res, columns=cols)
171
 
172
- # ==========================================
173
- # 🚀 主程式 (Gradio)
174
- # ==========================================
175
- def main_app():
176
- # 初始化
177
- prof_data = load_data(PROF_SAVE_FILE)
178
- comp_data = load_data(COMP_SAVE_FILE)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
- with gr.Blocks(title="Prof.404 x PPT.404 Ultimate", theme=gr.themes.Soft()) as demo:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
- # 全域 Key
183
- with gr.Accordion("🔑 系統設定 (API Key)", open=False):
184
- api_key = gr.Textbox(label="Google Gemini API Key", type="password", placeholder="若未填寫則使用系統預設")
185
-
186
- gr.Markdown(
187
- """
188
- <div align="center">
189
- <h1>🚀 Prof.404 Ultimate: 產學導航 & 文件工具站</h1>
190
- <h3>整合文件視覺處理 (PPT/Img) 與 產學資源導航 (Prof/Com) 的全方位平台</h3>
191
- </div>
192
- """
193
- )
194
-
195
- with gr.Tabs():
196
 
197
- # --- Tab 1: 工具箱 ---
198
- with gr.Tab("🛠️ 文件工具箱 (PPT.404)"):
199
- with gr.Row():
200
- with gr.Column():
201
- gr.Markdown("### 📄 PDF 轉 PPTX (含排版還原)")
202
- pdf_file = gr.File(label="上傳 PDF")
203
- pdf_btn = gr.Button("開始轉換", variant="primary")
204
- ppt_out = gr.File(label="下載 PPTX")
205
- pdf_msg = gr.Textbox(label="狀態", interactive=False)
206
-
207
- pdf_btn.click(
208
- lambda f, k: UnifiedService(k).analyze_pdf_to_pptx(f, gr.Progress()),
209
- inputs=[pdf_file, api_key], outputs=[ppt_out, pdf_msg]
210
- )
211
-
212
- with gr.Column():
213
- gr.Markdown("### 🎨 圖片智慧去字")
214
- img_in = gr.Image(type="pil", label="原圖")
215
- img_btn = gr.Button("一鍵去除", variant="primary")
216
- img_out = gr.Image(label="結果")
217
-
218
- img_btn.click(
219
- lambda i, k: UnifiedService(k).remove_text(i),
220
- inputs=[img_in, api_key], outputs=[img_out]
221
- )
222
-
223
- # --- Tab 2: 找教授 ---
224
- with gr.Tab("🎓 找教授 (Prof.404)"):
225
- p_state = gr.State(prof_data)
226
- p_current = gr.State(None) # 當前選中的教授
227
-
228
- with gr.Row():
229
- p_query = gr.Textbox(label="搜尋領域", scale=4)
230
- p_btn = gr.Button("搜尋", scale=1)
231
-
232
- with gr.Row():
233
- p_table = gr.Dataframe(headers=["姓名", "大學", "標籤"], interactive=False, scale=1)
234
- with gr.Column(scale=1, visible=False) as p_detail_col:
235
- p_md = gr.Markdown()
236
- p_chat = gr.Chatbot(height=300)
237
- p_msg = gr.Textbox(label="詢問關於此教授")
238
 
239
- # Logic Wrappers
240
- def search_p(q, k, saved):
241
- svc = UnifiedService(k)
242
- res = svc.search_professors(q)
243
- return res, format_df(res, ["姓名","大學","標籤"])
244
-
245
- def select_p(evt: gr.SelectData, res, k, saved):
246
- svc = UnifiedService(k)
247
- item = res[evt.index[0]]
248
- # 取得詳細資料
249
- det = svc.get_details(item, "Academic Consultant")
250
- item['details'] = det['text']
251
- # 簡易儲存邏輯 (為了Demo簡化,實際建議加上去重)
252
- saved.append(item)
253
- save_data(saved, PROF_SAVE_FILE)
254
-
255
- display_text = det['text'] + "\n\n📚 來源:\n" + "\n".join([f"- {s['title']}" for s in det['sources']])
256
- return gr.update(visible=True), display_text, [], item, saved
257
-
258
- def chat_p(hist, msg, item, k):
259
- svc = UnifiedService(k)
260
- reply = svc.chat(hist, msg, item.get('details'), "Academic Consultant")
261
- hist.append((msg, reply))
262
- return hist, ""
263
-
264
- p_btn.click(search_p, [p_query, api_key, p_state], [p_state, p_table])
265
- p_table.select(select_p, [p_state, api_key, p_state], [p_detail_col, p_md, p_chat, p_current, p_state])
266
- p_msg.submit(chat_p, [p_chat, p_msg, p_current, api_key], [p_chat, p_msg])
267
-
268
- # --- Tab 3: 找公司 ---
269
- with gr.Tab("🏢 找公司 (Com.404)"):
270
- c_state = gr.State(comp_data)
271
- c_current = gr.State(None)
272
 
273
- with gr.Row():
274
- c_query = gr.Textbox(label="搜尋產業/公司", scale=4)
275
- c_btn = gr.Button("搜尋", scale=1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
 
277
- with gr.Row():
278
- c_table = gr.Dataframe(headers=["公司", "產業", "標籤"], interactive=False, scale=1)
279
- with gr.Column(scale=1, visible=False) as c_detail_col:
280
- c_md = gr.Markdown()
281
- c_chat = gr.Chatbot(height=300)
282
- c_msg = gr.Textbox(label="詢問關於此公司")
283
-
284
- # Logic Wrappers (Similar structure)
285
- def search_c(q, k, saved):
286
- svc = UnifiedService(k)
287
- res = svc.search_companies(q)
288
- return res, format_df(res, ["公司","產業","標籤"])
289
-
290
- def select_c(evt: gr.SelectData, res, k, saved):
291
- svc = UnifiedService(k)
292
- item = res[evt.index[0]]
293
- det = svc.get_details(item, "Business Analyst")
294
- item['details'] = det['text']
295
- saved.append(item)
296
- save_data(saved, COMP_SAVE_FILE)
297
-
298
- display_text = det['text'] + "\n\n📚 來源:\n" + "\n".join([f"- {s['title']}" for s in det['sources']])
299
- return gr.update(visible=True), display_text, [], item, saved
300
-
301
- def chat_c(hist, msg, item, k):
302
- svc = UnifiedService(k)
303
- reply = svc.chat(hist, msg, item.get('details'), "Business Analyst")
304
- hist.append((msg, reply))
305
- return hist, ""
306
-
307
- c_btn.click(search_c, [c_query, api_key, c_state], [c_state, c_table])
308
- c_table.select(select_c, [c_state, api_key, c_state], [c_detail_col, c_md, c_chat, c_current, c_state])
309
- c_msg.submit(chat_c, [c_chat, c_msg, c_current, api_key], [c_chat, c_msg])
310
-
311
- demo.queue().launch()
312
 
313
  if __name__ == "__main__":
314
- main_app()
 
1
  import gradio as gr
 
 
2
  import json
3
+ import os
4
  import pandas as pd
5
  import tempfile
6
+ import zipfile
7
+ import shutil
 
 
 
8
  from dotenv import load_dotenv
9
+ from huggingface_hub import HfApi, hf_hub_download
10
+ from pdf2image import convert_from_path
11
+ import google.generativeai as genai
12
+ from google.genai import types # 確保相容性
13
+ from PIL import Image
14
 
15
+ # Load Env
16
  load_dotenv()
17
  PROF_SAVE_FILE = "saved_professors.json"
18
  COMP_SAVE_FILE = "saved_companies.json"
 
20
  DATASET_REPO_ID = os.getenv("DATASET_REPO_ID")
21
 
22
  # ==========================================
23
+ # 🧠 Unified AI Service (整合後端邏輯)
24
  # ==========================================
25
  class UnifiedService:
26
+ def __init__(self):
27
+ self.api_key = self._get_api_key()
28
  if self.api_key:
29
  genai.configure(api_key=self.api_key)
30
+ self.model_id = "gemini-2.0-flash-exp" # 使用較新的模型
31
+ else:
32
+ print("⚠️ Warning: No API Key found.")
33
+
34
+ def _get_api_key(self):
35
+ # 優先讀取環境變數 (Secrets)
36
+ return os.getenv("GEMINI_API_KEY")
37
+
38
+ def set_user_key(self, key):
39
+ """允許使用者在介面上暫時替換 Key"""
40
+ if key and key.strip():
41
+ self.api_key = key.strip()
42
+ genai.configure(api_key=self.api_key)
43
+
44
+ def _check_client(self):
45
+ if not self.api_key:
46
+ raise ValueError("API Key 未設定,請檢查 .env, Secrets 或在介面上輸入")
47
+
48
+ # --- 🛠️ New Feature: PDF 智能拆解 (NotebookLM 專用) ---
49
+ def decompose_pdf(self, pdf_file, progress=gr.Progress()):
50
+ self._check_client()
51
+ if not pdf_file: return None, None, "請上傳 PDF"
52
+
53
+ # 1. PDF 轉圖片
54
+ progress(0.1, desc="正在將 PDF 轉為圖片...")
55
+ try:
56
+ images = convert_from_path(pdf_file)
57
+ except Exception as e:
58
+ return None, None, f"PDF 轉換失敗 (請確認系統已安裝 poppler): {e}"
59
+
60
+ # 準備暫存資料夾
61
+ tmp_dir = tempfile.mkdtemp()
62
+ clean_img_dir = os.path.join(tmp_dir, "cleaned_images")
63
+ os.makedirs(clean_img_dir, exist_ok=True)
64
 
65
+ full_text_content = ""
66
+ processed_images = []
67
+ model = genai.GenerativeModel(self.model_id)
68
+
69
+ # 2. 逐頁處理
70
  for i, img in enumerate(images):
71
+ progress(0.1 + (0.8 * (i / len(images))), desc=f"AI 正在拆解第 {i+1}/{len(images)} 頁...")
 
72
 
73
+ # Action A: 提取文字 (OCR)
74
  try:
75
+ prompt_ocr = "Extract all text content from this image strictly. Do not describe the layout."
76
+ ocr_resp = model.generate_content([prompt_ocr, img])
77
+ page_text = ocr_resp.text
78
+ except:
79
+ page_text = "[Text Extraction Failed]"
 
 
 
 
 
 
 
80
 
81
+ full_text_content += f"--- Page {i+1} ---\n{page_text}\n\n"
82
+
83
+ # Action B: 移除文字 (In-painting)
84
+ try:
85
+ prompt_clean = "Remove all text from this image and fill in the background naturally. Return only the image."
86
+ clean_resp = model.generate_content([prompt_clean, img])
87
+ # 嘗試取得圖片 (處理 V1/V2 SDK 差異)
88
+ try:
89
+ clean_img = clean_resp.parts[0].image
90
+ except:
91
+ # Fallback SDK 版本不同或回傳格式不同
92
+ clean_img = img # 若失敗則保留原圖
93
+
94
+ # 存檔
95
+ img_filename = f"page_{i+1:03d}_clean.png"
96
+ img_path = os.path.join(clean_img_dir, img_filename)
97
+ clean_img.save(img_path)
98
+ processed_images.append(clean_img)
99
+ except Exception as e:
100
+ print(f"Clean Error on page {i}: {e}")
101
+ processed_images.append(img)
102
+
103
+ # 3. 打包結果
104
+ progress(0.9, desc="正在打包檔案...")
105
+
106
+ # 儲存文字檔
107
+ txt_path = os.path.join(tmp_dir, "extracted_text.txt")
108
+ with open(txt_path, "w", encoding="utf-8") as f:
109
+ f.write(full_text_content)
110
+
111
+ # 建立 ZIP
112
+ zip_path = os.path.join(tmp_dir, "notebooklm_result.zip")
113
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
114
+ zf.write(txt_path, "content.txt")
115
+ for root, dirs, files in os.walk(clean_img_dir):
116
+ for file in files:
117
+ zf.write(os.path.join(root, file), os.path.join("images", file))
118
+
119
+ return zip_path, full_text_content, processed_images
120
+
121
+ # --- 🎓 Professor Search Logic (Copied from original) ---
122
+ def search_professors(self, query, exclude_names=[]):
123
+ self._check_client()
124
+ exclusion = f"IMPORTANT: Do not include: {', '.join(exclude_names)}." if exclude_names else ""
125
+
126
+ # Phase 1: Search
127
  tools = [{"google_search": {}}]
128
+ model_tools = genai.GenerativeModel(self.model_id, tools=tools)
129
 
130
+ prompt = f"""
131
+ Using Google Search, find 10 prominent professors in universities across Taiwan who are experts in "{query}".
132
+ FACT CHECK: Must be current faculty. {exclusion}
133
+ List them (Name - University - Department) in Traditional Chinese.
134
+ """
135
+ resp1 = model_tools.generate_content(prompt)
136
 
137
+ # Phase 2: Extract JSON
138
+ model_pure = genai.GenerativeModel(self.model_id)
139
+ extract_prompt = f"""
140
+ Extract professor names, universities, and departments from the text below.
141
+ Return ONLY a JSON array: [{{"name": "...", "university": "...", "department": "...", "tags": ["tag1"]}}]
142
+ Text: {resp1.text}
143
+ """
144
+ resp2 = model_pure.generate_content(extract_prompt, generation_config={"response_mime_type": "application/json"})
145
  try: return json.loads(resp2.text)
146
  except: return []
147
 
148
+ def get_professor_details(self, professor):
149
+ self._check_client()
150
+ tools = [{"google_search": {}}]
151
+ model = genai.GenerativeModel(self.model_id, tools=tools)
152
+ prompt = f"Act as academic consultant. Investigate Professor {professor.get('name')} from {professor.get('university')}. Find key publications and industry projects. Report in Traditional Chinese Markdown."
153
+ resp = model.generate_content(prompt)
154
+ return self._format_response_with_sources(resp)
155
 
156
+ # --- 🏢 Company Search Logic (Copied from original) ---
157
+ def search_companies(self, query, exclude_names=[]):
158
+ self._check_client()
159
+ exclusion = f"IMPORTANT: Do not include: {', '.join(exclude_names)}." if exclude_names else ""
160
+
161
+ tools = [{"google_search": {}}]
162
+ model = genai.GenerativeModel(self.model_id, tools=tools)
163
+ prompt = f"""
164
+ Using Google Search, find 5-10 Taiwanese companies related to: "{query}".
165
+ {exclusion}
166
+ List them (Name - Industry) in Traditional Chinese.
167
+ """
168
+ resp1 = model.generate_content(prompt)
169
+
170
+ model_pure = genai.GenerativeModel(self.model_id)
171
+ extract_prompt = f"""
172
+ Extract company names and industry from text.
173
+ Return ONLY JSON array: [{{"name": "...", "industry": "...", "tags": ["tag1"]}}]
174
+ Text: {resp1.text}
175
+ """
176
+ resp2 = model_pure.generate_content(extract_prompt, generation_config={"response_mime_type": "application/json"})
177
+ try: return json.loads(resp2.text)
178
+ except: return []
179
 
180
+ def get_company_details(self, company):
181
+ self._check_client()
182
  tools = [{"google_search": {}}]
183
+ model = genai.GenerativeModel(self.model_id, tools=tools)
184
+ prompt = f"Act as Business Analyst. Investigate company: '{company.get('name')}'. Focus on products, culture, and disputes. Report in Traditional Chinese Markdown."
185
  resp = model.generate_content(prompt)
186
+ return self._format_response_with_sources(resp)
187
+
188
+ # --- Shared Helpers ---
189
+ def chat_with_ai(self, history, msg, context, role):
190
+ self._check_client()
191
+ model = genai.GenerativeModel(self.model_id)
192
+ sys_prompt = f"{role}:\nContext: {context}"
193
 
194
+ # Convert history for Gemini
195
+ chat_hist = []
196
+ for h in history:
197
+ chat_hist.append({"role": "user", "parts": [h[0]]})
198
+ if len(h) > 1: chat_hist.append({"role": "model", "parts": [h[1]]})
199
+
200
+ chat = model.start_chat(history=chat_hist)
201
+ resp = chat.send_message(f"{sys_prompt}\nUser: {msg}")
 
 
 
 
 
 
 
 
 
 
 
202
  return resp.text
203
 
204
+ def _format_response_with_sources(self, response):
205
+ sources = []
206
+ if hasattr(response.candidates[0], 'grounding_metadata'):
207
+ gm = response.candidates[0].grounding_metadata
208
+ if hasattr(gm, 'grounding_chunks'):
209
+ for chunk in gm.grounding_chunks:
210
+ if hasattr(chunk, 'web'):
211
+ sources.append({"title": chunk.web.title, "uri": chunk.web.uri})
212
+ # Deduplicate
213
+ unique_sources = list({v['uri']: v for v in sources}.values())
214
+ return {"text": response.text, "sources": unique_sources}
215
+
216
+ # Init Service
217
+ gemini_service = UnifiedService()
218
+
219
+ # --- Helper Functions (Preserved from your code) ---
220
+
221
  def load_data(filename):
222
+ data = []
223
  if HF_TOKEN and DATASET_REPO_ID:
224
  try: hf_hub_download(repo_id=DATASET_REPO_ID, filename=filename, repo_type="dataset", token=HF_TOKEN, local_dir=".")
225
  except: pass
226
  if os.path.exists(filename):
227
  try:
228
+ with open(filename, 'r', encoding='utf-8') as f: data = json.load(f)
229
+ except: data = []
230
+ return data
231
 
232
  def save_data(data, filename):
233
+ try:
234
+ with open(filename, 'w', encoding='utf-8') as f: json.dump(data, f, ensure_ascii=False, indent=2)
235
+ except: return
236
  if HF_TOKEN and DATASET_REPO_ID:
237
  try:
238
  api = HfApi(token=HF_TOKEN)
239
+ api.upload_file(path_or_fileobj=filename, path_in_repo=filename, repo_id=DATASET_REPO_ID, repo_type="dataset", commit_message=f"Sync {filename}")
240
  except: pass
241
 
242
+ def get_tags_text(item):
243
+ if not item or not item.get('tags'): return "目前標籤: (無)"
244
+ return "🏷️ " + ", ".join([f"`{t}`" for t in item['tags']])
 
 
 
 
 
 
 
245
 
246
+ def get_tags_choices(item): return item.get('tags', []) if item else []
247
+ def prof_get_key(p): return f"{p['name']}-{p['university']}"
248
+ def comp_get_key(c): return f"{c['name']}"
249
+
250
+ def prof_format_df(source_list, saved_list):
251
+ if not source_list: return pd.DataFrame(columns=["狀態", "姓名", "大學", "系所", "標籤"])
252
+ if saved_list is None: saved_list = []
253
+ saved_map = {prof_get_key(p): p for p in saved_list}
254
+ data = []
255
+ for p in source_list:
256
+ dp = saved_map.get(prof_get_key(p), p)
257
+ icon = {'match':'✅','mismatch':'❌','pending':'❓'}.get(dp.get('status'), '')
258
+ detail = "📄" if dp.get('details') else ""
259
+ data.append([f"{icon} {detail}", dp['name'], dp['university'], dp['department'], ", ".join(dp.get('tags', []))])
260
+ return pd.DataFrame(data, columns=["狀態", "姓名", "大學", "系所", "標籤"])
261
+
262
+ def comp_format_df(source_list, saved_list):
263
+ if not source_list: return pd.DataFrame(columns=["狀態", "公司名稱", "產業類別", "標籤"])
264
+ if saved_list is None: saved_list = []
265
+ saved_map = {comp_get_key(c): c for c in saved_list}
266
+ data = []
267
+ for c in source_list:
268
+ dc = saved_map.get(comp_get_key(c), c)
269
+ icon = {'good':'✅','risk':'⚠️','pending':'❓'}.get(dc.get('status'), '')
270
+ detail = "📄" if dc.get('details') else ""
271
+ data.append([f"{icon} {detail}", dc['name'], dc.get('industry','未知'), ", ".join(dc.get('tags', []))])
272
+ return pd.DataFrame(data, columns=["狀態", "公司名稱", "產業類別", "標籤"])
273
+
274
+ # --- Wrappers for Prof Logic ---
275
+ def prof_search(query, current_saved):
276
+ if not query: return gr.update(), current_saved, gr.update()
277
+ try:
278
+ res = gemini_service.search_professors(query)
279
+ return prof_format_df(res, current_saved), res, gr.update(visible=True)
280
+ except Exception as e: raise gr.Error(f"搜尋失敗: {e}")
281
+
282
+ def prof_load_more(query, cur_res, cur_saved):
283
+ if not query: return gr.update(), cur_res
284
+ try:
285
+ new_res = gemini_service.search_professors(query, exclude_names=[p['name'] for p in cur_res])
286
+ exist_keys = set(prof_get_key(p) for p in cur_res)
287
+ for p in new_res:
288
+ if prof_get_key(p) not in exist_keys: cur_res.append(p)
289
+ return prof_format_df(cur_res, cur_saved), cur_res
290
+ except Exception as e: raise gr.Error(f"載入失敗: {e}")
291
+
292
+ def prof_select(evt: gr.SelectData, search_res, saved_data, view_mode):
293
+ if not evt: return [gr.update()]*8
294
+ idx = evt.index[0]
295
+ target = saved_data if view_mode == "追蹤清單" else search_res
296
+ if not target or idx >= len(target): return [gr.update()]*8
297
+ p = target[idx]
298
+ key = prof_get_key(p)
299
+ saved_p = next((x for x in saved_data if prof_get_key(x) == key), None)
300
+ curr = saved_p if saved_p else p
301
+ md = ""
302
+ if curr.get('details') and len(curr.get('details')) > 10:
303
+ md = curr['details']
304
+ if not saved_p: saved_data.insert(0, curr); save_data(saved_data, PROF_SAVE_FILE)
305
+ else:
306
+ gr.Info(f"正在調查 {curr['name']}...")
307
+ try:
308
+ res = gemini_service.get_professor_details(curr)
309
+ curr['details'] = res['text']; curr['sources'] = res['sources']
310
+ md = res['text']
311
+ if saved_p: saved_p.update(curr)
312
+ else: saved_data.insert(0, curr)
313
+ save_data(saved_data, PROF_SAVE_FILE)
314
+ except Exception as e: raise gr.Error(f"調查失敗: {e}")
315
+ if curr.get('sources'): md += "\n\n### 📚 參考來源\n" + "\n".join([f"- [{s['title']}]({s['uri']})" for s in curr['sources']])
316
+ return gr.update(visible=True), md, [], curr, saved_data, get_tags_text(curr), gr.update(choices=get_tags_choices(curr), value=None), gr.update(visible=True)
317
+
318
+ def prof_chat(hist, msg, curr):
319
+ if not curr: return hist, ""
320
+ try:
321
+ reply = gemini_service.chat_with_ai(hist, msg, curr.get('details', ''), "你是學術顧問,請根據這份教授資料回答")
322
+ hist.append((msg, reply))
323
+ except Exception as e: hist.append((msg, f"Error: {e}"))
324
+ return hist, ""
325
+
326
+ def prof_add_tag(tag, curr, saved, mode, res):
327
+ if not curr or not tag: return gr.update(), gr.update(), gr.update(), saved, gr.update()
328
+ if 'tags' not in curr: curr['tags'] = []
329
+ if tag not in curr['tags']:
330
+ curr['tags'].append(tag)
331
+ key = prof_get_key(curr)
332
+ found = False
333
+ for i, p in enumerate(saved):
334
+ if prof_get_key(p) == key: saved[i] = curr; found=True; break
335
+ if not found: saved.insert(0, curr)
336
+ save_data(saved, PROF_SAVE_FILE)
337
+ return gr.update(value=""), get_tags_text(curr), gr.update(choices=curr['tags']), saved, prof_format_df(saved if mode=="追蹤清單" else res, saved)
338
+
339
+ def prof_remove_tag(tag, curr, saved, mode, res):
340
+ if not curr or not tag: return gr.update(), gr.update(), saved, gr.update()
341
+ if 'tags' in curr and tag in curr['tags']:
342
+ curr['tags'].remove(tag)
343
+ key = prof_get_key(curr)
344
+ for i, p in enumerate(saved):
345
+ if prof_get_key(p) == key: saved[i] = curr; break
346
+ save_data(saved, PROF_SAVE_FILE)
347
+ return get_tags_text(curr), gr.update(choices=curr['tags'], value=None), saved, prof_format_df(saved if mode=="追蹤清單" else res, saved)
348
+
349
+ def prof_update_status(stat, curr, saved, mode, res):
350
+ if not curr: return gr.update(), saved
351
+ curr['status'] = stat if curr.get('status') != stat else None
352
+ key = prof_get_key(curr)
353
+ for i, p in enumerate(saved):
354
+ if prof_get_key(p) == key: saved[i] = curr; break
355
+ save_data(saved, PROF_SAVE_FILE)
356
+ return prof_format_df(saved if mode=="追蹤清單" else res, saved), saved
357
+
358
+ def prof_remove(curr, saved, mode, res):
359
+ if not curr: return gr.update(), gr.update(value=None), saved, gr.update(visible=False)
360
+ key = prof_get_key(curr)
361
+ new_saved = [p for p in saved if prof_get_key(p) != key]
362
+ save_data(new_saved, PROF_SAVE_FILE)
363
+ return gr.Info("已移除"), prof_format_df(new_saved if mode=="追蹤清單" else res, new_saved), new_saved, gr.update(visible=False)
364
+
365
+ def prof_toggle(mode, res, saved):
366
+ return prof_format_df(res if mode=="搜尋結果" else saved, saved), gr.update(visible=mode=="搜尋結果")
367
+
368
+ # --- Wrappers for Company Logic ---
369
+ def comp_search(query, current_saved):
370
+ if not query: return gr.update(), current_saved, gr.update()
371
+ try:
372
+ res = gemini_service.search_companies(query)
373
+ return comp_format_df(res, current_saved), res, gr.update(visible=True)
374
+ except Exception as e: raise gr.Error(f"搜尋失敗: {e}")
375
+
376
+ def comp_load_more(query, cur_res, cur_saved):
377
+ if not query: return gr.update(), cur_res
378
+ try:
379
+ new_res = gemini_service.search_companies(query, exclude_names=[c['name'] for c in cur_res])
380
+ exist_keys = set(comp_get_key(c) for c in cur_res)
381
+ for c in new_res:
382
+ if comp_get_key(c) not in exist_keys: cur_res.append(c)
383
+ return comp_format_df(cur_res, cur_saved), cur_res
384
+ except Exception as e: raise gr.Error(f"載入失敗: {e}")
385
+
386
+ def comp_select(evt: gr.SelectData, search_res, saved_data, view_mode):
387
+ if not evt: return [gr.update()]*8
388
+ idx = evt.index[0]
389
+ target = saved_data if view_mode == "追蹤清單" else search_res
390
+ if not target or idx >= len(target): return [gr.update()]*8
391
+ c = target[idx]
392
+ key = comp_get_key(c)
393
+ saved_c = next((x for x in saved_data if comp_get_key(x) == key), None)
394
+ curr = saved_c if saved_c else c
395
+ md = ""
396
+ if curr.get('details') and len(curr.get('details')) > 10:
397
+ md = curr['details']
398
+ if not saved_c: saved_data.insert(0, curr); save_data(saved_data, COMP_SAVE_FILE)
399
+ else:
400
+ gr.Info(f"正在調查 {curr['name']}...")
401
+ try:
402
+ res = gemini_service.get_company_details(curr)
403
+ curr['details'] = res['text']; curr['sources'] = res['sources']
404
+ md = res['text']
405
+ if saved_c: saved_c.update(curr)
406
+ else: saved_data.insert(0, curr)
407
+ save_data(saved_data, COMP_SAVE_FILE)
408
+ except Exception as e: raise gr.Error(f"調查失敗: {e}")
409
+ if curr.get('sources'): md += "\n\n### 📚 資料來源\n" + "\n".join([f"- [{s['title']}]({s['uri']})" for s in curr['sources']])
410
+ return gr.update(visible=True), md, [], curr, saved_data, get_tags_text(curr), gr.update(choices=get_tags_choices(curr), value=None), gr.update(visible=True)
411
+
412
+ def comp_chat(hist, msg, curr):
413
+ if not curr: return hist, ""
414
+ try:
415
+ reply = gemini_service.chat_with_ai(hist, msg, curr.get('details', ''), "你是商業顧問,請根據這份公司調查報告回答")
416
+ hist.append((msg, reply))
417
+ except Exception as e: hist.append((msg, f"Error: {e}"))
418
+ return hist, ""
419
+
420
+ def comp_add_tag(tag, curr, saved, mode, res):
421
+ if not curr or not tag: return gr.update(), gr.update(), gr.update(), saved, gr.update()
422
+ if 'tags' not in curr: curr['tags'] = []
423
+ if tag not in curr['tags']:
424
+ curr['tags'].append(tag)
425
+ key = comp_get_key(curr)
426
+ found = False
427
+ for i, c in enumerate(saved):
428
+ if comp_get_key(c) == key: saved[i] = curr; found=True; break
429
+ if not found: saved.insert(0, curr)
430
+ save_data(saved, COMP_SAVE_FILE)
431
+ return gr.update(value=""), get_tags_text(curr), gr.update(choices=curr['tags']), saved, comp_format_df(saved if mode=="追蹤清單" else res, saved)
432
+
433
+ def comp_remove_tag(tag, curr, saved, mode, res):
434
+ if not curr or not tag: return gr.update(), gr.update(), saved, gr.update()
435
+ if 'tags' in curr and tag in curr['tags']:
436
+ curr['tags'].remove(tag)
437
+ key = comp_get_key(curr)
438
+ for i, c in enumerate(saved):
439
+ if comp_get_key(c) == key: saved[i] = curr; break
440
+ save_data(saved, COMP_SAVE_FILE)
441
+ return get_tags_text(curr), gr.update(choices=curr['tags'], value=None), saved, comp_format_df(saved if mode=="追蹤清單" else res, saved)
442
+
443
+ def comp_update_status(stat, curr, saved, mode, res):
444
+ if not curr: return gr.update(), saved
445
+ curr['status'] = stat if curr.get('status') != stat else None
446
+ key = comp_get_key(curr)
447
+ for i, c in enumerate(saved):
448
+ if comp_get_key(c) == key: saved[i] = curr; break
449
+ save_data(saved, COMP_SAVE_FILE)
450
+ return comp_format_df(saved if mode=="追蹤清單" else res, saved), saved
451
+
452
+ def comp_remove(curr, saved, mode, res):
453
+ if not curr: return gr.update(), gr.update(value=None), saved, gr.update(visible=False)
454
+ key = comp_get_key(curr)
455
+ new_saved = [c for c in saved if comp_get_key(c) != key]
456
+ save_data(new_saved, COMP_SAVE_FILE)
457
+ return gr.Info("已移除"), comp_format_df(new_saved if mode=="追蹤清單" else res, new_saved), new_saved, gr.update(visible=False)
458
 
459
+ def comp_toggle(mode, res, saved):
460
+ return comp_format_df(res if mode=="搜尋結果" else saved, saved), gr.update(visible=mode=="搜尋結果")
461
+
462
+ # Init
463
+ def prof_init(): d = load_data(PROF_SAVE_FILE); return d, prof_format_df(d, d)
464
+ def comp_init(): d = load_data(COMP_SAVE_FILE); return d, comp_format_df(d, d)
465
+
466
+
467
+ # ==========================
468
+ # 🖥️ UI Layout (Modified)
469
+ # ==========================
470
+ with gr.Blocks(title="Prof.404.Com 產學導航系統", theme=gr.themes.Soft()) as demo:
471
+
472
+ gr.Markdown("""
473
+ <div align="center">
474
+
475
+ # 🚀 Prof.404.Com 產學導航系統 (含 NotebookLM 擴充工具)
476
+ **學術研究啟程、產業導航、以及您的文件處理瑞士刀**
477
+ </div>
478
+ """)
479
+
480
+ with gr.Accordion("🔑 API Key 設定", open=False):
481
+ api_input = gr.Textbox(label="Gemini API Key", placeholder="若未設定環境變數,請在此輸入", type="password")
482
+ api_btn = gr.Button("設定 Key")
483
+ api_btn.click(lambda k: gemini_service.set_user_key(k), inputs=api_input)
484
+
485
+ with gr.Tabs():
486
 
487
+ # ==========================
488
+ # Tab 1: 🛠️ 工具箱 (PDF 智能拆解)
489
+ # ==========================
490
+ with gr.Tab("🛠️ NotebookLM 拆解工具"):
491
+ gr.Markdown("### 📄 PDF 智能拆解 (文字/圖片分離)")
492
+ gr.Markdown("上傳 NotebookLM 生成的 PDF,AI 將自動為您:**1. 提取全文文字** | **2. 移除圖片中的文字(還原背景)**")
 
 
 
 
 
 
 
 
493
 
494
+ with gr.Row():
495
+ with gr.Column(scale=1):
496
+ pdf_input = gr.File(label="上傳 PDF (來自 NotebookLM 或其他)")
497
+ process_btn = gr.Button("🚀 開始一鍵拆解", variant="primary")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
 
499
+ with gr.Column(scale=2):
500
+ zip_output = gr.File(label="📦 下載結果 (含 clean images 與 text)")
501
+ text_preview = gr.Textbox(label="📝 文字內容預覽", lines=10, max_lines=20)
502
+
503
+ gr.Markdown("#### 🖼️ 去字後圖片預覽 (Cleaned Images)")
504
+ gallery_output = gr.Gallery(label="背景還原預覽", columns=4)
505
+
506
+ process_btn.click(
507
+ gemini_service.decompose_pdf,
508
+ inputs=[pdf_input],
509
+ outputs=[zip_output, text_preview, gallery_output]
510
+ )
511
+
512
+ # ==========================
513
+ # Tab 2: 🎓 教授去哪兒? (保留原功能)
514
+ # ==========================
515
+ with gr.Tab("🎓 找教授 (Prof.404)"):
516
+ prof_saved = gr.State([])
517
+ prof_res = gr.State([])
518
+ prof_sel = gr.State(None)
519
+
520
+ with gr.Row():
521
+ p_in = gr.Textbox(label="搜尋教授", placeholder="輸入研究領域...", scale=4)
522
+ p_btn = gr.Button("🔍 搜尋", variant="primary", scale=1)
523
+
524
+ p_view = gr.Radio(["搜尋結果", "追蹤清單"], label="顯示模式", value="追蹤清單")
525
+
526
+ with gr.Row():
527
+ with gr.Column(scale=1):
528
+ p_df = gr.Dataframe(headers=["狀態","姓名","大學","系所","標籤"], datatype=["str","str","str","str","str"], interactive=False)
529
+ p_load = gr.Button("載入更多", visible=False)
 
 
530
 
531
+ with gr.Column(scale=2, visible=False) as p_col:
532
+ p_md = gr.Markdown("...")
533
+ with gr.Column():
534
+ gr.Markdown("### 🤖 學術顧問")
535
+ p_chat = gr.Chatbot(height=250)
536
+ with gr.Row():
537
+ p_msg = gr.Textbox(label="提問", scale=4)
538
+ p_send = gr.Button("送出", scale=1)
539
+ gr.Markdown("---")
540
+ with gr.Column(visible=False) as p_tag_row:
541
+ p_tag_disp = gr.Markdown("標籤: (無)")
542
+ with gr.Row():
543
+ p_tag_in = gr.Textbox(label="新增標籤", scale=3)
544
+ p_tag_add = gr.Button("➕", scale=1)
545
+ with gr.Accordion("刪除標籤", open=False):
546
+ with gr.Row():
547
+ p_tag_drop = gr.Dropdown(label="選擇標籤", choices=[], scale=3)
548
+ p_tag_del = gr.Button("🗑️", scale=1, variant="secondary")
549
+ with gr.Row():
550
+ p_good = gr.Button("✅ 符合")
551
+ p_bad = gr.Button("❌ 不符")
552
+ p_pend = gr.Button("❓ 待觀察")
553
+ p_rem = gr.Button("🗑️ 移除", variant="stop")
554
+
555
+ demo.load(prof_init, None, [prof_saved, p_df])
556
+ p_btn.click(prof_search, [p_in, prof_saved], [p_df, prof_res, p_load]).then(lambda: gr.update(value="搜尋結果"), outputs=[p_view])
557
+ p_load.click(prof_load_more, [p_in, prof_res, prof_saved], [p_df, prof_res])
558
+ p_view.change(prof_toggle, [p_view, prof_res, prof_saved], [p_df, p_load])
559
+ p_df.select(prof_select, [prof_res, prof_saved, p_view], [p_col, p_md, p_chat, prof_sel, prof_saved, p_tag_disp, p_tag_drop, p_tag_row])
560
+ p_send.click(prof_chat, [p_chat, p_msg, prof_sel], [p_chat, p_msg]); p_msg.submit(prof_chat, [p_chat, p_msg, prof_sel], [p_chat, p_msg])
561
+ p_tag_add.click(prof_add_tag, [p_tag_in, prof_sel, prof_saved, p_view, prof_res], [p_tag_in, p_tag_disp, p_tag_drop, prof_saved, p_df])
562
+ p_tag_del.click(prof_remove_tag, [p_tag_drop, prof_sel, prof_saved, p_view, prof_res], [p_tag_disp, p_tag_drop, prof_saved, p_df])
563
+ for btn, s in [(p_good,'match'),(p_bad,'mismatch'),(p_pend,'pending')]: btn.click(prof_update_status, [gr.State(s), prof_sel, prof_saved, p_view, prof_res], [p_df, prof_saved])
564
+ p_rem.click(prof_remove, [prof_sel, prof_saved, p_view, prof_res], [gr.State(None), p_df, prof_saved, p_col])
565
+
566
+ # ==========================
567
+ # Tab 3: 🏢 公司去那兒? (保留原功能)
568
+ # ==========================
569
+ with gr.Tab("🏢 找公司 (Com.404)"):
570
+ comp_saved = gr.State([])
571
+ comp_res = gr.State([])
572
+ comp_sel = gr.State(None)
573
+
574
+ with gr.Row():
575
+ c_in = gr.Textbox(label="搜尋公司/領域", placeholder="輸入產業或公司...", scale=4)
576
+ c_btn = gr.Button("🔍 搜尋", variant="primary", scale=1)
577
+
578
+ c_view = gr.Radio(["搜尋結果", "追蹤清單"], label="顯示模式", value="追蹤清單")
579
+
580
+ with gr.Row():
581
+ with gr.Column(scale=1):
582
+ c_df = gr.Dataframe(headers=["狀態","公司名稱","產業類別","標籤"], datatype=["str","str","str","str"], interactive=False)
583
+ c_load = gr.Button("載入更多", visible=False)
584
 
585
+ with gr.Column(scale=2, visible=False) as c_col:
586
+ c_md = gr.Markdown("...")
587
+ with gr.Column():
588
+ gr.Markdown("### 🤖 商業顧問")
589
+ c_chat = gr.Chatbot(height=250)
590
+ with gr.Row():
591
+ c_msg = gr.Textbox(label="提問", scale=4)
592
+ c_send = gr.Button("送出", scale=1)
593
+ gr.Markdown("---")
594
+ with gr.Column(visible=False) as c_tag_row:
595
+ c_tag_disp = gr.Markdown("標籤: (無)")
596
+ with gr.Row():
597
+ c_tag_in = gr.Textbox(label="新增標籤", scale=3)
598
+ c_tag_add = gr.Button("➕", scale=1)
599
+ with gr.Accordion("刪除標籤", open=False):
600
+ with gr.Row():
601
+ c_tag_drop = gr.Dropdown(label="選擇標籤", choices=[], scale=3)
602
+ c_tag_del = gr.Button("🗑️", scale=1, variant="secondary")
603
+ with gr.Row():
604
+ c_good = gr.Button("✅ 優質")
605
+ c_risk = gr.Button("⚠️ 風險")
606
+ c_pend = gr.Button(" 未定")
607
+ c_rem = gr.Button("🗑️ 移除", variant="stop")
608
+
609
+ demo.load(comp_init, None, [comp_saved, c_df])
610
+ c_btn.click(comp_search, [c_in, comp_saved], [c_df, comp_res, c_load]).then(lambda: gr.update(value="搜尋結果"), outputs=[c_view])
611
+ c_load.click(comp_load_more, [c_in, comp_res, comp_saved], [c_df, comp_res])
612
+ c_view.change(comp_toggle, [c_view, comp_res, comp_saved], [c_df, c_load])
613
+ c_df.select(comp_select, [comp_res, comp_saved, c_view], [c_col, c_md, c_chat, comp_sel, comp_saved, c_tag_disp, c_tag_drop, c_tag_row])
614
+ c_send.click(comp_chat, [c_chat, c_msg, comp_sel], [c_chat, c_msg]); c_msg.submit(comp_chat, [c_chat, c_msg, comp_sel], [c_chat, c_msg])
615
+ c_tag_add.click(comp_add_tag, [c_tag_in, comp_sel, comp_saved, c_view, comp_res], [c_tag_in, c_tag_disp, c_tag_drop, comp_saved, c_df])
616
+ c_tag_del.click(comp_remove_tag, [c_tag_drop, comp_sel, comp_saved, c_view, comp_res], [c_tag_disp, c_tag_drop, comp_saved, c_df])
617
+ for btn, s in [(c_good,'good'),(c_risk,'risk'),(c_pend,'pending')]: btn.click(comp_update_status, [gr.State(s), comp_sel, comp_saved, c_view, comp_res], [c_df, comp_saved])
618
+ c_rem.click(comp_remove, [comp_sel, comp_saved, c_view, comp_res], [gr.State(None), c_df, comp_saved, c_col])
 
619
 
620
  if __name__ == "__main__":
621
+ demo.launch()