curated jobs and tags
This commit is contained in:
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Job;
|
||||||
|
use App\Models\Tag;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
#[Signature('jobs:curate {--batch=50}')]
|
||||||
|
#[Description('Curate jobs by fetching tags from an external API')]
|
||||||
|
class CurateJobs extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$batchSize = (int) $this->option('batch');
|
||||||
|
//$apiUrl = $this->option('api-url');
|
||||||
|
$apiUrl = 'https://loki.c0rrup7.net';
|
||||||
|
|
||||||
|
if (empty($apiUrl)) {
|
||||||
|
$this->error('API URL is required. Use --api-url option to provide the endpoint.');
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Starting job curation...');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Fetch uncurated jobs
|
||||||
|
$uncuratedJobs = Job::uncurated()
|
||||||
|
->limit($batchSize)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($uncuratedJobs->isEmpty()) {
|
||||||
|
$this->info('No uncurated jobs found.');
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Found {$uncuratedJobs->count()} uncurated jobs.");
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$curatedCount = 0;
|
||||||
|
$failedCount = 0;
|
||||||
|
|
||||||
|
$progressBar = $this->output->createProgressBar($uncuratedJobs->count());
|
||||||
|
$progressBar->start();
|
||||||
|
|
||||||
|
foreach ($uncuratedJobs as $job) {
|
||||||
|
try {
|
||||||
|
$body = 'This is the content of a job ad, you need to read it and extract the relevant tags from it. Respond only with the tags in JSON format, NO OTHER TEXT BEFOR OR AFTER THE JSON.';
|
||||||
|
$body .= 'Title: '. htmlentities($job->title);
|
||||||
|
$body .= 'Description: '. htmlentities($job->description);
|
||||||
|
|
||||||
|
// Make POST request to API with job name and description
|
||||||
|
$response = Http::timeout(130)
|
||||||
|
->withHeaders([
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
'X-Api-Key' => '1620539ff09a897a7281a8b626c145e5',
|
||||||
|
])
|
||||||
|
->post($apiUrl, [
|
||||||
|
'service' => 'ollama',
|
||||||
|
'model' => 'llama3',
|
||||||
|
'stream' => false,
|
||||||
|
'prompt' => $body,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$tags = $response->json();
|
||||||
|
|
||||||
|
if (is_array($tags) && !empty($tags)) {
|
||||||
|
$tagIds = [];
|
||||||
|
|
||||||
|
foreach ($tags as $tagName) {
|
||||||
|
// Find or create tag
|
||||||
|
$tag = Tag::firstOrCreate(
|
||||||
|
['name' => $tagName]
|
||||||
|
);
|
||||||
|
|
||||||
|
$tagIds[] = $tag->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync tags with the job
|
||||||
|
$job->tags()->sync($tagIds);
|
||||||
|
|
||||||
|
// Mark job as curated
|
||||||
|
$job->update(['is_curated' => true]);
|
||||||
|
|
||||||
|
$curatedCount++;
|
||||||
|
} else {
|
||||||
|
// Mark as curated even if no tags returned
|
||||||
|
$job->update(['is_curated' => true]);
|
||||||
|
$curatedCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$failedCount++;
|
||||||
|
Log::warning("Failed to curate job #{$job->id}: API returned {$response->status()}");
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$failedCount++;
|
||||||
|
Log::error("Error curating job #{$job->id}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$progressBar->finish();
|
||||||
|
$this->newLine(2);
|
||||||
|
|
||||||
|
$this->info("Successfully curated {$curatedCount} jobs.");
|
||||||
|
|
||||||
|
if ($failedCount > 0) {
|
||||||
|
$this->warn("Failed to curate {$failedCount} jobs. Check logs for details.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,7 +56,7 @@ class JobController extends Controller
|
|||||||
public function show(Job $job)
|
public function show(Job $job)
|
||||||
{
|
{
|
||||||
// Load relationships
|
// Load relationships
|
||||||
$job->load(['source', 'category', 'jobLocation']);
|
$job->load(['source', 'category', 'jobLocation', 'tags']);
|
||||||
|
|
||||||
// Track job view event
|
// Track job view event
|
||||||
AnalyticsEvent::logEvent(AnalyticsEvent::EVENT_JOB_VIEW, $job->id);
|
AnalyticsEvent::logEvent(AnalyticsEvent::EVENT_JOB_VIEW, $job->id);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class Job extends Model
|
|||||||
'posted_at',
|
'posted_at',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
'remote_allowed',
|
'remote_allowed',
|
||||||
|
'is_curated',
|
||||||
'experience_level',
|
'experience_level',
|
||||||
'job_category_id',
|
'job_category_id',
|
||||||
'job_location_id',
|
'job_location_id',
|
||||||
@@ -56,6 +57,7 @@ class Job extends Model
|
|||||||
'posted_at' => 'datetime',
|
'posted_at' => 'datetime',
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
'remote_allowed' => 'boolean',
|
'remote_allowed' => 'boolean',
|
||||||
|
'is_curated' => 'boolean',
|
||||||
'salary_min' => 'decimal:2',
|
'salary_min' => 'decimal:2',
|
||||||
'salary_max' => 'decimal:2',
|
'salary_max' => 'decimal:2',
|
||||||
];
|
];
|
||||||
@@ -93,6 +95,14 @@ class Job extends Model
|
|||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tags for this job.
|
||||||
|
*/
|
||||||
|
public function tags(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Tag::class);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope a query to only include active jobs.
|
* Scope a query to only include active jobs.
|
||||||
*/
|
*/
|
||||||
@@ -104,6 +114,14 @@ class Job extends Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope a query to only include uncurated jobs.
|
||||||
|
*/
|
||||||
|
public function scopeUncurated($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_curated', false);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scope a query to only include remote jobs.
|
* Scope a query to only include remote jobs.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class Tag extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['name', 'slug'];
|
||||||
|
|
||||||
|
protected static function boot()
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
static::creating(function ($tag) {
|
||||||
|
if (empty($tag->slug)) {
|
||||||
|
$tag->slug = Str::slug($tag->name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jobs(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(Job::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,9 +80,11 @@ class AdzunaTransformer implements JobTransformer
|
|||||||
$description = strtolower($data['description'] ?? '');
|
$description = strtolower($data['description'] ?? '');
|
||||||
$location = strtolower($data['location']['display_name'] ?? '');
|
$location = strtolower($data['location']['display_name'] ?? '');
|
||||||
|
|
||||||
return str_contains($title, 'remote') ||
|
// this gives false positives, find a better way, until then should be false
|
||||||
str_contains($description, 'remote') ||
|
// return str_contains($title, 'remote') ||
|
||||||
str_contains($location, 'remote');
|
// str_contains($description, 'remote') ||
|
||||||
|
// str_contains($location, 'remote');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function parseDate(?string $date): ?DateTime
|
private function parseDate(?string $date): ?DateTime
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('tags', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name')->unique();
|
||||||
|
$table->string('slug')->unique();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index('slug');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('tags');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('jobs', function (Blueprint $table) {
|
||||||
|
$table->boolean('is_curated')->default(false)->after('remote_allowed');
|
||||||
|
$table->index('is_curated');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('jobs', function (Blueprint $table) {
|
||||||
|
$table->dropIndex(['is_curated']);
|
||||||
|
$table->dropColumn('is_curated');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('job_tag', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('job_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->foreignId('tag_id')->constrained()->onDelete('cascade');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['job_id', 'tag_id']);
|
||||||
|
$table->index('job_id');
|
||||||
|
$table->index('tag_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('job_tag');
|
||||||
|
}
|
||||||
|
};
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 15 KiB |
@@ -143,6 +143,20 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
@if($job->tags->count() > 0)
|
||||||
|
<div class="mb-6">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 mb-2">Tags</h3>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
@foreach($job->tags as $tag)
|
||||||
|
<span class="inline-flex items-center px-3 py-1 bg-emerald-50 text-emerald-700 rounded-full text-sm border border-emerald-200">
|
||||||
|
{{ $tag->name }}
|
||||||
|
</span>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
<!-- Job Source -->
|
<!-- Job Source -->
|
||||||
@if($job->source)
|
@if($job->source)
|
||||||
<div class="mb-6 text-sm text-gray-600">
|
<div class="mb-6 text-sm text-gray-600">
|
||||||
|
|||||||
Reference in New Issue
Block a user