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 data/db
vendor/ vendor/
build/ build/
.env
+1 -1
View File
@@ -63,7 +63,7 @@ func newRootCmd() *cobra.Command {
} }
// 4. Initialize Worker Pool // 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 pool.Start() // Auto start the worker pool
// Setup UI Server & Routes // 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: hard_keywords:
# Sponsorship / Clearance blockers
- "no sponsorship" - "no sponsorship"
- "without sponsorship"
- "us citizen only" - "us citizen only"
- "u.s. citizen only"
- "active security clearance"
- "top secret/sci"
- "polygraph"
# Non-target employment types
- "unpaid" - "unpaid"
- "volunteer" - "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: scoring_weights:
- keyword: golang # Tier 1: Core Systems, Cloud & AI Infrastructure (High Priority)
weight: 3.0 - keyword: "kubernetes"
- keyword: go weight: 15.0
weight: 2.0 - keyword: "k8s"
- keyword: python weight: 15.0
weight: 2.0 - keyword: "golang"
- keyword: kubernetes weight: 15.0
weight: 3.0 - keyword: "go"
- keyword: k8s weight: 12.0
weight: 3.0 - keyword: "distributed systems"
- keyword: docker weight: 15.0
weight: 2.0 - keyword: "ai runtime"
- keyword: aws weight: 15.0
weight: 1.0 - keyword: "deep learning system"
- keyword: gcp weight: 15.0
weight: 1.0 - keyword: "machine learning infrastructure"
- keyword: distributed systems weight: 15.0
weight: 4.0 - keyword: "gke"
- keyword: backend weight: 12.0
weight: 3.0 - keyword: "container runtime"
- keyword: ai weight: 12.0
weight: -5.0 - keyword: "containerd"
- keyword: machine learning weight: 10.0
weight: -5.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) - 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 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 platform: google_jobs
is_active: true is_active: false
- name: Apple Software Engineer (Seattle) - 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 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 platform: apple
is_active: true is_active: false
- name: Meta Software Engineer (Seattle) - 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 target_url: https://www.metacareers.com/jobsearch/?offices[0]=Seattle%2C%20WA&roles[0]=Full%20time%20employment&teams[0]=Software%20Engineering
platform: meta platform: meta
+5 -2
View File
@@ -9,6 +9,9 @@ import (
) )
type AnalysisOutput struct { type AnalysisOutput struct {
AIScore float64 `json:"ai_score"`
IsQualified bool `json:"is_qualified"`
Summary string `json:"summary"`
MatchedHighlights []string `json:"matched_highlights"` MatchedHighlights []string `json:"matched_highlights"`
MissingHighlights []string `json:"missing_highlights"` MissingHighlights []string `json:"missing_highlights"`
TailoringSuggestions []string `json:"tailoring_suggestions"` TailoringSuggestions []string `json:"tailoring_suggestions"`
@@ -25,12 +28,12 @@ func NewAnalyzer(cfg *config.Config) (LLMAnalyzer, error) {
if cfg.GeminiAPIKey == "" { if cfg.GeminiAPIKey == "" {
return nil, fmt.Errorf("GEMINI_API_KEY is required for provider 'gemini'") 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": case "deepseek":
if cfg.DeepSeekAPIKey == "" { if cfg.DeepSeekAPIKey == "" {
return nil, fmt.Errorf("DEEPSEEK_API_KEY is required for provider 'deepseek'") 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": case "mock":
return NewMockAnalyzer(), nil return NewMockAnalyzer(), nil
default: 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 { type DeepSeekAnalyzer struct {
apiKey string apiKey string
baseURL string baseURL string
client *http.Client systemPrompt string
userPromptTemplate string
client *http.Client
} }
func NewDeepSeekAnalyzer(apiKey, baseURL string) *DeepSeekAnalyzer { func NewDeepSeekAnalyzer(apiKey, baseURL, systemPrompt, userPromptTemplate string) *DeepSeekAnalyzer {
if baseURL == "" { if baseURL == "" {
baseURL = "https://api.deepseek.com" baseURL = "https://api.deepseek.com"
} }
return &DeepSeekAnalyzer{ return &DeepSeekAnalyzer{
apiKey: apiKey, apiKey: apiKey,
baseURL: strings.TrimRight(baseURL, "/"), baseURL: strings.TrimRight(baseURL, "/"),
client: &http.Client{Timeout: 30 * time.Second}, systemPrompt: systemPrompt,
userPromptTemplate: userPromptTemplate,
client: &http.Client{Timeout: 30 * time.Second},
} }
} }
func (d *DeepSeekAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) { func (d *DeepSeekAnalyzer) buildUserPrompt(jobTitle, rawContent string) string {
prompt := fmt.Sprintf(`You are an expert technical resume screener. Analyze the following job description for the title "%s". if d.userPromptTemplate == "" {
Provide a structured JSON output with 3 fields: 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)
- "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"]). res := strings.ReplaceAll(d.userPromptTemplate, "{{.JobTitle}}", jobTitle)
- "tailoring_suggestions": list of 2-3 specific action points for tailoring a resume to this JD. res = strings.ReplaceAll(res, "{{.JobContent}}", rawContent)
return res
}
Job Title: %s func (d *DeepSeekAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) {
Job Content: sysPrompt := d.systemPrompt
%s`, jobTitle, jobTitle, rawContent) 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{}{ reqBody := map[string]interface{}{
"model": "deepseek-chat", "model": "deepseek-chat",
"messages": []map[string]string{ "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": "system", "content": sysPrompt},
{"role": "user", "content": prompt}, {"role": "user", "content": userPrompt},
}, },
"response_format": map[string]string{ "response_format": map[string]string{
"type": "json_object", "type": "json_object",
@@ -99,7 +109,28 @@ Job Content:
var output AnalysisOutput var output AnalysisOutput
if err := json.Unmarshal([]byte(rawJSON), &output); err != nil { 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 return &output, nil
+36 -17
View File
@@ -7,40 +7,49 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"strings"
"time" "time"
) )
type GeminiAnalyzer struct { type GeminiAnalyzer struct {
apiKey string apiKey string
client *http.Client systemPrompt string
userPromptTemplate string
client *http.Client
} }
func NewGeminiAnalyzer(apiKey string) *GeminiAnalyzer { func NewGeminiAnalyzer(apiKey, systemPrompt, userPromptTemplate string) *GeminiAnalyzer {
return &GeminiAnalyzer{ return &GeminiAnalyzer{
apiKey: apiKey, apiKey: apiKey,
client: &http.Client{Timeout: 30 * time.Second}, 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) { 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". sysPrompt := g.systemPrompt
Provide a structured JSON output with 3 fields: if sysPrompt == "" {
- "matched_highlights": list of 3-5 core technical skills/requirements matched (e.g. ["Go (Golang)", "Microservices", "REST APIs"]). 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\": [...]}."
- "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.
Job Title: %s userPrompt := g.buildUserPrompt(jobTitle, rawContent)
Job Content: fullPrompt := fmt.Sprintf("%s\n\n%s", sysPrompt, userPrompt)
%s
Respond STRICTLY with valid JSON matching the format:
{"matched_highlights": [...], "missing_highlights": [...], "tailoring_suggestions": [...]}`, jobTitle, jobTitle, rawContent)
reqBody := map[string]interface{}{ reqBody := map[string]interface{}{
"contents": []map[string]interface{}{ "contents": []map[string]interface{}{
{ {
"parts": []map[string]string{ "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) 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 return &output, nil
} }
+5 -3
View File
@@ -10,11 +10,13 @@ func NewMockAnalyzer() *MockAnalyzer {
return &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) { func (m *MockAnalyzer) AnalyzeJD(ctx context.Context, jobTitle, rawContent string) (*AnalysisOutput, error) {
return &AnalysisOutput{ 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{}, MissingHighlights: []string{},
TailoringSuggestions: []string{}, TailoringSuggestions: []string{"Highlight Google GKE runtime experience."},
}, nil }, nil
} }
+116 -12
View File
@@ -5,18 +5,72 @@ import (
"strings" "strings"
"github.com/spf13/pflag" "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 { type Config struct {
Port string Port string
DBPath string DBPath string
TargetConfig string TargetConfig string
FilterConfig string FilterConfig string
LogLevel string AppConfig string
LLMProvider string // "gemini", "deepseek", "mock" LogLevel string
GeminiAPIKey string LLMProvider string // "gemini", "deepseek", "mock"
DeepSeekAPIKey string GeminiAPIKey string
DeepSeekBaseURL 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 { 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.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.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.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.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.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.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.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.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 return cfg
} }
func (c *Config) Normalize() { 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.LLMProvider == "" {
if c.GeminiAPIKey != "" { if c.DeepSeekAPIKey != "" {
c.LLMProvider = "gemini"
} else if c.DeepSeekAPIKey != "" {
c.LLMProvider = "deepseek" c.LLMProvider = "deepseek"
} else if c.GeminiAPIKey != "" {
c.LLMProvider = "gemini"
} else { } else {
c.LLMProvider = "mock" 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 { type FilterConfig struct {
HardKeywords []string `yaml:"hard_keywords"` HardKeywords []string `yaml:"hard_keywords"`
ScoringWeights []filter.KeywordWeight `yaml:"scoring_weights"` AnalysisThreshold float64 `yaml:"analysis_threshold"`
ScoringWeights []filter.KeywordWeight `yaml:"scoring_weights"`
} }
func LoadFilterConfig(path string) (*FilterConfig, error) { func LoadFilterConfig(path string) (*FilterConfig, error) {
@@ -20,6 +21,8 @@ func LoadFilterConfig(path string) (*FilterConfig, error) {
} }
var cfg FilterConfig var cfg FilterConfig
// Default threshold if not specified
cfg.AnalysisThreshold = 25.0
if err := yaml.Unmarshal(data, &cfg); err != nil { if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal filter config: %w", err) 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 ( CREATE TABLE IF NOT EXISTS job_analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
raw_job_id INTEGER UNIQUE NOT NULL REFERENCES raw_jobs(id) ON DELETE CASCADE, 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, score REAL DEFAULT 0.0,
is_qualified BOOLEAN NOT NULL DEFAULT 0, is_qualified BOOLEAN NOT NULL DEFAULT 0,
summary TEXT,
matched_highlights JSON, matched_highlights JSON,
missing_highlights JSON, missing_highlights JSON,
tailoring_suggestions 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 return nil
} }
+3 -1
View File
@@ -41,12 +41,14 @@ type Task struct {
type JobAnalysis struct { type JobAnalysis struct {
ID int64 `json:"id"` ID int64 `json:"id"`
RawJobID int64 `json:"raw_job_id"` RawJobID int64 `json:"raw_job_id"`
FilterScore float64 `json:"filter_score"`
Score float64 `json:"score"` Score float64 `json:"score"`
IsQualified bool `json:"is_qualified"` IsQualified bool `json:"is_qualified"`
Summary string `json:"summary"`
MatchedHighlights string `json:"matched_highlights"` // JSON array string MatchedHighlights string `json:"matched_highlights"` // JSON array string
MissingHighlights string `json:"missing_highlights"` // JSON array string MissingHighlights string `json:"missing_highlights"` // JSON array string
TailoringSuggestions string `json:"tailoring_suggestions"` // 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"` AnalyzedAt *time.Time `json:"analyzed_at"`
} }
+59 -35
View File
@@ -10,11 +10,11 @@ import (
) )
// Target Queries // Target Queries
func (db *DB) CreateTarget(name, targetURL, platform string) (*SearchTarget, error) { func (db *DB) CreateTarget(name, targetURL, platform string, isActive bool) (*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` 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 target SearchTarget
var lastCrawled sql.NullTime 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, &target.ID, &target.Name, &target.TargetURL, &target.Platform, &target.IsActive, &lastCrawled,
) )
if err != nil { if err != nil {
@@ -78,6 +78,12 @@ func (db *DB) ToggleTargetStatus(id int64) error {
return err 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 { func (db *DB) UpdateTargetCrawledAt(id int64) error {
query := `UPDATE search_targets SET last_crawled_at = CURRENT_TIMESTAMP WHERE id = ?` query := `UPDATE search_targets SET last_crawled_at = CURRENT_TIMESTAMP WHERE id = ?`
_, err := db.Exec(query, id) _, err := db.Exec(query, id)
@@ -408,49 +414,59 @@ func tokenizeToVector(text string) map[string]int {
} }
// Job Analyses Queries // 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 := ` query := `
INSERT INTO job_analyses (raw_job_id, score, is_qualified, status) INSERT INTO job_analyses (raw_job_id, filter_score, score, is_qualified, status)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(raw_job_id) DO UPDATE SET score=excluded.score, is_qualified=excluded.is_qualified, status=excluded.status 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 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 := ` query := `
INSERT INTO job_analyses (raw_job_id, score, is_qualified, matched_highlights, missing_highlights, tailoring_suggestions, status, analyzed_at) 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) 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 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 := ` query := `
UPDATE job_analyses 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 = ? 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 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 := ` query := `
UPDATE job_analyses 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 = ? 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 return err
} }
func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) { func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
query := ` 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, 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 FROM raw_jobs r
JOIN job_analyses a ON r.id = a.raw_job_id JOIN job_analyses a ON r.id = a.raw_job_id
WHERE a.is_qualified = 1 AND a.status = 'PENDING' WHERE a.is_qualified = 1 AND a.status = 'PENDING'
@@ -466,14 +482,14 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
var item JobWithAnalysis var item JobWithAnalysis
var tID sql.NullInt64 var tID sql.NullInt64
var aID int64 var aID int64
var score float64 var filterScore, score float64
var isQualified bool var isQualified bool
var matched, missing, suggestions, aStatus string var summary, matched, missing, suggestions, aStatus string
var analyzedAt sql.NullTime var analyzedAt sql.NullTime
err := rows.Scan( err := rows.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt, &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 { if err != nil {
return nil, err return nil, err
@@ -484,8 +500,10 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
analysis := &JobAnalysis{ analysis := &JobAnalysis{
ID: aID, ID: aID,
RawJobID: item.ID, RawJobID: item.ID,
FilterScore: filterScore,
Score: score, Score: score,
IsQualified: isQualified, IsQualified: isQualified,
Summary: summary,
MatchedHighlights: matched, MatchedHighlights: matched,
MissingHighlights: missing, MissingHighlights: missing,
TailoringSuggestions: suggestions, TailoringSuggestions: suggestions,
@@ -503,12 +521,12 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) { func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
query := ` 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, 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 app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
FROM raw_jobs r FROM raw_jobs r
LEFT JOIN job_analyses a ON r.id = a.raw_job_id LEFT JOIN job_analyses a ON r.id = a.raw_job_id
LEFT JOIN job_applications app ON r.id = app.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) rows, err := db.Query(query)
if err != nil { if err != nil {
@@ -521,9 +539,9 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
var item JobWithAnalysis var item JobWithAnalysis
var tID sql.NullInt64 var tID sql.NullInt64
var aID, appID sql.NullInt64 var aID, appID sql.NullInt64
var score sql.NullFloat64 var filterScore, score sql.NullFloat64
var isQualified sql.NullBool var isQualified sql.NullBool
var matched, missing, suggestions, aStatus sql.NullString var summary, matched, missing, suggestions, aStatus sql.NullString
var analyzedAt sql.NullTime var analyzedAt sql.NullTime
var appStatus, notes sql.NullString var appStatus, notes sql.NullString
var appliedAt sql.NullTime var appliedAt sql.NullTime
@@ -531,7 +549,7 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
err := rows.Scan( err := rows.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt, &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, &appID, &appStatus, &notes, &appliedAt, &updatedAt,
) )
if err != nil { if err != nil {
@@ -544,8 +562,10 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
analysis := &JobAnalysis{ analysis := &JobAnalysis{
ID: aID.Int64, ID: aID.Int64,
RawJobID: item.ID, RawJobID: item.ID,
FilterScore: filterScore.Float64,
Score: score.Float64, Score: score.Float64,
IsQualified: isQualified.Bool, IsQualified: isQualified.Bool,
Summary: summary.String,
MatchedHighlights: matched.String, MatchedHighlights: matched.String,
MissingHighlights: missing.String, MissingHighlights: missing.String,
TailoringSuggestions: suggestions.String, TailoringSuggestions: suggestions.String,
@@ -577,7 +597,7 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) { func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) {
query := ` 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, 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 app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
FROM raw_jobs r FROM raw_jobs r
LEFT JOIN job_analyses a ON r.id = a.raw_job_id 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 item JobWithAnalysis
var tID sql.NullInt64 var tID sql.NullInt64
var aID, appID sql.NullInt64 var aID, appID sql.NullInt64
var score sql.NullFloat64 var filterScore, score sql.NullFloat64
var isQualified sql.NullBool var isQualified sql.NullBool
var matched, missing, suggestions, aStatus sql.NullString var summary, matched, missing, suggestions, aStatus sql.NullString
var analyzedAt sql.NullTime var analyzedAt sql.NullTime
var appStatus, notes sql.NullString var appStatus, notes sql.NullString
var appliedAt sql.NullTime var appliedAt sql.NullTime
@@ -598,7 +618,7 @@ func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) {
err := row.Scan( err := row.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt, &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, &appID, &appStatus, &notes, &appliedAt, &updatedAt,
) )
if err != nil { if err != nil {
@@ -611,8 +631,10 @@ func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) {
analysis := &JobAnalysis{ analysis := &JobAnalysis{
ID: aID.Int64, ID: aID.Int64,
RawJobID: item.ID, RawJobID: item.ID,
FilterScore: filterScore.Float64,
Score: score.Float64, Score: score.Float64,
IsQualified: isQualified.Bool, IsQualified: isQualified.Bool,
Summary: summary.String,
MatchedHighlights: matched.String, MatchedHighlights: matched.String,
MissingHighlights: missing.String, MissingHighlights: missing.String,
TailoringSuggestions: suggestions.String, TailoringSuggestions: suggestions.String,
@@ -674,7 +696,7 @@ func (db *DB) CreateOrUpdateApplication(rawJobID int64, status, notes string) (*
func (db *DB) GetApplications() ([]JobWithAnalysis, error) { func (db *DB) GetApplications() ([]JobWithAnalysis, error) {
query := ` 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, 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 app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
FROM job_applications app FROM job_applications app
JOIN raw_jobs r ON app.raw_job_id = r.id 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 item JobWithAnalysis
var tID sql.NullInt64 var tID sql.NullInt64
var aID, appID sql.NullInt64 var aID, appID sql.NullInt64
var score sql.NullFloat64 var filterScore, score sql.NullFloat64
var isQualified sql.NullBool var isQualified sql.NullBool
var matched, missing, suggestions, aStatus sql.NullString var summary, matched, missing, suggestions, aStatus sql.NullString
var analyzedAt sql.NullTime var analyzedAt sql.NullTime
var appStatus, notes sql.NullString var appStatus, notes sql.NullString
var appliedAt sql.NullTime var appliedAt sql.NullTime
@@ -702,7 +724,7 @@ func (db *DB) GetApplications() ([]JobWithAnalysis, error) {
err := rows.Scan( err := rows.Scan(
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt, &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, &appID, &appStatus, &notes, &appliedAt, &updatedAt,
) )
if err != nil { if err != nil {
@@ -715,8 +737,10 @@ func (db *DB) GetApplications() ([]JobWithAnalysis, error) {
analysis := &JobAnalysis{ analysis := &JobAnalysis{
ID: aID.Int64, ID: aID.Int64,
RawJobID: item.ID, RawJobID: item.ID,
FilterScore: filterScore.Float64,
Score: score.Float64, Score: score.Float64,
IsQualified: isQualified.Bool, IsQualified: isQualified.Bool,
Summary: summary.String,
MatchedHighlights: matched.String, MatchedHighlights: matched.String,
MissingHighlights: missing.String, MissingHighlights: missing.String,
TailoringSuggestions: suggestions.String, TailoringSuggestions: suggestions.String,
+5 -5
View File
@@ -67,11 +67,11 @@ func TestDeleteTargetCascade(t *testing.T) {
defer func() { _ = database.Close() }() defer func() { _ = database.Close() }()
// 1. Create two search targets // 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 { if err != nil {
t.Fatalf("failed to create target 1: %v", err) 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 { if err != nil {
t.Fatalf("failed to create target 2: %v", err) 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 // 3. Create analyses and applications for target 1 jobs and target 2 jobs
_ = database.CreateJobAnalysis(job1.ID, 85.0, true, "DONE") _ = database.CreateJobAnalysis(job1.ID, 85.0, 85.0, true, "DONE")
_ = database.CreateJobAnalysis(job2.ID, 40.0, false, "DONE") _ = database.CreateJobAnalysis(job2.ID, 40.0, 40.0, false, "DONE")
_ = database.CreateJobAnalysis(job3.ID, 90.0, true, "DONE") _ = database.CreateJobAnalysis(job3.ID, 90.0, 90.0, true, "DONE")
_, _ = database.CreateOrUpdateApplication(job1.ID, "APPLIED", "Sent resume") _, _ = database.CreateOrUpdateApplication(job1.ID, "APPLIED", "Sent resume")
_, _ = database.CreateOrUpdateApplication(job3.ID, "SAVED", "Review later") _, _ = database.CreateOrUpdateApplication(job3.ID, "SAVED", "Review later")
+14 -8
View File
@@ -16,7 +16,7 @@ type targetConfig struct {
} `yaml:"targets"` } `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 { func (db *DB) LoadTargetsFromYAML(filepath string) error {
data, err := os.ReadFile(filepath) data, err := os.ReadFile(filepath)
if err != nil { if err != nil {
@@ -28,23 +28,29 @@ func (db *DB) LoadTargetsFromYAML(filepath string) error {
return fmt.Errorf("could not parse target config: %w", err) 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() existingTargets, err := db.GetAllTargets()
if err != nil { if err != nil {
return err return err
} }
existingURLs := make(map[string]bool) existingURLs := make(map[string]*SearchTarget)
for _, t := range existingTargets { for i := range existingTargets {
existingURLs[t.TargetURL] = true existingURLs[existingTargets[i].TargetURL] = &existingTargets[i]
} }
for _, t := range config.Targets { for _, t := range config.Targets {
if !existingURLs[t.TargetURL] { existing, found := existingURLs[t.TargetURL]
_, err := db.CreateTarget(t.Name, t.TargetURL, t.Platform) if !found {
_, err := db.CreateTarget(t.Name, t.TargetURL, t.Platform, t.IsActive)
if err != nil { if err != nil {
return fmt.Errorf("failed to insert target %s: %w", t.Name, err) 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 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) { func TestFilterPipeline(t *testing.T) {
ctx := context.Background() ctx := context.Background()
hardFilter := NewHardKeywordFilter([]string{"Unpaid", "No Sponsorship"}) hardFilter := NewHardKeywordFilter([]string{"Unpaid", "No Sponsorship"})
+29 -12
View File
@@ -3,25 +3,43 @@ package filter
import ( import (
"context" "context"
"fmt" "fmt"
"regexp"
"strings" "strings"
"github.com/hireme/hireme/internal/db" "github.com/hireme/hireme/internal/db"
) )
type compiledBlacklist struct {
keyword string
regex *regexp.Regexp
}
type HardKeywordFilter struct { type HardKeywordFilter struct {
Blacklist []string Blacklist []string
compiled []compiledBlacklist
} }
func NewHardKeywordFilter(blacklist []string) *HardKeywordFilter { func NewHardKeywordFilter(blacklist []string) *HardKeywordFilter {
if len(blacklist) == 0 { compiled := make([]compiledBlacklist, 0, len(blacklist))
blacklist = []string{ for _, kw := range blacklist {
"no sponsorship", term := strings.TrimSpace(kw)
"us citizen only", if term != "" {
"unpaid", quoted := regexp.QuoteMeta(term)
"volunteer", 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 { 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) { 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 { for _, cb := range f.compiled {
term := strings.ToLower(strings.TrimSpace(kw)) if cb.regex.MatchString(combined) {
if term != "" && strings.Contains(combined, term) {
return FilterResult{ return FilterResult{
Keep: false, Keep: false,
Score: 0, Score: 0,
Reason: fmt.Sprintf("Contains blacklisted keyword: %q", kw), Reason: fmt.Sprintf("Contains blacklisted keyword: %q", cb.keyword),
}, nil }, nil
} }
} }
+54 -23
View File
@@ -3,37 +3,62 @@ package filter
import ( import (
"context" "context"
"fmt" "fmt"
"regexp"
"strings" "strings"
"github.com/hireme/hireme/internal/db" "github.com/hireme/hireme/internal/db"
) )
type KeywordWeight struct { type KeywordWeight struct {
Keyword string Keyword string `yaml:"keyword"`
Weight float64 Weight float64 `yaml:"weight"`
}
type compiledWeight struct {
keyword string
weight float64
regex *regexp.Regexp
} }
type ScoringFilter struct { 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 { func NewScoringFilter(weights []KeywordWeight) *ScoringFilter {
if len(weights) == 0 { compiled := make([]compiledWeight, 0, len(weights))
weights = []KeywordWeight{ for _, kw := range weights {
{Keyword: "go", Weight: 25.0}, re := compilePattern(kw.Keyword)
{Keyword: "golang", Weight: 25.0}, if re != nil {
{Keyword: "backend", Weight: 15.0}, compiled = append(compiled, compiledWeight{
{Keyword: "distributed systems", Weight: 20.0}, keyword: kw.Keyword,
{Keyword: "microservices", Weight: 15.0}, weight: kw.Weight,
{Keyword: "sqlite", Weight: 10.0}, regex: re,
{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},
} }
} }
return &ScoringFilter{Weights: weights}
return &ScoringFilter{
Weights: weights,
compiled: compiled,
}
} }
func (s *ScoringFilter) Name() string { 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) { 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 totalScore float64
var matched []string var matched []string
for _, kw := range f.Weights { for _, cw := range f.compiled {
term := strings.ToLower(strings.TrimSpace(kw.Keyword)) if cw.regex.MatchString(combined) {
if term != "" && strings.Contains(combinedLower, term) { totalScore += cw.weight
totalScore += kw.Weight matched = append(matched, cw.keyword)
matched = append(matched, kw.Keyword)
} }
} }
if totalScore < 0 {
totalScore = 0
}
if totalScore > 100 {
totalScore = 100
}
return FilterResult{ return FilterResult{
Keep: true, Keep: true,
Score: totalScore, 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) 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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return 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) 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)} payload := worker.AnalyzePayload{RawJobID: int64(id)}
err = s.db.EnqueueTask("ANALYZE", payload) err = s.db.EnqueueTask("ANALYZE", payload)
if err != nil { if err != nil {
@@ -241,7 +252,7 @@ func (s *Server) handleAnalyzeJob(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusAccepted) 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) { 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() }() 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 { if err != nil {
t.Fatalf("failed to create target: %v", err) t.Fatalf("failed to create target: %v", err)
} }
+2 -2
View File
@@ -101,8 +101,8 @@ const applyTableHTML = `{{ define "applyTable" }}
<!-- Score --> <!-- Score -->
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ printf "%.0f" .Analysis.Score }}{{ else }}-1{{ end }}"> <td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ printf "%.0f" .Analysis.Score }}{{ else }}-1{{ end }}">
{{ if .Analysis }} {{ if .Analysis }}
<span class="font-bold {{ if .Analysis.IsQualified }}text-emerald-400{{ else }}text-slate-500{{ end }}"> <span class="font-bold {{ if .Analysis.IsQualified }}text-emerald-400{{ else }}text-slate-400{{ end }}">
{{ printf "%.0f" .Analysis.Score }} {{ if eq .Analysis.Status "PARSED" }}🤖 {{ printf "%.0f" .Analysis.Score }}{{ else }}⚡ {{ printf "%.0f" .Analysis.FilterScore }}{{ end }}
</span> </span>
{{ else }} {{ else }}
<span class="text-slate-600">–</span> <span class="text-slate-600">–</span>
+69 -18
View File
@@ -43,7 +43,7 @@ const jobListHTML = `{{ define "jobs" }}
<tr> <tr>
<th class="px-4 py-3" data-sort="title">Job Title &amp; Company</th> <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 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" data-sort="score">Score &amp; Qualification</th>
<th class="px-4 py-3">Reason / Tags</th> <th class="px-4 py-3">Reason / Tags</th>
<th class="px-4 py-3" data-sort="date">Last Updated</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 }}"> <td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ .Analysis.Status }}{{ else }}UNANALYZED{{ end }}">
{{ if .Analysis }} {{ if .Analysis }}
{{ if eq .Analysis.Status "PARSED" }} {{ 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" }} {{ 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" }} {{ 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> <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 }} {{ else }}
@@ -102,14 +106,32 @@ const jobListHTML = `{{ define "jobs" }}
<!-- Score & Qualification --> <!-- Score & Qualification -->
<td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ printf "%.0f" .Analysis.Score }}{{ else }}-1{{ end }}"> <td class="px-4 py-3" data-sort-val="{{ if .Analysis }}{{ printf "%.0f" .Analysis.Score }}{{ else }}-1{{ end }}">
{{ if .Analysis }} {{ if .Analysis }}
{{ if .Analysis.IsQualified }} {{ if eq .Analysis.Status "PARSED" }}
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black border {{ scoreBadgeClass .Analysis.Score }}"> {{ if .Analysis.IsQualified }}
★ {{ printf "%.0f" .Analysis.Score }} <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)">
</span> 🤖 {{ 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 }} {{ 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"> <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">
✗ Not Qualified {{ printf "%.0f" .Analysis.Score }}
</span> </span>
{{ end }} {{ end }}
{{ else }} {{ else }}
<span class="text-slate-500 text-[11px]">–</span> <span class="text-slate-500 text-[11px]">–</span>
@@ -138,6 +160,8 @@ const jobListHTML = `{{ define "jobs" }}
{{ $missing := parseJSON .Analysis.MissingHighlights }} {{ $missing := parseJSON .Analysis.MissingHighlights }}
{{ if $missing }} {{ if $missing }}
<span class="text-[10px] text-rose-400/90 leading-snug line-clamp-2" title="{{ index $missing 0 }}">{{ index $missing 0 }}</span> <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 }} {{ end }}
{{ end }} {{ end }}
@@ -232,23 +256,50 @@ const jobDetailHTML = `{{ define "jobDetail" }}
</div> </div>
{{ if .Analysis }} {{ if .Analysis }}
<!-- Score + Status Grid --> <!-- Dual Score Breakdown Grid -->
<div class="grid grid-cols-2 gap-3"> <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"> <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> <div class="flex items-center justify-between">
{{ if .Analysis.IsQualified }} <span class="text-[10px] text-indigo-300 uppercase tracking-wider font-bold">🤖 AI Match Score</span>
<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> <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 }} {{ 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 }} {{ end }}
</div> </div>
<!-- Keyword Relevance Card -->
<div class="p-4 rounded-xl bg-slate-800/60 border border-slate-700/60"> <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> <div class="flex items-center justify-between">
<span class="text-sm font-bold text-indigo-300 mt-1 block uppercase">{{ .Analysis.Status }}</span> <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>
</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"> <div class="space-y-4">
{{ if .Analysis.IsQualified }} {{ if .Analysis.IsQualified }}
<div> <div>
+1 -1
View File
@@ -63,7 +63,7 @@ const kanbanCardHTML = `{{ define "kanbanCard" }}
</div> </div>
{{ if .Analysis }} {{ if .Analysis }}
<div class="text-[10px] text-slate-400"> <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> </div>
{{ end }} {{ end }}
<div class="flex items-center justify-between pt-2 border-t border-slate-700/50 text-[10px]"> <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 { type WorkerPool struct {
db *db.DB db *db.DB
analyzer analyzer.LLMAnalyzer analyzer analyzer.LLMAnalyzer
filterPipe *filter.FilterPipeline filterPipe *filter.FilterPipeline
isRunning atomic.Bool analysisThreshold float64
workerCount int isRunning atomic.Bool
wg sync.WaitGroup workerCount int
ctx context.Context wg sync.WaitGroup
cancel context.CancelFunc 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( filterPipe := filter.NewPipeline(
filter.NewHardKeywordFilter(filterCfg.HardKeywords), filter.NewHardKeywordFilter(filterCfg.HardKeywords),
filter.NewScoringFilter(filterCfg.ScoringWeights), filter.NewScoringFilter(filterCfg.ScoringWeights),
) )
if threshold <= 0 {
threshold = filterCfg.AnalysisThreshold
}
if threshold <= 0 {
threshold = 25.0
}
return &WorkerPool{ return &WorkerPool{
db: database, db: database,
analyzer: analyzer, analyzer: analyzer,
filterPipe: filterPipe, filterPipe: filterPipe,
workerCount: numWorkers, 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) keep, score, reason := p.filterPipe.Execute(ctx, &job.RawJob)
if !keep { 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}) 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 return nil
} }
// It's qualified for analysis. Create the base record and enqueue it. // Gating threshold check
_ = p.db.CreateJobAnalysis(payload.RawJobID, score, true, "PENDING") 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) analyzePayload := AnalyzePayload(payload)
_ = p.db.EnqueueTask("ANALYZE", analyzePayload) _ = p.db.EnqueueTask("ANALYZE", analyzePayload)
@@ -281,9 +297,16 @@ func (p *WorkerPool) handleAnalyze(ctx context.Context, task *db.Task) error {
return err 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) result, err := p.analyzer.AnalyzeJD(ctx, job.Title, job.RawContent)
if err != nil { 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 return err
} }
@@ -293,6 +316,9 @@ func (p *WorkerPool) handleAnalyze(ctx context.Context, task *db.Task) error {
err = p.db.UpdateJobAnalysisLLM( err = p.db.UpdateJobAnalysisLLM(
payload.RawJobID, payload.RawJobID,
result.AIScore,
result.IsQualified,
result.Summary,
string(matchedStr), string(matchedStr),
string(missingStr), string(missingStr),
string(tailoringStr), string(tailoringStr),