jobs-monorepo/internal/openrouter/openrouter.go
2026-01-26 17:33:48 +05:00

362 lines
14 KiB
Go

package openrouter
import (
"encoding/json"
"fmt"
"log"
"strings"
"github.com/eduardolat/openroutergo"
"github.com/jobs-scraper/internal/domain"
)
// FieldType represents HTML input field types
type FieldType string
const (
FieldTypeText FieldType = "text"
FieldTypeEmail FieldType = "email"
FieldTypeTel FieldType = "tel"
FieldTypePassword FieldType = "password"
FieldTypeNumber FieldType = "number"
FieldTypeDate FieldType = "date"
FieldTypeDatetimeLocal FieldType = "datetime-local"
FieldTypeTime FieldType = "time"
FieldTypeMonth FieldType = "month"
FieldTypeWeek FieldType = "week"
FieldTypeURL FieldType = "url"
FieldTypeSearch FieldType = "search"
FieldTypeColor FieldType = "color"
FieldTypeRange FieldType = "range"
FieldTypeFile FieldType = "file"
FieldTypeHidden FieldType = "hidden"
FieldTypeCheckbox FieldType = "checkbox"
FieldTypeRadio FieldType = "radio"
FieldTypeSelect FieldType = "select"
FieldTypeTextarea FieldType = "textarea"
FieldTypeButton FieldType = "button"
FieldTypeSubmit FieldType = "submit"
FieldTypeReset FieldType = "reset"
)
// FormField represents a single form field extracted from HTML
type FormField struct {
Label string `json:"label"`
FieldName string `json:"field_name"`
FieldType FieldType `json:"field_type"`
Value string `json:"value"`
Placeholder string `json:"placeholder,omitempty"`
Required bool `json:"required"`
Selector string `json:"selector"`
}
// FormExtractionResult represents the structured response from form extraction
type FormExtractionResult struct {
Fields []FormField `json:"fields"`
ApplyButton string `json:"apply_button"`
AdjustedCV string `json:"adjusted_cv"`
}
// 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
}
// ExtractFormFields extracts form fields from HTML and populates them based on CV data.
// It also adjusts the CV based on job type (frontend/backend/fullstack).
func (s *OpenRouterService) CreateCV(htmlForm string, cvMarkdown string, jobDescription string) (*FormExtractionResult, error) {
client, err := openroutergo.
NewClient().
WithAPIKey(s.apiKey).
Create()
if err != nil {
return nil, fmt.Errorf("failed to create client: %v", err)
}
prompt := fmt.Sprintf(`You are a form field extractor, auto-filler, and CV adapter. Analyze the HTML form and job description, then:
1. Extract ALL form fields and populate them with appropriate values from the CV
2. Adjust the CV based on the job type (frontend/backend/fullstack)
HTML FORM:
%s
JOB DESCRIPTION:
%s
ORIGINAL CV (Markdown):
%s
CV ADJUSTMENT RULES:
- Detect if the job is: FRONTEND, BACKEND, or FULLSTACK based on the job description
- If FRONTEND: Keep only frontend-related skills, projects, and experience (React, Vue, Angular, CSS, HTML, UI/UX, etc.). Remove backend-specific content.
- If BACKEND: Keep only backend-related skills, projects, and experience (APIs, databases, servers, Go, Node.js, Python, etc.). Remove frontend-specific content.
- If FULLSTACK: Keep both frontend and backend content.
- For any OTHER job type (not frontend/backend/fullstack): Treat as FRONTEND by default.
- Maintain the same markdown structure and formatting as the original CV.
- Do NOT invent new skills or experience - only filter existing content.
CRITICAL RULES:
1. Return ONLY valid JSON - no markdown, no code blocks, no backticks, no explanations
2. Extract ALL input fields, textareas, selects, and buttons from the form
3. For each field, determine the appropriate value from the ADJUSTED CV
4. If a field cannot be populated from the CV (e.g., password, captcha), leave value as empty string
5. Identify the apply/submit button text
6. Use the exact JSON schema below
JSON Schema:
{
"fields": [
{
"label": "Field label or name attribute",
"field_name": "name or id attribute of the input",
"field_type": "text|email|tel|password|number|date|datetime-local|time|month|week|url|search|color|range|file|hidden|checkbox|radio|select|textarea|button|submit|reset",
"value": "Value to populate based on CV data",
"placeholder": "Placeholder text if any",
"required": true|false,
"selector": "Unique CSS selector for the input (e.g., #email, input[name='email'], .form-field-email). Use id selector if available, otherwise name attribute, otherwise class. Empty string if no unique selector can be determined."
}
],
"apply_button": "Unique CSS selector for the form's submit button",
"adjusted_cv": "The full adjusted CV in markdown format, tailored to the job type"
}
FIELD MAPPING GUIDELINES:
- Name fields: Extract full name, first name, last name from CV
- Email: Use email from CV contact info
- Phone: Use phone number from CV
- LinkedIn/Portfolio/Website: Use URLs from CV
- Experience/Years: Calculate from CV work history
- Current company/title: Use most recent from CV
- Skills: List relevant skills from ADJUSTED CV
- Education: Use education details from CV
- Cover letter/Message: Generate a brief professional message based on ADJUSTED CV
- Salary expectations: Leave empty unless specified in CV
- Location/Address: Use from CV contact info
- Resume/CV upload: Leave value empty (file upload)
Start your response with { and end with }`, htmlForm, jobDescription, cvMarkdown)
_, resp, err := client.
NewChatCompletion().
WithModel(s.model).
WithSystemMessage("You are an expert form analyzer and auto-filler. You extract form fields from HTML and intelligently populate them with data from a CV/resume. 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 form extraction: %s", jsonContent)
if len(jsonContent) > 0 && jsonContent[0] == '<' {
return nil, fmt.Errorf("API returned HTML/XML instead of JSON. Response: %s", jsonContent[:min(200, len(jsonContent))])
}
jsonContent = cleanMarkdownCodeBlocks(jsonContent)
var result FormExtractionResult
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])
}
return nil, fmt.Errorf("failed to parse JSON response: %v. Raw response: %s", err, jsonContent[:min(500, len(jsonContent))])
}
return &result, nil
}