initial public release
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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"
|
||||
}
|
||||
Reference in New Issue
Block a user