Zoey7Web commited on
Commit
5dda3f0
·
verified ·
1 Parent(s): be5b02b

Upload 13 files

Browse files
Files changed (2) hide show
  1. app.py +692 -673
  2. requirements.txt +3 -0
app.py CHANGED
@@ -1,673 +1,692 @@
1
- """
2
- 邮件智能分类与 Google Sheets 自动回填系统
3
- Gradio 前端界面(含配置面板)
4
-
5
- 配置面板功能:
6
- - LLM API 密钥输入(OpenAI / DeepSeek 切换)
7
- - Google Sheets ID、工作表名称、Service Account JSON 配置
8
- - 列映射、模糊匹配参数高级选项
9
- - 连接测试(LLM / Sheets 分别测试)
10
- - 配置持久化保存至 ~/.wellfound/config.json
11
- - 启动时自动加载已保存配置
12
- """
13
- import asyncio
14
- import json
15
- import logging
16
- import os
17
- import sys
18
- import threading
19
- from datetime import datetime
20
- from pathlib import Path
21
- from typing import Optional
22
-
23
- import gradio as gr
24
-
25
- sys.path.insert(0, str(Path(__file__).parent))
26
-
27
- from src import config as cfg
28
- from src.processor import (
29
- EmailProcessor, EmailProcessResult,
30
- STATUS_DONE, STATUS_AMBIGUOUS, STATUS_NOT_FOUND,
31
- STATUS_PARSE_ERROR, STATUS_AI_ERROR, STATUS_WRITE_ERROR, STATUS_SKIPPED
32
- )
33
- from src.sheets_updater import get_sheets_updater, reset_sheets_updater
34
-
35
- logging.basicConfig(
36
- level=logging.INFO,
37
- format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
38
- datefmt="%H:%M:%S",
39
- )
40
- logger = logging.getLogger(__name__)
41
-
42
- # ---- 全局状态 ----
43
- _processing_lock = threading.Lock()
44
- _all_results: list = []
45
- _pending_confirmations: list = []
46
- _log_lines: list = []
47
-
48
-
49
- def _log(msg: str, level: str = "INFO"):
50
- ts = datetime.now().strftime("%H:%M:%S")
51
- line = f"[{ts}] [{level}] {msg}"
52
- _log_lines.append(line)
53
- if len(_log_lines) > 200:
54
- _log_lines.pop(0)
55
- (logger.error if level == "ERROR" else
56
- logger.warning if level == "WARN" else logger.info)(msg)
57
-
58
-
59
- def _get_log_text() -> str:
60
- return "\n".join(_log_lines[-50:]) if _log_lines else "(暂无日志)"
61
-
62
-
63
- def _results_to_table(results: list) -> list:
64
- return [r.to_table_row for r in results]
65
-
66
-
67
- def _get_status_icon(status: str) -> str:
68
- if STATUS_DONE in status:
69
- return "✅"
70
- if STATUS_AMBIGUOUS in status:
71
- return "⚠️"
72
- if "❌" in status:
73
- return "❌"
74
- if STATUS_SKIPPED in status:
75
- return "⏭️"
76
- return "🔄"
77
-
78
-
79
- # ========== 配置面板回调 ==========
80
-
81
- def _format_sa_json_for_display(raw) -> str:
82
- if not raw:
83
- return ""
84
- if isinstance(raw, dict):
85
- return json.dumps(raw, indent=2, ensure_ascii=False)
86
- if isinstance(raw, str):
87
- raw = raw.strip()
88
- if not raw:
89
- return ""
90
- try:
91
- return json.dumps(json.loads(raw), indent=2, ensure_ascii=False)
92
- except json.JSONDecodeError:
93
- return raw
94
- return str(raw)
95
-
96
-
97
- def load_saved_config() -> list:
98
- """启动时加载持久化配置,回填到界面组件。"""
99
- d = cfg.get_config_dict()
100
- _log("已加载持久化配置")
101
- return [
102
- d.get("llm_provider", "deepseek"),
103
- d.get("openai_api_key", ""),
104
- d.get("openai_model", "gpt-4o-mini"),
105
- d.get("deepseek_api_key", ""),
106
- d.get("deepseek_model", "deepseek-chat"),
107
- d.get("google_sheet_id", ""),
108
- d.get("google_sheet_name", "West"),
109
- _format_sa_json_for_display(d.get("google_service_account_json", "")),
110
- d.get("sheet_company_col", "C"),
111
- d.get("sheet_email_type_col", "AA"),
112
- d.get("sheet_email_body_col", "AB"),
113
- d.get("sheet_email_date_col", "AC"),
114
- d.get("sheet_timestamp_col", "AD"),
115
- int(d.get("fuzzy_match_threshold", 85)),
116
- float(d.get("fuzzy_ambiguous_gap", 3)),
117
- ]
118
-
119
-
120
- def save_settings(
121
- provider, openai_key, openai_model,
122
- deepseek_key, deepseek_model,
123
- sheet_id, sheet_name, sa_json_str,
124
- col_company, col_type, col_body, col_date, col_ts,
125
- fuzzy_thresh, fuzzy_gap,
126
- ) -> str:
127
- """保存配置到文件并同步到内存。"""
128
- sa_parsed = _parse_sa_json(sa_json_str)
129
-
130
- config_dict = {
131
- "llm_provider": provider,
132
- "openai_api_key": openai_key or "",
133
- "openai_model": openai_model or "gpt-4o-mini",
134
- "deepseek_api_key": deepseek_key or "",
135
- "deepseek_model": deepseek_model or "deepseek-chat",
136
- "google_sheet_id": sheet_id or "",
137
- "google_sheet_name": sheet_name or "West",
138
- "google_service_account_json": sa_parsed,
139
- "sheet_company_col": col_company or "C",
140
- "sheet_email_type_col": col_type or "AA",
141
- "sheet_email_body_col": col_body or "AB",
142
- "sheet_email_date_col": col_date or "AC",
143
- "sheet_timestamp_col": col_ts or "AD",
144
- "fuzzy_match_threshold": int(fuzzy_thresh or 85),
145
- "fuzzy_ambiguous_gap": float(fuzzy_gap or 3),
146
- }
147
-
148
- success, msg = cfg.save_config(config_dict)
149
- reset_sheets_updater()
150
- _log(f"配置已保存:{msg}")
151
- return msg
152
-
153
-
154
- def _parse_sa_json(s: str):
155
- """解析 Service Account JSON 输入。"""
156
- if not s or not s.strip():
157
- return ""
158
- s = s.strip()
159
- try:
160
- return json.loads(s)
161
- except json.JSONDecodeError:
162
- return s # 原样返回,save_config 会报错
163
-
164
-
165
- def test_llm(provider, openai_key, openai_model,
166
- deepseek_key, deepseek_model) -> str:
167
- """测试 LLM API 连接。"""
168
- import asyncio
169
-
170
- if provider == "deepseek":
171
- api_key = deepseek_key or ""
172
- model = deepseek_model or "deepseek-chat"
173
- base_url = "https://api.deepseek.com"
174
- label = "DeepSeek"
175
- else:
176
- api_key = openai_key or ""
177
- model = openai_model or "gpt-4o-mini"
178
- base_url = None
179
- label = "OpenAI"
180
-
181
- if not api_key:
182
- return f"❌ 请先填写 {label} API Key"
183
-
184
- async def _run():
185
- try:
186
- from openai import AsyncOpenAI
187
- kwargs = {"api_key": api_key}
188
- if base_url:
189
- kwargs["base_url"] = base_url
190
- client = AsyncOpenAI(**kwargs)
191
- resp = await client.chat.completions.create(
192
- model=model,
193
- messages=[{"role": "user", "content": "Reply with just: OK"}],
194
- max_tokens=10,
195
- timeout=15,
196
- )
197
- content = resp.choices[0].message.content
198
- return f"✅ {label} API 连接成功!\n模型:{model}\n响应:{content}"
199
- except Exception as e:
200
- return f"❌ {label} API 连接失败:\n{str(e)}"
201
-
202
- try:
203
- return asyncio.run(_run())
204
- except RuntimeError:
205
- loop = asyncio.new_event_loop()
206
- result = loop.run_until_complete(_run())
207
- loop.close()
208
- return result
209
-
210
-
211
- def test_sheets(sheet_id, sheet_name, sa_json_str) -> str:
212
- """测试 Google Sheets 连接。"""
213
- if not sheet_id:
214
- return "❌ 请先填写 Google Sheet ID"
215
-
216
- sa_info = _parse_sa_json(sa_json_str)
217
- if not sa_info or (isinstance(sa_info, str) and not sa_info.startswith("{")):
218
- return "❌ Service Account JSON 格式错误,请检查粘贴内容"
219
-
220
- try:
221
- import gspread
222
- from google.oauth2.service_account import Credentials
223
-
224
- SCOPES = [
225
- "https://www.googleapis.com/auth/spreadsheets",
226
- "https://www.googleapis.com/auth/drive.file",
227
- ]
228
- creds = Credentials.from_service_account_info(sa_info, scopes=SCOPES)
229
- client = gspread.authorize(creds)
230
- spreadsheet = client.open_by_key(sheet_id.strip())
231
-
232
- try:
233
- ws = spreadsheet.worksheet(sheet_name.strip())
234
- col_idx = cfg._col_letter_to_index("C") + 1
235
- col_values = ws.col_values(col_idx)
236
- company_count = sum(1 for v in col_values[1:] if v.strip())
237
-
238
- return (
239
- f"✅ Google Sheets 连接成功!\n"
240
- f"表格:{spreadsheet.title}\n"
241
- f"工作表:{sheet_name}\n"
242
- f"总行数:{ws.row_count}\n"
243
- f"C列公司名数量:{company_count}"
244
- )
245
- except Exception:
246
- names = [s.title for s in spreadsheet.worksheets()]
247
- return (
248
- f"✅ 表格连接成功,但工作表 '{sheet_name}' 不存在\n"
249
- f"可用工作表:{names}"
250
- )
251
-
252
- except Exception as e:
253
- return f"❌ Google Sheets 连接失败:\n{str(e)}"
254
-
255
-
256
- # ========== 邮件处理回调 ==========
257
-
258
- def process_emails(files, progress=gr.Progress()) -> tuple:
259
- global _all_results, _pending_confirmations, _log_lines
260
-
261
- if not files:
262
- return [], [], _get_log_text(), "⚠️ 请先上传 .msg 文件", gr.update(visible=False)
263
-
264
- if not _processing_lock.acquire(blocking=False):
265
- return (_results_to_table(_all_results), [],
266
- _get_log_text(), "⚠️ 处理中,请稍候...", gr.update(visible=False))
267
-
268
- try:
269
- _all_results = []
270
- _pending_confirmations = []
271
- _log_lines = []
272
- file_paths = [f.name if hasattr(f, 'name') else f for f in files]
273
- total = len(file_paths)
274
- _log(f"开始处理 {total} 封邮件...")
275
-
276
- try:
277
- sheets_updater = get_sheets_updater(auto_init=True)
278
- sheets_ok = sheets_updater.is_connected()
279
- except Exception:
280
- sheets_updater = None
281
- sheets_ok = False
282
-
283
- if not sheets_ok:
284
- _log("⚠️ Google Sheets 未连接,将仅展示 AI 分析结果", "WARN")
285
-
286
- processor = EmailProcessor(sheets_updater=sheets_updater if sheets_ok else None)
287
-
288
- results_list: list = []
289
- pending_list: list = []
290
-
291
- async def _run():
292
- sem = asyncio.Semaphore(cfg.get_max_concurrent())
293
- done = [0]
294
-
295
- async def _one(fp):
296
- async with sem:
297
- r = await processor.process_single_email(fp)
298
- done[0] += 1
299
- results_list.append(r)
300
- if r.needs_confirmation:
301
- pending_list.append(r)
302
- icon = _get_status_icon(r.status)
303
- _log(f"[{done[0]}/{total}] {icon} {r.filename} -> {r.company_name} [{r.category}] {r.status}")
304
- progress(done[0] / total, desc=f"处理中 {done[0]}/{total}")
305
-
306
- await asyncio.gather(*[_one(fp) for fp in file_paths])
307
-
308
- try:
309
- asyncio.run(_run())
310
- except RuntimeError:
311
- loop = asyncio.new_event_loop()
312
- loop.run_until_complete(_run())
313
- loop.close()
314
-
315
- _all_results = results_list
316
- _pending_confirmations = pending_list
317
-
318
- done = sum(1 for r in results_list if STATUS_DONE in r.status)
319
- skipped = sum(1 for r in results_list if STATUS_SKIPPED in r.status)
320
- ambiguous = len(pending_list)
321
- errors = sum(1 for r in results_list if "❌" in r.status)
322
-
323
- summary = f"✅ 处理完成 | 成功:{done} | 跳过:{skipped} | 需确认:{ambiguous} | 失败:{errors} | 共 {total} 封"
324
- _log(summary)
325
-
326
- return (
327
- _results_to_table(results_list),
328
- _build_pending_table(pending_list),
329
- _get_log_text(),
330
- summary,
331
- gr.update(visible=ambiguous > 0),
332
- )
333
-
334
- except Exception as e:
335
- _log(f"处理异常:{e}", "ERROR")
336
- import traceback
337
- traceback.print_exc()
338
- return (_results_to_table(_all_results), [],
339
- _get_log_text(), f"❌ 处理失败:{str(e)}", gr.update(visible=False))
340
- finally:
341
- _processing_lock.release()
342
-
343
-
344
- def _build_pending_table(pending: list) -> list:
345
- rows = []
346
- for i, r in enumerate(pending):
347
- cand_str = " | ".join(f"{c['name']}({c['score']:.0f}%)" for c in r.candidates[:3])
348
- rows.append([i, r.filename, r.company_name, cand_str, r.category, r.date_str])
349
- return rows
350
-
351
-
352
- def confirm_selection(pending_table_data, sel_idx, sel_company) -> tuple:
353
- global _pending_confirmations, _all_results
354
-
355
- if sel_idx < 0 or sel_idx >= len(_pending_confirmations):
356
- return (_results_to_table(_all_results), _build_pending_table(_pending_confirmations),
357
- _get_log_text(), "⚠️ 请选择有效的待确认项")
358
-
359
- result = _pending_confirmations[sel_idx]
360
- confirmed_row = -1
361
- for c in result.candidates:
362
- if c["name"] == sel_company:
363
- confirmed_row = c["row"]
364
- break
365
-
366
- if confirmed_row < 0:
367
- return (_results_to_table(_all_results), _build_pending_table(_pending_confirmations),
368
- _get_log_text(), f"⚠️ 未找到所选公司 '{sel_company}' 的行号")
369
-
370
- try:
371
- updater = get_sheets_updater(auto_init=False)
372
- if not updater.is_connected():
373
- return (_results_to_table(_all_results), _build_pending_table(_pending_confirmations),
374
- _get_log_text(), "❌ Google Sheets 未连接,无法写入")
375
-
376
- processor = EmailProcessor(sheets_updater=updater)
377
- wr = processor.confirm_and_write(result, confirmed_row, sel_company)
378
-
379
- if wr.success:
380
- _log(f"✅ 人工确认写入成功:{result.filename} -> {sel_company} (第{confirmed_row}行)")
381
- _pending_confirmations.remove(result)
382
- else:
383
- _log(f"❌ 写入失败:{wr.error}", "ERROR")
384
-
385
- return (
386
- _results_to_table(_all_results),
387
- _build_pending_table(_pending_confirmations),
388
- _get_log_text(),
389
- f"✅ 已确认写入:{result.company_name} -> {sel_company}" if wr.success
390
- else f"❌ 写入失败:{wr.error}",
391
- )
392
-
393
- except Exception as e:
394
- _log(f"确认写入异常:{e}", "ERROR")
395
- return (_results_to_table(_all_results), _build_pending_table(_pending_confirmations),
396
- _get_log_text(), f"❌ 写入异常:{str(e)}")
397
-
398
-
399
- def get_candidate_choices(sel_idx) -> gr.update:
400
- idx = int(sel_idx) if sel_idx is not None else -1
401
- if idx < 0 or idx >= len(_pending_confirmations):
402
- return gr.update(choices=[], value=None)
403
- result = _pending_confirmations[idx]
404
- choices = [c["name"] for c in result.candidates]
405
- return gr.update(choices=choices, value=choices[0] if choices else None)
406
-
407
-
408
- # ========== Gradio UI ==========
409
-
410
- def build_ui() -> gr.Blocks:
411
- with gr.Blocks(
412
- title="📧 邮件智能分类 & Sheets 回填",
413
- theme=gr.themes.Soft(primary_hue="blue", secondary_hue="slate"),
414
- css="""
415
- .status-bar { background:#eff6ff; border-left:4px solid #3b82f6; padding:8px 16px; border-radius:6px; font-weight:500; margin-bottom:8px; }
416
- .settings-panel { background:#f8fafc; border:1px solid #e2e8f0; border-radius:10px; padding:16px; margin-bottom:12px; }
417
- .pending-section { border:2px solid #f59e0b; border-radius:10px; padding:16px; background:#fffbeb; margin-top:12px; }
418
- .log-box { font-family:'Courier New',monospace; font-size:12px; background:#1e1e2e; color:#cdd6f4; }
419
- """
420
- ) as demo:
421
-
422
- # ---- 标题 ----
423
- gr.Markdown("""
424
- # 📧 邮件智能分类 & Google Sheets 自动回填
425
- > 上传招聘回复邮件(.msg),AI 自动分类、提取公司信息并回填到 Google Sheets
426
- """)
427
-
428
- # ========== 设置面板 ==========
429
- with gr.Accordion("⚙️ 设置面板(点击展开配置 API 密钥和 Google Sheets)", open=False):
430
- with gr.Group(elem_classes=["settings-panel"]):
431
-
432
- # ---- LLM 配置 ----
433
- gr.Markdown("### 🤖 LLM API 配置")
434
-
435
- with gr.Row():
436
- llm_provider = gr.Dropdown(
437
- label="LLM 提供商", choices=["deepseek", "openai"],
438
- value="deepseek", info="选择使用的 LLM 服务"
439
- )
440
- llm_test_btn = gr.Button("🔌 测试 LLM 连接", size="sm")
441
-
442
- llm_test_result = gr.Textbox(label="LLM 连接测试结果", interactive=False, lines=3, visible=False)
443
-
444
- with gr.Row(visible=True) as openai_row:
445
- openai_api_key = gr.Textbox(label="OpenAI API Key", type="password", placeholder="sk-...")
446
- openai_model = gr.Textbox(label="OpenAI 模型", value="gpt-4o-mini", placeholder="gpt-4o-mini")
447
-
448
- with gr.Row(visible=True) as deepseek_row:
449
- deepseek_api_key = gr.Textbox(label="DeepSeek API Key", type="password", placeholder="sk-...")
450
- deepseek_model = gr.Textbox(label="DeepSeek 模型", value="deepseek-chat", placeholder="deepseek-chat")
451
-
452
- # JS:切换提供商时显示对应输入行
453
- llm_provider.change(
454
- fn=None, inputs=[llm_provider],
455
- outputs=[openai_row, deepseek_row],
456
- js="(p)=> [p==='openai'?'visible':'hidden', p==='deepseek'?'visible':'hidden']"
457
- )
458
-
459
- # ---- Google Sheets 配置 ----
460
- gr.Markdown("### 📊 Google Sheets 配置")
461
-
462
- with gr.Row():
463
- sheet_id = gr.Textbox(label="Google Sheet ID",
464
- placeholder="从 URL 中复制 /d/ 和 /edit 之间的部分",
465
- info="https://docs.google.com/spreadsheets/d/[此处是ID]/edit")
466
- sheet_name = gr.Textbox(label="工作表名称", value="West",
467
- info="根据 Wellfound.xlsx 结构,数据在 West 工作表")
468
-
469
- sa_json = gr.Textbox(
470
- label="Google Service Account JSON(粘贴完整内容)",
471
- lines=5,
472
- info="⚠️ 敏感信息仅保存在本地 ~/.wellfound/config.json"
473
- )
474
-
475
- with gr.Row():
476
- sheets_test_btn = gr.Button("📊 测试 Google Sheets 连接", size="sm")
477
- sheets_reload_btn = gr.Button("📥 重新加载公司列表", size="sm", variant="secondary")
478
-
479
- sheets_test_result = gr.Textbox(label="Sheets 连接测试结果", interactive=False, lines=4, visible=False)
480
-
481
- # ---- 高级选项 ----
482
- with gr.Accordion("高级选项(可选)", open=False):
483
- gr.Markdown("#### 列映射(根据 Wellfound.xlsx 结构已预设)")
484
- with gr.Row():
485
- col_company = gr.Textbox(label="公司名列(C)", value="C", scale=1)
486
- col_type = gr.Textbox(label="邮件类型列(AA)", value="AA", scale=1)
487
- col_body = gr.Textbox(label="邮件正文列(AB)", value="AB", scale=1)
488
- with gr.Row():
489
- col_date = gr.Textbox(label="邮件日期列(AC)", value="AC", scale=1)
490
- col_ts = gr.Textbox(label="时间戳列(AD)", value="AD", scale=1)
491
-
492
- gr.Markdown("#### 模糊匹配参数")
493
- with gr.Row():
494
- fuzzy_thresh = gr.Slider(label="模糊匹配阈值(%)", minimum=50, maximum=100, value=85, step=1)
495
- fuzzy_gap = gr.Slider(label="歧义间隔(%)", minimum=0, maximum=10, value=3, step=0.5)
496
-
497
- # ---- 保存 / 重置 ----
498
- with gr.Row():
499
- save_btn = gr.Button("💾 保存配置", variant="primary", size="lg")
500
- reset_btn = gr.Button("🔄 重置为默认值", size="sm")
501
-
502
- save_result = gr.Textbox(label="保存结果", interactive=False, visible=False)
503
-
504
- # ---- 回调绑定:设置面板 ----
505
- save_btn.click(
506
- fn=save_settings,
507
- inputs=[
508
- llm_provider, openai_api_key, openai_model,
509
- deepseek_api_key, deepseek_model,
510
- sheet_id, sheet_name, sa_json,
511
- col_company, col_type, col_body, col_date, col_ts,
512
- fuzzy_thresh, fuzzy_gap,
513
- ],
514
- outputs=[save_result],
515
- ).then(
516
- fn=lambda s: gr.update(visible=bool(s)),
517
- inputs=[save_result],
518
- outputs=[save_result],
519
- )
520
-
521
- llm_test_btn.click(
522
- fn=test_llm,
523
- inputs=[llm_provider, openai_api_key, openai_model,
524
- deepseek_api_key, deepseek_model],
525
- outputs=[llm_test_result],
526
- ).then(
527
- fn=lambda: gr.update(visible=True),
528
- outputs=[llm_test_result],
529
- )
530
-
531
- sheets_test_btn.click(
532
- fn=test_sheets,
533
- inputs=[sheet_id, sheet_name, sa_json],
534
- outputs=[sheets_test_result],
535
- ).then(
536
- fn=lambda: gr.update(visible=True),
537
- outputs=[sheets_test_result],
538
- )
539
-
540
- sheets_reload_btn.click(
541
- fn=lambda: (reset_sheets_updater(), "✅ 已重置连接,下次处理时重新初始化")[1],
542
- outputs=[sheets_test_result],
543
- ).then(
544
- fn=lambda: gr.update(visible=True),
545
- outputs=[sheets_test_result],
546
- )
547
-
548
- reset_btn.click(
549
- fn=lambda: (
550
- "deepseek", "", "gpt-4o-mini",
551
- "", "deepseek-chat",
552
- "", "West", "",
553
- "C", "AA", "AB", "AC", "AD",
554
- 85, 3.0,
555
- ),
556
- outputs=[
557
- llm_provider, openai_api_key, openai_model,
558
- deepseek_api_key, deepseek_model,
559
- sheet_id, sheet_name, sa_json,
560
- col_company, col_type, col_body, col_date, col_ts,
561
- fuzzy_thresh, fuzzy_gap,
562
- ],
563
- )
564
-
565
- # ========== 主操作区 ==========
566
- gr.Markdown("---")
567
-
568
- with gr.Row():
569
- with gr.Column(scale=3):
570
- file_input = gr.File(
571
- label="📁 上传邮件文件(.msg)",
572
- file_types=[".msg"], file_count="multiple", height=120,
573
- )
574
- with gr.Row():
575
- process_btn = gr.Button("🚀 开始处理", variant="primary", size="lg")
576
- clear_btn = gr.Button("🗑️ 清空", variant="secondary", size="lg")
577
-
578
- with gr.Column(scale=2):
579
- gr.Markdown("""
580
- **📋 操作说明**
581
- 1. 先在上方 **⚙️ 设置面板** 配置 API 密钥和 Sheets
582
- 2. 将 `.msg` 文件拖入上传区(支持多选)
583
- 3. 点击 **🚀 开始处理** 启动 AI 分析
584
- 4. 处理完成后在结果表格查看状态
585
- 5. 若有模糊匹配,在下方 **待确认区** 手动选择
586
- """)
587
-
588
- status_text = gr.Textbox(label="处理状态", value="⚠️ 请先在设置面板配置 API 密钥",
589
- interactive=False, elem_classes=["status-bar"])
590
-
591
- gr.Markdown("---")
592
- gr.Markdown("### 📊 处理结果")
593
-
594
- results_table = gr.Dataframe(
595
- headers=["文件名", "发件人", "日期", "公司名", "分类", "邮件摘要", "状态", "备注"],
596
- datatype=["str"] * 8, wrap=True, interactive=False,
597
- )
598
-
599
- gr.Markdown("---")
600
- gr.Markdown("### 📝 处理日志")
601
-
602
- log_display = gr.Textbox(label="日志输出", value="(暂无日志)",
603
- lines=10, interactive=False, max_lines=15, elem_classes=["log-box"])
604
-
605
- # ========== 待确认区 ==========
606
- with gr.Group(elem_classes=["pending-section"]) as pending_section:
607
- gr.Markdown("### ⚠️ 待人工确认项(模糊匹配)")
608
- gr.Markdown("以下邮件的公司名存在多个相似匹配,请人工选择正确的公司后点击确认回填。")
609
-
610
- pending_table = gr.Dataframe(
611
- headers=["序号", "文件名", "AI提取公司", "候选公司列表", "分类", "日期"],
612
- datatype=["number", "str", "str", "str", "str", "str"],
613
- interactive=False,
614
- )
615
-
616
- with gr.Row():
617
- with gr.Column(scale=1):
618
- pending_idx = gr.Number(label="选择待确认项序号(从0开始)", value=0, precision=0, minimum=0)
619
- with gr.Column(scale=2):
620
- candidate_dropdown = gr.Dropdown(label="选择正确的公司", choices=[], value=None, interactive=True)
621
- with gr.Column(scale=1):
622
- confirm_btn = gr.Button("✅ 确认回填", variant="primary")
623
-
624
- confirm_status = gr.Textbox(label="确认结果", interactive=False)
625
-
626
- pending_idx.change(fn=get_candidate_choices, inputs=[pending_idx], outputs=[candidate_dropdown])
627
-
628
- confirm_btn.click(
629
- fn=confirm_selection,
630
- inputs=[pending_table, pending_idx, candidate_dropdown],
631
- outputs=[results_table, pending_table, log_display, confirm_status],
632
- )
633
-
634
- # ---- 主区事件绑定 ----
635
- process_btn.click(
636
- fn=process_emails, inputs=[file_input],
637
- outputs=[results_table, pending_table, log_display, status_text, pending_section],
638
- show_progress=True,
639
- )
640
-
641
- def clear_all():
642
- global _all_results, _pending_confirmations, _log_lines
643
- _all_results = []
644
- _pending_confirmations = []
645
- _log_lines = []
646
- return (None, [], [], "等待上传文件...", "(已清空)", gr.update(visible=False))
647
-
648
- clear_btn.click(
649
- fn=clear_all,
650
- outputs=[file_input, results_table, pending_table,
651
- status_text, log_display, pending_section],
652
- )
653
-
654
- # ---- 启动时自动加载配置 ----
655
- demo.load(fn=load_saved_config, outputs=[
656
- llm_provider, openai_api_key, openai_model,
657
- deepseek_api_key, deepseek_model,
658
- sheet_id, sheet_name, sa_json,
659
- col_company, col_type, col_body, col_date, col_ts,
660
- fuzzy_thresh, fuzzy_gap,
661
- ])
662
-
663
- return demo
664
-
665
-
666
- if __name__ == "__main__":
667
- app = build_ui()
668
- app.launch(
669
- server_name="0.0.0.0",
670
- server_port=int(os.getenv("PORT", 7860)),
671
- show_error=True,
672
- debug=os.getenv("DEBUG", "false").lower() == "true",
673
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ 邮件智能处理系统
3
+ Gradio 前端界面 — 双标签页架构
4
+
5
+ 标签页 1:📧 邮件智能分类 & Google Sheets 自动回填
6
+ - .msg 文件上传 → AI 分类 → 模糊匹配 → Sheets 写入
7
+ - LLM API 配置(OpenAI / DeepSeek 切换)
8
+ - Google Sheets 配置(Service Account + Sheet ID)
9
+ - 配置持久化保存至 ~/.wellfound/config.json
10
+ - 人工确认区(模糊匹配歧义处理)
11
+
12
+ 标签页 2:📦 PST 邮件管理
13
+ - PST/OST 文件加载与解析
14
+ - 邮件浏览、搜索、过滤
15
+ - EML / MBOX / MSG 格式导出
16
+ - 数据统计与详情预览
17
+ """
18
+ import asyncio
19
+ import json
20
+ import logging
21
+ import os
22
+ import sys
23
+ import threading
24
+ from datetime import datetime
25
+ from pathlib import Path
26
+ from typing import Optional
27
+
28
+ import gradio as gr
29
+
30
+ sys.path.insert(0, str(Path(__file__).parent))
31
+
32
+ from src import config as cfg
33
+ from src.processor import (
34
+ EmailProcessor, EmailProcessResult,
35
+ STATUS_DONE, STATUS_AMBIGUOUS, STATUS_NOT_FOUND,
36
+ STATUS_PARSE_ERROR, STATUS_AI_ERROR, STATUS_WRITE_ERROR, STATUS_SKIPPED
37
+ )
38
+ from src.sheets_updater import get_sheets_updater, reset_sheets_updater
39
+
40
+ # PST 邮件管理系统
41
+ from pst_system.pst_app import build_pst_ui
42
+
43
+ logging.basicConfig(
44
+ level=logging.INFO,
45
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
46
+ datefmt="%H:%M:%S",
47
+ )
48
+ logger = logging.getLogger(__name__)
49
+
50
+ # ---- 全局状态 ----
51
+ _processing_lock = threading.Lock()
52
+ _all_results: list = []
53
+ _pending_confirmations: list = []
54
+ _log_lines: list = []
55
+
56
+
57
+ def _log(msg: str, level: str = "INFO"):
58
+ ts = datetime.now().strftime("%H:%M:%S")
59
+ line = f"[{ts}] [{level}] {msg}"
60
+ _log_lines.append(line)
61
+ if len(_log_lines) > 200:
62
+ _log_lines.pop(0)
63
+ (logger.error if level == "ERROR" else
64
+ logger.warning if level == "WARN" else logger.info)(msg)
65
+
66
+
67
+ def _get_log_text() -> str:
68
+ return "\n".join(_log_lines[-50:]) if _log_lines else "(暂无日志)"
69
+
70
+
71
+ def _results_to_table(results: list) -> list:
72
+ return [r.to_table_row for r in results]
73
+
74
+
75
+ def _get_status_icon(status: str) -> str:
76
+ if STATUS_DONE in status:
77
+ return "✅"
78
+ if STATUS_AMBIGUOUS in status:
79
+ return "⚠️"
80
+ if "❌" in status:
81
+ return "❌"
82
+ if STATUS_SKIPPED in status:
83
+ return "⏭️"
84
+ return "🔄"
85
+
86
+
87
+ # ========== 配置面板回调 ==========
88
+
89
+ def _format_sa_json_for_display(raw) -> str:
90
+ if not raw:
91
+ return ""
92
+ if isinstance(raw, dict):
93
+ return json.dumps(raw, indent=2, ensure_ascii=False)
94
+ if isinstance(raw, str):
95
+ raw = raw.strip()
96
+ if not raw:
97
+ return ""
98
+ try:
99
+ return json.dumps(json.loads(raw), indent=2, ensure_ascii=False)
100
+ except json.JSONDecodeError:
101
+ return raw
102
+ return str(raw)
103
+
104
+
105
+ def load_saved_config() -> list:
106
+ """启动时加载持久化配置,回填到界面组件。"""
107
+ d = cfg.get_config_dict()
108
+ _log("已加载持久化配置")
109
+ return [
110
+ d.get("llm_provider", "deepseek"),
111
+ d.get("openai_api_key", ""),
112
+ d.get("openai_model", "gpt-4o-mini"),
113
+ d.get("deepseek_api_key", ""),
114
+ d.get("deepseek_model", "deepseek-chat"),
115
+ d.get("google_sheet_id", ""),
116
+ d.get("google_sheet_name", "West"),
117
+ _format_sa_json_for_display(d.get("google_service_account_json", "")),
118
+ d.get("sheet_company_col", "C"),
119
+ d.get("sheet_email_type_col", "AA"),
120
+ d.get("sheet_email_body_col", "AB"),
121
+ d.get("sheet_email_date_col", "AC"),
122
+ d.get("sheet_timestamp_col", "AD"),
123
+ int(d.get("fuzzy_match_threshold", 85)),
124
+ float(d.get("fuzzy_ambiguous_gap", 3)),
125
+ ]
126
+
127
+
128
+ def save_settings(
129
+ provider, openai_key, openai_model,
130
+ deepseek_key, deepseek_model,
131
+ sheet_id, sheet_name, sa_json_str,
132
+ col_company, col_type, col_body, col_date, col_ts,
133
+ fuzzy_thresh, fuzzy_gap,
134
+ ) -> str:
135
+ """保存配置到文件并同步到内存。"""
136
+ sa_parsed = _parse_sa_json(sa_json_str)
137
+
138
+ config_dict = {
139
+ "llm_provider": provider,
140
+ "openai_api_key": openai_key or "",
141
+ "openai_model": openai_model or "gpt-4o-mini",
142
+ "deepseek_api_key": deepseek_key or "",
143
+ "deepseek_model": deepseek_model or "deepseek-chat",
144
+ "google_sheet_id": sheet_id or "",
145
+ "google_sheet_name": sheet_name or "West",
146
+ "google_service_account_json": sa_parsed,
147
+ "sheet_company_col": col_company or "C",
148
+ "sheet_email_type_col": col_type or "AA",
149
+ "sheet_email_body_col": col_body or "AB",
150
+ "sheet_email_date_col": col_date or "AC",
151
+ "sheet_timestamp_col": col_ts or "AD",
152
+ "fuzzy_match_threshold": int(fuzzy_thresh or 85),
153
+ "fuzzy_ambiguous_gap": float(fuzzy_gap or 3),
154
+ }
155
+
156
+ success, msg = cfg.save_config(config_dict)
157
+ reset_sheets_updater()
158
+ _log(f"配置已保存:{msg}")
159
+ return msg
160
+
161
+
162
+ def _parse_sa_json(s: str):
163
+ """解析 Service Account JSON 输入。"""
164
+ if not s or not s.strip():
165
+ return ""
166
+ s = s.strip()
167
+ try:
168
+ return json.loads(s)
169
+ except json.JSONDecodeError:
170
+ return s
171
+
172
+
173
+ def test_llm(provider, openai_key, openai_model,
174
+ deepseek_key, deepseek_model) -> str:
175
+ """测试 LLM API 连接。"""
176
+ import asyncio
177
+
178
+ if provider == "deepseek":
179
+ api_key = deepseek_key or ""
180
+ model = deepseek_model or "deepseek-chat"
181
+ base_url = "https://api.deepseek.com"
182
+ label = "DeepSeek"
183
+ else:
184
+ api_key = openai_key or ""
185
+ model = openai_model or "gpt-4o-mini"
186
+ base_url = None
187
+ label = "OpenAI"
188
+
189
+ if not api_key:
190
+ return f"❌ 请先填写 {label} API Key"
191
+
192
+ async def _run():
193
+ try:
194
+ from openai import AsyncOpenAI
195
+ kwargs = {"api_key": api_key}
196
+ if base_url:
197
+ kwargs["base_url"] = base_url
198
+ client = AsyncOpenAI(**kwargs)
199
+ resp = await client.chat.completions.create(
200
+ model=model,
201
+ messages=[{"role": "user", "content": "Reply with just: OK"}],
202
+ max_tokens=10,
203
+ timeout=15,
204
+ )
205
+ content = resp.choices[0].message.content
206
+ return f"✅ {label} API 连接成功!\n模型:{model}\n响应:{content}"
207
+ except Exception as e:
208
+ return f"❌ {label} API 连接失败:\n{str(e)}"
209
+
210
+ try:
211
+ return asyncio.run(_run())
212
+ except RuntimeError:
213
+ loop = asyncio.new_event_loop()
214
+ result = loop.run_until_complete(_run())
215
+ loop.close()
216
+ return result
217
+
218
+
219
+ def test_sheets(sheet_id, sheet_name, sa_json_str) -> str:
220
+ """测试 Google Sheets 连接。"""
221
+ if not sheet_id:
222
+ return "❌ 请先填写 Google Sheet ID"
223
+
224
+ sa_info = _parse_sa_json(sa_json_str)
225
+ if not sa_info or (isinstance(sa_info, str) and not sa_info.startswith("{")):
226
+ return "❌ Service Account JSON 格式错误,请检查粘贴内容"
227
+
228
+ try:
229
+ import gspread
230
+ from google.oauth2.service_account import Credentials
231
+
232
+ SCOPES = [
233
+ "https://www.googleapis.com/auth/spreadsheets",
234
+ "https://www.googleapis.com/auth/drive.file",
235
+ ]
236
+ creds = Credentials.from_service_account_info(sa_info, scopes=SCOPES)
237
+ client = gspread.authorize(creds)
238
+ spreadsheet = client.open_by_key(sheet_id.strip())
239
+
240
+ try:
241
+ ws = spreadsheet.worksheet(sheet_name.strip())
242
+ col_idx = cfg._col_letter_to_index("C") + 1
243
+ col_values = ws.col_values(col_idx)
244
+ company_count = sum(1 for v in col_values[1:] if v.strip())
245
+
246
+ return (
247
+ f"✅ Google Sheets 连接成功!\n"
248
+ f"表格:{spreadsheet.title}\n"
249
+ f"工作表:{sheet_name}\n"
250
+ f"总行数:{ws.row_count}\n"
251
+ f"C列公司名数量:{company_count}"
252
+ )
253
+ except Exception:
254
+ names = [s.title for s in spreadsheet.worksheets()]
255
+ return (
256
+ f"✅ 表格连接成功,但工作表 '{sheet_name}' 不存在\n"
257
+ f"可用工作表:{names}"
258
+ )
259
+
260
+ except Exception as e:
261
+ return f"❌ Google Sheets 连接失败:\n{str(e)}"
262
+
263
+
264
+ # ========== 邮件处理回调 ==========
265
+
266
+ def process_emails(files, progress=gr.Progress()) -> tuple:
267
+ global _all_results, _pending_confirmations, _log_lines
268
+
269
+ if not files:
270
+ return [], [], _get_log_text(), "⚠️ 请先上传 .msg 文件", gr.update(visible=False)
271
+
272
+ if not _processing_lock.acquire(blocking=False):
273
+ return (_results_to_table(_all_results), [],
274
+ _get_log_text(), "⚠️ 处理中,请稍候...", gr.update(visible=False))
275
+
276
+ try:
277
+ _all_results = []
278
+ _pending_confirmations = []
279
+ _log_lines = []
280
+ file_paths = [f.name if hasattr(f, 'name') else f for f in files]
281
+ total = len(file_paths)
282
+ _log(f"开始处理 {total} 封邮件...")
283
+
284
+ try:
285
+ sheets_updater = get_sheets_updater(auto_init=True)
286
+ sheets_ok = sheets_updater.is_connected()
287
+ except Exception:
288
+ sheets_updater = None
289
+ sheets_ok = False
290
+
291
+ if not sheets_ok:
292
+ _log("⚠️ Google Sheets 未连接,将仅展示 AI 分析结果", "WARN")
293
+
294
+ processor = EmailProcessor(sheets_updater=sheets_updater if sheets_ok else None)
295
+
296
+ results_list: list = []
297
+ pending_list: list = []
298
+
299
+ async def _run():
300
+ sem = asyncio.Semaphore(cfg.get_max_concurrent())
301
+ done = [0]
302
+
303
+ async def _one(fp):
304
+ async with sem:
305
+ r = await processor.process_single_email(fp)
306
+ done[0] += 1
307
+ results_list.append(r)
308
+ if r.needs_confirmation:
309
+ pending_list.append(r)
310
+ icon = _get_status_icon(r.status)
311
+ _log(f"[{done[0]}/{total}] {icon} {r.filename} -> {r.company_name} [{r.category}] {r.status}")
312
+ progress(done[0] / total, desc=f"处理中 {done[0]}/{total}")
313
+
314
+ await asyncio.gather(*[_one(fp) for fp in file_paths])
315
+
316
+ try:
317
+ asyncio.run(_run())
318
+ except RuntimeError:
319
+ loop = asyncio.new_event_loop()
320
+ loop.run_until_complete(_run())
321
+ loop.close()
322
+
323
+ _all_results = results_list
324
+ _pending_confirmations = pending_list
325
+
326
+ done = sum(1 for r in results_list if STATUS_DONE in r.status)
327
+ skipped = sum(1 for r in results_list if STATUS_SKIPPED in r.status)
328
+ ambiguous = len(pending_list)
329
+ errors = sum(1 for r in results_list if "❌" in r.status)
330
+
331
+ summary = f"✅ 处理完成 | 成功:{done} | 跳过:{skipped} | 需确认:{ambiguous} | 失败:{errors} | 共 {total} 封"
332
+ _log(summary)
333
+
334
+ return (
335
+ _results_to_table(results_list),
336
+ _build_pending_table(pending_list),
337
+ _get_log_text(),
338
+ summary,
339
+ gr.update(visible=ambiguous > 0),
340
+ )
341
+
342
+ except Exception as e:
343
+ _log(f"处理异常:{e}", "ERROR")
344
+ import traceback
345
+ traceback.print_exc()
346
+ return (_results_to_table(_all_results), [],
347
+ _get_log_text(), f"❌ 处理失败:{str(e)}", gr.update(visible=False))
348
+ finally:
349
+ _processing_lock.release()
350
+
351
+
352
+ def _build_pending_table(pending: list) -> list:
353
+ rows = []
354
+ for i, r in enumerate(pending):
355
+ cand_str = " | ".join(f"{c['name']}({c['score']:.0f}%)" for c in r.candidates[:3])
356
+ rows.append([i, r.filename, r.company_name, cand_str, r.category, r.date_str])
357
+ return rows
358
+
359
+
360
+ def confirm_selection(pending_table_data, sel_idx, sel_company) -> tuple:
361
+ global _pending_confirmations, _all_results
362
+
363
+ if sel_idx < 0 or sel_idx >= len(_pending_confirmations):
364
+ return (_results_to_table(_all_results), _build_pending_table(_pending_confirmations),
365
+ _get_log_text(), "⚠️ 请选择有效的待确认项")
366
+
367
+ result = _pending_confirmations[sel_idx]
368
+ confirmed_row = -1
369
+ for c in result.candidates:
370
+ if c["name"] == sel_company:
371
+ confirmed_row = c["row"]
372
+ break
373
+
374
+ if confirmed_row < 0:
375
+ return (_results_to_table(_all_results), _build_pending_table(_pending_confirmations),
376
+ _get_log_text(), f"⚠️ 未找到所选公司 '{sel_company}' 的行号")
377
+
378
+ try:
379
+ updater = get_sheets_updater(auto_init=False)
380
+ if not updater.is_connected():
381
+ return (_results_to_table(_all_results), _build_pending_table(_pending_confirmations),
382
+ _get_log_text(), "❌ Google Sheets 未连接,无法写入")
383
+
384
+ processor = EmailProcessor(sheets_updater=updater)
385
+ wr = processor.confirm_and_write(result, confirmed_row, sel_company)
386
+
387
+ if wr.success:
388
+ _log(f"✅ 人工确认写入成功:{result.filename} -> {sel_company} (第{confirmed_row}行)")
389
+ _pending_confirmations.remove(result)
390
+ else:
391
+ _log(f"❌ 写入失败:{wr.error}", "ERROR")
392
+
393
+ return (
394
+ _results_to_table(_all_results),
395
+ _build_pending_table(_pending_confirmations),
396
+ _get_log_text(),
397
+ f"✅ 已确认写入:{result.company_name} -> {sel_company}" if wr.success
398
+ else f"❌ 写入失败:{wr.error}",
399
+ )
400
+
401
+ except Exception as e:
402
+ _log(f"确认写入异常:{e}", "ERROR")
403
+ return (_results_to_table(_all_results), _build_pending_table(_pending_confirmations),
404
+ _get_log_text(), f"❌ 写入异常:{str(e)}")
405
+
406
+
407
+ def get_candidate_choices(sel_idx) -> gr.update:
408
+ idx = int(sel_idx) if sel_idx is not None else -1
409
+ if idx < 0 or idx >= len(_pending_confirmations):
410
+ return gr.update(choices=[], value=None)
411
+ result = _pending_confirmations[idx]
412
+ choices = [c["name"] for c in result.candidates]
413
+ return gr.update(choices=choices, value=choices[0] if choices else None)
414
+
415
+
416
+ # ========== Gradio UI ==========
417
+
418
+ def build_ui() -> gr.Blocks:
419
+ with gr.Blocks(
420
+ title="📧 邮件智能处理系统 — 分类回填 + PST 管理",
421
+ theme=gr.themes.Soft(primary_hue="blue", secondary_hue="slate"),
422
+ css="""
423
+ .status-bar { background:#eff6ff; border-left:4px solid #3b82f6; padding:8px 16px; border-radius:6px; font-weight:500; margin-bottom:8px; }
424
+ .settings-panel { background:#f8fafc; border:1px solid #e2e8f0; border-radius:10px; padding:16px; margin-bottom:12px; }
425
+ .pending-section { border:2px solid #f59e0b; border-radius:10px; padding:16px; background:#fffbeb; margin-top:12px; }
426
+ .log-box { font-family:'Courier New',monospace; font-size:12px; background:#1e1e2e; color:#cdd6f4; }
427
+ .tab-nav { font-size:15px; }
428
+ """
429
+ ) as demo:
430
+
431
+ # ========== 标签页容器 ==========
432
+ with gr.Tabs(elem_classes=["tab-nav"]):
433
+
434
+ # ===== Tab 1: 邮件智能分类 =====
435
+ with gr.TabItem("📧 邮件智能分类 & Sheets 回填", id="classifier"):
436
+
437
+ # ---- 标题 ----
438
+ gr.Markdown("""
439
+ # 📧 邮件智能分类 & Google Sheets 自动回填
440
+ > 上传招聘回复邮件(.msg),AI 自动分类、提取公司信息并回填到 Google Sheets
441
+ """)
442
+
443
+ # ===== 设置面板 =====
444
+ with gr.Accordion("⚙️ 设置面板(点击展开配置 API 密钥和 Google Sheets)", open=False):
445
+ with gr.Group(elem_classes=["settings-panel"]):
446
+
447
+ # ---- LLM 配置 ----
448
+ gr.Markdown("### 🤖 LLM API 配置")
449
+
450
+ with gr.Row():
451
+ llm_provider = gr.Dropdown(
452
+ label="LLM 提供商", choices=["deepseek", "openai"],
453
+ value="deepseek", info="选择使用的 LLM 服务"
454
+ )
455
+ llm_test_btn = gr.Button("🔌 测试 LLM 连接", size="sm")
456
+
457
+ llm_test_result = gr.Textbox(label="LLM 连接测试结果", interactive=False, lines=3, visible=False)
458
+
459
+ with gr.Row(visible=True) as openai_row:
460
+ openai_api_key = gr.Textbox(label="OpenAI API Key", type="password", placeholder="sk-...")
461
+ openai_model = gr.Textbox(label="OpenAI 模型", value="gpt-4o-mini", placeholder="gpt-4o-mini")
462
+
463
+ with gr.Row(visible=True) as deepseek_row:
464
+ deepseek_api_key = gr.Textbox(label="DeepSeek API Key", type="password", placeholder="sk-...")
465
+ deepseek_model = gr.Textbox(label="DeepSeek 模型", value="deepseek-chat", placeholder="deepseek-chat")
466
+
467
+ llm_provider.change(
468
+ fn=None, inputs=[llm_provider],
469
+ outputs=[openai_row, deepseek_row],
470
+ js="(p)=> [p==='openai'?'visible':'hidden', p==='deepseek'?'visible':'hidden']"
471
+ )
472
+
473
+ # ---- Google Sheets 配置 ----
474
+ gr.Markdown("### 📊 Google Sheets 配置")
475
+
476
+ with gr.Row():
477
+ sheet_id = gr.Textbox(
478
+ label="Google Sheet ID",
479
+ placeholder="从 URL 中复制 /d/ 和 /edit 之间的部分",
480
+ info="https://docs.google.com/spreadsheets/d/[此处是ID]/edit")
481
+ sheet_name = gr.Textbox(label="工作表名称", value="West",
482
+ info="根据 Wellfound.xlsx 结构,数据在 West 工作表")
483
+
484
+ sa_json = gr.Textbox(
485
+ label="Google Service Account JSON(粘贴完整内容)",
486
+ lines=5,
487
+ info="⚠️ 敏感信息仅保存在本地 ~/.wellfound/config.json"
488
+ )
489
+
490
+ with gr.Row():
491
+ sheets_test_btn = gr.Button("📊 测试 Google Sheets 连接", size="sm")
492
+ sheets_reload_btn = gr.Button("📥 重新加载公司列表", size="sm", variant="secondary")
493
+
494
+ sheets_test_result = gr.Textbox(label="Sheets 连接测试结果", interactive=False, lines=4, visible=False)
495
+
496
+ # ---- 高级选项 ----
497
+ with gr.Accordion("高级选项(可选)", open=False):
498
+ gr.Markdown("#### 列映射(根据 Wellfound.xlsx 结构已预设)")
499
+ with gr.Row():
500
+ col_company = gr.Textbox(label="公司名列(C)", value="C", scale=1)
501
+ col_type = gr.Textbox(label="邮件类型列(AA)", value="AA", scale=1)
502
+ col_body = gr.Textbox(label="邮件正文列(AB)", value="AB", scale=1)
503
+ with gr.Row():
504
+ col_date = gr.Textbox(label="邮件日期列(AC)", value="AC", scale=1)
505
+ col_ts = gr.Textbox(label="时间戳列(AD)", value="AD", scale=1)
506
+
507
+ gr.Markdown("#### 模糊匹配参数")
508
+ with gr.Row():
509
+ fuzzy_thresh = gr.Slider(label="模糊匹配阈值(%)", minimum=50, maximum=100, value=85, step=1)
510
+ fuzzy_gap = gr.Slider(label="歧义间隔(%)", minimum=0, maximum=10, value=3, step=0.5)
511
+
512
+ # ---- 保存 / 重置 ----
513
+ with gr.Row():
514
+ save_btn = gr.Button("💾 保存配置", variant="primary", size="lg")
515
+ reset_btn = gr.Button("🔄 重置为默认值", size="sm")
516
+
517
+ save_result = gr.Textbox(label="保存结果", interactive=False, visible=False)
518
+
519
+ # 设置面板事件绑定
520
+ save_btn.click(
521
+ fn=save_settings,
522
+ inputs=[
523
+ llm_provider, openai_api_key, openai_model,
524
+ deepseek_api_key, deepseek_model,
525
+ sheet_id, sheet_name, sa_json,
526
+ col_company, col_type, col_body, col_date, col_ts,
527
+ fuzzy_thresh, fuzzy_gap,
528
+ ],
529
+ outputs=[save_result],
530
+ ).then(
531
+ fn=lambda s: gr.update(visible=bool(s)),
532
+ inputs=[save_result],
533
+ outputs=[save_result],
534
+ )
535
+
536
+ llm_test_btn.click(
537
+ fn=test_llm,
538
+ inputs=[llm_provider, openai_api_key, openai_model,
539
+ deepseek_api_key, deepseek_model],
540
+ outputs=[llm_test_result],
541
+ ).then(
542
+ fn=lambda: gr.update(visible=True),
543
+ outputs=[llm_test_result],
544
+ )
545
+
546
+ sheets_test_btn.click(
547
+ fn=test_sheets,
548
+ inputs=[sheet_id, sheet_name, sa_json],
549
+ outputs=[sheets_test_result],
550
+ ).then(
551
+ fn=lambda: gr.update(visible=True),
552
+ outputs=[sheets_test_result],
553
+ )
554
+
555
+ sheets_reload_btn.click(
556
+ fn=lambda: (reset_sheets_updater(), "✅ 已重置连接,下次处理时重新初始化")[1],
557
+ outputs=[sheets_test_result],
558
+ ).then(
559
+ fn=lambda: gr.update(visible=True),
560
+ outputs=[sheets_test_result],
561
+ )
562
+
563
+ reset_btn.click(
564
+ fn=lambda: (
565
+ "deepseek", "", "gpt-4o-mini",
566
+ "", "deepseek-chat",
567
+ "", "West", "",
568
+ "C", "AA", "AB", "AC", "AD",
569
+ 85, 3.0,
570
+ ),
571
+ outputs=[
572
+ llm_provider, openai_api_key, openai_model,
573
+ deepseek_api_key, deepseek_model,
574
+ sheet_id, sheet_name, sa_json,
575
+ col_company, col_type, col_body, col_date, col_ts,
576
+ fuzzy_thresh, fuzzy_gap,
577
+ ],
578
+ )
579
+
580
+ # ===== 主操作区 =====
581
+ gr.Markdown("---")
582
+
583
+ with gr.Row():
584
+ with gr.Column(scale=3):
585
+ file_input = gr.File(
586
+ label="📁 上传邮件文件(.msg)",
587
+ file_types=[".msg"], file_count="multiple", height=120,
588
+ )
589
+ with gr.Row():
590
+ process_btn = gr.Button("🚀 开始处理", variant="primary", size="lg")
591
+ clear_btn = gr.Button("🗑️ 清空", variant="secondary", size="lg")
592
+
593
+ with gr.Column(scale=2):
594
+ gr.Markdown("""
595
+ **📋 操作说明**
596
+ 1. 先在上方 **⚙️ 设置面板** 配置 API 密钥和 Sheets
597
+ 2. 将 `.msg` 文件拖入上传区(支持多选)
598
+ 3. 点击 **🚀 开始处理** 启动 AI 分析
599
+ 4. 处理完成后在结果表格查看状态
600
+ 5. 若有模糊匹配,在下方 **待确认区** 手动选择
601
+ """)
602
+
603
+ status_text = gr.Textbox(label="处理状态", value="⚠️ 请先在设置面板配置 API 密钥",
604
+ interactive=False, elem_classes=["status-bar"])
605
+
606
+ gr.Markdown("---")
607
+ gr.Markdown("### 📊 处理结果")
608
+
609
+ results_table = gr.Dataframe(
610
+ headers=["文件名", "发件人", "日期", "公司名", "分类", "邮件摘要", "状态", "备注"],
611
+ datatype=["str"] * 8, wrap=True, interactive=False,
612
+ )
613
+
614
+ gr.Markdown("---")
615
+ gr.Markdown("### 📝 处理日志")
616
+
617
+ log_display = gr.Textbox(label="日志输出", value="(暂无日志)",
618
+ lines=10, interactive=False, max_lines=15, elem_classes=["log-box"])
619
+
620
+ # ===== 待确认区 =====
621
+ with gr.Group(elem_classes=["pending-section"]) as pending_section:
622
+ gr.Markdown("### ⚠️ 待人工确认项(模糊匹配)")
623
+ gr.Markdown("以下邮件的公司名存在多个相似匹配,请人工选择正确的公司后点击确认回填。")
624
+
625
+ pending_table = gr.Dataframe(
626
+ headers=["序号", "文件名", "AI提取公司", "候选公司列表", "分类", "日期"],
627
+ datatype=["number", "str", "str", "str", "str", "str"],
628
+ interactive=False,
629
+ )
630
+
631
+ with gr.Row():
632
+ with gr.Column(scale=1):
633
+ pending_idx = gr.Number(label="选择待确认项序号(从0开始)", value=0, precision=0, minimum=0)
634
+ with gr.Column(scale=2):
635
+ candidate_dropdown = gr.Dropdown(label="选择正确的公司", choices=[], value=None, interactive=True)
636
+ with gr.Column(scale=1):
637
+ confirm_btn = gr.Button("✅ 确认回填", variant="primary")
638
+
639
+ confirm_status = gr.Textbox(label="确认结果", interactive=False)
640
+
641
+ pending_idx.change(fn=get_candidate_choices, inputs=[pending_idx], outputs=[candidate_dropdown])
642
+
643
+ confirm_btn.click(
644
+ fn=confirm_selection,
645
+ inputs=[pending_table, pending_idx, candidate_dropdown],
646
+ outputs=[results_table, pending_table, log_display, confirm_status],
647
+ )
648
+
649
+ # 主区事件绑定
650
+ process_btn.click(
651
+ fn=process_emails, inputs=[file_input],
652
+ outputs=[results_table, pending_table, log_display, status_text, pending_section],
653
+ show_progress=True,
654
+ )
655
+
656
+ def clear_all():
657
+ global _all_results, _pending_confirmations, _log_lines
658
+ _all_results = []
659
+ _pending_confirmations = []
660
+ _log_lines = []
661
+ return (None, [], [], "等待上传文件...", "(已清空)", gr.update(visible=False))
662
+
663
+ clear_btn.click(
664
+ fn=clear_all,
665
+ outputs=[file_input, results_table, pending_table,
666
+ status_text, log_display, pending_section],
667
+ )
668
+
669
+ # ===== Tab 2: PST 邮件管理 =====
670
+ with gr.TabItem("📦 PST 邮件管理", id="pst_manager"):
671
+ build_pst_ui()
672
+
673
+ # ---- 启动时自动加载配置 ----
674
+ demo.load(fn=load_saved_config, outputs=[
675
+ llm_provider, openai_api_key, openai_model,
676
+ deepseek_api_key, deepseek_model,
677
+ sheet_id, sheet_name, sa_json,
678
+ col_company, col_type, col_body, col_date, col_ts,
679
+ fuzzy_thresh, fuzzy_gap,
680
+ ])
681
+
682
+ return demo
683
+
684
+
685
+ if __name__ == "__main__":
686
+ app = build_ui()
687
+ app.launch(
688
+ server_name="0.0.0.0",
689
+ server_port=int(os.getenv("PORT", 7860)),
690
+ show_error=True,
691
+ debug=os.getenv("DEBUG", "false").lower() == "true",
692
+ )
requirements.txt CHANGED
@@ -8,6 +8,9 @@ extract-msg==0.55.0
8
  beautifulsoup4==4.12.3
9
  html2text==2024.2.26
10
 
 
 
 
11
  # AI / LLM
12
  openai==1.51.0
13
 
 
8
  beautifulsoup4==4.12.3
9
  html2text==2024.2.26
10
 
11
+ # PST parsing (Outlook MAPI)
12
+ pywin32
13
+
14
  # AI / LLM
15
  openai==1.51.0
16