TIF_E41253310/Backend/Model/preprocessing/interpretasi.py

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, []