DeepLearning101 commited on
Commit
f0e6e15
·
verified ·
1 Parent(s): 7da7a76

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +106 -580
app.py CHANGED
@@ -1,621 +1,147 @@
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"
19
- HF_TOKEN = os.getenv("HF_TOKEN")
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()
 
1
  import gradio as gr
 
2
  import os
 
3
  import tempfile
4
  import zipfile
5
  import shutil
 
 
6
  from pdf2image import convert_from_path
 
 
7
  from PIL import Image
8
+ from dotenv import load_dotenv
9
+
10
+ # 使用 Google 新版 SDK
11
+ from google import genai
12
+ from google.genai import types
13
 
 
14
  load_dotenv()
 
 
 
 
15
 
16
+ class NotebookLMTool:
 
 
 
17
  def __init__(self):
18
+ # 嘗試從環境變數讀取 Key
19
+ self.api_key = os.getenv("GEMINI_API_KEY")
20
+ self.client = None
21
  if self.api_key:
22
+ self.client = genai.Client(api_key=self.api_key)
23
+
24
+ def set_key(self, user_key):
25
+ """讓使用者從介面設定 Key"""
26
+ if user_key and user_key.strip():
27
+ self.api_key = user_key.strip()
28
+ self.client = genai.Client(api_key=self.api_key)
29
+ return "✅ API Key 已更新!"
30
+ return "⚠️ Key 無效"
31
+
32
+ def process_pdf(self, pdf_file, progress=gr.Progress()):
33
+ if not self.client:
34
+ raise ValueError("請先輸入 Google API Key!")
35
+
36
+ if pdf_file is None:
37
+ return None, None, None
 
 
 
 
 
 
 
38
 
39
+ # 1. 準備暫存目錄
40
+ temp_dir = tempfile.mkdtemp()
41
+ img_output_dir = os.path.join(temp_dir, "cleaned_images")
42
+ os.makedirs(img_output_dir, exist_ok=True)
43
+
44
+ # 2. PDF 轉圖片
45
  progress(0.1, desc="正在將 PDF 轉為圖片...")
46
  try:
47
  images = convert_from_path(pdf_file)
48
  except Exception as e:
49
+ raise ValueError(f"PDF 轉換失敗 (請確認 packages.txt 有加入 poppler-utils): {str(e)}")
50
 
51
+ full_text = ""
52
+ cleaned_images_paths = []
53
+ gallery_preview = []
 
 
 
 
 
54
 
55
+ # 3. 逐頁處理
56
  for i, img in enumerate(images):
57
+ progress(0.1 + (0.8 * (i / len(images))), desc=f"AI 正在處理第 {i+1}/{len(images)} 頁...")
58
 
59
+ # --- 步驟 A: 提取文字 (OCR) ---
60
  try:
61
+ # 使用 Gemini 2.0 Flash 提取文字
62
+ response_text = self.client.models.generate_content(
63
+ model="gemini-2.0-flash",
64
+ contents=["Extract all text from this image directly. Do not describe the layout, just give me the text content.", img]
65
+ )
66
+ page_content = response_text.text if response_text.text else "[No Text Found]"
67
+ except Exception as e:
68
+ page_content = f"[OCR Error: {e}]"
69
 
70
+ full_text += f"=== Page {i+1} ===\n{page_content}\n\n"
71
 
72
+ # --- 步驟 B: 圖片去字 (Clean) ---
73
+ # 注意:Gemini 2.0 直接回傳 Image 的支援度視 prompt 而定,
74
+ # 這裡我們使用 prompt 讓它嘗試還原背景。
75
  try:
76
+ response_clean = self.client.models.generate_content(
77
+ model="gemini-2.0-flash",
78
+ contents=["Remove all text from this image and fill in the background to make it look like a clean slide background. Return the image.", img],
79
+ config=types.GenerateContentConfig(response_mime_type="image/png")
80
+ )
 
 
 
81
 
82
+ # 處理回傳的圖片 (Binary)
83
+ if response_clean.bytes:
84
+ saved_path = os.path.join(img_output_dir, f"slide_{i+1:02d}.png")
85
+ with open(saved_path, "wb") as f:
86
+ f.write(response_clean.bytes)
87
+ cleaned_images_paths.append(saved_path)
88
+ gallery_preview.append((saved_path, f"Page {i+1}"))
89
+ else:
90
+ # 如果 AI 拒絕生成圖片,我們保留原圖但標記失敗
91
+ print(f"Page {i+1}: Model did not return an image.")
92
  except Exception as e:
93
+ print(f"Clean Error Page {i+1}: {e}")
94
+
95
+ # 4. 打包結果
96
+ progress(0.9, desc="正在打包 ZIP...")
 
97
 
98
+ # 寫入文字檔
99
+ txt_path = os.path.join(temp_dir, "extracted_text.txt")
100
  with open(txt_path, "w", encoding="utf-8") as f:
101
+ f.write(full_text)
102
 
103
+ # 壓縮
104
+ zip_path = os.path.join(temp_dir, "notebooklm_clean_pack.zip")
105
  with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
106
+ zf.write(txt_path, "all_text.txt")
107
+ for img_path in cleaned_images_paths:
108
+ zf.write(img_path, os.path.join("cleaned_slides", os.path.basename(img_path)))
 
109
 
110
+ return zip_path, full_text, gallery_preview
111
 
112
+ # 初始化工具
113
+ tool = NotebookLMTool()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
+ # --- Gradio 介面 ---
116
+ with gr.Blocks(title="NotebookLM Slide Decomposer", theme=gr.themes.Soft()) as demo:
117
+ gr.Markdown("# 🛠️ NotebookLM 投影片拆解助手")
118
+ gr.Markdown("上傳 PDF,AI 自動幫你:**1. 抓出所有文字** | **2. 移除文字還原乾淨背景圖**")
 
 
 
119
 
120
+ with gr.Row():
121
+ with gr.Column():
122
+ api_input = gr.Textbox(label="Google API Key", type="password", placeholder="貼上你的 Gemini API Key")
123
+ btn_set_key = gr.Button("設定 Key")
124
+ status_msg = gr.Markdown("")
 
 
 
 
 
 
125
 
126
+ gr.Markdown("---")
127
+ pdf_input = gr.File(label="上傳 PDF")
128
+ btn_process = gr.Button("🚀 開始拆解", variant="primary")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
 
130
+ with gr.Column():
131
+ out_zip = gr.File(label="📦 下載懶人包 (ZIP)")
132
+ out_text = gr.Textbox(label="📝 文字內容預覽", lines=8)
133
+
134
+ gr.Markdown("### 🖼️ 背景還原預覽")
135
+ out_gallery = gr.Gallery(columns=4)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
+ # 事件綁定
138
+ btn_set_key.click(tool.set_key, inputs=api_input, outputs=status_msg)
139
+
140
+ btn_process.click(
141
+ tool.process_pdf,
142
+ inputs=[pdf_input],
143
+ outputs=[out_zip, out_text, out_gallery]
144
+ )
 
 
145
 
146
  if __name__ == "__main__":
147
  demo.launch()