feat: implement YAML-based configuration, .env support, and customizable prompt templates for AI analysis
This commit is contained in:
@@ -30,6 +30,39 @@ func TestHardKeywordFilter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoringFilterWordBoundaries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sf := NewScoringFilter([]KeywordWeight{
|
||||
{Keyword: "go", Weight: 20.0},
|
||||
{Keyword: "ai", Weight: 15.0},
|
||||
{Keyword: "c++", Weight: 10.0},
|
||||
})
|
||||
|
||||
// "algorithm" and "email" should NOT trigger "go" or "ai"
|
||||
res1, err := sf.Process(ctx, &db.RawJob{
|
||||
Title: "Algorithm Developer",
|
||||
RawContent: "Send email to apply for algorithm development.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res1.Score != 0.0 {
|
||||
t.Errorf("expected score 0 for false-positive substrings, got %f", res1.Score)
|
||||
}
|
||||
|
||||
// Real "go", "ai", and "c++"
|
||||
res2, err := sf.Process(ctx, &db.RawJob{
|
||||
Title: "AI Runtime Engineer",
|
||||
RawContent: "Must know Go and C++ programming.",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res2.Score != 45.0 {
|
||||
t.Errorf("expected score 45.0 (20+15+10), got %f", res2.Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPipeline(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
hardFilter := NewHardKeywordFilter([]string{"Unpaid", "No Sponsorship"})
|
||||
|
||||
@@ -3,25 +3,43 @@ package filter
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/hireme/hireme/internal/db"
|
||||
)
|
||||
|
||||
type compiledBlacklist struct {
|
||||
keyword string
|
||||
regex *regexp.Regexp
|
||||
}
|
||||
|
||||
type HardKeywordFilter struct {
|
||||
Blacklist []string
|
||||
compiled []compiledBlacklist
|
||||
}
|
||||
|
||||
func NewHardKeywordFilter(blacklist []string) *HardKeywordFilter {
|
||||
if len(blacklist) == 0 {
|
||||
blacklist = []string{
|
||||
"no sponsorship",
|
||||
"us citizen only",
|
||||
"unpaid",
|
||||
"volunteer",
|
||||
compiled := make([]compiledBlacklist, 0, len(blacklist))
|
||||
for _, kw := range blacklist {
|
||||
term := strings.TrimSpace(kw)
|
||||
if term != "" {
|
||||
quoted := regexp.QuoteMeta(term)
|
||||
quoted = regexp.MustCompile(`\\\s+`).ReplaceAllString(quoted, `\s+`)
|
||||
pattern := `(?i)(?:^|[^a-zA-Z0-9_])` + quoted + `(?:[^a-zA-Z0-9_]|$)`
|
||||
if re, err := regexp.Compile(pattern); err == nil {
|
||||
compiled = append(compiled, compiledBlacklist{
|
||||
keyword: kw,
|
||||
regex: re,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return &HardKeywordFilter{Blacklist: blacklist}
|
||||
|
||||
return &HardKeywordFilter{
|
||||
Blacklist: blacklist,
|
||||
compiled: compiled,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *HardKeywordFilter) Name() string {
|
||||
@@ -29,15 +47,14 @@ func (f *HardKeywordFilter) Name() string {
|
||||
}
|
||||
|
||||
func (f *HardKeywordFilter) Process(ctx context.Context, job *db.RawJob) (FilterResult, error) {
|
||||
combined := strings.ToLower(job.Title + " " + job.RawContent)
|
||||
combined := job.Title + " " + job.RawContent
|
||||
|
||||
for _, kw := range f.Blacklist {
|
||||
term := strings.ToLower(strings.TrimSpace(kw))
|
||||
if term != "" && strings.Contains(combined, term) {
|
||||
for _, cb := range f.compiled {
|
||||
if cb.regex.MatchString(combined) {
|
||||
return FilterResult{
|
||||
Keep: false,
|
||||
Score: 0,
|
||||
Reason: fmt.Sprintf("Contains blacklisted keyword: %q", kw),
|
||||
Reason: fmt.Sprintf("Contains blacklisted keyword: %q", cb.keyword),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
+54
-23
@@ -3,37 +3,62 @@ package filter
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/hireme/hireme/internal/db"
|
||||
)
|
||||
|
||||
type KeywordWeight struct {
|
||||
Keyword string
|
||||
Weight float64
|
||||
Keyword string `yaml:"keyword"`
|
||||
Weight float64 `yaml:"weight"`
|
||||
}
|
||||
|
||||
type compiledWeight struct {
|
||||
keyword string
|
||||
weight float64
|
||||
regex *regexp.Regexp
|
||||
}
|
||||
|
||||
type ScoringFilter struct {
|
||||
Weights []KeywordWeight
|
||||
Weights []KeywordWeight
|
||||
compiled []compiledWeight
|
||||
}
|
||||
|
||||
func compilePattern(term string) *regexp.Regexp {
|
||||
clean := strings.TrimSpace(term)
|
||||
if clean == "" {
|
||||
return nil
|
||||
}
|
||||
// Quote meta characters, replace multiple whitespace with \s+
|
||||
quoted := regexp.QuoteMeta(clean)
|
||||
quoted = regexp.MustCompile(`\\\s+`).ReplaceAllString(quoted, `\s+`)
|
||||
// Boundary regex that works for both alphanumeric and punctuation (e.g. c++)
|
||||
pattern := `(?i)(?:^|[^a-zA-Z0-9_])` + quoted + `(?:[^a-zA-Z0-9_]|$)`
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return re
|
||||
}
|
||||
|
||||
func NewScoringFilter(weights []KeywordWeight) *ScoringFilter {
|
||||
if len(weights) == 0 {
|
||||
weights = []KeywordWeight{
|
||||
{Keyword: "go", Weight: 25.0},
|
||||
{Keyword: "golang", Weight: 25.0},
|
||||
{Keyword: "backend", Weight: 15.0},
|
||||
{Keyword: "distributed systems", Weight: 20.0},
|
||||
{Keyword: "microservices", Weight: 15.0},
|
||||
{Keyword: "sqlite", Weight: 10.0},
|
||||
{Keyword: "postgres", Weight: 10.0},
|
||||
{Keyword: "api", Weight: 10.0},
|
||||
{Keyword: "full stack", Weight: 10.0},
|
||||
{Keyword: "rest", Weight: 5.0},
|
||||
{Keyword: "grpc", Weight: 15.0},
|
||||
compiled := make([]compiledWeight, 0, len(weights))
|
||||
for _, kw := range weights {
|
||||
re := compilePattern(kw.Keyword)
|
||||
if re != nil {
|
||||
compiled = append(compiled, compiledWeight{
|
||||
keyword: kw.Keyword,
|
||||
weight: kw.Weight,
|
||||
regex: re,
|
||||
})
|
||||
}
|
||||
}
|
||||
return &ScoringFilter{Weights: weights}
|
||||
|
||||
return &ScoringFilter{
|
||||
Weights: weights,
|
||||
compiled: compiled,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScoringFilter) Name() string {
|
||||
@@ -41,18 +66,24 @@ func (s *ScoringFilter) Name() string {
|
||||
}
|
||||
|
||||
func (f *ScoringFilter) Process(ctx context.Context, job *db.RawJob) (FilterResult, error) {
|
||||
combinedLower := strings.ToLower(job.Title + " " + job.RawContent)
|
||||
combined := job.Title + " " + job.RawContent
|
||||
var totalScore float64
|
||||
var matched []string
|
||||
|
||||
for _, kw := range f.Weights {
|
||||
term := strings.ToLower(strings.TrimSpace(kw.Keyword))
|
||||
if term != "" && strings.Contains(combinedLower, term) {
|
||||
totalScore += kw.Weight
|
||||
matched = append(matched, kw.Keyword)
|
||||
for _, cw := range f.compiled {
|
||||
if cw.regex.MatchString(combined) {
|
||||
totalScore += cw.weight
|
||||
matched = append(matched, cw.keyword)
|
||||
}
|
||||
}
|
||||
|
||||
if totalScore < 0 {
|
||||
totalScore = 0
|
||||
}
|
||||
if totalScore > 100 {
|
||||
totalScore = 100
|
||||
}
|
||||
|
||||
return FilterResult{
|
||||
Keep: true,
|
||||
Score: totalScore,
|
||||
|
||||
Reference in New Issue
Block a user