Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/matching/acronymresolver.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/matching/acronymresolver.go |
| Original SHA-256 | c1aa5ee414d2b0fafbbb19598959f6323dd92a37a9e9010d1c649308f583876b |
| Git blob SHA | c27fbab89b27d841f94ec9338229115d423670fb |
| Reading-copy SHA-256 | c1aa5ee414d2b0fafbbb19598959f6323dd92a37a9e9010d1c649308f583876b |
Every recorded occurrence
These 2 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / internal/modules/matching/acronymresolver.go | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / internal/modules/matching/acronymresolver.go | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
Recovered source transcript
Download complete source text ↓155 lines · 5.2 KB
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
}