214 lines
8.4 KiB
Python
214 lines
8.4 KiB
Python
"""
|
|
=============================================================
|
|
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, []
|