""" 邮件处理管线 - 协调解析、分类、匹配、写入的完整流程 """ import asyncio import logging from dataclasses import dataclass, field from datetime import datetime from typing import Optional, Callable from .email_parser import ParsedEmail, parse_eml_file from .ai_classifier import ClassificationResult, classify_email_async from .sheets_updater import SheetsUpdater, MatchResult, WriteResult logger = logging.getLogger(__name__) # 处理状态常量 STATUS_PENDING = "待处理" STATUS_PARSING = "解析中" STATUS_CLASSIFYING = "AI分析中" STATUS_MATCHING = "匹配公司中" STATUS_WRITING = "回填中" STATUS_DONE = "✅ 已完成" STATUS_AMBIGUOUS = "⚠️ 需人工确认" STATUS_NOT_FOUND = "❌ 未找到公司" STATUS_PARSE_ERROR = "❌ 解析失败" STATUS_AI_ERROR = "❌ AI分析失败" STATUS_WRITE_ERROR = "❌ 写入失败" STATUS_SKIPPED = "⏭️ 已跳过" @dataclass class EmailProcessResult: """单封邮件的完整处理结果""" # 输入信息 filename: str file_path: str = "" # 解析结果 sender: str = "" recipient: str = "" date_str: str = "" subject: str = "" body_preview: str = "" body_text: str = "" # AI 分析结果 company_name: str = "" category: str = "" summary: str = "" ai_confidence: str = "" # 匹配结果 match_status: str = "" matched_row: int = -1 matched_name: str = "" matched_sheet: str = "" # 匹配到的工作表名称(West/South/Northeast/Midwest) match_score: float = 0.0 candidates: list = field(default_factory=list) # 写入结果 write_action: str = "" write_reason: str = "" # 最终状态 status: str = STATUS_PENDING error_msg: str = "" # 时间戳 processed_at: str = "" @property def needs_confirmation(self) -> bool: return self.match_status == "ambiguous" @property def to_table_row(self) -> list: """格式化为表格展示行""" return [ self.filename, self.sender, self.date_str, self.company_name, self.category, self.summary or self.body_preview, self.status, self.write_reason or self.error_msg, ] class EmailProcessor: """邮件处理管线""" def __init__(self, sheets_updater: Optional[SheetsUpdater] = None): self.sheets_updater = sheets_updater self._results: list[EmailProcessResult] = [] self._pending_confirmations: list[EmailProcessResult] = [] def _update_status(self, result: EmailProcessResult, status: str, callback: Optional[Callable] = None): """更新状态并触发回调""" result.status = status if callback: callback(result) async def process_single_email( self, file_path: str, progress_callback: Optional[Callable] = None, ) -> EmailProcessResult: """ 处理单封邮件的完整流程 Args: file_path: .eml 文件路径 progress_callback: 进度回调 (result) -> None Returns: EmailProcessResult """ import os filename = os.path.basename(file_path) result = EmailProcessResult(filename=filename, file_path=file_path) # Step 1: 解析 self._update_status(result, STATUS_PARSING, progress_callback) try: email = parse_eml_file(file_path) if not email.is_valid: result.status = STATUS_PARSE_ERROR result.error_msg = email.parse_error return result result.sender = email.sender result.recipient = email.recipient result.date_str = email.date_str result.subject = email.subject result.body_preview = email.body_preview result.body_text = email.body_text except Exception as e: result.status = STATUS_PARSE_ERROR result.error_msg = str(e) logger.error(f"解析失败 ({filename}): {e}") return result # Step 2: AI 分类 self._update_status(result, STATUS_CLASSIFYING, progress_callback) try: classification = await classify_email_async(email) if not classification.is_valid: result.status = STATUS_AI_ERROR result.error_msg = classification.error # AI 失败但解析成功,保留邮件信息仍可人工处理 result.company_name = classification.company_name or email.sender_domain.title() return result result.company_name = classification.company_name result.category = classification.category result.summary = classification.summary result.ai_confidence = classification.confidence except Exception as e: result.status = STATUS_AI_ERROR result.error_msg = str(e) logger.error(f"AI 分类失败 ({filename}): {e}") return result # Step 3: 模糊匹配 if self.sheets_updater and self.sheets_updater.is_connected(): self._update_status(result, STATUS_MATCHING, progress_callback) try: match = self.sheets_updater.fuzzy_match_company(result.company_name) result.match_status = match.status result.matched_row = match.row_index result.matched_name = match.matched_name result.matched_sheet = match.sheet_name result.match_score = match.score result.candidates = match.candidates if match.status == "not_found": result.status = STATUS_NOT_FOUND result.error_msg = match.message return result if match.status == "ambiguous": result.status = STATUS_AMBIGUOUS result.error_msg = match.message return result # Step 4: 写入(传入匹配到的工作表名) self._update_status(result, STATUS_WRITING, progress_callback) write_result = self.sheets_updater.write_email_data( row_index=result.matched_row, category=result.category, body_text=result.body_text, date_str=result.date_str, sheet_name=result.matched_sheet, ) result.write_action = write_result.action result.write_reason = write_result.reason if not write_result.success: result.status = STATUS_WRITE_ERROR result.error_msg = write_result.error return result if write_result.action == "skipped": result.status = STATUS_SKIPPED else: result.status = STATUS_DONE except Exception as e: result.status = STATUS_WRITE_ERROR result.error_msg = str(e) logger.error(f"Sheets 操作失败 ({filename}): {e}") return result else: # Sheets 未连接,仅展示分析结果 result.match_status = "sheets_not_configured" result.status = "✅ 分析完成(未配置Sheets)" result.processed_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S") self._update_status(result, result.status, progress_callback) return result async def process_batch( self, file_paths: list[str], progress_callback: Optional[Callable] = None, ) -> tuple[list[EmailProcessResult], list[EmailProcessResult]]: """ 批量处理邮件 Args: file_paths: 文件路径列表 progress_callback: 进度回调 (current, total, result) -> None Returns: (completed_results, pending_confirmation_results) """ self._results = [] self._pending_confirmations = [] semaphore = asyncio.Semaphore(5) # 最大并发 5 completed_count = [0] async def process_one(file_path: str): async with semaphore: result = await self.process_single_email(file_path) completed_count[0] += 1 self._results.append(result) if result.needs_confirmation: self._pending_confirmations.append(result) if progress_callback: await asyncio.coroutine( lambda: progress_callback(completed_count[0], len(file_paths), result) )() if asyncio.iscoroutinefunction(progress_callback) else progress_callback( completed_count[0], len(file_paths), result ) return result tasks = [process_one(fp) for fp in file_paths] await asyncio.gather(*tasks, return_exceptions=True) return self._results, self._pending_confirmations def confirm_and_write( self, result: EmailProcessResult, confirmed_row: int, confirmed_name: str, confirmed_sheet: str = "", ) -> WriteResult: """ 人工确认后强制写入 Args: result: 待确认的处理结果 confirmed_row: 用户选择的行号 confirmed_name: 用户选择的公司名 confirmed_sheet: 用户选择的工作表名(West/South/Northeast/Midwest) Returns: WriteResult """ if not self.sheets_updater or not self.sheets_updater.is_connected(): from .sheets_updater import WriteResult return WriteResult(success=False, error="Google Sheets 未连接") # 优先使用传入的工作表名,否则从候选中查找 sheet_name = confirmed_sheet if not sheet_name: # 尝试从候选列表里根据行号找工作表名 for c in result.candidates: if c.get("row") == confirmed_row: sheet_name = c.get("sheet", "") break write_result = self.sheets_updater.write_email_data( row_index=confirmed_row, category=result.category, body_text=result.body_text, date_str=result.date_str, force_overwrite=True, sheet_name=sheet_name, ) if write_result.success: result.matched_row = confirmed_row result.matched_name = confirmed_name result.matched_sheet = sheet_name result.status = STATUS_DONE result.write_action = write_result.action sheet_info = f" [{sheet_name}]" if sheet_name else "" result.write_reason = f"人工确认写入({confirmed_name}{sheet_info})" return write_result