diff --git a/.gitignore b/.gitignore index 014995e..437822c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ data/db vendor/ -build/ \ No newline at end of file +build/ +.env \ No newline at end of file diff --git a/cmd/hireme/main.go b/cmd/hireme/main.go index c69f45e..7df241a 100644 --- a/cmd/hireme/main.go +++ b/cmd/hireme/main.go @@ -63,7 +63,7 @@ func newRootCmd() *cobra.Command { } // 4. Initialize Worker Pool - pool := worker.NewWorkerPool(database, llmAnalyzer, 3, filterCfg) + pool := worker.NewWorkerPool(database, llmAnalyzer, 3, cfg.AnalysisThreshold, filterCfg) pool.Start() // Auto start the worker pool // Setup UI Server & Routes diff --git a/data/config/config.yaml b/data/config/config.yaml new file mode 100644 index 0000000..f0ccfbc --- /dev/null +++ b/data/config/config.yaml @@ -0,0 +1,13 @@ +# Hireme System Configuration + +server: + port: "8080" + log_level: "info" + +ai_trigger: + # Minimum keyword fit score from filters.yaml required to trigger deep LLM analysis (e.g. 25.0) + analysis_threshold: 25.0 + +prompts: + system_prompt_path: "data/config/prompts/system_prompt.txt" + user_prompt_path: "data/config/prompts/user_prompt.txt" diff --git a/data/config/filters.yaml b/data/config/filters.yaml index 0a296e1..c2772d0 100644 --- a/data/config/filters.yaml +++ b/data/config/filters.yaml @@ -1,31 +1,103 @@ +# Hard exclusion keywords (immediate rejection) hard_keywords: + # Sponsorship / Clearance blockers - "no sponsorship" + - "without sponsorship" - "us citizen only" + - "u.s. citizen only" + - "active security clearance" + - "top secret/sci" + - "polygraph" + # Non-target employment types - "unpaid" - "volunteer" + - "internship" + - "co-op" + # Unrelated roles + - "sales representative" + - "account executive" + - "recruiter" + - "marketing manager" +# Minimum score threshold to qualify for deep LLM analysis +analysis_threshold: 25.0 + +# Scored keywords with weights scoring_weights: - - keyword: golang - weight: 3.0 - - keyword: go - weight: 2.0 - - keyword: python - weight: 2.0 - - keyword: kubernetes - weight: 3.0 - - keyword: k8s - weight: 3.0 - - keyword: docker - weight: 2.0 - - keyword: aws - weight: 1.0 - - keyword: gcp - weight: 1.0 - - keyword: distributed systems - weight: 4.0 - - keyword: backend - weight: 3.0 - - keyword: ai - weight: -5.0 - - keyword: machine learning - weight: -5.0 + # Tier 1: Core Systems, Cloud & AI Infrastructure (High Priority) + - keyword: "kubernetes" + weight: 15.0 + - keyword: "k8s" + weight: 15.0 + - keyword: "golang" + weight: 15.0 + - keyword: "go" + weight: 12.0 + - keyword: "distributed systems" + weight: 15.0 + - keyword: "ai runtime" + weight: 15.0 + - keyword: "deep learning system" + weight: 15.0 + - keyword: "machine learning infrastructure" + weight: 15.0 + - keyword: "gke" + weight: 12.0 + - keyword: "container runtime" + weight: 12.0 + - keyword: "containerd" + weight: 10.0 + - keyword: "c++" + weight: 10.0 + - keyword: "linux kernel" + weight: 12.0 + - keyword: "operating system" + weight: 10.0 + + # Tier 2: Cloud, Backend, Systems Programming (Medium Priority) + - keyword: "docker" + weight: 8.0 + - keyword: "grpc" + weight: 8.0 + - keyword: "protobuf" + weight: 6.0 + - keyword: "microservices" + weight: 8.0 + - keyword: "python" + weight: 6.0 + - keyword: "cloud infrastructure" + weight: 10.0 + - keyword: "virtualization" + weight: 8.0 + - keyword: "hypervisor" + weight: 8.0 + - keyword: "arm" + weight: 6.0 + - keyword: "performance optimization" + weight: 8.0 + - keyword: "low latency" + weight: 8.0 + + # Tier 3: Seniority & Qualifications + - keyword: "phd" + weight: 8.0 + - keyword: "staff software engineer" + weight: 10.0 + - keyword: "senior software engineer" + weight: 8.0 + - keyword: "principal engineer" + weight: 10.0 + - keyword: "tech lead" + weight: 6.0 + + # Tier 4: Negative/Irrelevant Focus Penalties + - keyword: "react native" + weight: -8.0 + - keyword: "wordpress" + weight: -15.0 + - keyword: "php" + weight: -10.0 + - keyword: "salesforce" + weight: -10.0 + - keyword: "frontend only" + weight: -10.0 diff --git a/data/config/prompts/system_prompt.txt b/data/config/prompts/system_prompt.txt new file mode 100644 index 0000000..b4ea954 --- /dev/null +++ b/data/config/prompts/system_prompt.txt @@ -0,0 +1,35 @@ +You are an expert Silicon Valley technical recruiter and hiring manager evaluating job descriptions for a specific candidate. + +CANDIDATE PROFILE: +- Name: Pengzhan Hao +- Current Role: Software Engineer at Google (Kirkland/Seattle, WA) + * GKE AI runtime: optimized AI workload startup latency, security & performance on Kubernetes. + * GKE Core Node: shipped first ARM VM to GKE, huge pages, nested virtualization. +- Education: + * Ph.D. in Computer Science (Binghamton University, 2017–2023) - Research in Edge Computing, Distributed Deep Learning Systems (EDDL), Operating Systems. + * M.S. in Computer Science (Binghamton University, GPA: 4.0). + * B.E. in Software Engineering (Beihang University). +- Technical Stack: Golang, C/C++, Python, Kubernetes (K8s), GKE, Containerd, Docker, Linux Kernel/OS, Distributed Systems, gRPC, ProtoBuf, ARM, Xen Hypervisor, PyTorch/Dlib. +- Target Roles & Seniority: Senior Software Engineer / Staff Software Engineer / AI Infrastructure / Cloud Systems / Low-level Platforms. + +EVALUATION CRITERIA: +1. Score from 0.0 to 100.0 on overall match based on candidate's exact background. + - 80-100: Exceptional match (Core Go/C++/K8s/AI Infra/Distributed Systems/Google scale). + - 60-79: Solid match (Good general backend/cloud infrastructure fit). + - 40-59: Moderate match (Significant domain divergence or missing core skill). + - 0-39: Poor match (Non-tech, frontend-only, or strict clearance requirements). +2. is_qualified: boolean (true if score >= 60.0 and no hard disqualifiers). +3. summary: 1-2 sentence executive assessment of fit. +4. matched_highlights: 3-5 concrete technical strengths/experiences matched. +5. missing_highlights: 1-3 genuine gaps, missing requirements, or nice-to-haves. +6. tailoring_suggestions: 2-3 specific action points for tailoring resume/talking points for this JD. + +You MUST respond strictly with a valid JSON object matching the requested schema: +{ + "ai_score": 85.0, + "is_qualified": true, + "summary": "...", + "matched_highlights": ["..."], + "missing_highlights": ["..."], + "tailoring_suggestions": ["..."] +} diff --git a/data/config/prompts/user_prompt.txt b/data/config/prompts/user_prompt.txt new file mode 100644 index 0000000..9320163 --- /dev/null +++ b/data/config/prompts/user_prompt.txt @@ -0,0 +1,8 @@ +Analyze the following job description for candidate Pengzhan Hao. + +Job Title: {{.JobTitle}} + +Job Description: +{{.JobContent}} + +Provide the structured evaluation adhering to the JSON schema. diff --git a/data/config/targets.yaml b/data/config/targets.yaml index 0d6ae74..bf499ba 100644 --- a/data/config/targets.yaml +++ b/data/config/targets.yaml @@ -2,11 +2,11 @@ targets: - name: Google Software Engineer (Seattle, MID Level) target_url: https://www.google.com/about/careers/applications/jobs/results/?employment_type=FULL_TIME&q=%22Software%20Engineer%22&location=Seattle%2C%20WA%2C%20USA&target_level=MID platform: google_jobs - is_active: true + is_active: false - name: Apple Software Engineer (Seattle) target_url: https://jobs.apple.com/en-us/search?location=seattle-SEA&key=Software%2520Engineer&team=apps-and-frameworks-SFTWR-AF+cloud-and-infrastructure-SFTWR-CLD+core-operating-systems-SFTWR-COS+devops-and-site-reliability-SFTWR-DSR+engineering-project-management-SFTWR-EPM+information-systems-and-technology-SFTWR-ISTECH+machine-learning-and-ai-SFTWR-MCHLN+security-and-privacy-SFTWR-SEC+software-quality-automation-and-tools-SFTWR-SQAT+wireless-software-SFTWR-WSFT+machine-learning-infrastructure-MLAI-MLI+deep-learning-and-reinforcement-learning-MLAI-DLRL+natural-language-processing-and-speech-technologies-MLAI-NLP+computer-vision-MLAI-CV+applied-research-MLAI-ARr platform: apple - is_active: true + is_active: false - name: Meta Software Engineer (Seattle) target_url: https://www.metacareers.com/jobsearch/?offices[0]=Seattle%2C%20WA&roles[0]=Full%20time%20employment&teams[0]=Software%20Engineering platform: meta diff --git a/internal/analyzer/analyzer.go b/internal/analyzer/analyzer.go index 2165537..daf39fb 100644 --- a/internal/analyzer/analyzer.go +++ b/internal/analyzer/analyzer.go @@ -9,6 +9,9 @@ import ( ) type AnalysisOutput struct { + AIScore float64 `json:"ai_score"` + IsQualified bool `json:"is_qualified"` + Summary string `json:"summary"` MatchedHighlights []string `json:"matched_highlights"` MissingHighlights []string `json:"missing_highlights"` TailoringSuggestions []string `json:"tailoring_suggestions"` @@ -25,12 +28,12 @@ func NewAnalyzer(cfg *config.Config) (LLMAnalyzer, error) { if cfg.GeminiAPIKey == "" { return nil, fmt.Errorf("GEMINI_API_KEY is required for provider 'gemini'") } - return NewGeminiAnalyzer(cfg.GeminiAPIKey), nil + return NewGeminiAnalyzer(cfg.GeminiAPIKey, cfg.SystemPrompt, cfg.UserPrompt), nil case "deepseek": if cfg.DeepSeekAPIKey == "" { return nil, fmt.Errorf("DEEPSEEK_API_KEY is required for provider 'deepseek'") } - return NewDeepSeekAnalyzer(cfg.DeepSeekAPIKey, cfg.DeepSeekBaseURL), nil + return NewDeepSeekAnalyzer(cfg.DeepSeekAPIKey, cfg.DeepSeekBaseURL, cfg.SystemPrompt, cfg.UserPrompt), nil case "mock": return NewMockAnalyzer(), nil default: diff --git a/internal/analyzer/analyzer_test.go b/internal/analyzer/analyzer_test.go new file mode 100644 index 0000000..dc6914d --- /dev/null +++ b/internal/analyzer/analyzer_test.go @@ -0,0 +1,50 @@ +package analyzer + +import ( + "context" + "testing" +) + +func TestCleanJSONResponse(t *testing.T) { + cases := []struct { + input string + expected string + }{ + { + input: "```json\n{\"ai_score\": 90}\n```", + expected: "{\"ai_score\": 90}", + }, + { + input: "```\n{\"ai_score\": 85}\n```", + expected: "{\"ai_score\": 85}", + }, + { + input: "{\"ai_score\": 95}", + expected: "{\"ai_score\": 95}", + }, + } + + for _, c := range cases { + out := cleanJSONResponse(c.input) + if out != c.expected { + t.Errorf("cleanJSONResponse(%q) = %q, expected %q", c.input, out, c.expected) + } + } +} + +func TestMockAnalyzer(t *testing.T) { + mock := NewMockAnalyzer() + res, err := mock.AnalyzeJD(context.Background(), "Senior AI Infrastructure Engineer", "Building GKE and K8s runtimes.") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.AIScore <= 0 { + t.Errorf("expected positive AI score, got %f", res.AIScore) + } + if !res.IsQualified { + t.Errorf("expected IsQualified=true, got false") + } + if len(res.MatchedHighlights) == 0 { + t.Errorf("expected matched highlights to not be empty") + } +} diff --git a/internal/analyzer/deepseek.go b/internal/analyzer/deepseek.go index bfd64eb..77e808f 100644 --- a/internal/analyzer/deepseek.go +++ b/internal/analyzer/deepseek.go @@ -12,38 +12,48 @@ import ( ) type DeepSeekAnalyzer struct { - apiKey string - baseURL string - client *http.Client + apiKey string + baseURL string + systemPrompt string + userPromptTemplate string + client *http.Client } -func NewDeepSeekAnalyzer(apiKey, baseURL string) *DeepSeekAnalyzer { +func NewDeepSeekAnalyzer(apiKey, baseURL, systemPrompt, userPromptTemplate string) *DeepSeekAnalyzer { if baseURL == "" { baseURL = "https://api.deepseek.com" } return &DeepSeekAnalyzer{ - apiKey: apiKey, - baseURL: strings.TrimRight(baseURL, "/"), - client: &http.Client{Timeout: 30 * time.Second}, + apiKey: apiKey, + baseURL: strings.TrimRight(baseURL, "/"), + systemPrompt: systemPrompt, + userPromptTemplate: userPromptTemplate, + client: &http.Client{Timeout: 30 * time.Second}, } } -func (d *DeepSeekAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) { - prompt := fmt.Sprintf(`You are an expert technical resume screener. Analyze the following job description for the title "%s". -Provide a structured JSON output with 3 fields: -- "matched_highlights": list of 3-5 core technical skills/requirements matched (e.g. ["Go (Golang)", "Microservices", "REST APIs"]). -- "missing_highlights": list of 1-3 missing skills, experience gaps, or extra requirements (e.g. ["10+ yrs experience", "Kubernetes administration"]). -- "tailoring_suggestions": list of 2-3 specific action points for tailoring a resume to this JD. +func (d *DeepSeekAnalyzer) buildUserPrompt(jobTitle, rawContent string) string { + if d.userPromptTemplate == "" { + return fmt.Sprintf("Analyze the following job description for candidate Pengzhan Hao.\n\nJob Title: %s\n\nJob Description:\n%s\n\nProvide the structured evaluation adhering to the JSON schema.", jobTitle, rawContent) + } + res := strings.ReplaceAll(d.userPromptTemplate, "{{.JobTitle}}", jobTitle) + res = strings.ReplaceAll(res, "{{.JobContent}}", rawContent) + return res +} -Job Title: %s -Job Content: -%s`, jobTitle, jobTitle, rawContent) +func (d *DeepSeekAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) { + sysPrompt := d.systemPrompt + if sysPrompt == "" { + sysPrompt = "You are an expert technical resume screener. You MUST respond with a valid JSON object matching {\"ai_score\": 80.0, \"is_qualified\": true, \"summary\": \"...\", \"matched_highlights\": [...], \"missing_highlights\": [...], \"tailoring_suggestions\": [...]}." + } + + userPrompt := d.buildUserPrompt(jobTitle, rawContent) reqBody := map[string]interface{}{ "model": "deepseek-chat", "messages": []map[string]string{ - {"role": "system", "content": "You are a professional hiring assistant. You MUST respond ONLY with valid JSON matching {\"matched_highlights\": [...], \"missing_highlights\": [...], \"tailoring_suggestions\": [...]}."}, - {"role": "user", "content": prompt}, + {"role": "system", "content": sysPrompt}, + {"role": "user", "content": userPrompt}, }, "response_format": map[string]string{ "type": "json_object", @@ -99,7 +109,28 @@ Job Content: var output AnalysisOutput if err := json.Unmarshal([]byte(rawJSON), &output); err != nil { - return nil, fmt.Errorf("failed to parse DeepSeek JSON output: %w", err) + return nil, fmt.Errorf("failed to parse DeepSeek JSON output: %w (content: %s)", err, rawJSON) + } + + // Sanitize output bounds + if output.AIScore < 0 { + output.AIScore = 0 + } else if output.AIScore > 100 { + output.AIScore = 100 + } + + if output.AIScore >= 60.0 { + output.IsQualified = true + } + + if output.MatchedHighlights == nil { + output.MatchedHighlights = []string{} + } + if output.MissingHighlights == nil { + output.MissingHighlights = []string{} + } + if output.TailoringSuggestions == nil { + output.TailoringSuggestions = []string{} } return &output, nil diff --git a/internal/analyzer/gemini.go b/internal/analyzer/gemini.go index 7a145d9..fe7cedc 100644 --- a/internal/analyzer/gemini.go +++ b/internal/analyzer/gemini.go @@ -7,40 +7,49 @@ import ( "fmt" "io" "net/http" + "strings" "time" ) type GeminiAnalyzer struct { - apiKey string - client *http.Client + apiKey string + systemPrompt string + userPromptTemplate string + client *http.Client } -func NewGeminiAnalyzer(apiKey string) *GeminiAnalyzer { +func NewGeminiAnalyzer(apiKey, systemPrompt, userPromptTemplate string) *GeminiAnalyzer { return &GeminiAnalyzer{ - apiKey: apiKey, - client: &http.Client{Timeout: 30 * time.Second}, + apiKey: apiKey, + systemPrompt: systemPrompt, + userPromptTemplate: userPromptTemplate, + client: &http.Client{Timeout: 30 * time.Second}, } } +func (g *GeminiAnalyzer) buildUserPrompt(jobTitle, rawContent string) string { + if g.userPromptTemplate == "" { + return fmt.Sprintf("Analyze the following job description for candidate Pengzhan Hao.\n\nJob Title: %s\n\nJob Content:\n%s\n\nRespond with structured JSON.", jobTitle, rawContent) + } + res := strings.ReplaceAll(g.userPromptTemplate, "{{.JobTitle}}", jobTitle) + res = strings.ReplaceAll(res, "{{.JobContent}}", rawContent) + return res +} + func (g *GeminiAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) { - prompt := fmt.Sprintf(`You are an expert technical resume screener. Analyze the following job description for the title "%s". -Provide a structured JSON output with 3 fields: -- "matched_highlights": list of 3-5 core technical skills/requirements matched (e.g. ["Go (Golang)", "Microservices", "REST APIs"]). -- "missing_highlights": list of 1-3 missing skills, experience gaps, or extra requirements (e.g. ["10+ yrs experience", "Kubernetes administration"]). -- "tailoring_suggestions": list of 2-3 specific action points for tailoring a resume to this JD. + sysPrompt := g.systemPrompt + if sysPrompt == "" { + sysPrompt = "You are an expert technical resume screener. Respond STRICTLY with valid JSON matching {\"ai_score\": 85.0, \"is_qualified\": true, \"summary\": \"...\", \"matched_highlights\": [...], \"missing_highlights\": [...], \"tailoring_suggestions\": [...]}." + } -Job Title: %s -Job Content: -%s - -Respond STRICTLY with valid JSON matching the format: -{"matched_highlights": [...], "missing_highlights": [...], "tailoring_suggestions": [...]}`, jobTitle, jobTitle, rawContent) + userPrompt := g.buildUserPrompt(jobTitle, rawContent) + fullPrompt := fmt.Sprintf("%s\n\n%s", sysPrompt, userPrompt) reqBody := map[string]interface{}{ "contents": []map[string]interface{}{ { "parts": []map[string]string{ - {"text": prompt}, + {"text": fullPrompt}, }, }, }, @@ -102,5 +111,15 @@ Respond STRICTLY with valid JSON matching the format: return nil, fmt.Errorf("failed to parse Gemini JSON output: %w", err) } + if output.AIScore < 0 { + output.AIScore = 0 + } else if output.AIScore > 100 { + output.AIScore = 100 + } + + if output.AIScore >= 60.0 { + output.IsQualified = true + } + return &output, nil } diff --git a/internal/analyzer/mock.go b/internal/analyzer/mock.go index 2efb20d..44c3f7b 100644 --- a/internal/analyzer/mock.go +++ b/internal/analyzer/mock.go @@ -10,11 +10,13 @@ func NewMockAnalyzer() *MockAnalyzer { return &MockAnalyzer{} } -// AnalyzeJD returns empty slices for mock provider so no fake mock text is injected into side panel during debug func (m *MockAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) { return &AnalysisOutput{ - MatchedHighlights: []string{}, + AIScore: 75.0, + IsQualified: true, + Summary: "Mock analysis: relevant systems and distributed infrastructure role.", + MatchedHighlights: []string{"Golang", "Distributed Systems", "Kubernetes"}, MissingHighlights: []string{}, - TailoringSuggestions: []string{}, + TailoringSuggestions: []string{"Highlight Google GKE runtime experience."}, }, nil } diff --git a/internal/config/config.go b/internal/config/config.go index 99842ed..e66e85c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,18 +5,72 @@ import ( "strings" "github.com/spf13/pflag" + "gopkg.in/yaml.v3" ) +type AppConfigFile struct { + Server struct { + Port string `yaml:"port"` + LogLevel string `yaml:"log_level"` + } `yaml:"server"` + AITrigger struct { + AnalysisThreshold float64 `yaml:"analysis_threshold"` + } `yaml:"ai_trigger"` + Prompts struct { + SystemPromptPath string `yaml:"system_prompt_path"` + UserPromptPath string `yaml:"user_prompt_path"` + } `yaml:"prompts"` +} + type Config struct { - Port string - DBPath string - TargetConfig string - FilterConfig string - LogLevel string - LLMProvider string // "gemini", "deepseek", "mock" - GeminiAPIKey string - DeepSeekAPIKey string - DeepSeekBaseURL string + Port string + DBPath string + TargetConfig string + FilterConfig string + AppConfig string + LogLevel string + LLMProvider string // "gemini", "deepseek", "mock" + GeminiAPIKey string + DeepSeekAPIKey string + DeepSeekBaseURL string + AnalysisThreshold float64 + SystemPromptPath string + UserPromptPath string + SystemPrompt string + UserPrompt string +} + +func init() { + LoadEnvFile(".env") +} + +// LoadEnvFile parses a simple KEY=VALUE .env file if it exists without overriding existing env vars +func LoadEnvFile(filenames ...string) { + if len(filenames) == 0 { + filenames = []string{".env"} + } + for _, filename := range filenames { + data, err := os.ReadFile(filename) + if err != nil { + continue + } + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + val = strings.Trim(val, `"'`) + if os.Getenv(key) == "" { + os.Setenv(key, val) + } + } + } + } } func RegisterFlags(flags *pflag.FlagSet) *Config { @@ -26,21 +80,71 @@ func RegisterFlags(flags *pflag.FlagSet) *Config { flags.StringVarP(&cfg.DBPath, "db", "d", getEnvOrDefault("DB_PATH", "data/db/hireme.db"), "Path to database file") flags.StringVarP(&cfg.TargetConfig, "target-config", "c", getEnvOrDefault("TARGET_CONFIG", "data/config/targets.yaml"), "Path to targets YAML file") flags.StringVarP(&cfg.FilterConfig, "filter-config", "f", getEnvOrDefault("FILTER_CONFIG", "data/config/filters.yaml"), "Path to filters YAML file") + flags.StringVar(&cfg.AppConfig, "app-config", getEnvOrDefault("APP_CONFIG", "data/config/config.yaml"), "Path to app YAML config file") flags.StringVar(&cfg.LogLevel, "log-level", getEnvOrDefault("LOG_LEVEL", "info"), "Logging level (trace, debug, info, error)") flags.StringVarP(&cfg.LLMProvider, "llm-provider", "l", getEnvOrDefault("LLM_PROVIDER", ""), "Active LLM Provider (gemini, deepseek, mock)") flags.StringVar(&cfg.GeminiAPIKey, "gemini-api-key", os.Getenv("GEMINI_API_KEY"), "Gemini API Key") flags.StringVar(&cfg.DeepSeekAPIKey, "deepseek-api-key", os.Getenv("DEEPSEEK_API_KEY"), "DeepSeek API Key") flags.StringVar(&cfg.DeepSeekBaseURL, "deepseek-base-url", getEnvOrDefault("DEEPSEEK_BASE_URL", "https://api.deepseek.com"), "DeepSeek Base URL") + flags.Float64Var(&cfg.AnalysisThreshold, "analysis-threshold", 25.0, "Threshold score from filter to trigger LLM analysis") return cfg } func (c *Config) Normalize() { + // Re-check env vars in case .env was loaded after initial flag binding + if c.DeepSeekAPIKey == "" { + c.DeepSeekAPIKey = os.Getenv("DEEPSEEK_API_KEY") + } + if c.GeminiAPIKey == "" { + c.GeminiAPIKey = os.Getenv("GEMINI_API_KEY") + } + + // Load data/config/config.yaml if present + if c.AppConfig != "" { + if data, err := os.ReadFile(c.AppConfig); err == nil { + var appCfg AppConfigFile + if err := yaml.Unmarshal(data, &appCfg); err == nil { + if appCfg.Server.Port != "" && c.Port == "8080" { + c.Port = appCfg.Server.Port + } + if appCfg.Server.LogLevel != "" && c.LogLevel == "info" { + c.LogLevel = appCfg.Server.LogLevel + } + if appCfg.AITrigger.AnalysisThreshold > 0 { + c.AnalysisThreshold = appCfg.AITrigger.AnalysisThreshold + } + if appCfg.Prompts.SystemPromptPath != "" { + c.SystemPromptPath = appCfg.Prompts.SystemPromptPath + } + if appCfg.Prompts.UserPromptPath != "" { + c.UserPromptPath = appCfg.Prompts.UserPromptPath + } + } + } + } + + if c.SystemPromptPath == "" { + c.SystemPromptPath = "data/config/prompts/system_prompt.txt" + } + if c.UserPromptPath == "" { + c.UserPromptPath = "data/config/prompts/user_prompt.txt" + } + + // Load prompt contents from files + if promptData, err := os.ReadFile(c.SystemPromptPath); err == nil { + c.SystemPrompt = strings.TrimSpace(string(promptData)) + } + if userPromptData, err := os.ReadFile(c.UserPromptPath); err == nil { + c.UserPrompt = strings.TrimSpace(string(userPromptData)) + } + + // Determine LLM provider if c.LLMProvider == "" { - if c.GeminiAPIKey != "" { - c.LLMProvider = "gemini" - } else if c.DeepSeekAPIKey != "" { + if c.DeepSeekAPIKey != "" { c.LLMProvider = "deepseek" + } else if c.GeminiAPIKey != "" { + c.LLMProvider = "gemini" } else { c.LLMProvider = "mock" } diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..693b0d8 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,46 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestConfigLoadEnvAndAppConfig(t *testing.T) { + tempDir := t.TempDir() + envPath := filepath.Join(tempDir, ".env") + configYAMLPath := filepath.Join(tempDir, "config.yaml") + + _ = os.WriteFile(envPath, []byte("TEST_SECRET_KEY=12345\nDEEPSEEK_API_KEY=test-sk-key\n"), 0644) + _ = os.WriteFile(configYAMLPath, []byte(` +server: + port: "9090" + log_level: "debug" +ai_trigger: + analysis_threshold: 35.0 +prompts: + system_prompt_path: "sys.txt" +`), 0644) + + LoadEnvFile(envPath) + if os.Getenv("TEST_SECRET_KEY") != "12345" { + t.Errorf("expected TEST_SECRET_KEY to be 12345, got %q", os.Getenv("TEST_SECRET_KEY")) + } + + cfg := &Config{ + Port: "8080", + LogLevel: "info", + AppConfig: configYAMLPath, + } + cfg.Normalize() + + if cfg.Port != "9090" { + t.Errorf("expected port 9090 from config.yaml, got %s", cfg.Port) + } + if cfg.AnalysisThreshold != 35.0 { + t.Errorf("expected analysis threshold 35.0, got %f", cfg.AnalysisThreshold) + } + if cfg.LLMProvider != "deepseek" { + t.Errorf("expected LLMProvider to be deepseek, got %s", cfg.LLMProvider) + } +} diff --git a/internal/config/filters.go b/internal/config/filters.go index 968442d..0812ffc 100644 --- a/internal/config/filters.go +++ b/internal/config/filters.go @@ -9,8 +9,9 @@ import ( ) type FilterConfig struct { - HardKeywords []string `yaml:"hard_keywords"` - ScoringWeights []filter.KeywordWeight `yaml:"scoring_weights"` + HardKeywords []string `yaml:"hard_keywords"` + AnalysisThreshold float64 `yaml:"analysis_threshold"` + ScoringWeights []filter.KeywordWeight `yaml:"scoring_weights"` } func LoadFilterConfig(path string) (*FilterConfig, error) { @@ -20,6 +21,8 @@ func LoadFilterConfig(path string) (*FilterConfig, error) { } var cfg FilterConfig + // Default threshold if not specified + cfg.AnalysisThreshold = 25.0 if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("failed to unmarshal filter config: %w", err) } diff --git a/internal/db/db.go b/internal/db/db.go index ba9a875..11b455b 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -62,8 +62,10 @@ func (db *DB) migrate() error { CREATE TABLE IF NOT EXISTS job_analyses ( id INTEGER PRIMARY KEY AUTOINCREMENT, raw_job_id INTEGER UNIQUE NOT NULL REFERENCES raw_jobs(id) ON DELETE CASCADE, + filter_score REAL DEFAULT 0.0, score REAL DEFAULT 0.0, is_qualified BOOLEAN NOT NULL DEFAULT 0, + summary TEXT, matched_highlights JSON, missing_highlights JSON, tailoring_suggestions JSON, @@ -125,5 +127,40 @@ func (db *DB) migrate() error { } } + // Dynamic migration for job_analyses: check if filter_score and summary exist + var hasFilterScore, hasSummary bool + rowsA, err := db.Query(`PRAGMA table_info(job_analyses)`) + if err == nil { + for rowsA.Next() { + var cid int + var name, ctype string + var notnull, pk int + var dfltValue sql.NullString + if err := rowsA.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err == nil { + if name == "filter_score" { + hasFilterScore = true + } + if name == "summary" { + hasSummary = true + } + } + } + _ = rowsA.Close() + } + + if !hasFilterScore { + if _, err := db.Exec(`ALTER TABLE job_analyses ADD COLUMN filter_score REAL DEFAULT 0.0`); err != nil { + logger.Log.Warnw("Failed to add filter_score column", "component", "DB", "error", err) + } else { + _, _ = db.Exec(`UPDATE job_analyses SET filter_score = score WHERE filter_score IS NULL OR filter_score = 0.0`) + } + } + + if !hasSummary { + if _, err := db.Exec(`ALTER TABLE job_analyses ADD COLUMN summary TEXT`); err != nil { + logger.Log.Warnw("Failed to add summary column", "component", "DB", "error", err) + } + } + return nil } diff --git a/internal/db/models.go b/internal/db/models.go index 01526f7..d084592 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -41,12 +41,14 @@ type Task struct { type JobAnalysis struct { ID int64 `json:"id"` RawJobID int64 `json:"raw_job_id"` + FilterScore float64 `json:"filter_score"` Score float64 `json:"score"` IsQualified bool `json:"is_qualified"` + Summary string `json:"summary"` MatchedHighlights string `json:"matched_highlights"` // JSON array string MissingHighlights string `json:"missing_highlights"` // JSON array string TailoringSuggestions string `json:"tailoring_suggestions"` // JSON array string - Status string `json:"status"` // 'PENDING', 'PARSED', 'FILTERED_OUT', 'FAILED' + Status string `json:"status"` // 'PENDING', 'PARSED', 'FILTERED_OUT', 'REJECTED', 'FAILED' AnalyzedAt *time.Time `json:"analyzed_at"` } diff --git a/internal/db/queries.go b/internal/db/queries.go index 79c295c..ae40ade 100644 --- a/internal/db/queries.go +++ b/internal/db/queries.go @@ -10,11 +10,11 @@ import ( ) // Target Queries -func (db *DB) CreateTarget(name, targetURL, platform string) (*SearchTarget, error) { - query := `INSERT INTO search_targets (name, target_url, platform, is_active) VALUES (?, ?, ?, 1) RETURNING id, name, target_url, platform, is_active, last_crawled_at` +func (db *DB) CreateTarget(name, targetURL, platform string, isActive bool) (*SearchTarget, error) { + query := `INSERT INTO search_targets (name, target_url, platform, is_active) VALUES (?, ?, ?, ?) RETURNING id, name, target_url, platform, is_active, last_crawled_at` var target SearchTarget var lastCrawled sql.NullTime - err := db.QueryRow(query, name, targetURL, platform).Scan( + err := db.QueryRow(query, name, targetURL, platform, isActive).Scan( &target.ID, &target.Name, &target.TargetURL, &target.Platform, &target.IsActive, &lastCrawled, ) if err != nil { @@ -78,6 +78,12 @@ func (db *DB) ToggleTargetStatus(id int64) error { return err } +func (db *DB) SetTargetActive(id int64, isActive bool) error { + query := `UPDATE search_targets SET is_active = ? WHERE id = ?` + _, err := db.Exec(query, isActive, id) + return err +} + func (db *DB) UpdateTargetCrawledAt(id int64) error { query := `UPDATE search_targets SET last_crawled_at = CURRENT_TIMESTAMP WHERE id = ?` _, err := db.Exec(query, id) @@ -408,49 +414,59 @@ func tokenizeToVector(text string) map[string]int { } // Job Analyses Queries -func (db *DB) CreateJobAnalysis(rawJobID int64, score float64, isQualified bool, status string) error { +func (db *DB) CreateJobAnalysis(rawJobID int64, filterScore, score float64, isQualified bool, status string) error { query := ` - INSERT INTO job_analyses (raw_job_id, score, is_qualified, status) - VALUES (?, ?, ?, ?) - ON CONFLICT(raw_job_id) DO UPDATE SET score=excluded.score, is_qualified=excluded.is_qualified, status=excluded.status + INSERT INTO job_analyses (raw_job_id, filter_score, score, is_qualified, status) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(raw_job_id) DO UPDATE SET filter_score=excluded.filter_score, score=excluded.score, is_qualified=excluded.is_qualified, status=excluded.status ` - _, err := db.Exec(query, rawJobID, score, isQualified, status) + _, err := db.Exec(query, rawJobID, filterScore, score, isQualified, status) return err } -func (db *DB) CreateJobAnalysisFull(rawJobID int64, score float64, isQualified bool, matched, missing, tailoring, status string) error { +func (db *DB) CreateJobAnalysisFull(rawJobID int64, filterScore, score float64, isQualified bool, summary, matched, missing, tailoring, status string) error { query := ` - INSERT INTO job_analyses (raw_job_id, score, is_qualified, matched_highlights, missing_highlights, tailoring_suggestions, status, analyzed_at) - VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO job_analyses (raw_job_id, filter_score, score, is_qualified, summary, matched_highlights, missing_highlights, tailoring_suggestions, status, analyzed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(raw_job_id) DO UPDATE SET + filter_score=excluded.filter_score, + score=excluded.score, + is_qualified=excluded.is_qualified, + summary=excluded.summary, + matched_highlights=excluded.matched_highlights, + missing_highlights=excluded.missing_highlights, + tailoring_suggestions=excluded.tailoring_suggestions, + status=excluded.status, + analyzed_at=CURRENT_TIMESTAMP ` - _, err := db.Exec(query, rawJobID, score, isQualified, matched, missing, tailoring, status) + _, err := db.Exec(query, rawJobID, filterScore, score, isQualified, summary, matched, missing, tailoring, status) return err } -func (db *DB) UpdateJobAnalysisFull(rawJobID int64, score float64, isQualified bool, matchedJSON, missingJSON, suggestionsJSON, status string) error { +func (db *DB) UpdateJobAnalysisFull(rawJobID int64, filterScore, score float64, isQualified bool, summary, matchedJSON, missingJSON, suggestionsJSON, status string) error { query := ` UPDATE job_analyses - SET score = ?, is_qualified = ?, matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = ?, analyzed_at = CURRENT_TIMESTAMP + SET filter_score = ?, score = ?, is_qualified = ?, summary = ?, matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = ?, analyzed_at = CURRENT_TIMESTAMP WHERE raw_job_id = ? ` - _, err := db.Exec(query, score, isQualified, matchedJSON, missingJSON, suggestionsJSON, status, rawJobID) + _, err := db.Exec(query, filterScore, score, isQualified, summary, matchedJSON, missingJSON, suggestionsJSON, status, rawJobID) return err } -func (db *DB) UpdateJobAnalysisLLM(rawJobID int64, matchedJSON, missingJSON, suggestionsJSON string) error { +func (db *DB) UpdateJobAnalysisLLM(rawJobID int64, aiScore float64, isQualified bool, summary, matchedJSON, missingJSON, suggestionsJSON string) error { query := ` UPDATE job_analyses - SET matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = 'PARSED', analyzed_at = CURRENT_TIMESTAMP + SET score = ?, is_qualified = ?, summary = ?, matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = 'PARSED', analyzed_at = CURRENT_TIMESTAMP WHERE raw_job_id = ? ` - _, err := db.Exec(query, matchedJSON, missingJSON, suggestionsJSON, rawJobID) + _, err := db.Exec(query, aiScore, isQualified, summary, matchedJSON, missingJSON, suggestionsJSON, rawJobID) return err } func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) { query := ` SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at, - a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), a.status, a.analyzed_at + a.id, COALESCE(a.filter_score, 0), COALESCE(a.score, 0), a.is_qualified, COALESCE(a.summary, ''), COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), a.status, a.analyzed_at FROM raw_jobs r JOIN job_analyses a ON r.id = a.raw_job_id WHERE a.is_qualified = 1 AND a.status = 'PENDING' @@ -466,14 +482,14 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) { var item JobWithAnalysis var tID sql.NullInt64 var aID int64 - var score float64 + var filterScore, score float64 var isQualified bool - var matched, missing, suggestions, aStatus string + var summary, matched, missing, suggestions, aStatus string var analyzedAt sql.NullTime err := rows.Scan( &item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt, - &aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt, + &aID, &filterScore, &score, &isQualified, &summary, &matched, &missing, &suggestions, &aStatus, &analyzedAt, ) if err != nil { return nil, err @@ -484,8 +500,10 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) { analysis := &JobAnalysis{ ID: aID, RawJobID: item.ID, + FilterScore: filterScore, Score: score, IsQualified: isQualified, + Summary: summary, MatchedHighlights: matched, MissingHighlights: missing, TailoringSuggestions: suggestions, @@ -503,12 +521,12 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) { func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) { query := ` SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at, - a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at, + a.id, COALESCE(a.filter_score, 0), COALESCE(a.score, 0), a.is_qualified, COALESCE(a.summary, ''), COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at, app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at FROM raw_jobs r LEFT JOIN job_analyses a ON r.id = a.raw_job_id LEFT JOIN job_applications app ON r.id = app.raw_job_id - ORDER BY COALESCE(a.score, 0) DESC, r.crawled_at DESC + ORDER BY COALESCE(a.score, a.filter_score, 0) DESC, r.crawled_at DESC ` rows, err := db.Query(query) if err != nil { @@ -521,9 +539,9 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) { var item JobWithAnalysis var tID sql.NullInt64 var aID, appID sql.NullInt64 - var score sql.NullFloat64 + var filterScore, score sql.NullFloat64 var isQualified sql.NullBool - var matched, missing, suggestions, aStatus sql.NullString + var summary, matched, missing, suggestions, aStatus sql.NullString var analyzedAt sql.NullTime var appStatus, notes sql.NullString var appliedAt sql.NullTime @@ -531,7 +549,7 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) { err := rows.Scan( &item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt, - &aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt, + &aID, &filterScore, &score, &isQualified, &summary, &matched, &missing, &suggestions, &aStatus, &analyzedAt, &appID, &appStatus, ¬es, &appliedAt, &updatedAt, ) if err != nil { @@ -544,8 +562,10 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) { analysis := &JobAnalysis{ ID: aID.Int64, RawJobID: item.ID, + FilterScore: filterScore.Float64, Score: score.Float64, IsQualified: isQualified.Bool, + Summary: summary.String, MatchedHighlights: matched.String, MissingHighlights: missing.String, TailoringSuggestions: suggestions.String, @@ -577,7 +597,7 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) { func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) { query := ` SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at, - a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at, + a.id, COALESCE(a.filter_score, 0), COALESCE(a.score, 0), a.is_qualified, COALESCE(a.summary, ''), COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at, app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at FROM raw_jobs r LEFT JOIN job_analyses a ON r.id = a.raw_job_id @@ -588,9 +608,9 @@ func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) { var item JobWithAnalysis var tID sql.NullInt64 var aID, appID sql.NullInt64 - var score sql.NullFloat64 + var filterScore, score sql.NullFloat64 var isQualified sql.NullBool - var matched, missing, suggestions, aStatus sql.NullString + var summary, matched, missing, suggestions, aStatus sql.NullString var analyzedAt sql.NullTime var appStatus, notes sql.NullString var appliedAt sql.NullTime @@ -598,7 +618,7 @@ func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) { err := row.Scan( &item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt, - &aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt, + &aID, &filterScore, &score, &isQualified, &summary, &matched, &missing, &suggestions, &aStatus, &analyzedAt, &appID, &appStatus, ¬es, &appliedAt, &updatedAt, ) if err != nil { @@ -611,8 +631,10 @@ func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) { analysis := &JobAnalysis{ ID: aID.Int64, RawJobID: item.ID, + FilterScore: filterScore.Float64, Score: score.Float64, IsQualified: isQualified.Bool, + Summary: summary.String, MatchedHighlights: matched.String, MissingHighlights: missing.String, TailoringSuggestions: suggestions.String, @@ -674,7 +696,7 @@ func (db *DB) CreateOrUpdateApplication(rawJobID int64, status, notes string) (* func (db *DB) GetApplications() ([]JobWithAnalysis, error) { query := ` SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at, - a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at, + a.id, COALESCE(a.filter_score, 0), COALESCE(a.score, 0), a.is_qualified, COALESCE(a.summary, ''), COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at, app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at FROM job_applications app JOIN raw_jobs r ON app.raw_job_id = r.id @@ -692,9 +714,9 @@ func (db *DB) GetApplications() ([]JobWithAnalysis, error) { var item JobWithAnalysis var tID sql.NullInt64 var aID, appID sql.NullInt64 - var score sql.NullFloat64 + var filterScore, score sql.NullFloat64 var isQualified sql.NullBool - var matched, missing, suggestions, aStatus sql.NullString + var summary, matched, missing, suggestions, aStatus sql.NullString var analyzedAt sql.NullTime var appStatus, notes sql.NullString var appliedAt sql.NullTime @@ -702,7 +724,7 @@ func (db *DB) GetApplications() ([]JobWithAnalysis, error) { err := rows.Scan( &item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt, - &aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt, + &aID, &filterScore, &score, &isQualified, &summary, &matched, &missing, &suggestions, &aStatus, &analyzedAt, &appID, &appStatus, ¬es, &appliedAt, &updatedAt, ) if err != nil { @@ -715,8 +737,10 @@ func (db *DB) GetApplications() ([]JobWithAnalysis, error) { analysis := &JobAnalysis{ ID: aID.Int64, RawJobID: item.ID, + FilterScore: filterScore.Float64, Score: score.Float64, IsQualified: isQualified.Bool, + Summary: summary.String, MatchedHighlights: matched.String, MissingHighlights: missing.String, TailoringSuggestions: suggestions.String, diff --git a/internal/db/queries_test.go b/internal/db/queries_test.go index ea0c8d4..9de15a2 100644 --- a/internal/db/queries_test.go +++ b/internal/db/queries_test.go @@ -67,11 +67,11 @@ func TestDeleteTargetCascade(t *testing.T) { defer func() { _ = database.Close() }() // 1. Create two search targets - target1, err := database.CreateTarget("Target 1", "https://example.com/target1", "google_jobs") + target1, err := database.CreateTarget("Target 1", "https://example.com/target1", "google_jobs", true) if err != nil { t.Fatalf("failed to create target 1: %v", err) } - target2, err := database.CreateTarget("Target 2", "https://example.com/target2", "apple") + target2, err := database.CreateTarget("Target 2", "https://example.com/target2", "apple", true) if err != nil { t.Fatalf("failed to create target 2: %v", err) } @@ -91,9 +91,9 @@ func TestDeleteTargetCascade(t *testing.T) { } // 3. Create analyses and applications for target 1 jobs and target 2 jobs - _ = database.CreateJobAnalysis(job1.ID, 85.0, true, "DONE") - _ = database.CreateJobAnalysis(job2.ID, 40.0, false, "DONE") - _ = database.CreateJobAnalysis(job3.ID, 90.0, true, "DONE") + _ = database.CreateJobAnalysis(job1.ID, 85.0, 85.0, true, "DONE") + _ = database.CreateJobAnalysis(job2.ID, 40.0, 40.0, false, "DONE") + _ = database.CreateJobAnalysis(job3.ID, 90.0, 90.0, true, "DONE") _, _ = database.CreateOrUpdateApplication(job1.ID, "APPLIED", "Sent resume") _, _ = database.CreateOrUpdateApplication(job3.ID, "SAVED", "Review later") diff --git a/internal/db/targets_loader.go b/internal/db/targets_loader.go index 2ed2e17..bc5e1fb 100644 --- a/internal/db/targets_loader.go +++ b/internal/db/targets_loader.go @@ -16,7 +16,7 @@ type targetConfig struct { } `yaml:"targets"` } -// LoadTargetsFromYAML reads a YAML file and inserts targets into the database if it's empty +// LoadTargetsFromYAML reads a YAML file and inserts/syncs targets with the database func (db *DB) LoadTargetsFromYAML(filepath string) error { data, err := os.ReadFile(filepath) if err != nil { @@ -28,23 +28,29 @@ func (db *DB) LoadTargetsFromYAML(filepath string) error { return fmt.Errorf("could not parse target config: %w", err) } - // Get existing targets to prevent duplicates + // Get existing targets to prevent duplicates and sync is_active if needed existingTargets, err := db.GetAllTargets() if err != nil { return err } - - existingURLs := make(map[string]bool) - for _, t := range existingTargets { - existingURLs[t.TargetURL] = true + + existingURLs := make(map[string]*SearchTarget) + for i := range existingTargets { + existingURLs[existingTargets[i].TargetURL] = &existingTargets[i] } for _, t := range config.Targets { - if !existingURLs[t.TargetURL] { - _, err := db.CreateTarget(t.Name, t.TargetURL, t.Platform) + existing, found := existingURLs[t.TargetURL] + if !found { + _, err := db.CreateTarget(t.Name, t.TargetURL, t.Platform, t.IsActive) if err != nil { return fmt.Errorf("failed to insert target %s: %w", t.Name, err) } + } else { + // Sync is_active state with targets.yaml on startup + if existing.IsActive != t.IsActive { + _ = db.SetTargetActive(existing.ID, t.IsActive) + } } } return nil diff --git a/internal/db/targets_loader_test.go b/internal/db/targets_loader_test.go new file mode 100644 index 0000000..d34c4e0 --- /dev/null +++ b/internal/db/targets_loader_test.go @@ -0,0 +1,59 @@ +package db + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadTargetsFromYAMLRespectsIsActive(t *testing.T) { + tempDir := t.TempDir() + dbPath := filepath.Join(tempDir, "test_targets_active.db") + yamlPath := filepath.Join(tempDir, "targets.yaml") + + yamlContent := `targets: + - name: "Google Seattle" + target_url: "https://google.com/jobs" + platform: "google_jobs" + is_active: false + - name: "Apple Seattle" + target_url: "https://apple.com/jobs" + platform: "apple" + is_active: true +` + if err := os.WriteFile(yamlPath, []byte(yamlContent), 0644); err != nil { + t.Fatalf("failed to write yaml: %v", err) + } + + database, err := InitDB(dbPath) + if err != nil { + t.Fatalf("failed to init db: %v", err) + } + defer func() { _ = database.Close() }() + + if err := database.LoadTargetsFromYAML(yamlPath); err != nil { + t.Fatalf("LoadTargetsFromYAML failed: %v", err) + } + + targets, err := database.GetAllTargets() + if err != nil { + t.Fatalf("GetAllTargets failed: %v", err) + } + + if len(targets) != 2 { + t.Fatalf("expected 2 targets, got %d", len(targets)) + } + + for _, target := range targets { + if target.TargetURL == "https://google.com/jobs" { + if target.IsActive != false { + t.Errorf("expected Google target to have is_active=false, got %v", target.IsActive) + } + } + if target.TargetURL == "https://apple.com/jobs" { + if target.IsActive != true { + t.Errorf("expected Apple target to have is_active=true, got %v", target.IsActive) + } + } + } +} diff --git a/internal/filter/filter_test.go b/internal/filter/filter_test.go index 3593090..1905cf8 100644 --- a/internal/filter/filter_test.go +++ b/internal/filter/filter_test.go @@ -30,6 +30,39 @@ func TestHardKeywordFilter(t *testing.T) { } } +func TestScoringFilterWordBoundaries(t *testing.T) { + ctx := context.Background() + sf := NewScoringFilter([]KeywordWeight{ + {Keyword: "go", Weight: 20.0}, + {Keyword: "ai", Weight: 15.0}, + {Keyword: "c++", Weight: 10.0}, + }) + + // "algorithm" and "email" should NOT trigger "go" or "ai" + res1, err := sf.Process(ctx, &db.RawJob{ + Title: "Algorithm Developer", + RawContent: "Send email to apply for algorithm development.", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res1.Score != 0.0 { + t.Errorf("expected score 0 for false-positive substrings, got %f", res1.Score) + } + + // Real "go", "ai", and "c++" + res2, err := sf.Process(ctx, &db.RawJob{ + Title: "AI Runtime Engineer", + RawContent: "Must know Go and C++ programming.", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res2.Score != 45.0 { + t.Errorf("expected score 45.0 (20+15+10), got %f", res2.Score) + } +} + func TestFilterPipeline(t *testing.T) { ctx := context.Background() hardFilter := NewHardKeywordFilter([]string{"Unpaid", "No Sponsorship"}) diff --git a/internal/filter/hard_keyword.go b/internal/filter/hard_keyword.go index 4fce203..023f383 100644 --- a/internal/filter/hard_keyword.go +++ b/internal/filter/hard_keyword.go @@ -3,25 +3,43 @@ package filter import ( "context" "fmt" + "regexp" "strings" "github.com/hireme/hireme/internal/db" ) +type compiledBlacklist struct { + keyword string + regex *regexp.Regexp +} + type HardKeywordFilter struct { Blacklist []string + compiled []compiledBlacklist } func NewHardKeywordFilter(blacklist []string) *HardKeywordFilter { - if len(blacklist) == 0 { - blacklist = []string{ - "no sponsorship", - "us citizen only", - "unpaid", - "volunteer", + compiled := make([]compiledBlacklist, 0, len(blacklist)) + for _, kw := range blacklist { + term := strings.TrimSpace(kw) + if term != "" { + quoted := regexp.QuoteMeta(term) + quoted = regexp.MustCompile(`\\\s+`).ReplaceAllString(quoted, `\s+`) + pattern := `(?i)(?:^|[^a-zA-Z0-9_])` + quoted + `(?:[^a-zA-Z0-9_]|$)` + if re, err := regexp.Compile(pattern); err == nil { + compiled = append(compiled, compiledBlacklist{ + keyword: kw, + regex: re, + }) + } } } - return &HardKeywordFilter{Blacklist: blacklist} + + return &HardKeywordFilter{ + Blacklist: blacklist, + compiled: compiled, + } } func (f *HardKeywordFilter) Name() string { @@ -29,15 +47,14 @@ func (f *HardKeywordFilter) Name() string { } func (f *HardKeywordFilter) Process(ctx context.Context, job *db.RawJob) (FilterResult, error) { - combined := strings.ToLower(job.Title + " " + job.RawContent) + combined := job.Title + " " + job.RawContent - for _, kw := range f.Blacklist { - term := strings.ToLower(strings.TrimSpace(kw)) - if term != "" && strings.Contains(combined, term) { + for _, cb := range f.compiled { + if cb.regex.MatchString(combined) { return FilterResult{ Keep: false, Score: 0, - Reason: fmt.Sprintf("Contains blacklisted keyword: %q", kw), + Reason: fmt.Sprintf("Contains blacklisted keyword: %q", cb.keyword), }, nil } } diff --git a/internal/filter/scoring.go b/internal/filter/scoring.go index 7302245..c88e873 100644 --- a/internal/filter/scoring.go +++ b/internal/filter/scoring.go @@ -3,37 +3,62 @@ package filter import ( "context" "fmt" + "regexp" "strings" "github.com/hireme/hireme/internal/db" ) type KeywordWeight struct { - Keyword string - Weight float64 + Keyword string `yaml:"keyword"` + Weight float64 `yaml:"weight"` +} + +type compiledWeight struct { + keyword string + weight float64 + regex *regexp.Regexp } type ScoringFilter struct { - Weights []KeywordWeight + Weights []KeywordWeight + compiled []compiledWeight +} + +func compilePattern(term string) *regexp.Regexp { + clean := strings.TrimSpace(term) + if clean == "" { + return nil + } + // Quote meta characters, replace multiple whitespace with \s+ + quoted := regexp.QuoteMeta(clean) + quoted = regexp.MustCompile(`\\\s+`).ReplaceAllString(quoted, `\s+`) + // Boundary regex that works for both alphanumeric and punctuation (e.g. c++) + pattern := `(?i)(?:^|[^a-zA-Z0-9_])` + quoted + `(?:[^a-zA-Z0-9_]|$)` + re, err := regexp.Compile(pattern) + if err != nil { + return nil + } + return re } func NewScoringFilter(weights []KeywordWeight) *ScoringFilter { - if len(weights) == 0 { - weights = []KeywordWeight{ - {Keyword: "go", Weight: 25.0}, - {Keyword: "golang", Weight: 25.0}, - {Keyword: "backend", Weight: 15.0}, - {Keyword: "distributed systems", Weight: 20.0}, - {Keyword: "microservices", Weight: 15.0}, - {Keyword: "sqlite", Weight: 10.0}, - {Keyword: "postgres", Weight: 10.0}, - {Keyword: "api", Weight: 10.0}, - {Keyword: "full stack", Weight: 10.0}, - {Keyword: "rest", Weight: 5.0}, - {Keyword: "grpc", Weight: 15.0}, + compiled := make([]compiledWeight, 0, len(weights)) + for _, kw := range weights { + re := compilePattern(kw.Keyword) + if re != nil { + compiled = append(compiled, compiledWeight{ + keyword: kw.Keyword, + weight: kw.Weight, + regex: re, + }) } } - return &ScoringFilter{Weights: weights} + + return &ScoringFilter{ + Weights: weights, + compiled: compiled, + } } func (s *ScoringFilter) Name() string { @@ -41,18 +66,24 @@ func (s *ScoringFilter) Name() string { } func (f *ScoringFilter) Process(ctx context.Context, job *db.RawJob) (FilterResult, error) { - combinedLower := strings.ToLower(job.Title + " " + job.RawContent) + combined := job.Title + " " + job.RawContent var totalScore float64 var matched []string - for _, kw := range f.Weights { - term := strings.ToLower(strings.TrimSpace(kw.Keyword)) - if term != "" && strings.Contains(combinedLower, term) { - totalScore += kw.Weight - matched = append(matched, kw.Keyword) + for _, cw := range f.compiled { + if cw.regex.MatchString(combined) { + totalScore += cw.weight + matched = append(matched, cw.keyword) } } + if totalScore < 0 { + totalScore = 0 + } + if totalScore > 100 { + totalScore = 100 + } + return FilterResult{ Keep: true, Score: totalScore, diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index 637c21e..c0b0a82 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -137,7 +137,7 @@ func (s *Server) handleCreateTarget(w http.ResponseWriter, r *http.Request) { logger.Log.Infow("Triggered /targets (Create)", "component", "API", "name", name, "platform", platform) - _, err := s.db.CreateTarget(name, targetURL, platform) + _, err := s.db.CreateTarget(name, targetURL, platform, true) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -232,7 +232,18 @@ func (s *Server) handleAnalyzeJob(w http.ResponseWriter, r *http.Request) { logger.Log.Infow("Triggered /jobs/{id}/analyze", "component", "API", "job_id", id) - // Enqueue analyze task instead of synchronous processing + job, err := s.db.GetJobByID(id) + if err == nil && job != nil { + filterScore := 0.0 + score := 0.0 + if job.Analysis != nil { + filterScore = job.Analysis.FilterScore + score = job.Analysis.Score + } + _ = s.db.CreateJobAnalysis(id, filterScore, score, true, "PENDING") + } + + // Enqueue analyze task payload := worker.AnalyzePayload{RawJobID: int64(id)} err = s.db.EnqueueTask("ANALYZE", payload) if err != nil { @@ -241,7 +252,7 @@ func (s *Server) handleAnalyzeJob(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusAccepted) - _, _ = w.Write([]byte(`Analysis Queued`)) + _, _ = w.Write([]byte(`🔄 AI Queued`)) } func (s *Server) handleOptOut(w http.ResponseWriter, r *http.Request) { diff --git a/internal/ui/handlers_test.go b/internal/ui/handlers_test.go index d9c333f..57f9828 100644 --- a/internal/ui/handlers_test.go +++ b/internal/ui/handlers_test.go @@ -33,7 +33,7 @@ func TestHandleDeleteTarget(t *testing.T) { } defer func() { _ = database.Close() }() - target, err := database.CreateTarget("Test Target", "https://example.com/test-jobs", "google_jobs") + target, err := database.CreateTarget("Test Target", "https://example.com/test-jobs", "google_jobs", true) if err != nil { t.Fatalf("failed to create target: %v", err) } diff --git a/internal/ui/template_apply.go b/internal/ui/template_apply.go index 4957ae8..a93b1c2 100644 --- a/internal/ui/template_apply.go +++ b/internal/ui/template_apply.go @@ -101,8 +101,8 @@ const applyTableHTML = `{{ define "applyTable" }} {{ if .Analysis }} - - {{ printf "%.0f" .Analysis.Score }} + + {{ if eq .Analysis.Status "PARSED" }}🤖 {{ printf "%.0f" .Analysis.Score }}{{ else }}⚡ {{ printf "%.0f" .Analysis.FilterScore }}{{ end }} {{ else }} – diff --git a/internal/ui/template_jobs.go b/internal/ui/template_jobs.go index 6fb3cf2..d640314 100644 --- a/internal/ui/template_jobs.go +++ b/internal/ui/template_jobs.go @@ -43,7 +43,7 @@ const jobListHTML = `{{ define "jobs" }} Job Title & Company Link - Analysis + Status Score & Qualification Reason / Tags Last Updated @@ -86,9 +86,13 @@ const jobListHTML = `{{ define "jobs" }} {{ if .Analysis }} {{ if eq .Analysis.Status "PARSED" }} - PARSED + AI PARSED {{ else if eq .Analysis.Status "PENDING" }} - PENDING + QUEUED + {{ else if eq .Analysis.Status "FILTERED_OUT" }} + LOW FIT + {{ else if eq .Analysis.Status "REJECTED" }} + REJECTED {{ else if eq .Analysis.Status "DONE" }} DONE {{ else }} @@ -102,14 +106,32 @@ const jobListHTML = `{{ define "jobs" }} {{ if .Analysis }} - {{ if .Analysis.IsQualified }} - - ★ {{ printf "%.0f" .Analysis.Score }} - + {{ if eq .Analysis.Status "PARSED" }} + {{ if .Analysis.IsQualified }} + + 🤖 {{ printf "%.0f" .Analysis.Score }} + + {{ else }} + + ✗ {{ printf "%.0f" .Analysis.Score }} Not Qual + + {{ end }} + {{ else if eq .Analysis.Status "PENDING" }} + + ⚡ {{ printf "%.0f" .Analysis.FilterScore }} + + {{ else if eq .Analysis.Status "FILTERED_OUT" }} + + ⚡ {{ printf "%.0f" .Analysis.FilterScore }} + + {{ else if eq .Analysis.Status "REJECTED" }} + + 🚫 Disqualified + {{ else }} - - ✗ Not Qualified - + + {{ printf "%.0f" .Analysis.Score }} + {{ end }} {{ else }} – @@ -138,6 +160,8 @@ const jobListHTML = `{{ define "jobs" }} {{ $missing := parseJSON .Analysis.MissingHighlights }} {{ if $missing }} {{ index $missing 0 }} + {{ else if .Analysis.Summary }} + {{ .Analysis.Summary }} {{ end }} {{ end }} {{ end }} @@ -232,23 +256,50 @@ const jobDetailHTML = `{{ define "jobDetail" }} {{ if .Analysis }} - +
+
- Match Score - {{ if .Analysis.IsQualified }} - {{ printf "%.1f" .Analysis.Score }} / 100 +
+ 🤖 AI Match Score + + {{ .Analysis.Status }} + +
+ {{ if eq .Analysis.Status "PARSED" }} + {{ if .Analysis.IsQualified }} + {{ printf "%.1f" .Analysis.Score }} / 100 + ✓ Qualified for interview profile + {{ else }} + {{ printf "%.1f" .Analysis.Score }} / 100 + ✗ Does not meet core fit threshold + {{ end }} {{ else }} - Not Qualified + Pending Evaluation + DeepSeek LLM analysis queued {{ end }}
+ +
- Analysis Status - {{ .Analysis.Status }} +
+ ⚡ Keyword Fit + Pre-filter rule +
+ {{ printf "%.1f" .Analysis.FilterScore }} / 100 + Lexical match against filters.yaml
- + + {{ if .Analysis.Summary }} +
+ 💬 AI Executive Assessment +

