| """ |
| Degradation Manager - Fallback orchestration when AI is unavailable. |
| |
| Handles: |
| - Failure detection (AI disabled, timeout, API error, invalid response) |
| - Graceful degradation to regex-based extraction |
| - Non-AI source tagging ("ε€ζ³¨" column injection) |
| - Structured error logging with timestamps, failure reasons, degradation paths |
| - Full chain traceability |
| """ |
|
|
| import logging |
| import traceback |
| from datetime import datetime |
| from enum import Enum |
| from typing import Optional, Dict, Any, List, Tuple |
|
|
| from .address_processor import AddressProcessor |
| from .contact_finder import ContactFinder |
|
|
|
|
| |
|
|
| class FailureType(Enum): |
| """Classification of AI failure scenarios.""" |
| AI_DISABLED = "ai_disabled" |
| AI_TIMEOUT = "ai_timeout" |
| AI_API_ERROR = "ai_api_error" |
| AI_PARSE_ERROR = "ai_parse_error" |
| AI_INVALID_RESPONSE = "ai_invalid_response" |
| AI_AUTH_ERROR = "ai_auth_error" |
| AI_NETWORK_ERROR = "ai_network_error" |
| AI_UNKNOWN_ERROR = "ai_unknown_error" |
| AI_NOT_NEEDED = "ai_not_needed" |
|
|
|
|
| class DegradationLogger: |
| """Structured error logger with timestamped entries for traceability.""" |
|
|
| def __init__(self, log_dir: str = "data/logs"): |
| from pathlib import Path |
| self.log_dir = Path(log_dir) |
| self.log_dir.mkdir(parents=True, exist_ok=True) |
|
|
| |
| self._file_logger = logging.getLogger("degradation") |
| self._file_logger.setLevel(logging.INFO) |
| self._file_logger.propagate = False |
|
|
| |
| log_file = self.log_dir / f"degradation_{datetime.now().strftime('%Y%m%d')}.log" |
| handler = logging.FileHandler(str(log_file), encoding="utf-8") |
| handler.setFormatter(logging.Formatter( |
| "%(asctime)s | %(levelname)-8s | %(message)s", |
| datefmt="%Y-%m-%d %H:%M:%S" |
| )) |
| self._file_logger.addHandler(handler) |
|
|
| |
| self._session_summary: List[Dict[str, Any]] = [] |
|
|
| def log_degradation( |
| self, |
| row_idx: int, |
| company: str, |
| failure_type: FailureType, |
| failure_reason: str, |
| degradation_path: str, |
| fields_affected: List[str], |
| fallback_used: List[str], |
| ): |
| """Record a single degradation event. |
| |
| Args: |
| row_idx: Excel row index where degradation occurred. |
| company: Company name for context. |
| failure_type: Enum classifying the failure. |
| failure_reason: Human-readable description of what went wrong. |
| degradation_path: e.g. "ai_extractor -> address_processor + contact_finder" |
| fields_affected: Which target fields were filled by fallback. |
| fallback_used: Which fallback modules were activated. |
| """ |
| event = { |
| "timestamp": datetime.now().isoformat(), |
| "row_index": row_idx, |
| "company": company, |
| "failure_type": failure_type.value, |
| "failure_reason": failure_reason[:300], |
| "degradation_path": degradation_path, |
| "fields_affected": fields_affected, |
| "fallback_used": fallback_used, |
| } |
|
|
| |
| self._file_logger.info( |
| "row=%d | company=%s | type=%s | reason=%s | path=%s | fields=%s | fallback=%s", |
| row_idx, company, failure_type.value, failure_reason[:200], |
| degradation_path, fields_affected, fallback_used, |
| ) |
|
|
| |
| self._session_summary.append(event) |
|
|
| def get_session_summary(self) -> Dict[str, Any]: |
| """Get aggregated degradation stats for the current session.""" |
| if not self._session_summary: |
| return { |
| "total_degradations": 0, |
| "by_type": {}, |
| "by_field": {}, |
| "affected_rows": [], |
| } |
|
|
| by_type: Dict[str, int] = {} |
| by_field: Dict[str, int] = {} |
| affected_rows = [] |
|
|
| for event in self._session_summary: |
| ft = event["failure_type"] |
| by_type[ft] = by_type.get(ft, 0) + 1 |
| for field in event["fields_affected"]: |
| by_field[field] = by_field.get(field, 0) + 1 |
| affected_rows.append(event["row_index"]) |
|
|
| return { |
| "total_degradations": len(self._session_summary), |
| "by_type": by_type, |
| "by_field": by_field, |
| "affected_rows": sorted(set(affected_rows)), |
| } |
|
|
| def get_recent_entries(self, limit: int = 20) -> List[Dict[str, Any]]: |
| """Get the most recent degradation entries.""" |
| return self._session_summary[-limit:] |
|
|
|
|
| |
|
|
| class FailureClassifier: |
| """Classify exceptions into typed FailureType values.""" |
|
|
| |
| _TIMEOUT_PATTERNS = ["timeout", "timed out", "deadline"] |
| _AUTH_PATTERNS = [ |
| "401", "403", "unauthorized", "forbidden", |
| "invalid api key", "authentication", "quota", |
| "rate limit", "429", |
| ] |
| _NETWORK_PATTERNS = [ |
| "connection", "dns", "resolve", "network", |
| "socket", "ssl", "certificate", "connecterror", |
| ] |
| _PARSE_PATTERNS = ["json", "decode", "parse", "expecting", "malformed"] |
|
|
| @classmethod |
| def classify(cls, exc: Exception) -> FailureType: |
| """Classify an exception into a FailureType. |
| |
| Args: |
| exc: The caught exception instance. |
| |
| Returns: |
| A FailureType enum value. |
| """ |
| msg = str(exc).lower() |
| exc_type = type(exc).__name__.lower() |
|
|
| |
| if any(p in msg for p in cls._TIMEOUT_PATTERNS): |
| return FailureType.AI_TIMEOUT |
|
|
| |
| if any(p in msg for p in cls._AUTH_PATTERNS) or any(p in msg for p in cls._AUTH_PATTERNS): |
| return FailureType.AI_AUTH_ERROR |
|
|
| |
| if any(p in msg for p in cls._NETWORK_PATTERNS) or "connecterror" in exc_type: |
| return FailureType.AI_NETWORK_ERROR |
|
|
| |
| if any(p in msg for p in cls._PARSE_PATTERNS) or "jsondecode" in exc_type: |
| return FailureType.AI_PARSE_ERROR |
|
|
| |
| if any(c in msg for c in ["400", "404", "405", "408", "500", "502", "503"]): |
| return FailureType.AI_API_ERROR |
|
|
| return FailureType.AI_UNKNOWN_ERROR |
|
|
| @classmethod |
| def classify_api_status(cls, status_code: int) -> FailureType: |
| """Classify an HTTP status code into a FailureType.""" |
| if status_code == 401 or status_code == 403: |
| return FailureType.AI_AUTH_ERROR |
| elif status_code == 429: |
| return FailureType.AI_AUTH_ERROR |
| elif status_code == 408 or status_code == 504: |
| return FailureType.AI_TIMEOUT |
| elif 500 <= status_code <= 599: |
| return FailureType.AI_API_ERROR |
| else: |
| return FailureType.AI_API_ERROR |
|
|
|
|
| |
|
|
| class DegradationManager: |
| """Orchestrates fallback when AI extraction fails. |
| |
| Provides: |
| - detect_failure(): Classify what went wrong |
| - run_fallback(): Execute contact_finder + address_processor as fallback |
| - build_remark(): Generate the "ε€ζ³¨" column value for traceability |
| """ |
|
|
| def __init__(self, logger: Optional[DegradationLogger] = None): |
| self.address_processor = AddressProcessor() |
| self.contact_finder = ContactFinder() |
| self.logger = logger or DegradationLogger() |
|
|
| |
|
|
| @staticmethod |
| def detect_failure( |
| ai_extractor=None, |
| exc: Optional[Exception] = None, |
| ai_result: Optional[Dict[str, Any]] = None, |
| ) -> Optional[FailureType]: |
| """Detect and classify AI failure. |
| |
| Checks (in priority order): |
| 1. AI extractor is None β AI_DISABLED |
| 2. An exception was raised β classify by exception type |
| 3. AI result exists but all target fields are null β AI_INVALID_RESPONSE |
| 4. No failure detected β None |
| |
| Returns: |
| FailureType if degradation is needed, None if AI succeeded. |
| """ |
| |
| if ai_extractor is None: |
| return FailureType.AI_DISABLED |
|
|
| |
| if exc is not None: |
| return FailureClassifier.classify(exc) |
|
|
| |
| if ai_result is not None: |
| loc = ai_result.get("location_apply") |
| state = ai_result.get("state_apply") |
| contact = ai_result.get("contact") |
|
|
| if not any([loc, state, contact]): |
| return FailureType.AI_INVALID_RESPONSE |
|
|
| return None |
|
|
| |
|
|
| def run_fallback( |
| self, |
| emails: List[str], |
| links: List[Dict], |
| addresses: List[str], |
| homepage_html: str = "", |
| base_url: str = "", |
| wellfound_location: str = "", |
| combined_text: str = "", |
| ) -> Tuple[Dict[str, Any], List[str]]: |
| """Execute fallback extraction using regex-based modules. |
| |
| Returns: |
| Tuple of: |
| - result dict with location_apply, state_apply, contact |
| - list of fallback module names that were activated |
| """ |
| result = { |
| "location_apply": None, |
| "state_apply": None, |
| "contact": None, |
| } |
| fallbacks_used = [] |
|
|
| |
| if not result["contact"]: |
| |
| best_email = self.contact_finder.find_best_contact_email( |
| emails, combined_text |
| ) |
| if best_email: |
| result["contact"] = best_email |
| fallbacks_used.append("contact_finder.email_scoring") |
|
|
| |
| if not result["contact"] and links and base_url: |
| form_url = self.contact_finder.find_contact_form_url( |
| base_url, links, homepage_html |
| ) |
| if form_url: |
| result["contact"] = ContactFinder._clean_url(form_url) |
| fallbacks_used.append("contact_finder.form_url") |
|
|
| |
| if not result["contact"] and links and base_url: |
| careers_url = self.contact_finder.find_careers_page_url( |
| base_url, links, homepage_html |
| ) |
| if careers_url: |
| result["contact"] = ContactFinder._clean_url(careers_url) |
| fallbacks_used.append("contact_finder.careers_url") |
|
|
| |
| if not result["location_apply"] or not result["state_apply"]: |
| loc, state = self.address_processor.determine_location_apply( |
| wellfound_location=wellfound_location, |
| ai_contact_data={}, |
| scraped_addresses=addresses, |
| ) |
| if loc: |
| result["location_apply"] = loc |
| fallbacks_used.append("address_processor.location") |
| if state: |
| result["state_apply"] = state |
| fallbacks_used.append("address_processor.state") |
|
|
| return result, fallbacks_used |
|
|
| |
|
|
| @staticmethod |
| def build_remark( |
| failure_type: FailureType, |
| fallbacks_used: List[str], |
| fields_filled: List[str], |
| ) -> str: |
| """Build the "ε€ζ³¨" column value. |
| |
| Format: source: "non-ai" | reason: <type> | fallback: <modules> |
| |
| Examples: |
| source: "non-ai" | reason: ai_timeout | fallback: contact_finder.email_scoring, address_processor.location |
| source: "non-ai" | reason: ai_disabled | fallback: address_processor.location, address_processor.state |
| """ |
| parts = [f'source: "non-ai"'] |
| parts.append(f"reason: {failure_type.value}") |
| if fallbacks_used: |
| parts.append(f"fallback: {', '.join(fallbacks_used)}") |
| if fields_filled: |
| parts.append(f"fields: {', '.join(fields_filled)}") |
| return " | ".join(parts) |
|
|
| |
|
|
| async def handle_degradation( |
| self, |
| row_idx: int, |
| company: str, |
| ai_extractor=None, |
| exc: Optional[Exception] = None, |
| ai_result: Optional[Dict[str, Any]] = None, |
| scraped_data: Optional[Dict[str, Any]] = None, |
| wellfound_location: str = "", |
| ) -> Dict[str, Any]: |
| """Full degradation pipeline: detect β fallback β tag β log. |
| |
| Args: |
| row_idx: Excel row index. |
| company: Company name. |
| ai_extractor: The AIExtractor instance (None if AI disabled). |
| exc: Exception caught during AI call (if any). |
| ai_result: The AI result dict (if AI ran but returned empty). |
| scraped_data: Raw scraped data from website (emails, links, etc.). |
| wellfound_location: Wellfound location string. |
| |
| Returns: |
| Dict with keys: |
| - location_apply, state_apply, contact (fallback values) |
| - remark: the "ε€ζ³¨" column value |
| - degraded: bool, whether degradation was applied |
| - failure_type: FailureType enum |
| """ |
| |
| output = { |
| "location_apply": None, |
| "state_apply": None, |
| "contact": None, |
| "remark": None, |
| "degraded": False, |
| "failure_type": None, |
| } |
|
|
| scraped_data = scraped_data or {} |
|
|
| |
| failure_type = self.detect_failure(ai_extractor, exc, ai_result) |
|
|
| if failure_type is None: |
| |
| if ai_result: |
| output["location_apply"] = ai_result.get("location_apply") |
| output["state_apply"] = ai_result.get("state_apply") |
| output["contact"] = ai_result.get("contact") |
| return output |
|
|
| |
| output["degraded"] = True |
| output["failure_type"] = failure_type |
|
|
| |
| if exc: |
| failure_reason = f"{type(exc).__name__}: {str(exc)[:200]}" |
| elif failure_type == FailureType.AI_DISABLED: |
| failure_reason = "AI service not enabled by user configuration" |
| elif failure_type == FailureType.AI_INVALID_RESPONSE: |
| failure_reason = "AI returned null/empty for all target fields" |
| else: |
| failure_reason = f"Unclassified failure: {failure_type.value}" |
|
|
| |
| fallback_result, fallbacks_used = self.run_fallback( |
| emails=scraped_data.get("emails", []), |
| links=scraped_data.get("links", []), |
| addresses=scraped_data.get("addresses", []), |
| homepage_html=scraped_data.get("html", ""), |
| base_url=scraped_data.get("base_url", ""), |
| wellfound_location=wellfound_location, |
| combined_text=scraped_data.get("combined_text", ""), |
| ) |
|
|
| |
| fields_filled = [] |
| for field in ["location_apply", "state_apply", "contact"]: |
| if fallback_result.get(field): |
| output[field] = fallback_result[field] |
| fields_filled.append(field) |
|
|
| |
| if fields_filled or fallbacks_used: |
| output["remark"] = self.build_remark( |
| failure_type, fallbacks_used, fields_filled |
| ) |
|
|
| |
| degradation_path_parts = [] |
| if failure_type == FailureType.AI_DISABLED: |
| degradation_path_parts.append("ai_skipped") |
| else: |
| degradation_path_parts.append("ai_extractor") |
| degradation_path_parts.append(f"({failure_type.value})") |
|
|
| if fallbacks_used: |
| degradation_path_parts.append("->") |
| degradation_path_parts.append(" + ".join(fallbacks_used)) |
| else: |
| degradation_path_parts.append("-> no_fallback_available") |
|
|
| degradation_path = " ".join(degradation_path_parts) |
|
|
| |
| self.logger.log_degradation( |
| row_idx=row_idx, |
| company=company, |
| failure_type=failure_type, |
| failure_reason=failure_reason, |
| degradation_path=degradation_path, |
| fields_affected=fields_filled, |
| fallback_used=fallbacks_used, |
| ) |
|
|
| return output |
|
|