Spaces:
Sleeping
Sleeping
| """Experience letter field extraction and validation โ pure OCR-based.""" | |
| from __future__ import annotations | |
| import re | |
| from config import FREE_EMAIL_DOMAINS | |
| from ml_utils.ocr import OcrResult, get_full_text, get_average_confidence | |
| from ml_utils.extract import find_value_near_label, get_text_in_region | |
| EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}") | |
| PHONE_RE = re.compile(r"(?:\+91[\s\-]?)?[6-9]\d{9}") | |
| DATE_RE = re.compile( | |
| r"\d{1,2}[/\-]\d{1,2}[/\-]\d{2,4}|" | |
| r"\d{1,2}\s+(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+\d{4}", | |
| re.I, | |
| ) | |
| COMPANY_RE = re.compile( | |
| r"([A-Z][A-Za-z0-9\s&.,'\-]+(?:Pvt\.?\s*Ltd\.?|Limited|LLP|Inc\.?|Corporation|Technologies|Solutions|Services|Consulting|Enterprises|Group|Company))", | |
| re.I, | |
| ) | |
| EXP_KEYWORDS = ["experience", "certify", "employed", "worked", "tenure", "designation", | |
| "employment", "relieving", "service", "joining"] | |
| PREFERRED_EMAIL_PREFIXES = ("hr@", "careers@", "jobs@", "recruitment@", "talent@", "admin@") | |
| NAME_LABELS = ["name", "employee", "mr.", "ms.", "mrs."] | |
| DESIGNATION_LABELS = ["designation", "position", "role", "title", "เคชเคฆเคจเคพเคฎ"] | |
| DEPARTMENT_LABELS = ["department", "dept", "division", "เคตเคฟเคญเคพเค"] | |
| JOINING_LABELS = ["joining", "join date", "date of joining", "from", "start"] | |
| EXIT_LABELS = ["relieving", "exit", "last working", "to", "end date", "separation"] | |
| SALARY_LABELS = ["salary", "ctc", "compensation", "remuneration", "package"] | |
| def _rank_email(emails: list[str], company_name: str | None) -> str | None: | |
| """Pick the most relevant email (HR > corporate > personal).""" | |
| if not emails: | |
| return None | |
| def score(email: str) -> int: | |
| lower = email.lower() | |
| s = 0 | |
| if any(lower.startswith(p) for p in PREFERRED_EMAIL_PREFIXES): | |
| s += 10 | |
| if company_name: | |
| company_token = company_name.split()[0].lower() | |
| if company_token and company_token in lower.split("@")[-1]: | |
| s += 8 | |
| if lower.split("@")[-1] in FREE_EMAIL_DOMAINS: | |
| s -= 5 | |
| return s | |
| return max(emails, key=score) | |
| # โโ Parser (from OCR results) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def parse_experience_fields_from_ocr(ocr_results: list[OcrResult]) -> dict: | |
| """Extract experience letter fields from OCR results.""" | |
| full_text = get_full_text(ocr_results) | |
| return _parse_from_text(full_text, ocr_results) | |
| # โโ Parser (from plain text โ legacy compat) โโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def parse_experience_fields(text: str) -> dict: | |
| """Extract experience letter fields from plain text.""" | |
| return _parse_from_text(text, []) | |
| def _parse_from_text(text: str, ocr_results: list[OcrResult]) -> dict: | |
| fields: dict = {} | |
| # Company Name | |
| company_match = COMPANY_RE.search(text) | |
| company_name = company_match.group(1).strip() if company_match else None | |
| if not company_name: | |
| for line in text.splitlines()[:12]: | |
| if any(x in line for x in ["Ltd", "Limited", "Pvt", "LLP", "Inc", "Technologies", "Solutions"]): | |
| company_name = line.strip()[:120] | |
| break | |
| fields["company_name"] = company_name | |
| # Letterhead detection (top 15%) | |
| if ocr_results: | |
| top_text = get_text_in_region(ocr_results, y_start_pct=0.0, y_end_pct=0.15) | |
| fields["letterhead_detected"] = bool(company_name and company_name.lower() in top_text.lower()) | |
| else: | |
| header_lines = "\n".join(text.splitlines()[:3]).lower() | |
| fields["letterhead_detected"] = bool(company_name and company_name.lower() in header_lines) | |
| # HR Email | |
| emails = EMAIL_RE.findall(text) | |
| fields["hr_email"] = _rank_email(emails, company_name) | |
| # Phone | |
| phones = PHONE_RE.findall(text) | |
| fields["phone"] = phones[0] if phones else None | |
| # Employee Name | |
| employee_name = None | |
| for pat in [ | |
| r"(?:certify that|hereby certify that)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)", | |
| r"Dear\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)", | |
| r"(?:Mr\.|Ms\.|Mrs\.)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)", | |
| r"(?:employee|name)\s*[:\-]\s*([A-Za-z\s.]+)", | |
| ]: | |
| m = re.search(pat, text, re.I) | |
| if m: | |
| employee_name = m.group(1).strip()[:80] | |
| break | |
| if not employee_name and ocr_results: | |
| name_val = find_value_near_label(ocr_results, NAME_LABELS) | |
| if name_val: | |
| cleaned = re.sub(r"[^A-Za-z\s.]", "", name_val).strip() | |
| if len(cleaned) >= 3: | |
| employee_name = cleaned | |
| fields["employee_name"] = employee_name | |
| # Dates | |
| dates = DATE_RE.findall(text) | |
| fields["date_from"] = dates[0] if len(dates) > 0 else None | |
| fields["date_to"] = dates[1] if len(dates) > 1 else None | |
| # Also try spatial extraction for dates | |
| if ocr_results: | |
| if not fields["date_from"]: | |
| joining = find_value_near_label(ocr_results, JOINING_LABELS) | |
| if joining: | |
| dm = DATE_RE.search(joining) | |
| if dm: | |
| fields["date_from"] = dm.group(0) | |
| if not fields["date_to"]: | |
| exit_date = find_value_near_label(ocr_results, EXIT_LABELS) | |
| if exit_date: | |
| dm = DATE_RE.search(exit_date) | |
| if dm: | |
| fields["date_to"] = dm.group(0) | |
| # Designation | |
| designation = None | |
| des_match = re.search(r"(?:designation|position|role|title)\s*[:\-]?\s*([A-Za-z\s/]+)", text, re.I) | |
| if des_match: | |
| designation = des_match.group(1).strip()[:80] | |
| if not designation and ocr_results: | |
| des_val = find_value_near_label(ocr_results, DESIGNATION_LABELS) | |
| if des_val: | |
| designation = des_val[:80] | |
| fields["designation"] = designation | |
| # Department | |
| dept = None | |
| dept_match = re.search(r"(?:department|dept|division)\s*[:\-]?\s*([A-Za-z\s/]+)", text, re.I) | |
| if dept_match: | |
| dept = dept_match.group(1).strip()[:60] | |
| if not dept and ocr_results: | |
| dept_val = find_value_near_label(ocr_results, DEPARTMENT_LABELS) | |
| if dept_val: | |
| dept = dept_val[:60] | |
| fields["department"] = dept | |
| # Salary/CTC (optional) | |
| salary_match = re.search(r"(?:salary|ctc|compensation|remuneration)\s*[:\-]?\s*([\d,.\s]+(?:lpa|per annum|p\.a\.)?)", text, re.I) | |
| if salary_match: | |
| fields["salary"] = salary_match.group(1).strip() | |
| # Seal/Signature presence (bottom 25%) | |
| if ocr_results: | |
| bottom_text = get_text_in_region(ocr_results, y_start_pct=0.75, y_end_pct=1.0) | |
| else: | |
| lines = text.splitlines() | |
| bottom_text = "\n".join(lines[-(len(lines) // 4):]) if lines else "" | |
| fields["seal_present"] = bool(re.search(r"seal|stamp|เคฎเฅเคฆเฅเคฐเคพ", bottom_text, re.IGNORECASE)) | |
| fields["signature_present"] = bool(re.search(r"signature|sign|authorized|เคนเคธเฅเคคเคพเคเฅเคทเคฐ", bottom_text, re.IGNORECASE)) | |
| return fields | |
| # โโ Validator โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def is_free_email(email: str | None) -> bool: | |
| if not email: | |
| return False | |
| domain = email.split("@")[-1].lower() | |
| return domain in FREE_EMAIL_DOMAINS | |
| def validate_experience(fields: dict, text: str, text_source: str, ocr_results: list[OcrResult]) -> tuple[float, list[str]]: | |
| """Validate experience letter. Returns (validation_score_0_to_20, flags).""" | |
| flags: list[str] = [] | |
| checks_total = 0 | |
| checks_passed = 0 | |
| # Email presence | |
| checks_total += 1 | |
| if fields.get("hr_email"): | |
| if is_free_email(fields["hr_email"]): | |
| flags.append("FREE_EMAIL") | |
| checks_passed += 0.5 | |
| else: | |
| checks_passed += 1 | |
| else: | |
| flags.append("NO_EMAIL") | |
| # Company name | |
| checks_total += 1 | |
| if fields.get("company_name"): | |
| checks_passed += 1 | |
| else: | |
| flags.append("NO_COMPANY") | |
| # Dates | |
| checks_total += 1 | |
| if fields.get("date_from") or fields.get("date_to"): | |
| checks_passed += 1 | |
| else: | |
| flags.append("MISSING_DATES") | |
| # Employee name | |
| checks_total += 1 | |
| if fields.get("employee_name"): | |
| checks_passed += 1 | |
| else: | |
| flags.append("MISSING_EMPLOYEE_NAME") | |
| # Experience keywords | |
| checks_total += 1 | |
| lower = text.lower() | |
| if any(k in lower for k in EXP_KEYWORDS): | |
| checks_passed += 1 | |
| else: | |
| flags.append("MISSING_EXPERIENCE_KEYWORDS") | |
| # Text source quality | |
| if text_source == "ocr": | |
| flags.append("SCANNED_DOCUMENT") | |
| elif text_source == "ocr_hindi": | |
| flags.append("HINDI_OCR_USED") | |
| # OCR confidence | |
| if ocr_results: | |
| avg_conf = get_average_confidence(ocr_results) | |
| if avg_conf < 0.4: | |
| flags.append("LOW_OCR_CONFIDENCE") | |
| validation_ratio = checks_passed / max(checks_total, 1) | |
| return validation_ratio * 20.0, flags | |
| def score_experience(fields: dict, text: str, text_source: str) -> tuple[float, list[str]]: | |
| """Legacy compatibility โ returns (score_0_100, flags).""" | |
| validation_score, flags = validate_experience(fields, text, text_source, []) | |
| return validation_score * 5.0, flags # Scale to 0-100 | |