Spaces:
Running
Running
| """CV/Resume parser using multimodal LLM (vision + text). | |
| Sends the raw file (PDF or image) as base64 to the LLM's vision endpoint. | |
| Works for both text-based PDFs and scanned images since the model reads them directly. | |
| Workflow: | |
| 1. Send raw file bytes to LLM with structured prompt | |
| 2. Parse JSON response into structured ParsedCVResponse | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from typing import TYPE_CHECKING, Any, Optional | |
| if TYPE_CHECKING: | |
| from .llm_client import LLMClient | |
| # ββ MIME type helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _get_mime_type(filename: str, content_bytes: bytes) -> str: | |
| """Detect MIME type from filename or magic bytes.""" | |
| if not filename: | |
| # Magic byte detection | |
| if content_bytes[:3] == b"%PDF": | |
| return "application/pdf" | |
| elif content_bytes[:8] == b"\x89PNG\r\n\x1a\n": | |
| return "image/png" | |
| elif content_bytes[:2] in (b"\xff\xd8",): | |
| return "image/jpeg" | |
| elif content_bytes[:4][:3] == b"WBP": | |
| return "image/webp" | |
| return "application/octet-stream" | |
| ext = filename.lower().split(".")[-1] | |
| ext_map = { | |
| "pdf": "application/pdf", | |
| "png": "image/png", | |
| "jpg": "image/jpeg", | |
| "jpeg": "image/jpeg", | |
| "webp": "image/webp", | |
| } | |
| return ext_map.get(ext, "application/octet-stream") | |
| # ββ Main parser βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _STUDENT_CV_PROMPT = """You are an expert CV/resume analyst. Extract student profile data from the provided document (CV, resume, or academic record). | |
| Extract ALL fields below. If a field is not found in the document, use null for strings/numbers and empty arrays/lists for collections. Return ONLY valid JSON β no markdown, no backticks, no explanations. | |
| Required JSON structure: | |
| { | |
| "personal": { | |
| "full_name": string or null, | |
| "gender": string or null (e.g., "Female", "Male"), | |
| "date_of_birth": string in YYYY-MM-DD format or null, | |
| "province": string or null (Indonesian province if applicable), | |
| "economic_background": string or null (e.g., "Low Income", "Middle Income", "Upper Middle Income", "High Income"), | |
| "from_underrepresented_region": boolean or null | |
| }, | |
| "academic": { | |
| "school_level": string or null (e.g., "SMA", "SMK", "MA", "University", "Bachelor", "Master"), | |
| "major_program": string or null (e.g., "IPA", "IPS", "Computer Science"), | |
| "grade_class": string or null (e.g., "Grade 12", "Semester 5", "Year 3"), | |
| "school_name": string or null, | |
| "school_tier_accreditation": string or null (e.g., "Public School - Accredited A", "Accredited A"), | |
| "expected_graduation_year": integer or null, | |
| "average_grade": number or null (scale 0-100), | |
| "math_score": number or null (scale 0-100), | |
| "english_score": number or null (scale 0-100), | |
| "major_subject_average": number or null (scale 0-100), | |
| "extracurricular_achievements": string or null, | |
| "olympiad_level": string or null (e.g., "City / District", "Provincial", "National", "International"), | |
| "intended_career_track": string or null (e.g., "Industry / Tech", "Academic / Research", "Public Service"), | |
| "willing_to_return_home": boolean or null, | |
| "needs_full_funding": boolean or null | |
| }, | |
| "skills": { | |
| "hard_skills": [string], | |
| "soft_skills": [string], | |
| "languages": [string], | |
| "language_certificates": [ | |
| {"test_type": string, "score": number, "valid_until": string} | |
| ], | |
| "target_countries": [string] | |
| } | |
| } | |
| Rules: | |
| - For academic scores, look for GPA, average grade, subject scores on a scale of 0-100 | |
| - olympiad_level should be one of: None, School Level, City / District, Provincial, National, International | |
| - economic_background should reflect the family income level mentioned (or null if not stated) | |
| - from_underrepresented_region is true only if explicitly mentioned (e.g., 3T region, underprivileged area) | |
| - Extract ALL skills listed β do not limit to a few examples | |
| - For language certificates, extract test type (IELTS, TOEFL, etc.), score, and expiry date | |
| - If the document contains both personal CV info AND academic records, combine them into one profile | |
| """ | |
| def _extract_json_from_text(text: str) -> Optional[dict[str, Any]]: | |
| """Extract JSON from LLM response text.""" | |
| cleaned = text.strip() | |
| # Strip markdown code fences if present | |
| if cleaned.startswith("```"): | |
| lines = cleaned.split("\n") | |
| json_lines = [] | |
| inside_block = False | |
| for line in lines: | |
| stripped = line.strip() | |
| if stripped.startswith("```"): | |
| inside_block = not inside_block | |
| continue | |
| if inside_block or not any(c.isalpha() for c in stripped[:1]): | |
| json_lines.append(line) | |
| cleaned = "\n".join(json_lines).strip() | |
| # Try finding JSON object boundaries | |
| start = cleaned.find("{") | |
| end = cleaned.rfind("}") | |
| if start >= 0 and end > start: | |
| try: | |
| return json.loads(cleaned[start:end + 1]) | |
| except json.JSONDecodeError: | |
| pass | |
| # Try parsing the whole text as JSON | |
| try: | |
| result = json.loads(cleaned) | |
| if isinstance(result, dict): | |
| return result | |
| except json.JSONDecodeError: | |
| pass | |
| return None | |
| def parse_cv( | |
| llm_client: "LLMClient", | |
| file_bytes: bytes, | |
| filename: str = "", | |
| ) -> Optional[dict[str, Any]]: | |
| """Parse a CV/resume file and extract student profile data. | |
| Sends the raw file (PDF or image) to the LLM's vision endpoint for parsing. | |
| Works for both text-based PDFs and scanned images. | |
| Args: | |
| llm_client: LLMClient instance with valid configuration. | |
| file_bytes: Raw file content (PDF or image). | |
| filename: Original filename for MIME detection. | |
| Returns: | |
| Parsed student profile dict, or None on failure. | |
| """ | |
| if not llm_client.is_available: | |
| print("[CVParser] Skipping β LLM is unavailable", flush=True) | |
| return None | |
| mime_type = _get_mime_type(filename, file_bytes) | |
| try: | |
| response = llm_client._call_with_pdf_images( | |
| file_bytes, mime_type, _STUDENT_CV_PROMPT | |
| ) | |
| return _extract_json_from_text(response) if response else None | |
| except Exception as e: | |
| print(f"[CVParser] Parsing failed: {e}", flush=True) | |
| return None |