Files

207 lines
7.1 KiB
PHP
Raw Permalink Normal View History

2026-04-03 00:39:57 +03:00
<?php
namespace App\Services;
use App\Models\Job;
use App\Models\JobCategory;
use Illuminate\Support\Facades\Log;
class JobEnrichmentService
{
/**
* Category keywords mapping
*/
private array $categoryKeywords = [
'Software Development' => ['software', 'developer', 'engineer', 'programming', 'coding', 'web', 'mobile', 'frontend', 'backend', 'fullstack', 'full-stack'],
'Data Science' => ['data', 'analytics', 'scientist', 'machine learning', 'ml', 'ai', 'artificial intelligence', 'statistician'],
'Design' => ['designer', 'ux', 'ui', 'graphic', 'creative', 'figma', 'sketch', 'product design'],
'Marketing' => ['marketing', 'seo', 'social media', 'content', 'growth', 'digital marketing', 'brand'],
'Sales' => ['sales', 'account executive', 'business development', 'account manager', 'sales representative'],
'Customer Support' => ['support', 'customer service', 'help desk', 'technical support', 'customer success'],
'Human Resources' => ['hr', 'human resources', 'recruiter', 'talent', 'recruiting'],
'Finance' => ['finance', 'accountant', 'accounting', 'financial', 'controller', 'analyst'],
'Operations' => ['operations', 'logistics', 'supply chain', 'project manager', 'program manager'],
'DevOps' => ['devops', 'infrastructure', 'cloud', 'aws', 'kubernetes', 'docker', 'sre'],
'Product Management' => ['product manager', 'product owner', 'product lead'],
'Quality Assurance' => ['qa', 'quality', 'testing', 'test engineer', 'sdet'],
'Writing' => ['writer', 'content writer', 'copywriter', 'technical writer', 'editor'],
];
/**
* Auto-categorize a job based on title and description
*/
public function categorizeJob(Job $job): ?JobCategory
{
if ($job->job_category_id) {
return $job->category;
}
$text = strtolower($job->title . ' ' . $job->description);
foreach ($this->categoryKeywords as $categoryName => $keywords) {
foreach ($keywords as $keyword) {
if (str_contains($text, $keyword)) {
$category = JobCategory::firstOrCreate(
['slug' => \Str::slug($categoryName)],
['name' => $categoryName, 'description' => "Jobs related to {$categoryName}"]
);
$job->update(['job_category_id' => $category->id]);
Log::debug("Auto-categorized job #{$job->id} as '{$categoryName}'");
return $category;
}
}
}
return null;
}
/**
* Enrich job with additional data
*/
public function enrichJob(Job $job): void
{
// Auto-categorize if not categorized
if (!$job->job_category_id) {
$this->categorizeJob($job);
}
// Detect if remote from title/description
if (!$job->remote_allowed) {
$this->detectRemote($job);
}
// Parse salary if not set
if (!$job->salary_min && !$job->salary_max) {
$this->parseSalaryFromDescription($job);
}
// Infer experience level if not set
if (!$job->experience_level) {
$this->inferExperienceLevel($job);
}
}
/**
* Detect if job is remote based on title/description
*/
private function detectRemote(Job $job): void
{
$text = strtolower($job->title . ' ' . $job->description . ' ' . ($job->location ?? ''));
$remoteKeywords = ['remote', 'work from home', 'wfh', 'telecommute', 'distributed', 'anywhere'];
foreach ($remoteKeywords as $keyword) {
if (str_contains($text, $keyword)) {
$job->update(['remote_allowed' => true]);
Log::debug("Detected remote job #{$job->id}");
return;
}
}
}
/**
* Try to parse salary from description
*/
private function parseSalaryFromDescription(Job $job): void
{
$description = $job->description;
// Match patterns like $50,000-$80,000, $50k-80k, 50-80k, etc.
$patterns = [
'/\$?([\d,]+)k?\s*-\s*\$?([\d,]+)k?/i',
'/salary:\s*\$?([\d,]+)k?\s*-\s*\$?([\d,]+)k?/i',
'/compensation:\s*\$?([\d,]+)k?\s*-\s*\$?([\d,]+)k?/i',
];
foreach ($patterns as $pattern) {
if (preg_match($pattern, $description, $matches)) {
$min = (float) str_replace(',', '', $matches[1]);
$max = (float) str_replace(',', '', $matches[2]);
// If values look like they're in 'k' format (e.g., 50 instead of 50000)
if ($min < 1000) {
$min *= 1000;
$max *= 1000;
}
$job->update([
'salary_min' => $min,
'salary_max' => $max,
]);
Log::debug("Parsed salary for job #{$job->id}: {$min}-{$max}");
return;
}
}
}
/**
* Infer experience level from title/description
*/
private function inferExperienceLevel(Job $job): void
{
$text = strtolower($job->title);
$levelKeywords = [
'entry' => ['junior', 'entry', 'associate', 'intern', 'graduate', 'trainee'],
'mid' => ['mid', 'intermediate', 'regular'],
'senior' => ['senior', 'sr', 'lead developer', 'staff'],
'lead' => ['lead', 'principal', 'architect', 'head of'],
'executive' => ['director', 'vp', 'vice president', 'chief', 'cto', 'ceo', 'cfo', 'coo'],
];
foreach ($levelKeywords as $level => $keywords) {
foreach ($keywords as $keyword) {
if (str_contains($text, $keyword)) {
$job->update(['experience_level' => $level]);
Log::debug("Inferred experience level for job #{$job->id}: {$level}");
return;
}
}
}
// Default to mid-level if no keywords found
$job->update(['experience_level' => 'mid']);
}
/**
* Batch enrich multiple jobs
*/
public function enrichJobs(int $batchSize = 100): int
{
$jobs = Job::whereNull('job_category_id')
->orWhereNull('experience_level')
->limit($batchSize)
->get();
$enrichedCount = 0;
foreach ($jobs as $job) {
try {
$this->enrichJob($job);
$enrichedCount++;
} catch (\Exception $e) {
Log::error("Failed to enrich job #{$job->id}: " . $e->getMessage());
}
}
return $enrichedCount;
}
/**
* Normalize salary to USD (if we had currency conversion)
*/
public function normalizeSalary(Job $job): void
{
// Placeholder for currency conversion logic
// Would use an API like exchangerate-api.com or similar
if ($job->salary_currency !== 'USD') {
// Convert to USD
// $job->update(['salary_min' => $converted, 'salary_max' => $converted]);
}
}
}