package matching import ( "context" "strings" "sync" "time" "github.com/jackc/pgx/v5" "github.com/shopspring/decimal" "github.com/missionworkforce/missionmatch/internal/modules/matching/engine" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) // compile-time assertion that AcronymResolver satisfies engine.SkillMatcher. var _ engine.SkillMatcher = (*AcronymResolver)(nil) // AcronymResolver matches a required skill to a candidate skill when the two names are the SAME // TERM in different forms: the acronym and its expansion (JWICS and Joint Worldwide Intelligence // Communications System), normalized through the term_expansions table (the global GovCon and DoD // seed plus the tenant's own additions). It is deterministic string normalization, not similarity: // both names are canonicalized (whole name first, then token wise) and compared for equality, so // it can never invent a match the table does not license. It sits between the curated ontology // alias resolver and the semantic matcher in the chain. type AcronymResolver struct { db *tenant.DB ttl time.Duration clock func() time.Time mu sync.RWMutex cache map[string]acronymCacheEntry // keyed by org id, "" when no org in context } type acronymCacheEntry struct { canon map[string]string // lower(alias) -> lower(expansion) loadedAt time.Time } // NewAcronymResolver builds the resolver over the tenant DB. Terms are cached per org for a short // TTL so the hot scoring path does one read per org per window, not per skill. func NewAcronymResolver(db *tenant.DB) *AcronymResolver { return &AcronymResolver{db: db, ttl: 5 * time.Minute, clock: time.Now, cache: map[string]acronymCacheEntry{}} } // NewAcronymResolverFromMap builds a resolver over a fixed alias to expansion map, for tests and // for callers that already hold the terms. Keys and values are lowercased internally. func NewAcronymResolverFromMap(terms map[string]string) *AcronymResolver { canon := make(map[string]string, len(terms)) for k, v := range terms { canon[strings.ToLower(strings.TrimSpace(k))] = strings.ToLower(strings.TrimSpace(v)) } a := &AcronymResolver{ttl: time.Hour, clock: time.Now, cache: map[string]acronymCacheEntry{}} a.cache[""] = acronymCacheEntry{canon: canon, loadedAt: time.Now().Add(24 * time.Hour)} return a } // Match satisfies engine.SkillMatcher: a hit is an exact canonical equality between the required // name and a candidate name under term expansion, reported at similarity 1 and Exact false (it is // an alias grade equivalence, not a verbatim string match). func (a *AcronymResolver) Match(ctx context.Context, required engine.RequiredSkill, cands []engine.CandidateSkill, _ decimal.Decimal) (engine.SkillMatch, bool) { terms := a.terms(ctx) if len(terms) == 0 { return engine.SkillMatch{}, false } want := canonicalTerm(required.Name, terms) if want == "" { return engine.SkillMatch{}, false } for _, cs := range cands { if canonicalTerm(cs.Name, terms) == want { return engine.SkillMatch{ CandidateSkillID: cs.SkillID, CandidateName: cs.Name, Similarity: decimal.NewFromInt(1), Exact: false, Matched: true, }, true } } return engine.SkillMatch{}, false } // canonicalTerm lowercases and expands a skill name: the whole name first (the common case, the // name IS the acronym), then token by token, so "JWICS administration" canonicalizes the JWICS // token inside the phrase. Deterministic: one pass, no recursion, table driven. func canonicalTerm(name string, terms map[string]string) string { n := strings.ToLower(strings.TrimSpace(name)) if n == "" { return "" } if exp, ok := terms[n]; ok { return exp } fields := strings.Fields(n) changed := false for i, f := range fields { if exp, ok := terms[f]; ok { fields[i] = exp changed = true } } if changed { return strings.Join(fields, " ") } return n } // terms returns the merged global plus org term map for the caller's org, cached for the TTL. // Best effort: on any load error the previous cache (or nothing) is used, so the acronym layer can // degrade to a no op but never fail a score. func (a *AcronymResolver) terms(ctx context.Context) map[string]string { if a.db == nil { a.mu.RLock() defer a.mu.RUnlock() return a.cache[""].canon } org, _ := tenant.OrgFromContext(ctx) a.mu.RLock() entry, ok := a.cache[org] a.mu.RUnlock() if ok && a.clock().Sub(entry.loadedAt) < a.ttl { return entry.canon } canon := map[string]string{} err := a.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { rows, err := tx.Query(ctx, ` SELECT lower(alias), lower(expansion) FROM term_expansions ORDER BY organization_id NULLS FIRST, lower(alias), lower(expansion)`) if err != nil { return err } defer rows.Close() for rows.Next() { var al, ex string if err := rows.Scan(&al, &ex); err != nil { return err } // A tenant row (ordered after the global seed) overrides a global row for the same alias. canon[strings.TrimSpace(al)] = strings.TrimSpace(ex) } return rows.Err() }) if err != nil { if ok { return entry.canon } return nil } a.mu.Lock() a.cache[org] = acronymCacheEntry{canon: canon, loadedAt: a.clock()} a.mu.Unlock() return canon }