package openrouter import ( "encoding/json" "fmt" "log" "strings" "github.com/eduardolat/openroutergo" "github.com/jobs-scraper/internal/pkg/domain" ) // 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"` } type OpenRouterService struct { model string apiKey string } func NewOpenRouterService(model string, apiKey string) OpenRouterService { return OpenRouterService{ model: model, apiKey: apiKey, } } func (s *OpenRouterService) AnalyzeJobDescription(cv string, jobDesc domain.JobDescription) (*JobAnalysisResult, error) { client, err := openroutergo. NewClient(). WithAPIKey(s.apiKey). Create() if err != nil { log.Fatalf("Failed to create client: %v", err) } // 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, preferbly 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) _, resp, err := client. NewChatCompletion(). WithModel(s.model). WithSystemMessage("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 }."). WithUserMessage(userMessage). Execute() 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: %s", jsonContent) if len(jsonContent) > 0 && jsonContent[0] == '<' { return nil, fmt.Errorf("API returned HTML/XML instead of JSON. This usually indicates an API error, authentication issue, or rate limiting. Response: %s", jsonContent[:min(200, len(jsonContent))]) } jsonContent = cleanMarkdownCodeBlocks(jsonContent) 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 } func min(a, b int) int { if a < b { return a } return b } // cleanMarkdownCodeBlocks removes only markdown code block formatting func cleanMarkdownCodeBlocks(content string) string { content = strings.TrimSpace(content) // Remove markdown code blocks if after, ok := strings.CutPrefix(content, "```json"); ok { content = after } else if after0, ok0 := strings.CutPrefix(content, "```"); ok0 { content = after0 } content = strings.TrimSuffix(content, "```") return strings.TrimSpace(content) } func (s *OpenRouterService) CreateJobDescription(htmlContent string) (*domain.JobDescription, error) { client, err := openroutergo. NewClient(). WithAPIKey(s.apiKey). Create() if err != nil { log.Fatalf("Failed to create client: %v", err) } prompt := fmt.Sprintf(`You are a job posting data extractor. Analyze the HTML content and extract data according to this JSON structure: {"description":"job description text","criteria":{"title":"Job Title","company":"Company","location":"Location","salary":"Salary","skills":"Skills","experience":"Experience","job_type":"Job Type","remote":"Remote"}} 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 (" " ' ') 7. If no job description is found, use empty string for "description": "" 8. All criteria fields must be present with empty string "" if not found Example output: {"description":"Develop software applications...","criteria":{"title":"Backend Engineer","company":"Tech Corp","location":"Remote","salary":"$120k","skills":"Go, Docker","experience":"3+ years","job_type":"Full-time","remote":"Yes"}} Extract from this HTML Content: %s`, htmlContent) _, resp, err := client. NewChatCompletion(). WithModel(s.model). WithSystemMessage("You are an expert job description extractor. You convert unstructured job description text into a structured JSON format. CRITICAL: You must respond with ONLY raw JSON - no markdown, no code blocks, no backticks, no additional text. Start directly with { and end with }."). WithUserMessage(prompt). Execute() 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) 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 := max(int(syntaxErr.Offset)-50, 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]) } } jobDescription := &domain.JobDescription{ Description: result.Description, Criteria: result.Criteria, } return jobDescription, nil }