package ai import ( "regexp" "strings" ) // Mission scoping (WS6 Option A). A RAG question can name a mission area (SIGINT, GEOINT, ...). When it // does, the retrieval is constrained to candidates whose full_name, headline, or location text matches // that mission keyword, using the existing full text index (search_tsv). This is the ONLY mission filter // available without structured mission tags on candidates and requirements, which are WS7 work: it filters // on that candidate TEXT, not on a semantic mission alignment, and not on the resume body. The limit is // documented in docs/AI_RESEARCH.md. // // The vocabulary mirrors the curated mission_spine seed (migration 0021_ontology.sql), which is the // source of truth. The ten terms change rarely and are controlled IC vocabulary, so mirroring them in // code (rather than a database round trip on every question) keeps retrieval deterministic and avoids a // dependency from the AI path onto a reference query. If mission_spine grows, extend this list to match. // missionPattern pairs the canonical full text keyword fed to websearch_to_tsquery with the natural // language forms a user might type. Acronyms and short tokens are matched on a word boundary so a bare // acronym never matches inside another word. type missionPattern struct { canonical string // the keyword passed to the mission scoped query ($mission_query) forms []*regexp.Regexp // question phrasings that select this mission } // wb compiles a case insensitive, word boundary anchored matcher for a mission form. Any single space in a // multi word form becomes \s+, so a line break or a double space between phrase tokens still matches (for // example "satellite command and control"). The forms contain no regex metacharacters, so the literal is // safe to embed directly. func wb(s string) *regexp.Regexp { return regexp.MustCompile(`(?i)\b` + strings.ReplaceAll(s, " ", `\s+`) + `\b`) } // missionVocabulary is iterated in mission_spine order, so when a question names more than one mission the // result is deterministic (the earliest spine term wins). Order MUST match migration 0021. var missionVocabulary = []missionPattern{ {"SIGINT", []*regexp.Regexp{wb("sigint"), wb("signals intelligence")}}, {"GEOINT", []*regexp.Regexp{wb("geoint"), wb("geospatial intelligence")}}, {"HUMINT", []*regexp.Regexp{wb("humint"), wb("human intelligence")}}, {"MASINT", []*regexp.Regexp{wb("masint"), wb("measurement and signature intelligence")}}, {"OSINT", []*regexp.Regexp{wb("osint"), wb("open source intelligence")}}, {"Satellite C2", []*regexp.Regexp{wb("satellite c2"), wb("satellite command and control")}}, {"Cross Domain Solution", []*regexp.Regexp{wb("cross domain solution"), wb("cross domain solutions")}}, {"COMSEC", []*regexp.Regexp{wb("comsec"), wb("communications security")}}, {"TEMPEST", []*regexp.Regexp{wb("tempest")}}, {"Electronic Warfare", []*regexp.Regexp{wb("electronic warfare")}}, } // MissionFromQuestion returns the canonical mission keyword named in the question, or "" if none is named. // The returned value is drawn only from the fixed mission vocabulary above, never from free user text, so // it can never carry a demographic reference into the retrieval path. func MissionFromQuestion(question string) string { if question == "" { return "" } for _, m := range missionVocabulary { for _, form := range m.forms { if form.MatchString(question) { return m.canonical } } } return "" } // missionTermToCode maps the canonical mission TERM that missionVocabulary uses (and that // MissionFromQuestion returns) to the stable mission CODE that candidate_mission_tags.mission_code keys on. // The codes mirror the mission_areas table (migration 0025) and missionclass.Codes; the multi word terms map // to their short codes (Satellite C2 to SAT_C2, Cross Domain Solution to CDS, Electronic Warfare to EW). For // the acronym missions the term already equals the code. The AI package keeps its own copy rather than // importing missionclass so the retrieval path takes no dependency on the classifier package; a drift here is // caught by the unit test below, which covers all ten terms. var missionTermToCode = map[string]string{ "SIGINT": "SIGINT", "GEOINT": "GEOINT", "HUMINT": "HUMINT", "MASINT": "MASINT", "OSINT": "OSINT", "Satellite C2": "SAT_C2", "Cross Domain Solution": "CDS", "COMSEC": "COMSEC", "TEMPEST": "TEMPEST", "Electronic Warfare": "EW", } // MissionCodeForTerm maps a canonical mission term (the value MissionFromQuestion returns) to its mission // CODE, or "" when the term is not a known mission. It is the bridge from the WS6 keyword the retrieval leg // already carries to the structured candidate_mission_tags.mission_code that the WS7 tagged query gates on. func MissionCodeForTerm(term string) string { return missionTermToCode[term] } // MissionCodeFromQuestion returns the structured mission CODE named in the question, or "" if none is named. // It detects the mission exactly as MissionFromQuestion does (the same fixed vocabulary, the same word // boundary forms, the same deterministic first match order) and maps the resulting term to its code. Like // MissionFromQuestion, the result is drawn only from the fixed vocabulary, never from free user text. func MissionCodeFromQuestion(question string) string { return MissionCodeForTerm(MissionFromQuestion(question)) }