From adca106244594b523dd1c30ba58c4235020e4873 Mon Sep 17 00:00:00 2001 From: Doar Mihai Date: Sat, 4 Apr 2026 20:37:35 +0300 Subject: [PATCH] curated jobs and tags --- app/Console/Commands/CurateJobs.php | 122 ++++++++++++++++++ app/Http/Controllers/Web/JobController.php | 2 +- app/Models/Job.php | 18 +++ app/Models/Tag.php | 28 ++++ .../JobApi/Transformers/AdzunaTransformer.php | 8 +- .../2026_04_04_155255_create_tags_table.php | 31 +++++ ...04_155301_add_is_curated_to_jobs_table.php | 30 +++++ ...2026_04_04_155311_create_job_tag_table.php | 33 +++++ public/favicon.ico | Bin 0 -> 15406 bytes resources/views/jobs/show.blade.php | 14 ++ 10 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 app/Console/Commands/CurateJobs.php create mode 100644 app/Models/Tag.php create mode 100644 database/migrations/2026_04_04_155255_create_tags_table.php create mode 100644 database/migrations/2026_04_04_155301_add_is_curated_to_jobs_table.php create mode 100644 database/migrations/2026_04_04_155311_create_job_tag_table.php diff --git a/app/Console/Commands/CurateJobs.php b/app/Console/Commands/CurateJobs.php new file mode 100644 index 0000000..f0353e0 --- /dev/null +++ b/app/Console/Commands/CurateJobs.php @@ -0,0 +1,122 @@ +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; + } +} diff --git a/app/Http/Controllers/Web/JobController.php b/app/Http/Controllers/Web/JobController.php index cf66676..64ceff1 100644 --- a/app/Http/Controllers/Web/JobController.php +++ b/app/Http/Controllers/Web/JobController.php @@ -56,7 +56,7 @@ class JobController extends Controller public function show(Job $job) { // Load relationships - $job->load(['source', 'category', 'jobLocation']); + $job->load(['source', 'category', 'jobLocation', 'tags']); // Track job view event AnalyticsEvent::logEvent(AnalyticsEvent::EVENT_JOB_VIEW, $job->id); diff --git a/app/Models/Job.php b/app/Models/Job.php index b227c72..ecf86d8 100644 --- a/app/Models/Job.php +++ b/app/Models/Job.php @@ -46,6 +46,7 @@ class Job extends Model 'posted_at', 'expires_at', 'remote_allowed', + 'is_curated', 'experience_level', 'job_category_id', 'job_location_id', @@ -56,6 +57,7 @@ class Job extends Model 'posted_at' => 'datetime', 'expires_at' => 'datetime', 'remote_allowed' => 'boolean', + 'is_curated' => 'boolean', 'salary_min' => 'decimal:2', 'salary_max' => 'decimal:2', ]; @@ -93,6 +95,14 @@ class Job extends Model ->withTimestamps(); } + /** + * Get the tags for this job. + */ + public function tags(): BelongsToMany + { + return $this->belongsToMany(Tag::class); + } + /** * 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. */ diff --git a/app/Models/Tag.php b/app/Models/Tag.php new file mode 100644 index 0000000..e6d270e --- /dev/null +++ b/app/Models/Tag.php @@ -0,0 +1,28 @@ +slug)) { + $tag->slug = Str::slug($tag->name); + } + }); + } + + public function jobs(): BelongsToMany + { + return $this->belongsToMany(Job::class); + } +} diff --git a/app/Services/JobApi/Transformers/AdzunaTransformer.php b/app/Services/JobApi/Transformers/AdzunaTransformer.php index 2d7355c..d78cb82 100644 --- a/app/Services/JobApi/Transformers/AdzunaTransformer.php +++ b/app/Services/JobApi/Transformers/AdzunaTransformer.php @@ -80,9 +80,11 @@ class AdzunaTransformer implements JobTransformer $description = strtolower($data['description'] ?? ''); $location = strtolower($data['location']['display_name'] ?? ''); - return str_contains($title, 'remote') || - str_contains($description, 'remote') || - str_contains($location, 'remote'); + // this gives false positives, find a better way, until then should be false +// return str_contains($title, 'remote') || +// str_contains($description, 'remote') || +// str_contains($location, 'remote'); + return false; } private function parseDate(?string $date): ?DateTime diff --git a/database/migrations/2026_04_04_155255_create_tags_table.php b/database/migrations/2026_04_04_155255_create_tags_table.php new file mode 100644 index 0000000..3832307 --- /dev/null +++ b/database/migrations/2026_04_04_155255_create_tags_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name')->unique(); + $table->string('slug')->unique(); + $table->timestamps(); + + $table->index('slug'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tags'); + } +}; diff --git a/database/migrations/2026_04_04_155301_add_is_curated_to_jobs_table.php b/database/migrations/2026_04_04_155301_add_is_curated_to_jobs_table.php new file mode 100644 index 0000000..3e11e47 --- /dev/null +++ b/database/migrations/2026_04_04_155301_add_is_curated_to_jobs_table.php @@ -0,0 +1,30 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_04_04_155311_create_job_tag_table.php b/database/migrations/2026_04_04_155311_create_job_tag_table.php new file mode 100644 index 0000000..23543cc --- /dev/null +++ b/database/migrations/2026_04_04_155311_create_job_tag_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/public/favicon.ico b/public/favicon.ico index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..d479f4041e69519351ba978f763f647f052afa43 100644 GIT binary patch literal 15406 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 literal 0 HcmV?d00001 diff --git a/resources/views/jobs/show.blade.php b/resources/views/jobs/show.blade.php index 3cad587..8e7454f 100644 --- a/resources/views/jobs/show.blade.php +++ b/resources/views/jobs/show.blade.php @@ -143,6 +143,20 @@ @endif + + @if($job->tags->count() > 0) +

+

Tags

+
+ @foreach($job->tags as $tag) + + {{ $tag->name }} + + @endforeach +
+
+ @endif + @if($job->source)