diff --git a/app/Livewire/GenerateCoverLetter.php b/app/Livewire/GenerateCoverLetter.php index 06899076..5fc35006 100644 --- a/app/Livewire/GenerateCoverLetter.php +++ b/app/Livewire/GenerateCoverLetter.php @@ -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 { @@ -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) { } } @@ -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); + 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([ @@ -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'); diff --git a/app/Providers/AIServiceProvider.php b/app/Providers/AIServiceProvider.php new file mode 100644 index 00000000..44042b0e --- /dev/null +++ b/app/Providers/AIServiceProvider.php @@ -0,0 +1,40 @@ +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'); + }); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e5529534..3665d0cf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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(), @@ -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 { diff --git a/app/Services/AI/BaseAIService.php b/app/Services/AI/BaseAIService.php new file mode 100644 index 00000000..d3d66bc1 --- /dev/null +++ b/app/Services/AI/BaseAIService.php @@ -0,0 +1,178 @@ +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; +} diff --git a/app/Services/AI/OpenAIService.php b/app/Services/AI/OpenAIService.php new file mode 100644 index 00000000..8cda21ad --- /dev/null +++ b/app/Services/AI/OpenAIService.php @@ -0,0 +1,161 @@ +withOptions(['stream' => true]) + ->withHeaders(['Accept' => 'text/event-stream']) + ->post('/completions', [ + 'model' => 'gpt-3.5-turbo-16k', + 'messages' => $messages, + 'temperature' => 0.7, + 'stream' => true, + 'max_tokens' => 1000, + 'presence_penalty' => 0.6, + 'frequency_penalty' => 0.5 + ]); + + if ($response->failed()) { + $errorBody = $response->json(); + + if ($response->status() === 429 && isset($errorBody['error']['code']) && $errorBody['error']['code'] === 'insufficient_quota') { + throw new OpenAPICreditExceedException('OpenAI API quota exceeded'); + } + + throw new \RuntimeException('Failed to get response from OpenAI: ' . ($errorBody['error']['message'] ?? 'Unknown error')); + } + + return $this->handleStream($response->getBody(), $callback); + + } catch (\Illuminate\Http\Client\RequestException $e) { + Log::error('HTTP Request Exception', [ + 'message' => $e->getMessage(), + 'response' => $e->response?->json() + ]); + throw $e; + } + + } catch (\Exception $e) { + Log::error('OpenAI Service Fatal Error', [ + 'error' => $e->getMessage(), + 'class' => get_class($e), + 'trace' => $e->getTraceAsString() + ]); + throw $e; + } + } + + protected function handleStream($stream, callable $callback): string + { + try { + $buffer = ''; + $fullResponse = ''; + + while (!$stream->eof()) { + try { + $chunk = $stream->read(1024); + + $lines = explode("\n", $chunk); + + foreach ($lines as $line) { + $line = trim($line); + if (empty($line)) continue; + if ($line === 'data: [DONE]') break; + + if (str_starts_with($line, 'data: ')) { + $json = substr($line, 6); + $data = json_decode($json, true); + + if (isset($data['choices'][0]['delta']['content'])) { + $text = $data['choices'][0]['delta']['content']; + $buffer .= $text; + $fullResponse .= $text; + + if ($this->shouldFlushBuffer($buffer)) { + $callback($buffer); + $buffer = ''; + } + } + } + } + } catch (\Exception $e) { + Log::error('Stream processing error', [ + 'error' => $e->getMessage(), + 'position' => ftell($stream) + ]); + throw $e; + } + } + + if (!empty($buffer)) { + $callback($buffer); + } + + return $fullResponse; + + } catch (\Exception $e) { + Log::error('Stream handling error', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString() + ]); + throw $e; + } + } + protected function shouldFlushBuffer(string $buffer): bool + { + return str_ends_with($buffer, '.') || + str_ends_with($buffer, '!') || + str_ends_with($buffer, '?') || + str_ends_with($buffer, "\n") || + strlen($buffer) > 100; + } + + protected function getPromptTemplate(User $user, array $jobData, string $skills, string $experience): string + { + return <<name} + Current Role: {$user->occupation} + Location: {$user->state}, {$user->country} + + Key Skills: + {$skills} + + Professional Experience: + {$experience} + + Additional Information: + {$user->bio} + + Guidelines: + 1. Start with a strong opening paragraph that mentions the specific role and company + 2. Demonstrate understanding of the company's needs and how the candidate's experience matches them + 3. Use specific examples from the candidate's experience to demonstrate relevant skills + 4. Keep a professional yet enthusiastic tone + 5. Include a strong closing paragraph expressing interest in next steps + 6. Format the letter properly with appropriate spacing and paragraphs + 7. Ensure the letter is concise but comprehensive (around 300-400 words) + 8. Highlight key achievements and skills that directly relate to the job requirements + + Note: Focus on creating a personalized letter that shows why this candidate is uniquely qualified for this specific role. + EOT; + } +} diff --git a/app/Services/AIService.php b/app/Services/AIService.php deleted file mode 100644 index 51f72430..00000000 --- a/app/Services/AIService.php +++ /dev/null @@ -1,179 +0,0 @@ -where('user_id', $user->id) - ->whereDate('created_at', today()) - ->count(); - - return $todayResponses < self::DAILY_LIMIT; - } - - /** - * @throws \Throwable - */ - public function getChatResponse(User $user, array $jobData, callable $callback, ?string $feedback = null, ?string $previousAnswer = null): string - { - throw_if(! $this->checkUserLimit($user) , new DailyChatLimitExceededException("You've reached your daily limit of " . self::DAILY_LIMIT . " cover letter generations")); - - $messages = [ - [ - 'role' => 'system', - 'content' => '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.' - ] - ]; - - if ($feedback) { - $messages[] = [ - 'role' => 'user', - 'content' => $this->buildPrompt($user, $jobData) - ]; - $messages[] = [ - 'role' => 'assistant', - 'content' => $previousAnswer ?? '' - ]; - $messages[] = [ - 'role' => 'user', - 'content' => "Please improve the cover letter based on this feedback: {$feedback}. Keep the same professional tone but incorporate these changes." - ]; - } else { - $messages[] = [ - 'role' => 'user', - 'content' => $this->buildPrompt($user, $jobData) - ]; - } - - $response = Http::openai() - ->withOptions([ - 'stream' => true, - ]) - ->withHeaders([ - 'Accept' => 'text/event-stream', - ]) - ->post('completions', [ - 'model' => 'gpt-3.5-turbo-16k', - 'messages' => $messages, - 'temperature' => 0.7, - 'stream' => true, - 'max_tokens' => 1000, - 'presence_penalty' => 0.6, - 'frequency_penalty' => 0.5 - ]); - throw_if($response->status() === 429, new OpenAPICreditExceedException()); - $buffer = ''; - $fullResponse = ''; - - - - $stream = $response->getBody(); - - while (!$stream->eof()) { - $chunk = $stream->read(1024); - $lines = explode("\n", $chunk); - - foreach ($lines as $line) { - $line = trim($line); - if (empty($line)) continue; - if ($line === 'data: [DONE]') break; - - if (str_starts_with($line, 'data: ')) { - $json = substr($line, 6); - $data = json_decode($json, true); - - if (isset($data['choices'][0]['delta']['content'])) { - $text = $data['choices'][0]['delta']['content']; - $buffer .= $text; - $fullResponse .= $text; - - if (str_ends_with($buffer, '.') || - str_ends_with($buffer, '!') || - str_ends_with($buffer, '?') || - str_ends_with($buffer, "\n") || - strlen($buffer) > 100) { - $callback($buffer); - $buffer = ''; - } - } - } - } - } - - if (!empty($buffer)) { - $callback($buffer); - } - - return $fullResponse; - } - - private function buildPrompt(User $user, array $jobData): string - { - $skills = is_array($user->skills) ? implode(', ', $user->skills) : ''; - - $experience = ''; - if (is_array($user->experience)) { - $experienceItems = []; - foreach ($user->experience as $exp) { - if (isset($exp['title']) && isset($exp['company'])) { - $duration = ''; - if (isset($exp['start_date'])) { - $duration .= $exp['start_date']; - if (isset($exp['end_date'])) { - $duration .= " - " . $exp['end_date']; - } - } - $experienceItems[] = "{$exp['title']} at {$exp['company']} ($duration)"; - } - } - $experience = implode("\n", $experienceItems); - } - - return <<name} - Current Role: {$user->occupation} - Location: {$user->state}, {$user->country} - - Key Skills: - {$skills} - - Professional Experience: - {$experience} - - Additional Information: - {$user->bio} - - Guidelines: - 1. Start with a strong opening paragraph that mentions the specific role and company - 2. Demonstrate understanding of the company's needs and how the candidate's experience matches them - 3. Use specific examples from the candidate's experience to demonstrate relevant skills - 4. Keep a professional yet enthusiastic tone - 5. Include a strong closing paragraph expressing interest in next steps - 6. Format the letter properly with appropriate spacing and paragraphs - 7. Ensure the letter is concise but comprehensive (around 300-400 words) - 8. Highlight key achievements and skills that directly relate to the job requirements - - Note: Focus on creating a personalized letter that shows why this candidate is uniquely qualified for this specific role. - EOT; - } -} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 22744d11..b67f4f23 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,6 +1,7 @@