Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 42 additions & 33 deletions app/Livewire/GenerateCoverLetter.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
use App\Exceptions\OpenAPICreditExceedException;
use App\Models\Airesponse;
use App\Models\JobListing;
use App\Services\AIService;
use App\Services\AI\BaseAIService;
use Illuminate\Support\Facades\Log;
use Livewire\Component;
use Illuminate\Support\Facades\Storage;

class GenerateCoverLetter extends Component
{
Expand Down Expand Up @@ -50,17 +50,8 @@ public function startGeneration(): void
$this->dispatch('open-chat');

$this->generateCoverLetter();
} catch (NonAuthenticatedUser|IncompleteProfileException $e) {
$this->dispatch('notify', [
'message' => $e->getMessage(),
'type' => 'error'
]);
} catch (AIServiceAPIKeyNotFound $e) {
ExceptionHappenEvent::dispatch($e);
$this->dispatch('notify', [
'message' => 'Something went wrong with ai service. Please try later',
'type' => 'error'
]);
} catch (NonAuthenticatedUser|IncompleteProfileException | AIServiceAPIKeyNotFound $e) {
$this->handleError($e->getMessage());
} catch (\Throwable $e) {
}
}
Expand All @@ -84,17 +75,28 @@ public function regenerateWithFeedback(): void
private function generateCoverLetter(bool $isRegeneration = false, ?string $previousAnswer = null): void
{
try {
$aiService = app(AIService::class);

$response = $aiService->getChatResponse(
auth()->user(),
$this->jobListing->toArray(),
function($partial) {
$this->stream('answer', $partial);
},
$isRegeneration ? $this->feedback : null,
$previousAnswer
);
$aiService = app(BaseAIService::class);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't create services like this. better use dependency injection. It will be easy to test.

try {
$response = $aiService->getChatResponse(
auth()->user(),
$this->jobListing->toArray(),
function($partial) {
Illuminate\Support\Facades\Log::info('Streaming partial response', ['length' => strlen($partial)]);
$this->stream('answer', $partial);
},
$isRegeneration ? $this->feedback : null,
$previousAnswer
);
} catch (\Throwable $e) {
Log::error('Error in getChatResponse', [
'message' => $e->getMessage(),
'class' => get_class($e),
'file' => $e->getFile(),
'line' => $e->getLine(),
'trace' => $e->getTraceAsString()
]);
throw $e;
}

Airesponse::query()
->create([
Expand All @@ -106,20 +108,27 @@ function($partial) {
$this->answer = $response;
$this->isGenerating = false;


} catch (DailyChatLimitExceededException $e){
$this->answer = 'Sorry, you have exceeded the daily limit for chat requests. Please try again later.';
$this->isGenerating = false;
} catch (DailyChatLimitExceededException $e) {
$this->handleError('Sorry, you have exceeded the daily limit for chat requests. Please try again later.');
} catch (OpenAPICreditExceedException $e) {
$this->answer = 'Something went wrong with ai service. Please try later';
$this->isGenerating = false;
} catch (\Exception $e) {
$this->answer = 'Sorry, there was an error generating your cover letter. Please try again.';
Log::error('OpenAI credits exceeded');
$this->handleError('Our AI service is currently unavailable. Please try again later.');
} catch (\Exception $e) {
Log::error('Cover Letter Generation Error', [
'error' => $e->getMessage(),
'class' => get_class($e),
'trace' => $e->getTraceAsString()
]);
$this->handleError('An error occurred while generating your cover letter. Please try again.');
} finally {
$this->isGenerating = false;
} catch (\Throwable $e) {
}
}

private function handleError(string $message): void
{
$this->answer = $message;
}
public function render()
{
return view('livewire.generate-cover-letter');
Expand Down
40 changes: 40 additions & 0 deletions app/Providers/AIServiceProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace App\Providers;

use App\Services\AI\BaseAIService;
use App\Services\AI\OpenAIService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\ServiceProvider;

class AIServiceProvider extends ServiceProvider
{
/**
* Register services.
*/
public function register(): void
{
$this->app->bind(BaseAIService::class, OpenAIService::class);
}


/**
* Bootstrap services.
*/
public function boot(): void
{
$this->registerOpenAIMacro();
}

private function registerOpenAIMacro(): void
{
Http::macro('openai', function () {
$apiKey = config('ai.chat_gpt_api_key');

return Http::withHeaders([
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $apiKey,
])->baseUrl('https://api.openai.com/v1/chat');
});
}
}
11 changes: 0 additions & 11 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ private function registerHttpMacros(): void
try {

$this->registerJobMacro();
$this->registerOpenAIMacro();
} catch (\Exception $e){
logger('Error on app service provider', [
'message' => $e->getMessage(),
Expand Down Expand Up @@ -180,17 +179,7 @@ private function registerJobMacro(): void
});
}

private function registerOpenAIMacro(): void
{
Http::macro('openai', function () {
$apiKey = config('ai.chat_gpt_api_key');

return Http::withHeaders([
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . $apiKey,
])->baseUrl('https://api.openai.com/v1/chat');
});
}

private function configureRateLimiter(): void
{
Expand Down
178 changes: 178 additions & 0 deletions app/Services/AI/BaseAIService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
<?php

namespace App\Services\AI;

use App\Models\User;
use App\Exceptions\DailyChatLimitExceededException;
use Illuminate\Support\Facades\Log;

abstract class BaseAIService
{
protected const DAILY_LIMIT = 40;

final public function getChatResponse(User $user, array $jobData, callable $callback, ?string $feedback = null, ?string $previousAnswer = null): string
{
try {
Log::info('BaseAIService: Starting getChatResponse');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You shouldn't log unnecessary information too much as info. Better use these as debug logs if necessary. Otherwise, your log will get full of information that might not be needed generally.


Log::info('BaseAIService: Checking limits');
$this->checkLimits($user);

Log::info('BaseAIService: Preparing messages');
$messages = $this->prepareMessages($user, $jobData, $feedback, $previousAnswer);

Log::info('BaseAIService: Calling processResponse', [
'messagesCount' => count($messages)
]);

return $this->processResponse($messages, $callback);

} catch (\Exception $e) {
Log::error('BaseAIService: Error in getChatResponse', [
'error' => $e->getMessage(),
'class' => get_class($e),
'trace' => $e->getTraceAsString()
]);
throw $e;
}
}


protected function checkLimits(User $user): void
{
try {
$todayResponses = $this->getUserDailyResponses($user);
Log::info('BaseAIService: Checking limits', [
'todayResponses' => $todayResponses,
'limit' => static::DAILY_LIMIT
]);

if ($todayResponses >= static::DAILY_LIMIT) {
Log::warning('BaseAIService: Daily limit exceeded', [
'userId' => $user->id,
'limit' => static::DAILY_LIMIT
]);
throw new DailyChatLimitExceededException();
}
} catch (\Exception $e) {
Log::error('BaseAIService: Error in checkLimits', [
'error' => $e->getMessage()
]);
throw $e;
}
}


protected function prepareMessages(User $user, array $jobData, ?string $feedback, ?string $previousAnswer): array
{
$messages = [
[
'role' => 'system',
'content' => $this->getSystemPrompt()
]
];

if ($feedback) {
$messages[] = [
'role' => 'user',
'content' => $this->buildPrompt($user, $jobData)
];
$messages[] = [
'role' => 'assistant',
'content' => $previousAnswer ?? ''
];
$messages[] = [
'role' => 'user',
'content' => $this->buildFeedbackPrompt($feedback)
];
} else {
$messages[] = [
'role' => 'user',
'content' => $this->buildPrompt($user, $jobData)
];
}

return $messages;
}


protected function getUserDailyResponses(User $user): int
{
try {
$count = \App\Models\Airesponse::query()
->where('user_id', $user->id)
->whereDate('created_at', today())
->count();

Log::info('BaseAIService: Daily responses count', ['count' => $count]);

return $count;
} catch (\Exception $e) {
Log::error('BaseAIService: Error getting daily responses', [
'error' => $e->getMessage()
]);
throw $e;
}
}


protected function buildPrompt(User $user, array $jobData): string
{
$skills = is_array($user->skills) ? implode(', ', $user->skills) : '';

$experienceData = is_string($user->experience)
? json_decode($user->experience, true)
: $user->experience;

if (json_last_error() !== JSON_ERROR_NONE && is_string($user->experience)) {
Log::error('JSON decode error', [
'error' => json_last_error_msg(),
'experience' => $user->experience
]);
$experienceData = [];
}

$experience = $this->formatExperience($experienceData ?? []);

return $this->getPromptTemplate($user, $jobData, $skills, $experience);
}

protected function formatExperience(array $experience): string
{
$experienceItems = [];
foreach ($experience as $exp) {
if (isset($exp['title']) && isset($exp['company'])) {
$duration = $this->formatDuration($exp);
$experienceItems[] = "{$exp['title']} at {$exp['company']} ($duration)";
}
}
return implode("\n", $experienceItems);
}


protected function formatDuration(array $exp): string
{
$duration = '';
if (isset($exp['start_date'])) {
$duration .= $exp['start_date'];
if (isset($exp['end_date'])) {
$duration .= " - " . $exp['end_date'];
}
}
return $duration;
}

protected function buildFeedbackPrompt(string $feedback): string
{
return "Please improve the cover letter based on this feedback: {$feedback}. Keep the same professional tone but incorporate these changes.";
}


protected function getSystemPrompt(): string
{
return 'You are a professional CV writer helping to generate a cover letter. Create compelling, personalized cover letters that highlight the candidate\'s relevant experience and skills.';
}

abstract protected function processResponse(array $messages, callable $callback): string;
abstract protected function getPromptTemplate(User $user, array $jobData, string $skills, string $experience): string;
}
Loading