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
|
data/db
|
||||||
vendor/
|
vendor/
|
||||||
build/
|
build/
|
||||||
|
.env
|
||||||
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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:
|
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
|
||||||
|
|||||||
@@ -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)
|
- 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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
type DeepSeekAnalyzer struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
baseURL string
|
baseURL string
|
||||||
|
systemPrompt string
|
||||||
|
userPromptTemplate string
|
||||||
client *http.Client
|
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, "/"),
|
||||||
|
systemPrompt: systemPrompt,
|
||||||
|
userPromptTemplate: userPromptTemplate,
|
||||||
client: &http.Client{Timeout: 30 * time.Second},
|
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
|
||||||
|
|||||||
+32
-13
@@ -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
|
||||||
|
systemPrompt string
|
||||||
|
userPromptTemplate string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGeminiAnalyzer(apiKey string) *GeminiAnalyzer {
|
func NewGeminiAnalyzer(apiKey, systemPrompt, userPromptTemplate string) *GeminiAnalyzer {
|
||||||
return &GeminiAnalyzer{
|
return &GeminiAnalyzer{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
|
systemPrompt: systemPrompt,
|
||||||
|
userPromptTemplate: userPromptTemplate,
|
||||||
client: &http.Client{Timeout: 30 * time.Second},
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-3
@@ -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
|
||||||
|
AppConfig string
|
||||||
LogLevel string
|
LogLevel string
|
||||||
LLMProvider string // "gemini", "deepseek", "mock"
|
LLMProvider string // "gemini", "deepseek", "mock"
|
||||||
GeminiAPIKey string
|
GeminiAPIKey string
|
||||||
DeepSeekAPIKey string
|
DeepSeekAPIKey string
|
||||||
DeepSeekBaseURL 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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
type FilterConfig struct {
|
||||||
HardKeywords []string `yaml:"hard_keywords"`
|
HardKeywords []string `yaml:"hard_keywords"`
|
||||||
|
AnalysisThreshold float64 `yaml:"analysis_threshold"`
|
||||||
ScoringWeights []filter.KeywordWeight `yaml:"scoring_weights"`
|
ScoringWeights []filter.KeywordWeight `yaml:"scoring_weights"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ¬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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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, ¬es, &appliedAt, &updatedAt,
|
&appID, &appStatus, ¬es, &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, ¬es, &appliedAt, &updatedAt,
|
&appID, &appStatus, ¬es, &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, ¬es, &appliedAt, &updatedAt,
|
&appID, &appStatus, ¬es, &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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
func TestFilterPipeline(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
hardFilter := NewHardKeywordFilter([]string{"Unpaid", "No Sponsorship"})
|
hardFilter := NewHardKeywordFilter([]string{"Unpaid", "No Sponsorship"})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-22
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const jobListHTML = `{{ define "jobs" }}
|
|||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-3" data-sort="title">Job Title & Company</th>
|
<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 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" data-sort="score">Score & 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,13 +106,31 @@ 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 eq .Analysis.Status "PARSED" }}
|
||||||
{{ if .Analysis.IsQualified }}
|
{{ if .Analysis.IsQualified }}
|
||||||
<span class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-black border {{ scoreBadgeClass .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 }}
|
🤖 {{ printf "%.0f" .Analysis.Score }}
|
||||||
</span>
|
</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" 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">
|
<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>
|
</span>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
@@ -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">
|
||||||
|
<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 }}
|
{{ 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 }}
|
{{ 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 }}
|
{{ 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>
|
||||||
|
|||||||
@@ -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]">
|
||||||
|
|||||||
+32
-6
@@ -24,6 +24,7 @@ type WorkerPool struct {
|
|||||||
db *db.DB
|
db *db.DB
|
||||||
analyzer analyzer.LLMAnalyzer
|
analyzer analyzer.LLMAnalyzer
|
||||||
filterPipe *filter.FilterPipeline
|
filterPipe *filter.FilterPipeline
|
||||||
|
analysisThreshold float64
|
||||||
isRunning atomic.Bool
|
isRunning atomic.Bool
|
||||||
workerCount int
|
workerCount int
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
@@ -31,16 +32,24 @@ type WorkerPool struct {
|
|||||||
cancel context.CancelFunc
|
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,
|
||||||
|
analysisThreshold: threshold,
|
||||||
workerCount: numWorkers,
|
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)
|
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),
|
||||||
|
|||||||
Reference in New Issue
Block a user