Zoey7Web's picture
Upload 13 files
feef6eb verified
Raw
History Blame Contribute Delete
27.1 kB
"""
邮件智能分类与 Google Sheets 自动回填系统
Gradio 前端界面(含配置面板)
配置面板功能:
- LLM API 密钥输入(OpenAI / DeepSeek 切换)
- Google Sheets ID、工作表名称、Service Account JSON 配置
- 列映射、模糊匹配参数高级选项
- 连接测试(LLM / Sheets 分别测试)
- 配置持久化保存至 ~/.wellfound/config.json
- 启动时自动加载已保存配置
"""
import asyncio
import json
import logging
import os
import sys
import threading
from datetime import datetime
from pathlib import Path
from typing import Optional
import gradio as gr
sys.path.insert(0, str(Path(__file__).parent))
from src import config as cfg
from src.processor import (
EmailProcessor, EmailProcessResult,
STATUS_DONE, STATUS_AMBIGUOUS, STATUS_NOT_FOUND,
STATUS_PARSE_ERROR, STATUS_AI_ERROR, STATUS_WRITE_ERROR, STATUS_SKIPPED
)
from src.sheets_updater import get_sheets_updater, reset_sheets_updater
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger(__name__)
# ---- 全局状态 ----
_processing_lock = threading.Lock()
_all_results: list = []
_pending_confirmations: list = []
_log_lines: list = []
def _log(msg: str, level: str = "INFO"):
ts = datetime.now().strftime("%H:%M:%S")
line = f"[{ts}] [{level}] {msg}"
_log_lines.append(line)
if len(_log_lines) > 200:
_log_lines.pop(0)
(logger.error if level == "ERROR" else
logger.warning if level == "WARN" else logger.info)(msg)
def _get_log_text() -> str:
return "\n".join(_log_lines[-50:]) if _log_lines else "(暂无日志)"
def _results_to_table(results: list) -> list:
return [r.to_table_row for r in results]
def _get_status_icon(status: str) -> str:
if STATUS_DONE in status:
return "✅"
if STATUS_AMBIGUOUS in status:
return "⚠️"
if "❌" in status:
return "❌"
if STATUS_SKIPPED in status:
return "⏭️"
return "🔄"
# ========== 配置面板回调 ==========
def _format_sa_json_for_display(raw) -> str:
if not raw:
return ""
if isinstance(raw, dict):
return json.dumps(raw, indent=2, ensure_ascii=False)
if isinstance(raw, str):
raw = raw.strip()
if not raw:
return ""
try:
return json.dumps(json.loads(raw), indent=2, ensure_ascii=False)
except json.JSONDecodeError:
return raw
return str(raw)
def load_saved_config() -> list:
"""启动时加载持久化配置,回填到界面组件。"""
d = cfg.get_config_dict()
_log("已加载持久化配置")
return [
d.get("llm_provider", "deepseek"),
d.get("openai_api_key", ""),
d.get("openai_model", "gpt-4o-mini"),
d.get("deepseek_api_key", ""),
d.get("deepseek_model", "deepseek-chat"),
d.get("google_sheet_id", ""),
d.get("google_sheet_name", "West"),
_format_sa_json_for_display(d.get("google_service_account_json", "")),
d.get("sheet_company_col", "C"),
d.get("sheet_email_type_col", "AA"),
d.get("sheet_email_body_col", "AB"),
d.get("sheet_email_date_col", "AC"),
d.get("sheet_timestamp_col", "AD"),
int(d.get("fuzzy_match_threshold", 85)),
float(d.get("fuzzy_ambiguous_gap", 3)),
]
def save_settings(
provider, openai_key, openai_model,
deepseek_key, deepseek_model,
sheet_id, sheet_name, sa_json_str,
col_company, col_type, col_body, col_date, col_ts,
fuzzy_thresh, fuzzy_gap,
) -> str:
"""保存配置到文件并同步到内存。"""
sa_parsed = _parse_sa_json(sa_json_str)
config_dict = {
"llm_provider": provider,
"openai_api_key": openai_key or "",
"openai_model": openai_model or "gpt-4o-mini",
"deepseek_api_key": deepseek_key or "",
"deepseek_model": deepseek_model or "deepseek-chat",
"google_sheet_id": sheet_id or "",
"google_sheet_name": sheet_name or "West",
"google_service_account_json": sa_parsed,
"sheet_company_col": col_company or "C",
"sheet_email_type_col": col_type or "AA",
"sheet_email_body_col": col_body or "AB",
"sheet_email_date_col": col_date or "AC",
"sheet_timestamp_col": col_ts or "AD",
"fuzzy_match_threshold": int(fuzzy_thresh or 85),
"fuzzy_ambiguous_gap": float(fuzzy_gap or 3),
}
success, msg = cfg.save_config(config_dict)
reset_sheets_updater()
_log(f"配置已保存:{msg}")
return msg
def _parse_sa_json(s: str):
"""解析 Service Account JSON 输入。"""
if not s or not s.strip():
return ""
s = s.strip()
try:
return json.loads(s)
except json.JSONDecodeError:
return s # 原样返回,save_config 会报错
def test_llm(provider, openai_key, openai_model,
deepseek_key, deepseek_model) -> str:
"""测试 LLM API 连接。"""
import asyncio
if provider == "deepseek":
api_key = deepseek_key or ""
model = deepseek_model or "deepseek-chat"
base_url = "https://api.deepseek.com"
label = "DeepSeek"
else:
api_key = openai_key or ""
model = openai_model or "gpt-4o-mini"
base_url = None
label = "OpenAI"
if not api_key:
return f"❌ 请先填写 {label} API Key"
async def _run():
try:
from openai import AsyncOpenAI
kwargs = {"api_key": api_key}
if base_url:
kwargs["base_url"] = base_url
client = AsyncOpenAI(**kwargs)
resp = await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": "Reply with just: OK"}],
max_tokens=10,
timeout=15,
)
content = resp.choices[0].message.content
return f"✅ {label} API 连接成功!\n模型:{model}\n响应:{content}"
except Exception as e:
return f"❌ {label} API 连接失败:\n{str(e)}"
try:
return asyncio.run(_run())
except RuntimeError:
loop = asyncio.new_event_loop()
result = loop.run_until_complete(_run())
loop.close()
return result
def test_sheets(sheet_id, sheet_name, sa_json_str) -> str:
"""测试 Google Sheets 连接。"""
if not sheet_id:
return "❌ 请先填写 Google Sheet ID"
sa_info = _parse_sa_json(sa_json_str)
if not sa_info or (isinstance(sa_info, str) and not sa_info.startswith("{")):
return "❌ Service Account JSON 格式错误,请检查粘贴内容"
try:
import gspread
from google.oauth2.service_account import Credentials
SCOPES = [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/drive.file",
]
creds = Credentials.from_service_account_info(sa_info, scopes=SCOPES)
client = gspread.authorize(creds)
spreadsheet = client.open_by_key(sheet_id.strip())
try:
ws = spreadsheet.worksheet(sheet_name.strip())
col_idx = cfg._col_letter_to_index("C") + 1
col_values = ws.col_values(col_idx)
company_count = sum(1 for v in col_values[1:] if v.strip())
return (
f"✅ Google Sheets 连接成功!\n"
f"表格:{spreadsheet.title}\n"
f"工作表:{sheet_name}\n"
f"总行数:{ws.row_count}\n"
f"C列公司名数量:{company_count}"
)
except Exception:
names = [s.title for s in spreadsheet.worksheets()]
return (
f"✅ 表格连接成功,但工作表 '{sheet_name}' 不存在\n"
f"可用工作表:{names}"
)
except Exception as e:
return f"❌ Google Sheets 连接失败:\n{str(e)}"
# ========== 邮件处理回调 ==========
def process_emails(files, progress=gr.Progress()) -> tuple:
global _all_results, _pending_confirmations, _log_lines
if not files:
return [], [], _get_log_text(), "⚠️ 请先上传 .eml 文件", gr.update(visible=False)
if not _processing_lock.acquire(blocking=False):
return (_results_to_table(_all_results), [],
_get_log_text(), "⚠️ 处理中,请稍候...", gr.update(visible=False))
try:
_all_results = []
_pending_confirmations = []
_log_lines = []
file_paths = [f.name if hasattr(f, 'name') else f for f in files]
total = len(file_paths)
_log(f"开始处理 {total} 封邮件...")
try:
sheets_updater = get_sheets_updater(auto_init=True)
sheets_ok = sheets_updater.is_connected()
except Exception:
sheets_updater = None
sheets_ok = False
if not sheets_ok:
_log("⚠️ Google Sheets 未连接,将仅展示 AI 分析结果", "WARN")
processor = EmailProcessor(sheets_updater=sheets_updater if sheets_ok else None)
results_list: list = []
pending_list: list = []
async def _run():
sem = asyncio.Semaphore(cfg.get_max_concurrent())
done = [0]
async def _one(fp):
async with sem:
r = await processor.process_single_email(fp)
done[0] += 1
results_list.append(r)
if r.needs_confirmation:
pending_list.append(r)
icon = _get_status_icon(r.status)
_log(f"[{done[0]}/{total}] {icon} {r.filename} -> {r.company_name} [{r.category}] {r.status}")
progress(done[0] / total, desc=f"处理中 {done[0]}/{total}")
await asyncio.gather(*[_one(fp) for fp in file_paths])
try:
asyncio.run(_run())
except RuntimeError:
loop = asyncio.new_event_loop()
loop.run_until_complete(_run())
loop.close()
_all_results = results_list
_pending_confirmations = pending_list
done = sum(1 for r in results_list if STATUS_DONE in r.status)
skipped = sum(1 for r in results_list if STATUS_SKIPPED in r.status)
ambiguous = len(pending_list)
errors = sum(1 for r in results_list if "❌" in r.status)
summary = f"✅ 处理完成 | 成功:{done} | 跳过:{skipped} | 需确认:{ambiguous} | 失败:{errors} | 共 {total} 封"
_log(summary)
return (
_results_to_table(results_list),
_build_pending_table(pending_list),
_get_log_text(),
summary,
gr.update(visible=ambiguous > 0),
)
except Exception as e:
_log(f"处理异常:{e}", "ERROR")
import traceback
traceback.print_exc()
return (_results_to_table(_all_results), [],
_get_log_text(), f"❌ 处理失败:{str(e)}", gr.update(visible=False))
finally:
_processing_lock.release()
def _build_pending_table(pending: list) -> list:
rows = []
for i, r in enumerate(pending):
# 候选项显示:名称、所在工作表、相似度
cand_str = " | ".join(
f"{c['name']}[{c.get('sheet', '?')}]({c['score']:.0f}%)"
for c in r.candidates[:3]
)
rows.append([i, r.filename, r.company_name, cand_str, r.category, r.date_str])
return rows
def confirm_selection(pending_table_data, sel_idx, sel_company) -> tuple:
global _pending_confirmations, _all_results
if sel_idx < 0 or sel_idx >= len(_pending_confirmations):
return (_results_to_table(_all_results), _build_pending_table(_pending_confirmations),
_get_log_text(), "⚠️ 请选择有效的待确认项")
result = _pending_confirmations[sel_idx]
confirmed_row = -1
confirmed_sheet = ""
for c in result.candidates:
if c["name"] == sel_company:
confirmed_row = c["row"]
confirmed_sheet = c.get("sheet", "")
break
if confirmed_row < 0:
return (_results_to_table(_all_results), _build_pending_table(_pending_confirmations),
_get_log_text(), f"⚠️ 未找到所选公司 '{sel_company}' 的行号")
try:
updater = get_sheets_updater(auto_init=False)
if not updater.is_connected():
return (_results_to_table(_all_results), _build_pending_table(_pending_confirmations),
_get_log_text(), "❌ Google Sheets 未连接,无法写入")
processor = EmailProcessor(sheets_updater=updater)
wr = processor.confirm_and_write(
result, confirmed_row, sel_company, confirmed_sheet=confirmed_sheet
)
if wr.success:
sheet_info = f" [{confirmed_sheet}]" if confirmed_sheet else ""
_log(f"人工确认写入成功:{result.filename} -> {sel_company}{sheet_info} (第{confirmed_row}行)")
_pending_confirmations.remove(result)
else:
_log(f"写入失败:{wr.error}", "ERROR")
return (
_results_to_table(_all_results),
_build_pending_table(_pending_confirmations),
_get_log_text(),
f"✅ 已确认写入:{result.company_name} -> {sel_company}" if wr.success
else f"❌ 写入失败:{wr.error}",
)
except Exception as e:
_log(f"确认写入异常:{e}", "ERROR")
return (_results_to_table(_all_results), _build_pending_table(_pending_confirmations),
_get_log_text(), f"❌ 写入异常:{str(e)}")
def get_candidate_choices(sel_idx) -> gr.update:
idx = int(sel_idx) if sel_idx is not None else -1
if idx < 0 or idx >= len(_pending_confirmations):
return gr.update(choices=[], value=None)
result = _pending_confirmations[idx]
# 选项显示格式:公司名 [工作表]
choices = [
f"{c['name']} [{c.get('sheet', '?')}]" if c.get('sheet') else c["name"]
for c in result.candidates
]
# 保持 sel_company 和候选列表中的 name 一致,需要转换回去
raw_choices = [c["name"] for c in result.candidates]
return gr.update(choices=raw_choices, value=raw_choices[0] if raw_choices else None)
# ========== Gradio UI ==========
def build_ui() -> gr.Blocks:
with gr.Blocks(
title="📧 邮件智能分类 & Sheets 回填",
theme=gr.themes.Soft(primary_hue="blue", secondary_hue="slate"),
css="""
.status-bar { background:#eff6ff; border-left:4px solid #3b82f6; padding:8px 16px; border-radius:6px; font-weight:500; margin-bottom:8px; }
.settings-panel { background:#f8fafc; border:1px solid #e2e8f0; border-radius:10px; padding:16px; margin-bottom:12px; }
.pending-section { border:2px solid #f59e0b; border-radius:10px; padding:16px; background:#fffbeb; margin-top:12px; }
.log-box { font-family:'Courier New',monospace; font-size:12px; background:#1e1e2e; color:#cdd6f4; }
"""
) as demo:
# ---- 标题 ----
gr.Markdown("""
# 📧 邮件智能分类 & Google Sheets 自动回填
> 上传招聘回复邮件(.eml),AI 自动分类、提取公司信息并回填到 Google Sheets
""")
# ========== 设置面板 ==========
with gr.Accordion("⚙️ 设置面板(点击展开配置 API 密钥和 Google Sheets)", open=False):
with gr.Group(elem_classes=["settings-panel"]):
# ---- LLM 配置 ----
gr.Markdown("### 🤖 LLM API 配置")
with gr.Row():
llm_provider = gr.Dropdown(
label="LLM 提供商", choices=["deepseek", "openai"],
value="deepseek", info="选择使用的 LLM 服务"
)
llm_test_btn = gr.Button("🔌 测试 LLM 连接", size="sm")
llm_test_result = gr.Textbox(label="LLM 连接测试结果", interactive=False, lines=3, visible=False)
with gr.Row(visible=True) as openai_row:
openai_api_key = gr.Textbox(label="OpenAI API Key", type="password", placeholder="sk-...")
openai_model = gr.Textbox(label="OpenAI 模型", value="gpt-4o-mini", placeholder="gpt-4o-mini")
with gr.Row(visible=True) as deepseek_row:
deepseek_api_key = gr.Textbox(label="DeepSeek API Key", type="password", placeholder="sk-...")
deepseek_model = gr.Textbox(label="DeepSeek 模型", value="deepseek-chat", placeholder="deepseek-chat")
# JS:切换提供商时显示对应输入行
llm_provider.change(
fn=None, inputs=[llm_provider],
outputs=[openai_row, deepseek_row],
js="(p)=> [p==='openai'?'visible':'hidden', p==='deepseek'?'visible':'hidden']"
)
# ---- Google Sheets 配置 ----
gr.Markdown("### 📊 Google Sheets 配置")
with gr.Row():
sheet_id = gr.Textbox(label="Google Sheet ID",
placeholder="从 URL 中复制 /d/ 和 /edit 之间的部分",
info="https://docs.google.com/spreadsheets/d/[此处是ID]/edit")
sheet_name = gr.Textbox(label="工作表名称", value="West",
info="根据 Wellfound.xlsx 结构,数据在 West 工作表")
sa_json = gr.Textbox(
label="Google Service Account JSON(粘贴完整内容)",
lines=5,
info="⚠️ 敏感信息仅保存在本地 ~/.wellfound/config.json"
)
with gr.Row():
sheets_test_btn = gr.Button("📊 测试 Google Sheets 连接", size="sm")
sheets_reload_btn = gr.Button("📥 重新加载公司列表", size="sm", variant="secondary")
sheets_test_result = gr.Textbox(label="Sheets 连接测试结果", interactive=False, lines=4, visible=False)
# ---- 高级选项 ----
with gr.Accordion("高级选项(可选)", open=False):
gr.Markdown("#### 列映射(根据 Wellfound.xlsx 结构已预设)")
with gr.Row():
col_company = gr.Textbox(label="公司名列(C)", value="C", scale=1)
col_type = gr.Textbox(label="邮件类型列(AA)", value="AA", scale=1)
col_body = gr.Textbox(label="邮件正文列(AB)", value="AB", scale=1)
with gr.Row():
col_date = gr.Textbox(label="邮件日期列(AC)", value="AC", scale=1)
col_ts = gr.Textbox(label="时间戳列(AD)", value="AD", scale=1)
gr.Markdown("#### 模糊匹配参数")
with gr.Row():
fuzzy_thresh = gr.Slider(label="模糊匹配阈值(%)", minimum=50, maximum=100, value=85, step=1)
fuzzy_gap = gr.Slider(label="歧义间隔(%)", minimum=0, maximum=10, value=3, step=0.5)
# ---- 保存 / 重置 ----
with gr.Row():
save_btn = gr.Button("💾 保存配置", variant="primary", size="lg")
reset_btn = gr.Button("🔄 重置为默认值", size="sm")
save_result = gr.Textbox(label="保存结果", interactive=False, visible=False)
# ---- 回调绑定:设置面板 ----
save_btn.click(
fn=save_settings,
inputs=[
llm_provider, openai_api_key, openai_model,
deepseek_api_key, deepseek_model,
sheet_id, sheet_name, sa_json,
col_company, col_type, col_body, col_date, col_ts,
fuzzy_thresh, fuzzy_gap,
],
outputs=[save_result],
).then(
fn=lambda s: gr.update(visible=bool(s)),
inputs=[save_result],
outputs=[save_result],
)
llm_test_btn.click(
fn=test_llm,
inputs=[llm_provider, openai_api_key, openai_model,
deepseek_api_key, deepseek_model],
outputs=[llm_test_result],
).then(
fn=lambda: gr.update(visible=True),
outputs=[llm_test_result],
)
sheets_test_btn.click(
fn=test_sheets,
inputs=[sheet_id, sheet_name, sa_json],
outputs=[sheets_test_result],
).then(
fn=lambda: gr.update(visible=True),
outputs=[sheets_test_result],
)
sheets_reload_btn.click(
fn=lambda: (reset_sheets_updater(), "✅ 已重置连接,下次处理时重新初始化")[1],
outputs=[sheets_test_result],
).then(
fn=lambda: gr.update(visible=True),
outputs=[sheets_test_result],
)
reset_btn.click(
fn=lambda: (
"deepseek", "", "gpt-4o-mini",
"", "deepseek-chat",
"", "West", "",
"C", "AA", "AB", "AC", "AD",
85, 3.0,
),
outputs=[
llm_provider, openai_api_key, openai_model,
deepseek_api_key, deepseek_model,
sheet_id, sheet_name, sa_json,
col_company, col_type, col_body, col_date, col_ts,
fuzzy_thresh, fuzzy_gap,
],
)
# ========== 主操作区 ==========
gr.Markdown("---")
with gr.Row():
with gr.Column(scale=3):
file_input = gr.File(
label="📁 上传邮件文件(.eml)",
file_types=[".eml"], file_count="multiple", height=120,
)
with gr.Row():
process_btn = gr.Button("🚀 开始处理", variant="primary", size="lg")
clear_btn = gr.Button("🗑️ 清空", variant="secondary", size="lg")
with gr.Column(scale=2):
gr.Markdown("""
**📋 操作说明**
1. 先在上方 **⚙️ 设置面板** 配置 API 密钥和 Sheets
2. 将 `.eml` 文件拖入上传区(支持多选)
3. 点击 **🚀 开始处理** 启动 AI 分析
4. 处理完成后在结果表格查看状态
5. 若有模糊匹配,在下方 **待确认区** 手动选择
""")
status_text = gr.Textbox(label="处理状态", value="⚠️ 请先在设置面板配置 API 密钥",
interactive=False, elem_classes=["status-bar"])
gr.Markdown("---")
gr.Markdown("### 📊 处理结果")
results_table = gr.Dataframe(
headers=["文件名", "发件人", "日期", "公司名", "分类", "邮件摘要", "状态", "备注"],
datatype=["str"] * 8, wrap=True, interactive=False,
)
gr.Markdown("---")
gr.Markdown("### 📝 处理日志")
log_display = gr.Textbox(label="日志输出", value="(暂无日志)",
lines=10, interactive=False, max_lines=15, elem_classes=["log-box"])
# ========== 待确认区 ==========
with gr.Group(elem_classes=["pending-section"]) as pending_section:
gr.Markdown("### ⚠️ 待人工确认项(模糊匹配)")
gr.Markdown("以下邮件的公司名存在多个相似匹配,请人工选择正确的公司后点击确认回填。")
pending_table = gr.Dataframe(
headers=["序号", "文件名", "AI提取公司", "候选公司列表", "分类", "日期"],
datatype=["number", "str", "str", "str", "str", "str"],
interactive=False,
)
with gr.Row():
with gr.Column(scale=1):
pending_idx = gr.Number(label="选择待确认项序号(从0开始)", value=0, precision=0, minimum=0)
with gr.Column(scale=2):
candidate_dropdown = gr.Dropdown(label="选择正确的公司", choices=[], value=None, interactive=True)
with gr.Column(scale=1):
confirm_btn = gr.Button("✅ 确认回填", variant="primary")
confirm_status = gr.Textbox(label="确认结果", interactive=False)
pending_idx.change(fn=get_candidate_choices, inputs=[pending_idx], outputs=[candidate_dropdown])
confirm_btn.click(
fn=confirm_selection,
inputs=[pending_table, pending_idx, candidate_dropdown],
outputs=[results_table, pending_table, log_display, confirm_status],
)
# ---- 主区事件绑定 ----
process_btn.click(
fn=process_emails, inputs=[file_input],
outputs=[results_table, pending_table, log_display, status_text, pending_section],
show_progress=True,
)
def clear_all():
global _all_results, _pending_confirmations, _log_lines
_all_results = []
_pending_confirmations = []
_log_lines = []
return (None, [], [], "等待上传文件...", "(已清空)", gr.update(visible=False))
clear_btn.click(
fn=clear_all,
outputs=[file_input, results_table, pending_table,
status_text, log_display, pending_section],
)
# ---- 启动时自动加载配置 ----
demo.load(fn=load_saved_config, outputs=[
llm_provider, openai_api_key, openai_model,
deepseek_api_key, deepseek_model,
sheet_id, sheet_name, sa_json,
col_company, col_type, col_body, col_date, col_ts,
fuzzy_thresh, fuzzy_gap,
])
return demo
if __name__ == "__main__":
app = build_ui()
app.launch(
server_name="0.0.0.0",
server_port=int(os.getenv("PORT", 7860)),
show_error=True,
debug=os.getenv("DEBUG", "false").lower() == "true",
)