feat: implement YAML-based configuration, .env support, and customizable prompt templates for AI analysis

This commit is contained in:
2026-08-22 10:17:38 -07:00
parent 7ec91f384f
commit 0de312edbd
30 changed files with 895 additions and 211 deletions
+2 -1
View File
@@ -1,3 +1,4 @@
data/db
vendor/
build/
build/
.env
+1 -1
View File
@@ -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
+13
View File
@@ -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"
+96 -24
View File
@@ -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
+35
View File
@@ -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": ["..."]
}
+8
View File
@@ -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.
+2 -2
View File
@@ -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
+5 -2
View File
@@ -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:
+50
View File
@@ -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")
}
}
+50 -19
View File
@@ -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
+36 -17
View File
@@ -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
}
+5 -3
View File
@@ -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
}
+116 -12
View File
@@ -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"
}
+46
View File
@@ -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)
}
}
+5 -2
View File
@@ -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)
}
+37
View File
@@ -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, &notnull, &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
}
+3 -1
View File
@@ -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"`
}
+59 -35
View File
@@ -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, &notes, &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, &notes, &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, &notes, &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,
+5 -5
View File
@@ -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")
+14 -8
View File
@@ -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
+59
View File
@@ -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)
}
}
}
}
+33
View File
@@ -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"})
+29 -12
View File
@@ -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
}
}
+54 -23
View File
@@ -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,
+14 -3
View File
@@ -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(`<span class="text-xs px-2 py-1 bg-yellow-100 text-yellow-800 rounded">Analysis Queued</span>`))
_, _ = w.Write([]byte(`<span class="text-xs px-2.5 py-1 bg-amber-500/20 text-amber-300 border border-amber-500/30 rounded-lg font-semibold">🔄 AI Queued</span>`))
}
func (s *Server) handleOptOut(w http.ResponseWriter, r *http.Request) {
+1 -1
View File
@@ -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)
}
+2 -2
View File
@@ -101,8 +101,8 @@ const applyTableHTML = `{{ define "applyTable" }}
<!-- Score -->
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ printf "%.0f" .Analysis.Score }}{{ else }}-1{{ end }}">
{{ if .Analysis }}
<span class="font-bold {{ if .Analysis.IsQualified }}text-emerald-400{{ else }}text-slate-500{{ end }}">
{{ printf "%.0f" .Analysis.Score }}
<span class="font-bold {{ if .Analysis.IsQualified }}text-emerald-400{{ else }}text-slate-400{{ end }}">
{{ if eq .Analysis.Status "PARSED" }}🤖 {{ printf "%.0f" .Analysis.Score }}{{ else }}⚡ {{ printf "%.0f" .Analysis.FilterScore }}{{ end }}
</span>
{{ else }}
<span class="text-slate-600">–</span>
+69 -18
View File
@@ -43,7 +43,7 @@ const jobListHTML = `{{ define "jobs" }}
<tr>
<th class="px-4 py-3" data-sort="title">Job Title &amp; Company</th>
<th class="px-4 py-3 text-center">Link</th>
<th class="px-4 py-3" data-sort="status">Analysis</th>
<th class="px-4 py-3" data-sort="status">Status</th>
<th class="px-4 py-3" data-sort="score">Score &amp; Qualification</th>
<th class="px-4 py-3">Reason / Tags</th>
<th class="px-4 py-3" data-sort="date">Last Updated</th>
@@ -86,9 +86,13 @@ const jobListHTML = `{{ define "jobs" }}
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ .Analysis.Status }}{{ else }}UNANALYZED{{ end }}">
{{ if .Analysis }}
{{ if eq .Analysis.Status "PARSED" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">PARSED</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">AI PARSED</span>
{{ else if eq .Analysis.Status "PENDING" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400 border border-amber-500/30">PENDING</span>
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-amber-500/20 text-amber-400 border border-amber-500/30">QUEUED</span>
{{ else if eq .Analysis.Status "FILTERED_OUT" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-700/40 text-slate-400 border border-slate-600/30">LOW FIT</span>
{{ else if eq .Analysis.Status "REJECTED" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30">REJECTED</span>
{{ else if eq .Analysis.Status "DONE" }}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">DONE</span>
{{ else }}
@@ -102,14 +106,32 @@ const jobListHTML = `{{ define "jobs" }}
<!-- Score & Qualification -->
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ printf "%.0f" .Analysis.Score }}{{ else }}-1{{ end }}">
{{ if .Analysis }}
{{ if .Analysis.IsQualified }}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black border {{ scoreBadgeClass .Analysis.Score }}">
★ {{ printf "%.0f" .Analysis.Score }}
</span>
{{ if eq .Analysis.Status "PARSED" }}
{{ if .Analysis.IsQualified }}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black border {{ scoreBadgeClass .Analysis.Score }}" title="AI Match Score (DeepSeek)">
🤖 {{ printf "%.0f" .Analysis.Score }}
</span>
{{ else }}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20" title="AI Score: {{ printf "%.0f" .Analysis.Score }}">
✗ {{ printf "%.0f" .Analysis.Score }} Not Qual
</span>
{{ end }}
{{ else if eq .Analysis.Status "PENDING" }}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-300 border border-amber-500/30" title="Keyword Fit: {{ printf "%.0f" .Analysis.FilterScore }} - Waiting for AI">
⚡ {{ printf "%.0f" .Analysis.FilterScore }}
</span>
{{ else if eq .Analysis.Status "FILTERED_OUT" }}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-slate-800 text-slate-400 border border-slate-700" title="Keyword Fit: {{ printf "%.0f" .Analysis.FilterScore }}">
⚡ {{ printf "%.0f" .Analysis.FilterScore }}
</span>
{{ else if eq .Analysis.Status "REJECTED" }}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
🚫 Disqualified
</span>
{{ else }}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
✗ Not Qualified
</span>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-slate-700/50 text-slate-400 border border-slate-600/30">
{{ printf "%.0f" .Analysis.Score }}
</span>
{{ end }}
{{ else }}
<span class="text-slate-500 text-[11px]">–</span>
@@ -138,6 +160,8 @@ const jobListHTML = `{{ define "jobs" }}
{{ $missing := parseJSON .Analysis.MissingHighlights }}
{{ if $missing }}
<span class="text-[10px] text-rose-400/90 leading-snug line-clamp-2" title="{{ index $missing 0 }}">{{ index $missing 0 }}</span>
{{ else if .Analysis.Summary }}
<span class="text-[10px] text-slate-400/90 leading-snug line-clamp-2" title="{{ .Analysis.Summary }}">{{ .Analysis.Summary }}</span>
{{ end }}
{{ end }}
{{ end }}
@@ -232,23 +256,50 @@ const jobDetailHTML = `{{ define "jobDetail" }}
</div>
{{ if .Analysis }}
<!-- Score + Status Grid -->
<!-- Dual Score Breakdown Grid -->
<div class="grid grid-cols-2 gap-3">
<!-- AI Match Score Card -->
<div class="p-4 rounded-xl bg-slate-800/60 border border-slate-700/60">
<span class="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block">Match Score</span>
{{ if .Analysis.IsQualified }}
<span class="text-2xl font-black text-emerald-400 mt-1 block">{{ printf "%.1f" .Analysis.Score }} <span class="text-sm font-medium text-slate-400">/ 100</span></span>
<div class="flex items-center justify-between">
<span class="text-[10px] text-indigo-300 uppercase tracking-wider font-bold">🤖 AI Match Score</span>
<span class="text-[10px] font-semibold px-2 py-0.5 rounded {{ if eq .Analysis.Status "PARSED" }}bg-indigo-500/20 text-indigo-300 border border-indigo-500/30{{ else }}bg-amber-500/20 text-amber-300 border border-amber-500/30{{ end }}">
{{ .Analysis.Status }}
</span>
</div>
{{ if eq .Analysis.Status "PARSED" }}
{{ if .Analysis.IsQualified }}
<span class="text-2xl font-black text-emerald-400 mt-1.5 block">{{ printf "%.1f" .Analysis.Score }} <span class="text-xs font-normal text-slate-400">/ 100</span></span>
<span class="text-[10px] text-emerald-400/90 font-medium">✓ Qualified for interview profile</span>
{{ else }}
<span class="text-2xl font-black text-rose-400 mt-1.5 block">{{ printf "%.1f" .Analysis.Score }} <span class="text-xs font-normal text-slate-400">/ 100</span></span>
<span class="text-[10px] text-rose-400 font-medium">✗ Does not meet core fit threshold</span>
{{ end }}
{{ else }}
<span class="text-base font-bold text-rose-400 mt-1 block">Not Qualified</span>
<span class="text-lg font-bold text-amber-300 mt-1.5 block">Pending Evaluation</span>
<span class="text-[10px] text-slate-400">DeepSeek LLM analysis queued</span>
{{ end }}
</div>
<!-- Keyword Relevance Card -->
<div class="p-4 rounded-xl bg-slate-800/60 border border-slate-700/60">
<span class="text-[10px] text-slate-400 uppercase tracking-wider font-semibold block">Analysis Status</span>
<span class="text-sm font-bold text-indigo-300 mt-1 block uppercase">{{ .Analysis.Status }}</span>
<div class="flex items-center justify-between">
<span class="text-[10px] text-amber-400 uppercase tracking-wider font-bold">⚡ Keyword Fit</span>
<span class="text-[10px] text-slate-400">Pre-filter rule</span>
</div>
<span class="text-2xl font-black text-white mt-1.5 block">{{ printf "%.1f" .Analysis.FilterScore }} <span class="text-xs font-normal text-slate-400">/ 100</span></span>
<span class="text-[10px] text-slate-400">Lexical match against filters.yaml</span>
</div>
</div>
<!-- Highlights -->
<!-- AI Executive Summary -->
{{ if .Analysis.Summary }}
<div class="p-3.5 rounded-xl bg-indigo-950/40 border border-indigo-500/30">
<span class="text-[10px] font-bold text-indigo-300 uppercase tracking-wider block mb-1">💬 AI Executive Assessment</span>
<p class="text-xs text-indigo-100/90 leading-relaxed">{{ .Analysis.Summary }}</p>
</div>
{{ end }}
<!-- Highlights & Gaps -->
<div class="space-y-4">
{{ if .Analysis.IsQualified }}
<div>
+1 -1
View File
@@ -63,7 +63,7 @@ const kanbanCardHTML = `{{ define "kanbanCard" }}
</div>
{{ if .Analysis }}
<div class="text-[10px] text-slate-400">
Score: <strong class="{{ scoreBadgeClass .Analysis.Score }}">{{ printf "%.0f" .Analysis.Score }}</strong>
Score: <strong class="{{ scoreBadgeClass .Analysis.Score }}">{{ if eq .Analysis.Status "PARSED" }}🤖 {{ else }}⚡ {{ end }}{{ printf "%.0f" .Analysis.Score }}</strong>
</div>
{{ end }}
<div class="flex items-center justify-between pt-2 border-t border-slate-700/50 text-[10px]">
+45 -19
View File
@@ -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),