package missionclass import ( "sort" "strings" ) // laborKeywords maps profile or solicitation text to Galapagos labor categories. It was lifted verbatim // from matching/intel.go so candidates with no structured skills still surface the categories they fit, // and so opportunities and awards can mine an incumbent labor mix from the same word list. The match is a // lowercased SUBSTRING match (strings.Contains), preserved exactly from the original inline logic so the // candidate intelligence output stays byte identical after the move. Extend with vetted entries only. var laborKeywords = []struct { lcat string words []string }{ {"ISSO", []string{"isso", "information system security officer", "information systems security", "security officer", "issm"}}, {"Cyber Architect", []string{"security architect", "cyber architect", "cybersecurity architect", "soc architect"}}, {"Cloud Architect", []string{"cloud architect", "solutions architect", "aws architect", "azure architect"}}, {"Systems Engineer", []string{"systems engineer", "system engineer", "software engineer", "software developer", "devops"}}, {"Cyber Engineer", []string{"cyber engineer", "cybersecurity engineer", "security engineer", "soc analyst", "network security", "penetration"}}, {"Program Manager", []string{"program manager", "project manager", "pmp", "capture manager"}}, {"Data Scientist", []string{"data scientist", "machine learning", "data engineer"}}, {"Intelligence Analyst", []string{"intelligence analyst", "all source", "sigint", "geoint", "intel analyst"}}, } // ClassifyLabor returns the Galapagos labor categories whose keyword forms appear in text, as a stable // sorted slice. The match is a lowercased substring match over the curated word list, the same logic the // Candidates page used inline, so a caller can filter the result against the labor categories an org // actually has open and reproduce the previous output exactly. Pure, no DB. func ClassifyLabor(text string) []string { if strings.TrimSpace(text) == "" { return nil } lower := strings.ToLower(text) cov := map[string]bool{} for _, lk := range laborKeywords { if containsAny(lower, lk.words) { cov[lk.lcat] = true } } if len(cov) == 0 { return nil } out := make([]string, 0, len(cov)) for l := range cov { out = append(out, l) } sort.Strings(out) return out } // containsAny reports whether any of words is a substring of haystack. haystack is expected lowercased. func containsAny(haystack string, words []string) bool { for _, w := range words { if strings.Contains(haystack, w) { return true } } return false } // LaborHit is one incumbent labor category mined from text, with a deterministic occurrence count and the // first matched phrase as evidence. It feeds award_incumbent_roles so a recompete can show the incumbent // staffing mix, each row traceable to the source text. Count is never fabricated: it is the literal total // of matched keyword phrase occurrences in the text. type LaborHit struct { LCAT string Count int Evidence string } // ClassifyLaborCounts returns one LaborHit per labor category whose keyword forms appear in text, in the // stable laborKeywords order. Count is the deterministic total occurrences of that category's matched // keyword phrases in the lowercased text (the sum of strings.Count over every matched word), so two // mentions of the same phrase count twice and a category mentioned through two phrases counts both. // Evidence is the FIRST matched phrase in the curated word list order. It reuses the same laborKeywords // list ClassifyLabor uses, so the incumbent mix and the candidate categories stay in lockstep. Pure, no DB. func ClassifyLaborCounts(text string) []LaborHit { if strings.TrimSpace(text) == "" { return nil } lower := strings.ToLower(text) out := make([]LaborHit, 0, len(laborKeywords)) for _, lk := range laborKeywords { total := 0 evidence := "" for _, w := range lk.words { n := strings.Count(lower, w) if n == 0 { continue } if evidence == "" { evidence = w } total += n } if total > 0 { out = append(out, LaborHit{LCAT: lk.lcat, Count: total, Evidence: evidence}) } } if len(out) == 0 { return nil } return out }