feat: implement YAML-based configuration, .env support, and customizable prompt templates for AI analysis
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
data/db
|
||||
vendor/
|
||||
build/
|
||||
.env
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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": ["..."]
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -14,36 +14,46 @@ import (
|
||||
type DeepSeekAnalyzer struct {
|
||||
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, "/"),
|
||||
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
|
||||
|
||||
+32
-13
@@ -7,40 +7,49 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type GeminiAnalyzer struct {
|
||||
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,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+107
-3
@@ -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
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
type FilterConfig struct {
|
||||
HardKeywords []string `yaml:"hard_keywords"`
|
||||
AnalysisThreshold float64 `yaml:"analysis_threshold"`
|
||||
ScoringWeights []filter.KeywordWeight `yaml:"scoring_weights"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -62,8 +62,10 @@ func (db *DB) migrate() error {
|
||||
CREATE TABLE IF NOT EXISTS job_analyses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
raw_job_id INTEGER UNIQUE NOT NULL REFERENCES raw_jobs(id) ON DELETE CASCADE,
|
||||
filter_score REAL DEFAULT 0.0,
|
||||
score REAL DEFAULT 0.0,
|
||||
is_qualified BOOLEAN NOT NULL DEFAULT 0,
|
||||
summary TEXT,
|
||||
matched_highlights JSON,
|
||||
missing_highlights JSON,
|
||||
tailoring_suggestions JSON,
|
||||
@@ -125,5 +127,40 @@ func (db *DB) migrate() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic migration for job_analyses: check if filter_score and summary exist
|
||||
var hasFilterScore, hasSummary bool
|
||||
rowsA, err := db.Query(`PRAGMA table_info(job_analyses)`)
|
||||
if err == nil {
|
||||
for rowsA.Next() {
|
||||
var cid int
|
||||
var name, ctype string
|
||||
var notnull, pk int
|
||||
var dfltValue sql.NullString
|
||||
if err := rowsA.Scan(&cid, &name, &ctype, ¬null, &dfltValue, &pk); err == nil {
|
||||
if name == "filter_score" {
|
||||
hasFilterScore = true
|
||||
}
|
||||
if name == "summary" {
|
||||
hasSummary = true
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = rowsA.Close()
|
||||
}
|
||||
|
||||
if !hasFilterScore {
|
||||
if _, err := db.Exec(`ALTER TABLE job_analyses ADD COLUMN filter_score REAL DEFAULT 0.0`); err != nil {
|
||||
logger.Log.Warnw("Failed to add filter_score column", "component", "DB", "error", err)
|
||||
} else {
|
||||
_, _ = db.Exec(`UPDATE job_analyses SET filter_score = score WHERE filter_score IS NULL OR filter_score = 0.0`)
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSummary {
|
||||
if _, err := db.Exec(`ALTER TABLE job_analyses ADD COLUMN summary TEXT`); err != nil {
|
||||
logger.Log.Warnw("Failed to add summary column", "component", "DB", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
@@ -10,11 +10,11 @@ import (
|
||||
)
|
||||
|
||||
// Target Queries
|
||||
func (db *DB) CreateTarget(name, targetURL, platform string) (*SearchTarget, error) {
|
||||
query := `INSERT INTO search_targets (name, target_url, platform, is_active) VALUES (?, ?, ?, 1) RETURNING id, name, target_url, platform, is_active, last_crawled_at`
|
||||
func (db *DB) CreateTarget(name, targetURL, platform string, isActive bool) (*SearchTarget, error) {
|
||||
query := `INSERT INTO search_targets (name, target_url, platform, is_active) VALUES (?, ?, ?, ?) RETURNING id, name, target_url, platform, is_active, last_crawled_at`
|
||||
var target SearchTarget
|
||||
var lastCrawled sql.NullTime
|
||||
err := db.QueryRow(query, name, targetURL, platform).Scan(
|
||||
err := db.QueryRow(query, name, targetURL, platform, isActive).Scan(
|
||||
&target.ID, &target.Name, &target.TargetURL, &target.Platform, &target.IsActive, &lastCrawled,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -78,6 +78,12 @@ func (db *DB) ToggleTargetStatus(id int64) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) SetTargetActive(id int64, isActive bool) error {
|
||||
query := `UPDATE search_targets SET is_active = ? WHERE id = ?`
|
||||
_, err := db.Exec(query, isActive, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) UpdateTargetCrawledAt(id int64) error {
|
||||
query := `UPDATE search_targets SET last_crawled_at = CURRENT_TIMESTAMP WHERE id = ?`
|
||||
_, err := db.Exec(query, id)
|
||||
@@ -408,49 +414,59 @@ func tokenizeToVector(text string) map[string]int {
|
||||
}
|
||||
|
||||
// Job Analyses Queries
|
||||
func (db *DB) CreateJobAnalysis(rawJobID int64, score float64, isQualified bool, status string) error {
|
||||
func (db *DB) CreateJobAnalysis(rawJobID int64, filterScore, score float64, isQualified bool, status string) error {
|
||||
query := `
|
||||
INSERT INTO job_analyses (raw_job_id, score, is_qualified, status)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(raw_job_id) DO UPDATE SET score=excluded.score, is_qualified=excluded.is_qualified, status=excluded.status
|
||||
INSERT INTO job_analyses (raw_job_id, filter_score, score, is_qualified, status)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(raw_job_id) DO UPDATE SET filter_score=excluded.filter_score, score=excluded.score, is_qualified=excluded.is_qualified, status=excluded.status
|
||||
`
|
||||
_, err := db.Exec(query, rawJobID, score, isQualified, status)
|
||||
_, err := db.Exec(query, rawJobID, filterScore, score, isQualified, status)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) CreateJobAnalysisFull(rawJobID int64, score float64, isQualified bool, matched, missing, tailoring, status string) error {
|
||||
func (db *DB) CreateJobAnalysisFull(rawJobID int64, filterScore, score float64, isQualified bool, summary, matched, missing, tailoring, status string) error {
|
||||
query := `
|
||||
INSERT INTO job_analyses (raw_job_id, score, is_qualified, matched_highlights, missing_highlights, tailoring_suggestions, status, analyzed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
INSERT INTO job_analyses (raw_job_id, filter_score, score, is_qualified, summary, matched_highlights, missing_highlights, tailoring_suggestions, status, analyzed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(raw_job_id) DO UPDATE SET
|
||||
filter_score=excluded.filter_score,
|
||||
score=excluded.score,
|
||||
is_qualified=excluded.is_qualified,
|
||||
summary=excluded.summary,
|
||||
matched_highlights=excluded.matched_highlights,
|
||||
missing_highlights=excluded.missing_highlights,
|
||||
tailoring_suggestions=excluded.tailoring_suggestions,
|
||||
status=excluded.status,
|
||||
analyzed_at=CURRENT_TIMESTAMP
|
||||
`
|
||||
_, err := db.Exec(query, rawJobID, score, isQualified, matched, missing, tailoring, status)
|
||||
_, err := db.Exec(query, rawJobID, filterScore, score, isQualified, summary, matched, missing, tailoring, status)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) UpdateJobAnalysisFull(rawJobID int64, score float64, isQualified bool, matchedJSON, missingJSON, suggestionsJSON, status string) error {
|
||||
func (db *DB) UpdateJobAnalysisFull(rawJobID int64, filterScore, score float64, isQualified bool, summary, matchedJSON, missingJSON, suggestionsJSON, status string) error {
|
||||
query := `
|
||||
UPDATE job_analyses
|
||||
SET score = ?, is_qualified = ?, matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = ?, analyzed_at = CURRENT_TIMESTAMP
|
||||
SET filter_score = ?, score = ?, is_qualified = ?, summary = ?, matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = ?, analyzed_at = CURRENT_TIMESTAMP
|
||||
WHERE raw_job_id = ?
|
||||
`
|
||||
_, err := db.Exec(query, score, isQualified, matchedJSON, missingJSON, suggestionsJSON, status, rawJobID)
|
||||
_, err := db.Exec(query, filterScore, score, isQualified, summary, matchedJSON, missingJSON, suggestionsJSON, status, rawJobID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) UpdateJobAnalysisLLM(rawJobID int64, matchedJSON, missingJSON, suggestionsJSON string) error {
|
||||
func (db *DB) UpdateJobAnalysisLLM(rawJobID int64, aiScore float64, isQualified bool, summary, matchedJSON, missingJSON, suggestionsJSON string) error {
|
||||
query := `
|
||||
UPDATE job_analyses
|
||||
SET matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = 'PARSED', analyzed_at = CURRENT_TIMESTAMP
|
||||
SET score = ?, is_qualified = ?, summary = ?, matched_highlights = ?, missing_highlights = ?, tailoring_suggestions = ?, status = 'PARSED', analyzed_at = CURRENT_TIMESTAMP
|
||||
WHERE raw_job_id = ?
|
||||
`
|
||||
_, err := db.Exec(query, matchedJSON, missingJSON, suggestionsJSON, rawJobID)
|
||||
_, err := db.Exec(query, aiScore, isQualified, summary, matchedJSON, missingJSON, suggestionsJSON, rawJobID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
|
||||
query := `
|
||||
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at,
|
||||
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), a.status, a.analyzed_at
|
||||
a.id, COALESCE(a.filter_score, 0), COALESCE(a.score, 0), a.is_qualified, COALESCE(a.summary, ''), COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), a.status, a.analyzed_at
|
||||
FROM raw_jobs r
|
||||
JOIN job_analyses a ON r.id = a.raw_job_id
|
||||
WHERE a.is_qualified = 1 AND a.status = 'PENDING'
|
||||
@@ -466,14 +482,14 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
|
||||
var item JobWithAnalysis
|
||||
var tID sql.NullInt64
|
||||
var aID int64
|
||||
var score float64
|
||||
var filterScore, score float64
|
||||
var isQualified bool
|
||||
var matched, missing, suggestions, aStatus string
|
||||
var summary, matched, missing, suggestions, aStatus string
|
||||
var analyzedAt sql.NullTime
|
||||
|
||||
err := rows.Scan(
|
||||
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt,
|
||||
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
|
||||
&aID, &filterScore, &score, &isQualified, &summary, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -484,8 +500,10 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
|
||||
analysis := &JobAnalysis{
|
||||
ID: aID,
|
||||
RawJobID: item.ID,
|
||||
FilterScore: filterScore,
|
||||
Score: score,
|
||||
IsQualified: isQualified,
|
||||
Summary: summary,
|
||||
MatchedHighlights: matched,
|
||||
MissingHighlights: missing,
|
||||
TailoringSuggestions: suggestions,
|
||||
@@ -503,12 +521,12 @@ func (db *DB) GetPendingQualifiedAnalyses() ([]JobWithAnalysis, error) {
|
||||
func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
|
||||
query := `
|
||||
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at,
|
||||
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
|
||||
a.id, COALESCE(a.filter_score, 0), COALESCE(a.score, 0), a.is_qualified, COALESCE(a.summary, ''), COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
|
||||
app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
|
||||
FROM raw_jobs r
|
||||
LEFT JOIN job_analyses a ON r.id = a.raw_job_id
|
||||
LEFT JOIN job_applications app ON r.id = app.raw_job_id
|
||||
ORDER BY COALESCE(a.score, 0) DESC, r.crawled_at DESC
|
||||
ORDER BY COALESCE(a.score, a.filter_score, 0) DESC, r.crawled_at DESC
|
||||
`
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
@@ -521,9 +539,9 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
|
||||
var item JobWithAnalysis
|
||||
var tID sql.NullInt64
|
||||
var aID, appID sql.NullInt64
|
||||
var score sql.NullFloat64
|
||||
var filterScore, score sql.NullFloat64
|
||||
var isQualified sql.NullBool
|
||||
var matched, missing, suggestions, aStatus sql.NullString
|
||||
var summary, matched, missing, suggestions, aStatus sql.NullString
|
||||
var analyzedAt sql.NullTime
|
||||
var appStatus, notes sql.NullString
|
||||
var appliedAt sql.NullTime
|
||||
@@ -531,7 +549,7 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
|
||||
|
||||
err := rows.Scan(
|
||||
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt,
|
||||
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
|
||||
&aID, &filterScore, &score, &isQualified, &summary, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
|
||||
&appID, &appStatus, ¬es, &appliedAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -544,8 +562,10 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
|
||||
analysis := &JobAnalysis{
|
||||
ID: aID.Int64,
|
||||
RawJobID: item.ID,
|
||||
FilterScore: filterScore.Float64,
|
||||
Score: score.Float64,
|
||||
IsQualified: isQualified.Bool,
|
||||
Summary: summary.String,
|
||||
MatchedHighlights: matched.String,
|
||||
MissingHighlights: missing.String,
|
||||
TailoringSuggestions: suggestions.String,
|
||||
@@ -577,7 +597,7 @@ func (db *DB) GetJobsWithAnalysis() ([]JobWithAnalysis, error) {
|
||||
func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) {
|
||||
query := `
|
||||
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at,
|
||||
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
|
||||
a.id, COALESCE(a.filter_score, 0), COALESCE(a.score, 0), a.is_qualified, COALESCE(a.summary, ''), COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
|
||||
app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
|
||||
FROM raw_jobs r
|
||||
LEFT JOIN job_analyses a ON r.id = a.raw_job_id
|
||||
@@ -588,9 +608,9 @@ func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) {
|
||||
var item JobWithAnalysis
|
||||
var tID sql.NullInt64
|
||||
var aID, appID sql.NullInt64
|
||||
var score sql.NullFloat64
|
||||
var filterScore, score sql.NullFloat64
|
||||
var isQualified sql.NullBool
|
||||
var matched, missing, suggestions, aStatus sql.NullString
|
||||
var summary, matched, missing, suggestions, aStatus sql.NullString
|
||||
var analyzedAt sql.NullTime
|
||||
var appStatus, notes sql.NullString
|
||||
var appliedAt sql.NullTime
|
||||
@@ -598,7 +618,7 @@ func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) {
|
||||
|
||||
err := row.Scan(
|
||||
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt,
|
||||
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
|
||||
&aID, &filterScore, &score, &isQualified, &summary, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
|
||||
&appID, &appStatus, ¬es, &appliedAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -611,8 +631,10 @@ func (db *DB) GetJobByID(rawJobID int64) (*JobWithAnalysis, error) {
|
||||
analysis := &JobAnalysis{
|
||||
ID: aID.Int64,
|
||||
RawJobID: item.ID,
|
||||
FilterScore: filterScore.Float64,
|
||||
Score: score.Float64,
|
||||
IsQualified: isQualified.Bool,
|
||||
Summary: summary.String,
|
||||
MatchedHighlights: matched.String,
|
||||
MissingHighlights: missing.String,
|
||||
TailoringSuggestions: suggestions.String,
|
||||
@@ -674,7 +696,7 @@ func (db *DB) CreateOrUpdateApplication(rawJobID int64, status, notes string) (*
|
||||
func (db *DB) GetApplications() ([]JobWithAnalysis, error) {
|
||||
query := `
|
||||
SELECT r.id, r.canonical_id, r.target_id, r.title, r.company, r.location, r.apply_url, r.raw_content, r.crawled_at, r.last_seen_at,
|
||||
a.id, a.score, a.is_qualified, COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
|
||||
a.id, COALESCE(a.filter_score, 0), COALESCE(a.score, 0), a.is_qualified, COALESCE(a.summary, ''), COALESCE(a.matched_highlights, '[]'), COALESCE(a.missing_highlights, '[]'), COALESCE(a.tailoring_suggestions, '[]'), COALESCE(a.status, 'PENDING'), a.analyzed_at,
|
||||
app.id, app.status, COALESCE(app.notes, ''), app.applied_at, app.updated_at
|
||||
FROM job_applications app
|
||||
JOIN raw_jobs r ON app.raw_job_id = r.id
|
||||
@@ -692,9 +714,9 @@ func (db *DB) GetApplications() ([]JobWithAnalysis, error) {
|
||||
var item JobWithAnalysis
|
||||
var tID sql.NullInt64
|
||||
var aID, appID sql.NullInt64
|
||||
var score sql.NullFloat64
|
||||
var filterScore, score sql.NullFloat64
|
||||
var isQualified sql.NullBool
|
||||
var matched, missing, suggestions, aStatus sql.NullString
|
||||
var summary, matched, missing, suggestions, aStatus sql.NullString
|
||||
var analyzedAt sql.NullTime
|
||||
var appStatus, notes sql.NullString
|
||||
var appliedAt sql.NullTime
|
||||
@@ -702,7 +724,7 @@ func (db *DB) GetApplications() ([]JobWithAnalysis, error) {
|
||||
|
||||
err := rows.Scan(
|
||||
&item.ID, &item.CanonicalID, &tID, &item.Title, &item.Company, &item.Location, &item.ApplyURL, &item.RawContent, &item.CrawledAt, &item.LastSeenAt,
|
||||
&aID, &score, &isQualified, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
|
||||
&aID, &filterScore, &score, &isQualified, &summary, &matched, &missing, &suggestions, &aStatus, &analyzedAt,
|
||||
&appID, &appStatus, ¬es, &appliedAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -715,8 +737,10 @@ func (db *DB) GetApplications() ([]JobWithAnalysis, error) {
|
||||
analysis := &JobAnalysis{
|
||||
ID: aID.Int64,
|
||||
RawJobID: item.ID,
|
||||
FilterScore: filterScore.Float64,
|
||||
Score: score.Float64,
|
||||
IsQualified: isQualified.Bool,
|
||||
Summary: summary.String,
|
||||
MatchedHighlights: matched.String,
|
||||
MissingHighlights: missing.String,
|
||||
TailoringSuggestions: suggestions.String,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+53
-22
@@ -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
|
||||
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
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -43,7 +43,7 @@ const jobListHTML = `{{ define "jobs" }}
|
||||
<tr>
|
||||
<th class="px-4 py-3" data-sort="title">Job Title & 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 & 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,13 +106,31 @@ 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 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 }}">
|
||||
★ {{ 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)">
|
||||
🤖 {{ 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">
|
||||
✗ Not Qualified
|
||||
🚫 Disqualified
|
||||
</span>
|
||||
{{ else }}
|
||||
<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 }}
|
||||
@@ -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>
|
||||
<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 block">{{ printf "%.1f" .Analysis.Score }} <span class="text-sm font-medium text-slate-400">/ 100</span></span>
|
||||
<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-base font-bold text-rose-400 mt-1 block">Not Qualified</span>
|
||||
<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-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>
|
||||
|
||||
@@ -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]">
|
||||
|
||||
+32
-6
@@ -24,6 +24,7 @@ type WorkerPool struct {
|
||||
db *db.DB
|
||||
analyzer analyzer.LLMAnalyzer
|
||||
filterPipe *filter.FilterPipeline
|
||||
analysisThreshold float64
|
||||
isRunning atomic.Bool
|
||||
workerCount int
|
||||
wg sync.WaitGroup
|
||||
@@ -31,16 +32,24 @@ type WorkerPool struct {
|
||||
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,
|
||||
analysisThreshold: threshold,
|
||||
workerCount: numWorkers,
|
||||
}
|
||||
}
|
||||
@@ -255,14 +264,21 @@ 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),
|
||||
|
||||
Reference in New Issue
Block a user