""" ============================================================= INTERPRETASI CLUSTER & RELEVANSI PROFIL LULUSAN (C0 - C3) ============================================================= PRD Reference: Section 7, 18, & 24 Tujuan: - Normalisasi teks dan pemecahan kata majemuk (Smart CamelCase / Suffix Split) - Pencocokan teks pekerjaan dengan kamus profil prodi (Rule-Based Matching) - Penentuan kategori relevansi karir C0, C1, C2, C3 secara deterministik ============================================================= """ import re import pandas as pd from typing import Dict, List, Tuple, Optional, Any from Model.config.default_keywords import ( PRODI_CODES, DEFAULT_KEYWORD_DICTIONARY, DEFAULT_BIDANG_KERJA_UMUM, ) # Export default dictionary for backwards compatibility KEYWORD_DICTIONARY = DEFAULT_KEYWORD_DICTIONARY BIDANG_KERJA_UMUM = DEFAULT_BIDANG_KERJA_UMUM NON_PROFILE_KEYWORDS = DEFAULT_BIDANG_KERJA_UMUM def normalize_text(text: Any) -> str: """ Normalisasi teks untuk pencocokan kata kunci: 1. Pemecahan CamelCase (misal: 'IndraTech' -> 'Indra Tech') 2. Pemecahan kata majemuk berakhiran teknologi/dev/soft/craft/coding 3. Case folding & penghapusan tanda baca khusus """ if pd.isna(text) or text is None or text == '': return '' text = str(text) # 1. Pisahkan camelCase text = re.sub(r'([a-z])([A-Z])', r'\1 \2', text) text = text.lower() # 2. Pisahkan kata majemuk yang menempel dengan akhiran tech/dev/soft/coding/craft text = re.sub( r'(\b[a-zA-Z]{3,})(tech|technology|teknologi|dev|soft|software|coding|koding|craft)\b', r'\1 \2', text ) # 3. Ganti tanda baca pemisah menjadi spasi text = re.sub(r'[/\\_.\-,()\[\]{}:;+*&^%$#@!~?<>|\"\']', ' ', text) # 4. Hapus spasi ganda text = re.sub(r'\s+', ' ', text) return text.strip() # Cache pola regex terkompilasi agar proses matching berjalan instan (<0.05 detik untuk ribuan alumni) _COMPILED_KEYWORDS_CACHE: Dict[str, List[Tuple[str, Any]]] = {} def _get_compiled_keywords_for_prodi(prodi_name: str, dict_source: Dict[str, List[str]]) -> List[Tuple[str, Any]]: keywords = dict_source.get(prodi_name, []) cache_key = f"{prodi_name}_{len(keywords)}" if cache_key not in _COMPILED_KEYWORDS_CACHE: compiled_list = [] for kw in keywords: kw_norm = normalize_text(kw) if kw_norm: compiled_list.append((kw, re.compile(r'\b' + re.escape(kw_norm) + r'\b'))) _COMPILED_KEYWORDS_CACHE[cache_key] = compiled_list return _COMPILED_KEYWORDS_CACHE[cache_key] def match_keywords( teks_pekerjaan: str, prodi_code: str, custom_keyword_dict: Optional[Dict[str, List[str]]] = None ) -> Tuple[bool, List[str], bool]: """ Cek apakah teks pekerjaan match dengan kamus prodi menggunakan Word-Boundary Matching (\b...\b). Dapat menerima kamus dinamis dari database (custom_keyword_dict). """ teks_normalized = normalize_text(teks_pekerjaan) if not teks_normalized: return False, [], False # Resolusi prodi_code ke singkatan resmi (TIF, MIF, TKK) prodi_name = PRODI_CODES.get(str(prodi_code).strip(), str(prodi_code).strip()) # Gunakan kamus dinamis jika disediakan, fallback ke DEFAULT dict_source = custom_keyword_dict if custom_keyword_dict is not None else DEFAULT_KEYWORD_DICTIONARY if prodi_name not in dict_source: return False, [], False compiled_kws = _get_compiled_keywords_for_prodi(prodi_name, dict_source) matched_keywords = [] for orig_kw, pattern in compiled_kws: if pattern.search(teks_normalized): matched_keywords.append(orig_kw) return len(matched_keywords) > 0, matched_keywords, False _BIDANG_COMPILED_CACHE: List[Any] = [] def _get_compiled_bidang(bidang_list: List[str]) -> List[Any]: global _BIDANG_COMPILED_CACHE if not _BIDANG_COMPILED_CACHE or len(_BIDANG_COMPILED_CACHE) != len(bidang_list): _BIDANG_COMPILED_CACHE = [re.compile(r'\b' + re.escape(kw) + r'\b') for kw in bidang_list if kw] return _BIDANG_COMPILED_CACHE def is_bidang_diketahui( teks_pekerjaan: str, custom_bidang_list: Optional[List[str]] = None ) -> bool: """ Cek apakah bidang pekerjaan alumni dapat diidentifikasi pada sektor formal non-IT (C1 vs C2). """ teks = normalize_text(teks_pekerjaan) if len(teks) < 3: return False bidang_list = custom_bidang_list if custom_bidang_list is not None else DEFAULT_BIDANG_KERJA_UMUM compiled_bidang = _get_compiled_bidang(bidang_list) for pattern in compiled_bidang: if pattern.search(teks): return True return False def interpretasi_categori( row: Dict[str, Any], c3_df: Optional[Any] = None, custom_keyword_dict: Optional[Dict[str, List[str]]] = None ) -> Tuple[str, bool, List[str]]: """ Interpretasi alumni ke kategori C0 - C3 (PRD Section 18): - Priority 1 (C3): Belum/tidak bekerja ATAU data pekerjaan kosong - Priority 2 (C0): Bekerja + Match kamus kurikulum prodi asal (Sesuai Profil) - Priority 3 (C1): Bekerja + Sektor formal teridentifikasi tapi non-IT (Bidang Diketahui) - Priority 4 (C2): Bekerja + Deskripsi generik/ambigu (Kurang Teridentifikasi) """ nim = str(row.get('nim', '')).strip() # Priority 1A: Cek apakah terdaftar di list C3 if c3_df is not None and hasattr(c3_df, 'empty') and not c3_df.empty and 'nim' in c3_df.columns: if nim in c3_df['nim'].astype(str).values: return 'C3', False, [] # Priority 1B: Cek status tidak/belum bekerja status = str(row.get('status_pekerjaan', row.get('status_kerja', ''))).lower().strip() if status in ['belum_kerja', 'mencari_kerja', 'belum_bekerja', 'tidak_kerja', 'melanjutkan', 'belum bekerja', 'tidak bekerja', 'mencari kerja']: return 'C3', False, [] # Priority 1C: Cek apakah seluruh data kosong total tanpa status bekerja nama_perusahaan = str(row.get('nama_perusahaan', row.get('pekerjaan', ''))).strip() jabatan = str(row.get('jabatan', row.get('instansi', ''))).strip() jenis_perusahaan = str(row.get('jenis_perusahaan', '')).strip() def is_empty_val(v: str) -> bool: return v.lower() in ['-', '', 'none', 'null', 'kosong', 'tidak ada', 'belum kerja', 'tidak bekerja', 'n/a', '0', '0.0'] # Jika status kosong/tidak jelas dan semua field pekerjaan kosong total -> C3 if is_empty_val(status) and is_empty_val(nama_perusahaan) and is_empty_val(jabatan) and is_empty_val(jenis_perusahaan): return 'C3', False, [] teks_pekerjaan = str(row.get('teks_pekerjaan', '')).strip() if not teks_pekerjaan or is_empty_val(teks_pekerjaan): teks_pekerjaan = f"{status if not is_empty_val(status) else ''} {jenis_perusahaan if not is_empty_val(jenis_perusahaan) else ''} {nama_perusahaan if not is_empty_val(nama_perusahaan) else ''} {jabatan if not is_empty_val(jabatan) else ''}".strip() if len(normalize_text(teks_pekerjaan)) < 3 and is_empty_val(status): return 'C3', False, [] # Ekstraksi kode prodi kode_prodi = str(row.get('kode_prodi', '')).strip() kode_prodi_clean = kode_prodi.replace('.0', '').strip() match = re.search(r'\((\d+)\)', kode_prodi) if match: kode_prodi_clean = match.group(1) prodi = PRODI_CODES.get(kode_prodi_clean, None) or PRODI_CODES.get(kode_prodi, None) # Priority 2: Cek Match Kamus Profil Prodi Asal -> C0 if prodi: is_match, matched_kws, _ = match_keywords(teks_pekerjaan, prodi, custom_keyword_dict) else: is_match, matched_kws, _ = False, [], False if is_match: return 'C0', True, matched_kws # Priority 3: Cek apakah sektor formal non-IT diketahui -> C1 if is_bidang_diketahui(teks_pekerjaan): return 'C1', False, [] # Cek nama institusi formal eksplisit clean_norm = normalize_text(teks_pekerjaan) formal_orgs = ['pt', 'cv', 'ud', 'dinas', 'badan', 'yayasan', 'bank', 'sekolah', 'rumah sakit', 'rsud', 'polres', 'kodim', 'bumn', 'bumd', 'kementerian'] if len(clean_norm) >= 5 and any(w in clean_norm.split() for w in formal_orgs): tokens = [t for t in clean_norm.split() if t not in ['bekerja', 'perusahaan', 'swasta', 'wiraswasta', 'lainnya', 'tuliskan', 'f1102', 'f1101', 'f8', 'sendiri']] if len(tokens) >= 2: return 'C1', False, [] # Priority 4: Deskripsi tidak spesifik / hanya status formalitas -> C2 return 'C2', False, []