Spaces:
Runtime error
Runtime error
| """ | |
| 邮件智能分类与 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", | |
| ) | |