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 e69de29..d479f40 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ 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)