357 lines
12 KiB
Go
357 lines
12 KiB
Go
package openai
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
|
|
"github.com/jobs-scraper/internal/pkg/domain"
|
|
"github.com/sashabaranov/go-openai"
|
|
)
|
|
|
|
// JobAnalysisResult represents the structured response from job analysis
|
|
type JobAnalysisResult struct {
|
|
Recommendation string `json:"recommendation"`
|
|
ConfidenceScore int `json:"confidence_score"`
|
|
MatchingSkills []string `json:"matching_skills"`
|
|
MissingSkills []string `json:"missing_skills"`
|
|
ExperienceMatch string `json:"experience_match"`
|
|
Summary string `json:"summary"`
|
|
ImprovementSuggestions []string `json:"improvement_suggestions"`
|
|
}
|
|
|
|
// ShouldApply returns true if the recommendation is to apply for the job
|
|
func (r *JobAnalysisResult) ShouldApply() bool {
|
|
return r.Recommendation == "apply"
|
|
}
|
|
|
|
// IsHighConfidence returns true if the confidence score is 70 or above
|
|
func (r *JobAnalysisResult) IsHighConfidence() bool {
|
|
return r.ConfidenceScore >= 70
|
|
}
|
|
|
|
// OpenAIService provides job analysis using OpenAI's API
|
|
type OpenAIService struct {
|
|
client *openai.Client
|
|
model string
|
|
}
|
|
|
|
// NewOpenAIService creates a new OpenAI service instance
|
|
func NewOpenAIService(apiKey, model string) *OpenAIService {
|
|
return NewOpenAIServiceWithBaseURL(apiKey, model, "")
|
|
}
|
|
|
|
// NewOpenAIServiceWithBaseURL creates a new OpenAI service instance with a custom base URL
|
|
func NewOpenAIServiceWithBaseURL(apiKey, model, baseURL string) *OpenAIService {
|
|
if model == "" {
|
|
model = openai.GPT4oMini // Default to GPT-4o-mini for cost efficiency
|
|
}
|
|
|
|
// Create client configuration
|
|
config := openai.DefaultConfig(apiKey)
|
|
|
|
// Use provided baseURL, or check environment variable, or use default
|
|
if baseURL != "" {
|
|
config.BaseURL = baseURL
|
|
} else if envBaseURL := os.Getenv("OPENAI_BASE_URL"); envBaseURL != "" {
|
|
config.BaseURL = envBaseURL
|
|
}
|
|
|
|
client := openai.NewClientWithConfig(config)
|
|
|
|
return &OpenAIService{
|
|
client: client,
|
|
model: model,
|
|
}
|
|
}
|
|
|
|
// AnalyzeJobDescription analyzes a job description against a CV using OpenAI
|
|
func (s *OpenAIService) AnalyzeJobDescription(cv string, jobDesc domain.JobDescription) (*JobAnalysisResult, error) {
|
|
// Build the user message with all the provided data
|
|
userMessage := fmt.Sprintf(`Analyze the following CV against the job description and criteria, then provide a recommendation following the schema below.
|
|
1) If there are missing skills, try to guess if they still match based on similar skills or experience in the cv.
|
|
for example: Javascript is mentioned in the cv, but the job requires Vanilla js, since they are the same thing, it should be included in the matching skills.
|
|
|
|
2) The job shouldn't require any language skills, preferably only english.
|
|
|
|
3) The job should be remote, or provide relocation to the country.
|
|
CV:
|
|
%s
|
|
|
|
Job Description:
|
|
%s
|
|
|
|
Job Criteria (key-value):
|
|
%v
|
|
|
|
CRITICAL OUTPUT REQUIREMENTS:
|
|
- Return ONLY raw JSON - NO markdown formatting whatsoever
|
|
- NO backticks, NO code blocks, NO json prefix
|
|
- NO additional text before or after the JSON
|
|
- Start your response directly with { and end with }
|
|
- Use this exact schema and key names:
|
|
{
|
|
"recommendation": "apply" | "do_not_apply",
|
|
"confidence_score": number, // integer 0-100
|
|
"matching_skills": [string],
|
|
"missing_skills": [string],
|
|
"experience_match": "excellent" | "good" | "fair" | "poor",
|
|
"summary": string,
|
|
"improvement_suggestions": [string]
|
|
}`, cv, jobDesc.Description, jobDesc.Criteria)
|
|
|
|
// Create the chat completion request
|
|
req := openai.ChatCompletionRequest{
|
|
Model: s.model,
|
|
Messages: []openai.ChatCompletionMessage{
|
|
{
|
|
Role: openai.ChatMessageRoleSystem,
|
|
Content: "You are an expert HR assistant specializing in job application analysis. You help candidates determine if they should apply for specific positions based on their CV and the job requirements. CRITICAL: You must respond with ONLY raw JSON - no markdown, no code blocks, no backticks, no additional text. Start directly with { and end with }.",
|
|
},
|
|
{
|
|
Role: openai.ChatMessageRoleUser,
|
|
Content: userMessage,
|
|
},
|
|
},
|
|
Temperature: 0.3, // Lower temperature for more consistent responses
|
|
MaxTokens: 1000,
|
|
}
|
|
|
|
// Execute the request
|
|
resp, err := s.client.CreateChatCompletion(context.Background(), req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create chat completion: %v", err)
|
|
}
|
|
|
|
if len(resp.Choices) == 0 {
|
|
return nil, fmt.Errorf("no response choices received from OpenAI API")
|
|
}
|
|
|
|
// Extract JSON from the response
|
|
jsonContent := resp.Choices[0].Message.Content
|
|
|
|
// Log the raw response for debugging
|
|
log.Printf("Raw OpenAI response: %s", jsonContent)
|
|
|
|
// Clean up markdown code blocks if present
|
|
jsonContent = cleanMarkdownCodeBlocks(jsonContent)
|
|
|
|
// Parse the JSON response into our struct
|
|
var result JobAnalysisResult
|
|
if err := json.Unmarshal([]byte(jsonContent), &result); err != nil {
|
|
return nil, fmt.Errorf("failed to parse JSON response: %v. Raw response: %s", err, jsonContent[:min(200, len(jsonContent))])
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
// GenerateCV creates a tailored CV based on a job description
|
|
func (s *OpenAIService) GenerateCV(originalCV string, jobDesc domain.JobDescription) (string, error) {
|
|
userMessage := fmt.Sprintf(`Based on the original CV and job description below, create a tailored CV that highlights relevant experience and skills for this specific position.
|
|
|
|
Original CV:
|
|
%s
|
|
|
|
Job Description:
|
|
%s
|
|
|
|
Job Criteria:
|
|
%v
|
|
|
|
Instructions:
|
|
1. Keep all factual information accurate - do not fabricate experience or skills
|
|
2. Reorganize and emphasize relevant experience and skills
|
|
3. Use keywords from the job description where appropriate
|
|
4. Maintain professional formatting
|
|
5. Focus on achievements and quantifiable results
|
|
6. Return the tailored CV in plain text format`, originalCV, jobDesc.Description, jobDesc.Criteria)
|
|
|
|
req := openai.ChatCompletionRequest{
|
|
Model: s.model,
|
|
Messages: []openai.ChatCompletionMessage{
|
|
{
|
|
Role: openai.ChatMessageRoleSystem,
|
|
Content: "You are an expert CV writer who specializes in tailoring resumes for specific job applications. You help candidates present their existing experience and skills in the most relevant way for each position, without fabricating information.",
|
|
},
|
|
{
|
|
Role: openai.ChatMessageRoleUser,
|
|
Content: userMessage,
|
|
},
|
|
},
|
|
Temperature: 0.7, // Higher temperature for more creative writing
|
|
MaxTokens: 2000,
|
|
}
|
|
|
|
resp, err := s.client.CreateChatCompletion(context.Background(), req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create chat completion: %v", err)
|
|
}
|
|
|
|
if len(resp.Choices) == 0 {
|
|
return "", fmt.Errorf("no response choices received from OpenAI API")
|
|
}
|
|
|
|
return resp.Choices[0].Message.Content, nil
|
|
}
|
|
|
|
// GenerateCoverLetter creates a cover letter based on CV and job description
|
|
func (s *OpenAIService) GenerateCoverLetter(cv string, jobDesc domain.JobDescription, companyName string) (string, error) {
|
|
userMessage := fmt.Sprintf(`Create a professional cover letter based on the CV and job description below.
|
|
|
|
CV:
|
|
%s
|
|
|
|
Job Description:
|
|
%s
|
|
|
|
Job Criteria:
|
|
%v
|
|
|
|
Company Name: %s
|
|
|
|
Instructions:
|
|
1. Write a compelling opening paragraph
|
|
2. Highlight relevant experience and skills from the CV
|
|
3. Show enthusiasm for the specific role and company
|
|
4. Keep it concise (3-4 paragraphs)
|
|
5. Use a professional but engaging tone
|
|
6. Include a strong closing paragraph`, cv, jobDesc.Description, jobDesc.Criteria, companyName)
|
|
|
|
req := openai.ChatCompletionRequest{
|
|
Model: s.model,
|
|
Messages: []openai.ChatCompletionMessage{
|
|
{
|
|
Role: openai.ChatMessageRoleSystem,
|
|
Content: "You are an expert career counselor who writes compelling cover letters that help candidates stand out while maintaining professionalism.",
|
|
},
|
|
{
|
|
Role: openai.ChatMessageRoleUser,
|
|
Content: userMessage,
|
|
},
|
|
},
|
|
Temperature: 0.8, // Higher temperature for more creative and engaging writing
|
|
MaxTokens: 1500,
|
|
}
|
|
|
|
resp, err := s.client.CreateChatCompletion(context.Background(), req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create chat completion: %v", err)
|
|
}
|
|
|
|
if len(resp.Choices) == 0 {
|
|
return "", fmt.Errorf("no response choices received from OpenAI API")
|
|
}
|
|
|
|
return resp.Choices[0].Message.Content, nil
|
|
}
|
|
|
|
// min returns the smaller of two integers
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// CreateJobDescription extracts structured job information from HTML content
|
|
func (s *OpenAIService) CreateJobDescription(htmlContent string) (*domain.JobDescription, error) {
|
|
prompt := fmt.Sprintf(`Extract job posting information from the following HTML content.
|
|
|
|
CRITICAL RULES:
|
|
1. Return ONLY valid JSON - no markdown, no code blocks, no backticks, no explanations
|
|
2. Use SINGLE-LINE JSON (no pretty printing, no newlines inside the JSON)
|
|
3. All criteria values MUST be strings (never use booleans, numbers, or arrays)
|
|
4. Properly escape all quotes inside strings using \"
|
|
5. Use only standard ASCII quotes ("), never smart quotes (" " ' ')
|
|
|
|
Required fields:
|
|
- description: Full job description as a single string
|
|
- criteria: Object with keys like title, company, location, salary, skills, experience, job_type, remote
|
|
|
|
Example (SINGLE LINE):
|
|
{"description":"Full job description text here...","criteria":{"title":"Software Engineer","location":"City, Country","salary":"$100k-$150k","skills":"Go, Python, Docker","experience":"3-5 years","job_type":"Full-time","remote":"Remote"}}
|
|
|
|
HTML Content:
|
|
%s`, htmlContent)
|
|
|
|
req := openai.ChatCompletionRequest{
|
|
Model: s.model,
|
|
Messages: []openai.ChatCompletionMessage{
|
|
{
|
|
Role: openai.ChatMessageRoleSystem,
|
|
Content: "You are a JSON extraction bot. You ONLY output valid single-line JSON. No markdown. No code blocks. No explanations. No pretty printing. Just raw minified JSON starting with { and ending with }. Always use standard ASCII double quotes (\"), never smart quotes.",
|
|
},
|
|
{
|
|
Role: openai.ChatMessageRoleUser,
|
|
Content: prompt,
|
|
},
|
|
},
|
|
Temperature: 0.1, // Low temperature for consistent structured output
|
|
MaxTokens: 2000,
|
|
}
|
|
|
|
resp, err := s.client.CreateChatCompletion(context.Background(), req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to execute completion: %v", err)
|
|
}
|
|
|
|
if len(resp.Choices) == 0 {
|
|
return nil, fmt.Errorf("no response choices received from API")
|
|
}
|
|
|
|
jsonContent := resp.Choices[0].Message.Content
|
|
|
|
log.Printf("Raw API response for job description: %s", jsonContent)
|
|
|
|
jsonContent = cleanMarkdownCodeBlocks(jsonContent)
|
|
|
|
log.Printf("Cleaned JSON (first 500 chars): %s", jsonContent[:min(500, len(jsonContent))])
|
|
|
|
var result struct {
|
|
Description string `json:"description"`
|
|
Criteria map[string]string `json:"criteria"`
|
|
}
|
|
|
|
if err := json.Unmarshal([]byte(jsonContent), &result); err != nil {
|
|
if syntaxErr, ok := err.(*json.SyntaxError); ok {
|
|
start := int(syntaxErr.Offset) - 50
|
|
if start < 0 {
|
|
start = 0
|
|
}
|
|
end := int(syntaxErr.Offset) + 50
|
|
if end > len(jsonContent) {
|
|
end = len(jsonContent)
|
|
}
|
|
log.Printf("JSON parse error at position %d, context: ...%s...", syntaxErr.Offset, jsonContent[start:end])
|
|
}
|
|
return nil, fmt.Errorf("failed to parse JSON response: %v. Cleaned JSON length: %d", err, len(jsonContent))
|
|
}
|
|
|
|
jobDescription := &domain.JobDescription{
|
|
Description: result.Description,
|
|
Criteria: result.Criteria,
|
|
}
|
|
|
|
return jobDescription, nil
|
|
}
|
|
|
|
// cleanMarkdownCodeBlocks removes markdown code block formatting from JSON response
|
|
func cleanMarkdownCodeBlocks(content string) string {
|
|
// Remove ```json prefix and ``` suffix if present
|
|
content = strings.TrimSpace(content)
|
|
|
|
// Remove opening code block markers
|
|
if strings.HasPrefix(content, "```json") {
|
|
content = strings.TrimPrefix(content, "```json")
|
|
} else if strings.HasPrefix(content, "```") {
|
|
content = strings.TrimPrefix(content, "```")
|
|
}
|
|
|
|
// Remove closing code block markers
|
|
content = strings.TrimSuffix(content, "```")
|
|
|
|
return strings.TrimSpace(content)
|
|
}
|