299 lines
7.6 KiB
Go
299 lines
7.6 KiB
Go
package linkedin
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// MessageType classifies the type of recruiter message.
|
|
type MessageType int
|
|
|
|
const (
|
|
// MsgTypeVague indicates a generic "are you open?" style message.
|
|
MsgTypeVague MessageType = iota
|
|
// MsgTypeSpecific indicates a message with role title and details.
|
|
MsgTypeSpecific
|
|
// MsgTypeFollowUp indicates a follow-up on a previous conversation.
|
|
MsgTypeFollowUp
|
|
// MsgTypeSpam indicates mass outreach or generic spam.
|
|
MsgTypeSpam
|
|
)
|
|
|
|
// ClassifyMessage classifies a recruiter message thread using heuristic patterns.
|
|
// No LLM call — purely pattern-based for speed.
|
|
func ClassifyMessage(thread Thread) MessageType {
|
|
if len(thread.Messages) == 0 {
|
|
return MsgTypeSpam
|
|
}
|
|
|
|
lastMsg := thread.Messages[len(thread.Messages)-1]
|
|
text := strings.ToLower(lastMsg.Text)
|
|
|
|
// Check for follow-up pattern if thread has multiple messages
|
|
if len(thread.Messages) > 1 && isFollowUpMessage(text) {
|
|
return MsgTypeFollowUp
|
|
}
|
|
|
|
// Check for spam indicators
|
|
if isSpamMessage(text) {
|
|
return MsgTypeSpam
|
|
}
|
|
|
|
// Check for specific role details (title + salary/location)
|
|
if hasJobTitle(text) && (hasSalaryKeywords(text) || hasLocationKeywords(text)) {
|
|
return MsgTypeSpecific
|
|
}
|
|
|
|
// Check for vague "open to opportunities" pattern
|
|
if isVagueMessage(text) {
|
|
return MsgTypeVague
|
|
}
|
|
|
|
// Default: if we have a job title but no salary/location, still specific
|
|
if hasJobTitle(text) {
|
|
return MsgTypeSpecific
|
|
}
|
|
|
|
// Default to vague if none of the above
|
|
return MsgTypeVague
|
|
}
|
|
|
|
// MissingFields returns a list of standard fields that are absent from the thread.
|
|
// Checks for: title, company, compensation, remote, location.
|
|
func MissingFields(thread Thread) []string {
|
|
combinedText := ""
|
|
for _, msg := range thread.Messages {
|
|
combinedText += " " + msg.Text + " "
|
|
}
|
|
combinedText = strings.ToLower(combinedText)
|
|
|
|
var missing []string
|
|
|
|
if !hasJobTitle(combinedText) {
|
|
missing = append(missing, "title")
|
|
}
|
|
|
|
if !hasCompanyName(combinedText) {
|
|
missing = append(missing, "company")
|
|
}
|
|
|
|
if !hasSalaryKeywords(combinedText) {
|
|
missing = append(missing, "compensation")
|
|
}
|
|
|
|
if !hasRemotePolicy(combinedText) {
|
|
missing = append(missing, "remote")
|
|
}
|
|
|
|
if !hasLocationKeywords(combinedText) {
|
|
missing = append(missing, "location")
|
|
}
|
|
|
|
return missing
|
|
}
|
|
|
|
// DraftInfoRequest creates a polite reply requesting missing information.
|
|
func DraftInfoRequest(thread Thread, missingFields []string) string {
|
|
senderFirstName := extractFirstName(thread.Sender)
|
|
|
|
draft := "Hi " + senderFirstName + ",\n\n"
|
|
draft += "Thanks for reaching out! I'm selectively open to the right opportunity. "
|
|
draft += "Could you share a few more details?\n\n"
|
|
|
|
for _, field := range missingFields {
|
|
switch field {
|
|
case "title":
|
|
draft += "- What's the role title and team?\n"
|
|
case "company":
|
|
draft += "- Which company is this for?\n"
|
|
case "compensation":
|
|
draft += "- What's the compensation range?\n"
|
|
case "remote":
|
|
draft += "- Is the role remote, hybrid, or on-site?\n"
|
|
case "location":
|
|
draft += "- Where is the role based?\n"
|
|
}
|
|
}
|
|
|
|
draft += "\nLooking forward to hearing more.\n"
|
|
|
|
return draft
|
|
}
|
|
|
|
// isVagueMessage checks for vague recruiter phrases without job title.
|
|
func isVagueMessage(text string) bool {
|
|
vaguePatterns := []string{
|
|
"open to opportunities",
|
|
"open to the right role",
|
|
"are you open",
|
|
"interested in talking",
|
|
"i came across your profile",
|
|
"i saw your profile",
|
|
"reach out",
|
|
"connect with you",
|
|
"let's connect",
|
|
"would love to chat",
|
|
"great profile",
|
|
"impressed with your profile",
|
|
}
|
|
|
|
for _, pattern := range vaguePatterns {
|
|
if strings.Contains(text, pattern) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isFollowUpMessage checks for follow-up indicators.
|
|
func isFollowUpMessage(text string) bool {
|
|
followUpPatterns := []string{
|
|
"following up",
|
|
"just checking in",
|
|
"any update",
|
|
"any thoughts",
|
|
"did you have a chance",
|
|
"circle back",
|
|
"wanted to follow up",
|
|
}
|
|
|
|
for _, pattern := range followUpPatterns {
|
|
if strings.Contains(text, pattern) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// isSpamMessage checks for spam indicators.
|
|
func isSpamMessage(text string) bool {
|
|
// Very short message is often spam
|
|
if len(strings.Fields(text)) < 5 {
|
|
return true
|
|
}
|
|
|
|
spamPatterns := []string{
|
|
"i came across your profile",
|
|
"i saw your profile",
|
|
}
|
|
|
|
for _, pattern := range spamPatterns {
|
|
if strings.Contains(text, pattern) {
|
|
// Check if there's actual job detail after the pattern
|
|
if !hasJobTitle(text) && !hasSalaryKeywords(text) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// hasJobTitle checks for standard job title patterns.
|
|
func hasJobTitle(text string) bool {
|
|
// Match patterns like "Senior Engineer", "Lead Developer", "Principal Manager"
|
|
titlePattern := regexp.MustCompile(
|
|
`(?i)\b(senior|staff|principal|lead|junior|mid-level|entry-level|manager|director|head|vp|vice president)\s+(engineer|developer|manager|analyst|architect|lead|coordinator|specialist|consultant|scientist|designer)\b`,
|
|
)
|
|
return titlePattern.MatchString(text)
|
|
}
|
|
|
|
// hasSalaryKeywords checks for compensation-related keywords.
|
|
func hasSalaryKeywords(text string) bool {
|
|
salaryKeywords := []string{
|
|
"salary",
|
|
"compensation",
|
|
"comp",
|
|
"/year",
|
|
"k/year",
|
|
"/annually",
|
|
"annual",
|
|
"signing bonus",
|
|
"equity",
|
|
"stock options",
|
|
"benefits",
|
|
}
|
|
|
|
for _, keyword := range salaryKeywords {
|
|
if strings.Contains(text, keyword) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Check for dollar amounts: $XXXk, $XXX,XXX, etc.
|
|
dollarPattern := regexp.MustCompile(`\$[\d,]+`)
|
|
return dollarPattern.MatchString(text)
|
|
}
|
|
|
|
// hasRemotePolicy checks for work location policy keywords.
|
|
func hasRemotePolicy(text string) bool {
|
|
remoteKeywords := []string{
|
|
"remote",
|
|
"hybrid",
|
|
"on-site",
|
|
"on-site",
|
|
"in-office",
|
|
"office",
|
|
"wfh",
|
|
"work from home",
|
|
"distributed",
|
|
}
|
|
|
|
for _, keyword := range remoteKeywords {
|
|
if strings.Contains(text, keyword) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// hasLocationKeywords checks for geographic location mentions.
|
|
func hasLocationKeywords(text string) bool {
|
|
// Simple heuristic: look for US state abbreviations or common city patterns
|
|
statePattern := regexp.MustCompile(
|
|
`(?i)\b(ca|ny|tx|fl|il|pa|oh|ga|nc|mi|nj|va|wa|az|ma|tn|in|md|mo|wi|co|mn|sc|al|la|ky|or|ok|ct|ut|ia|nv|ar|ms|kansas|new york|california|texas|florida|illinois|pennsylvania|ohio|georgia|north carolina|michigan|new jersey|virginia|washington|arizona|massachusetts|tennessee|indiana|maryland|missouri|wisconsin|colorado|minnesota|south carolina|alabama|louisiana|kentucky|oregon|oklahoma|connecticut|utah|iowa|nevada|arkansas|mississippi)\b`,
|
|
)
|
|
|
|
if statePattern.MatchString(text) {
|
|
return true
|
|
}
|
|
|
|
// Check for major cities or country names
|
|
cityPattern := regexp.MustCompile(
|
|
`(?i)\b(new york|los angeles|chicago|boston|san francisco|seattle|denver|austin|miami|atlanta|london|toronto|vancouver|sydney|berlin|dubai|singapore|tokyo|india|uk|ireland|canada|australia)\b`,
|
|
)
|
|
|
|
return cityPattern.MatchString(text)
|
|
}
|
|
|
|
// hasCompanyName checks if a company name is mentioned.
|
|
// Simple heuristic: looks for capitalized words after "at " or "with ".
|
|
func hasCompanyName(text string) bool {
|
|
// Check for explicit "at [Company]" pattern
|
|
atPattern := regexp.MustCompile(`(?i)\bat\s+([A-Z][a-zA-Z0-9\s&-]+)`)
|
|
if atPattern.MatchString(text) {
|
|
return true
|
|
}
|
|
|
|
// Check for "with [Company]" pattern
|
|
withPattern := regexp.MustCompile(`(?i)\bwith\s+([A-Z][a-zA-Z0-9\s&-]+)`)
|
|
if withPattern.MatchString(text) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// extractFirstName extracts the first name from a full name string.
|
|
func extractFirstName(fullName string) string {
|
|
fullName = strings.TrimSpace(fullName)
|
|
parts := strings.Fields(fullName)
|
|
if len(parts) > 0 {
|
|
return parts[0]
|
|
}
|
|
return "there"
|
|
}
|