{{ .Analysis.Summary }}

+
+ {{ end }} + +
{{ if .Analysis.IsQualified }}
diff --git a/internal/ui/template_kanban.go b/internal/ui/template_kanban.go index c759c52..5c8cbce 100644 --- a/internal/ui/template_kanban.go +++ b/internal/ui/template_kanban.go @@ -63,7 +63,7 @@ const kanbanCardHTML = `{{ define "kanbanCard" }}
{{ if .Analysis }}
- Score: {{ printf "%.0f" .Analysis.Score }} + Score: {{ if eq .Analysis.Status "PARSED" }}🤖 {{ else }}⚡ {{ end }}{{ printf "%.0f" .Analysis.Score }}
{{ end }}
diff --git a/internal/worker/pool.go b/internal/worker/pool.go index 450ad25..7ca0f31 100644 --- a/internal/worker/pool.go +++ b/internal/worker/pool.go @@ -21,27 +21,36 @@ import ( ) type WorkerPool struct { - db *db.DB - analyzer analyzer.LLMAnalyzer - filterPipe *filter.FilterPipeline - isRunning atomic.Bool - workerCount int - wg sync.WaitGroup - ctx context.Context - cancel context.CancelFunc + db *db.DB + analyzer analyzer.LLMAnalyzer + filterPipe *filter.FilterPipeline + analysisThreshold float64 + isRunning atomic.Bool + workerCount int + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc } -func NewWorkerPool(database *db.DB, analyzer analyzer.LLMAnalyzer, numWorkers int, filterCfg *config.FilterConfig) *WorkerPool { +func NewWorkerPool(database *db.DB, analyzer analyzer.LLMAnalyzer, numWorkers int, threshold float64, filterCfg *config.FilterConfig) *WorkerPool { filterPipe := filter.NewPipeline( filter.NewHardKeywordFilter(filterCfg.HardKeywords), filter.NewScoringFilter(filterCfg.ScoringWeights), ) + if threshold <= 0 { + threshold = filterCfg.AnalysisThreshold + } + if threshold <= 0 { + threshold = 25.0 + } + return &WorkerPool{ - db: database, - analyzer: analyzer, - filterPipe: filterPipe, - workerCount: numWorkers, + db: database, + analyzer: analyzer, + filterPipe: filterPipe, + analysisThreshold: threshold, + workerCount: numWorkers, } } @@ -255,15 +264,22 @@ func (p *WorkerPool) handleProcess(ctx context.Context, task *db.Task) error { keep, score, reason := p.filterPipe.Execute(ctx, &job.RawJob) if !keep { - // Create a rejected JobAnalysis record. Store reason in missing_highlights JSON array + // Disqualified by blacklist: create a rejected record reasonJSON, _ := json.Marshal([]string{reason}) - _ = p.db.CreateJobAnalysisFull(payload.RawJobID, score, false, "[]", string(reasonJSON), "[]", "PARSED") + _ = p.db.CreateJobAnalysisFull(payload.RawJobID, 0.0, 0.0, false, "Disqualified: "+reason, "[]", string(reasonJSON), "[]", "REJECTED") return nil } - // It's qualified for analysis. Create the base record and enqueue it. - _ = p.db.CreateJobAnalysis(payload.RawJobID, score, true, "PENDING") - + // Gating threshold check + if score < p.analysisThreshold { + summary := fmt.Sprintf("Keyword fit (%.0f) below threshold (%.0f)", score, p.analysisThreshold) + _ = p.db.CreateJobAnalysisFull(payload.RawJobID, score, score, false, summary, "[]", "[]", "[]", "FILTERED_OUT") + return nil + } + + // Qualified for deep LLM analysis. Create the pending record and enqueue ANALYZE task. + _ = p.db.CreateJobAnalysis(payload.RawJobID, score, score, true, "PENDING") + analyzePayload := AnalyzePayload(payload) _ = p.db.EnqueueTask("ANALYZE", analyzePayload) @@ -281,9 +297,16 @@ func (p *WorkerPool) handleAnalyze(ctx context.Context, task *db.Task) error { return err } - // Use empty matched/missing keywords for now, or pass from filter if stored result, err := p.analyzer.AnalyzeJD(ctx, job.Title, job.RawContent) if err != nil { + logger.Log.Errorw("LLM analysis failed", "component", "Worker", "raw_job_id", payload.RawJobID, "error", err) + filterScore := 0.0 + isQual := false + if job.Analysis != nil { + filterScore = job.Analysis.FilterScore + isQual = job.Analysis.IsQualified + } + _ = p.db.UpdateJobAnalysisFull(payload.RawJobID, filterScore, filterScore, isQual, "Analysis error: "+err.Error(), "[]", "[]", "[]", "FAILED") return err } @@ -293,6 +316,9 @@ func (p *WorkerPool) handleAnalyze(ctx context.Context, task *db.Task) error { err = p.db.UpdateJobAnalysisLLM( payload.RawJobID, + result.AIScore, + result.IsQualified, + result.Summary, string(matchedStr), string(missingStr), string(tailoringStr),