Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/matching/service.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 1675031db74316e100782d063e9eda1a597fe3a1 |
| Branch | workstream-a-opportunity-intelligence |
| Path or archive member | internal/modules/matching/service.go |
| Original SHA-256 | 649c9a4054a2f23b245bca9b21176b5a9ec640aa6babe22dff81182084fb5f2c |
| Git blob SHA | be1ce30fed6ea14be156ca6b8354a063f3f9a903 |
| Reading-copy SHA-256 | 649c9a4054a2f23b245bca9b21176b5a9ec640aa6babe22dff81182084fb5f2c |
Every recorded occurrence
These 11 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/service.go | workstream-a-opportunity-intelligence | 1675031db74316e100782d063e9eda1a597fe3a1 | Open original |
| Janady13/missionmatch / internal/modules/matching/service.go | workstream-d-command-center | 255e7d1d420abf5e277d0cd8c9b14f6f72b6acb9 | Open original |
| Janady13/missionmatch / internal/modules/matching/service.go | workstream-e-commercial-stubs | 4437da660539284a400ee380b9b427a300c369ee | Open original |
| Janady13/missionmatch / internal/modules/matching/service.go | ruthless-debug-sweep | b7d742f8f032984be3817efffa3eb8b13b651c1f | Open original |
| Janady13/missionmatch / internal/modules/matching/service.go | workstream-b-sam-contract-awards | d5101cceb98e70000cebb31d3e7ac73d747d3a6d | Open original |
| Janady13/missionmatch / internal/modules/matching/service.go | workstream-a-shared-core | e0de03ac3a1ec826bd15d0e048e3b02baa23eeff | Open original |
| Janady13/missionmatch / internal/modules/matching/service.go | workstream-d-command-center | 255e7d1d420abf5e277d0cd8c9b14f6f72b6acb9 | Open original |
| Janady13/missionmatch / internal/modules/matching/service.go | ruthless-debug-sweep | b7d742f8f032984be3817efffa3eb8b13b651c1f | Open original |
| Janady13/missionmatch / internal/modules/matching/service.go | workstream-a-opportunity-intelligence | 1675031db74316e100782d063e9eda1a597fe3a1 | Open original |
| Janady13/missionmatch / internal/modules/matching/service.go | workstream-e-commercial-stubs | 4437da660539284a400ee380b9b427a300c369ee | Open original |
| Janady13/missionmatch / internal/modules/matching/service.go | workstream-b-sam-contract-awards | d5101cceb98e70000cebb31d3e7ac73d747d3a6d | Open original |
Recovered source transcript
Download complete source text ↓554 lines · 20.5 KB
package matching
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/shopspring/decimal"
"github.com/missionworkforce/missionmatch/internal/modules/matching/engine"
"github.com/missionworkforce/missionmatch/internal/modules/matching/intelligence"
"github.com/missionworkforce/missionmatch/internal/platform/audit"
"github.com/missionworkforce/missionmatch/internal/platform/database/dbgen"
"github.com/missionworkforce/missionmatch/internal/platform/extraction"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
// ErrNoDatabase is returned when a DB-backed operation is attempted but no pool is configured.
var (
ErrNoDatabase = errors.New("matching: no database configured")
ErrNotFound = errors.New("matching: not found")
ErrBadRequest = errors.New("matching: bad request")
ErrNoExtractor = errors.New("matching: no extractor configured")
)
const defaultMaxCandidates = 500
// Service is the slate-loop application service. It runs every DB operation inside a tenant
// transaction (RLS-scoped) and delegates scoring to the pure engine.
type Service struct {
db *tenant.DB
matcher engine.SkillMatcher // injected semantic skill matcher; may be nil (exact-only)
aud *audit.Writer // AEDT audit trail; may be nil (tests skip audit)
extractor extraction.Extractor // Mission Check resume/JD extractor; may be nil (Mission Check then 503)
clock func() time.Time // injectable for deterministic tests
}
// NewService builds the service. db may be nil (DB routes then return ErrNoDatabase). aud may be nil.
func NewService(db *tenant.DB, matcher engine.SkillMatcher, aud *audit.Writer) *Service {
return &Service{db: db, matcher: matcher, aud: aud, clock: time.Now}
}
// WithExtractor injects the Mission Check structured extractor and returns the service for chaining. It
// is a separate setter so the NewService signature (and its existing callers, including the integration
// tests) stay unchanged. A service with no extractor returns ErrNoExtractor on the Mission Check paths.
func (s *Service) WithExtractor(ext extraction.Extractor) *Service {
s.extractor = ext
return s
}
// auditOnTx records an AEDT event on the caller's tenant tx if an audit writer is configured. The
// actor is the validated session principal; demographics are NEVER carried in an audit event. It
// returns the write error so the caller fails closed: an AEDT score or decision that cannot be
// recorded rolls back rather than being served unaudited (the audit package contract).
func (s *Service) auditOnTx(ctx context.Context, tx pgx.Tx, e audit.Event) error {
if s.aud == nil {
return nil
}
if p, ok := tenant.UserFromContext(ctx); ok {
if id, err := uuid.Parse(p.UserID); err == nil {
e.ActorUserID = &id
}
}
return s.aud.OnTx(ctx, tx, e)
}
// servedScoreEvent builds the aedt.score.served event for a single served candidate score. It carries
// the candidate, the tool, the score, and the band so the compliance pool can reconstruct a screening
// stage adverse impact analysis by joining to eeo, and NOTHING about demographics.
func servedScoreEvent(tool string, candidateID uuid.UUID, res engine.MatchResult) audit.Event {
payload, _ := json.Marshal(map[string]any{
"tool": tool,
"score": res.Total.StringFixed(2),
"band": res.Band.Code(),
})
return audit.Event{Action: "aedt.score.served", EntityType: "candidate", EntityID: candidateID.String(), NewValues: payload}
}
func (s *Service) orgID(ctx context.Context) (uuid.UUID, error) {
raw, ok := tenant.OrgFromContext(ctx)
if !ok {
return uuid.Nil, tenant.ErrNoTenant
}
id, err := uuid.Parse(raw)
if err != nil {
return uuid.Nil, fmt.Errorf("matching: invalid org id in context: %w", err)
}
return id, nil
}
// ScreenCandidate scores one candidate against one requirement (screenMATCH / POST /match). Read-only.
func (s *Service) ScreenCandidate(ctx context.Context, candidateID, requirementID uuid.UUID) (engine.MatchResult, error) {
if s.db == nil {
return engine.MatchResult{}, ErrNoDatabase
}
asOf := s.clock()
var res engine.MatchResult
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
q := dbgen.New(tx)
er, err := loadRequirement(ctx, q, requirementID)
if err != nil {
return err
}
c, err := q.GetCandidate(ctx, candidateID)
if err != nil {
return fmt.Errorf("get candidate: %w", err)
}
ec, err := loadCandidate(ctx, q, c, asOf)
if err != nil {
return err
}
res = engine.ScoreCandidate(ctx, ec, er, s.matcher)
// A focused single candidate qualification is a screening stage AEDT output. Record it as a
// served score (candidate, tool, score, band, no demographics) so adverse impact at the
// screening stage stays visible even before any slate decision exists. Fail closed if it cannot
// be recorded.
return s.auditOnTx(ctx, tx, servedScoreEvent("screenMATCH.match", candidateID, res))
})
return res, err
}
// GenerateSlate scores every candidate against a requirement, ranks them, and persists the slate
// plus the frozen decision records (slateMATCH / POST /slates/generate). One atomic transaction.
func (s *Service) GenerateSlate(ctx context.Context, in GenerateSlateInput) (SlateResult, error) {
if s.db == nil {
return SlateResult{}, ErrNoDatabase
}
if in.Name == "" {
return SlateResult{}, errors.New("matching: slate name is required")
}
if in.MaxCandidates <= 0 {
in.MaxCandidates = defaultMaxCandidates
}
orgID, err := s.orgID(ctx)
if err != nil {
return SlateResult{}, err
}
asOf := s.clock()
var out SlateResult
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var e error
out, e = generateSlate(ctx, dbgen.New(tx), orgID, in, s.matcher, asOf)
if e != nil {
return e
}
payload, _ := json.Marshal(map[string]any{"requirement_id": in.RequirementID.String(), "members": len(out.Members)})
return s.auditOnTx(ctx, tx, audit.Event{Action: "aedt.slate.generated", EntityType: "slate", EntityID: out.SlateID.String(), NewValues: payload})
})
return out, err
}
// DecideSlateMember records the human approve or reject of a slate member. This is the human in the
// loop control: the engine never decides; a person with slate authority does, and the action is
// recorded with the actor and the time, plus an AEDT audit event. decision must be approved or
// rejected (pending is the un decided default and cannot be set here).
func (s *Service) DecideSlateMember(ctx context.Context, slateID, candidateID uuid.UUID, decision string) error {
if s.db == nil {
return ErrNoDatabase
}
if decision != "approved" && decision != "rejected" {
return ErrBadRequest
}
p, ok := tenant.UserFromContext(ctx)
if !ok {
return tenant.ErrNoTenant
}
decidedBy, err := uuid.Parse(p.UserID)
if err != nil {
return tenant.ErrNoTenant
}
return s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
q := dbgen.New(tx)
row, e := q.SetSlateMemberDecision(ctx, dbgen.SetSlateMemberDecisionParams{
SlateID: slateID, CandidateID: candidateID, Decision: decision, DecidedBy: &decidedBy,
})
if errors.Is(e, pgx.ErrNoRows) {
return ErrNotFound
}
if e != nil {
return e
}
payload, _ := json.Marshal(map[string]any{"decision": decision, "band": row.Band})
return s.auditOnTx(ctx, tx, audit.Event{Action: "aedt.slate_member.decided", EntityType: "candidate", EntityID: candidateID.String(), NewValues: payload})
})
}
// SlateMemberTrace returns the persisted per component trace, rationale, and gap summary for a frozen
// decision, the audit and candidate facing explanation of what was assessed and why.
func (s *Service) SlateMemberTrace(ctx context.Context, slateID, candidateID uuid.UUID) (dbgen.SlateMember, error) {
if s.db == nil {
return dbgen.SlateMember{}, ErrNoDatabase
}
var m dbgen.SlateMember
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
row, e := dbgen.New(tx).GetSlateMember(ctx, dbgen.GetSlateMemberParams{SlateID: slateID, CandidateID: candidateID})
if errors.Is(e, pgx.ErrNoRows) {
return ErrNotFound
}
if e != nil {
return e
}
m = row
return nil
})
return m, err
}
// ListCandidates / ListRequirements are the read endpoints (GET /candidates, /requirements).
func (s *Service) ListCandidates(ctx context.Context, limit, offset int32) ([]dbgen.Candidate, error) {
if s.db == nil {
return nil, ErrNoDatabase
}
var out []dbgen.Candidate
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var e error
out, e = dbgen.New(tx).ListCandidates(ctx, dbgen.ListCandidatesParams{Limit: limit, Offset: offset})
return e
})
return out, err
}
func (s *Service) ListRequirements(ctx context.Context, limit, offset int32) ([]dbgen.Requirement, error) {
if s.db == nil {
return nil, ErrNoDatabase
}
var out []dbgen.Requirement
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var e error
out, e = dbgen.New(tx).ListRequirements(ctx, dbgen.ListRequirementsParams{Limit: limit, Offset: offset})
return e
})
return out, err
}
// GetCandidate returns one candidate by id, scoped to the caller's org by RLS. A missing id or one in
// another org both surface as ErrNotFound: RLS makes the two indistinguishable, which is the intended
// privacy floor (no cross org existence oracle). The by id read backs a deep linked evaluation, so the
// page no longer depends on the candidate being inside the first list page.
func (s *Service) GetCandidate(ctx context.Context, id uuid.UUID) (dbgen.Candidate, error) {
if s.db == nil {
return dbgen.Candidate{}, ErrNoDatabase
}
var out dbgen.Candidate
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
c, e := dbgen.New(tx).GetCandidate(ctx, id)
if errors.Is(e, pgx.ErrNoRows) {
return ErrNotFound
}
if e != nil {
return e
}
out = c
return nil
})
return out, err
}
// GetRequirement returns one requirement by id, scoped to the caller's org by RLS. Missing and cross
// org both surface as ErrNotFound, mirroring GetCandidate.
func (s *Service) GetRequirement(ctx context.Context, id uuid.UUID) (dbgen.Requirement, error) {
if s.db == nil {
return dbgen.Requirement{}, ErrNoDatabase
}
var out dbgen.Requirement
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
rq, e := dbgen.New(tx).GetRequirement(ctx, id)
if errors.Is(e, pgx.ErrNoRows) {
return ErrNotFound
}
if e != nil {
return e
}
out = rq
return nil
})
return out, err
}
// generateSlate is the pure orchestration over a Querier, testable with a fake, no live DB.
func generateSlate(ctx context.Context, q dbgen.Querier, orgID uuid.UUID, in GenerateSlateInput, matcher engine.SkillMatcher, asOf time.Time) (SlateResult, error) {
er, err := loadRequirement(ctx, q, in.RequirementID)
if err != nil {
return SlateResult{}, err
}
cands, err := q.ListCandidates(ctx, dbgen.ListCandidatesParams{Limit: in.MaxCandidates, Offset: 0})
if err != nil {
return SlateResult{}, fmt.Errorf("list candidates: %w", err)
}
type scored struct {
id uuid.UUID
res engine.MatchResult
}
results := make([]scored, 0, len(cands))
for _, c := range cands {
ec, err := loadCandidate(ctx, q, c, asOf)
if err != nil {
return SlateResult{}, err
}
results = append(results, scored{id: c.ID, res: engine.ScoreCandidate(ctx, ec, er, matcher)})
}
// Deterministic ranking: total score descending, candidate id ascending as the unique tiebreaker.
sort.SliceStable(results, func(i, j int) bool {
if d := results[i].res.Total.Cmp(results[j].res.Total); d != 0 {
return d > 0
}
return results[i].id.String() < results[j].id.String()
})
slate, err := q.CreateSlate(ctx, dbgen.CreateSlateParams{
OrganizationID: orgID, RequirementID: in.RequirementID, Name: in.Name, Status: "draft", CreatedBy: in.CreatedBy,
})
if err != nil {
return SlateResult{}, fmt.Errorf("create slate: %w", err)
}
members := make([]SlateMemberView, 0, len(results))
for i, sc := range results {
rank := int32(i + 1)
params, err := ToSlateMemberParams(orgID, slate.ID, sc.id, sc.res, &rank)
if err != nil {
return SlateResult{}, err
}
if _, err := q.AddSlateMember(ctx, params); err != nil {
return SlateResult{}, fmt.Errorf("add slate member: %w", err)
}
members = append(members, slateMemberView(sc.id.String(), int(rank), sc.res))
}
return SlateResult{SlateID: slate.ID, RequirementID: in.RequirementID, Name: in.Name, Members: members}, nil
}
func loadRequirement(ctx context.Context, q dbgen.Querier, reqID uuid.UUID) (engine.Requirement, error) {
r, err := q.GetRequirement(ctx, reqID)
if err != nil {
return engine.Requirement{}, fmt.Errorf("get requirement: %w", err)
}
return reqFromRow(ctx, q, r)
}
func reqFromRow(ctx context.Context, q dbgen.Querier, r dbgen.Requirement) (engine.Requirement, error) {
rs, err := q.ListRequiredSkills(ctx, r.ID)
if err != nil {
return engine.Requirement{}, fmt.Errorf("list required skills: %w", err)
}
rc, err := q.ListRequiredCertifications(ctx, r.ID)
if err != nil {
return engine.Requirement{}, fmt.Errorf("list required certifications: %w", err)
}
return ToEngineRequirement(r, rs, rc)
}
// loadCandidateByID loads a candidate, its sub-lists, and the engine input in one place. It returns
// the DB row (for clearance dates), the engine candidate, and the customer agencies.
func loadCandidateByID(ctx context.Context, q dbgen.Querier, id uuid.UUID, asOf time.Time) (dbgen.Candidate, engine.Candidate, []string, error) {
c, err := q.GetCandidate(ctx, id)
if err != nil {
return dbgen.Candidate{}, engine.Candidate{}, nil, fmt.Errorf("get candidate: %w", err)
}
skills, err := q.ListCandidateSkills(ctx, id)
if err != nil {
return dbgen.Candidate{}, engine.Candidate{}, nil, fmt.Errorf("list candidate skills: %w", err)
}
certs, err := q.ListCandidateCertifications(ctx, id)
if err != nil {
return dbgen.Candidate{}, engine.Candidate{}, nil, fmt.Errorf("list candidate certifications: %w", err)
}
cust, err := q.ListCandidateCustomerExperience(ctx, id)
if err != nil {
return dbgen.Candidate{}, engine.Candidate{}, nil, fmt.Errorf("list candidate customer experience: %w", err)
}
ec, err := ToEngineCandidate(c, skills, certs, cust, asOf)
if err != nil {
return dbgen.Candidate{}, engine.Candidate{}, nil, err
}
return c, ec, cust, nil
}
// Screen returns the full screenMATCH™ intelligence view for one candidate vs one requirement,
// the explainable score plus the Mission Readiness panels. Read only.
func (s *Service) Screen(ctx context.Context, candidateID, requirementID uuid.UUID) (ScreenView, error) {
if s.db == nil {
return ScreenView{}, ErrNoDatabase
}
asOf := s.clock()
var view ScreenView
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
q := dbgen.New(tx)
er, err := loadRequirement(ctx, q, requirementID)
if err != nil {
return err
}
c, ec, cust, err := loadCandidateByID(ctx, q, candidateID, asOf)
if err != nil {
return err
}
res := engine.ScoreCandidate(ctx, ec, er, s.matcher)
view = assembleScreenView(ec, er, res, ClearanceFactsFrom(c, cust), asOf)
return s.auditOnTx(ctx, tx, servedScoreEvent("screenMATCH.screen", candidateID, res))
})
return view, err
}
// RankCandidates scores EVERY candidate in the org against one requirement and returns them ranked
// (Mission Readiness descending), the Candidate Screening Queue (Screen 1). Read-only: no slate is
// persisted (use GenerateSlate to freeze a slate).
func (s *Service) RankCandidates(ctx context.Context, requirementID uuid.UUID) ([]RankedCandidateView, error) {
if s.db == nil {
return nil, ErrNoDatabase
}
asOf := s.clock()
var out []RankedCandidateView
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
q := dbgen.New(tx)
er, err := loadRequirement(ctx, q, requirementID)
if err != nil {
return err
}
cands, err := q.ListCandidates(ctx, dbgen.ListCandidatesParams{Limit: 500, Offset: 0})
if err != nil {
return fmt.Errorf("list candidates: %w", err)
}
type scored struct {
view RankedCandidateView
total decimal.Decimal
}
ranked := make([]scored, 0, len(cands))
for _, c := range cands {
ec, err := loadCandidate(ctx, q, c, asOf)
if err != nil {
return err
}
res := engine.ScoreCandidate(ctx, ec, er, s.matcher)
// The Candidate Screening Queue scores everyone for a requirement, a screening stage AEDT
// output. Record each as a served score so the screening stage adverse impact denominator is
// complete, not biased toward the focused single candidate screens.
if e := s.auditOnTx(ctx, tx, servedScoreEvent("screenMATCH.rank", c.ID, res)); e != nil {
return e
}
ranked = append(ranked, scored{view: rankedCandidateView(c, res), total: res.Total})
}
sort.SliceStable(ranked, func(i, j int) bool {
if d := ranked[i].total.Cmp(ranked[j].total); d != 0 {
return d > 0
}
return ranked[i].view.FullName < ranked[j].view.FullName
})
out = make([]RankedCandidateView, len(ranked))
for i, sc := range ranked {
out[i] = sc.view
}
return nil
})
return out, err
}
// OpportunitiesForCandidate scores one candidate against every open requirement in the org and
// returns the best fits, ranked, the Opportunity Fit Engine™ ("best opportunities for this person").
func (s *Service) OpportunitiesForCandidate(ctx context.Context, candidateID uuid.UUID, limit int) ([]OpportunityFitView, error) {
if s.db == nil {
return nil, ErrNoDatabase
}
if limit <= 0 || limit > 100 {
limit = 10
}
asOf := s.clock()
var out []OpportunityFitView
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
q := dbgen.New(tx)
_, ec, _, err := loadCandidateByID(ctx, q, candidateID, asOf)
if err != nil {
return err
}
out, err = s.rankOpenOpportunities(ctx, tx, ec, limit, func(res engine.MatchResult) error {
// Scoring this person against each opportunity is a screening stage AEDT output; record each
// served score so the screening stage analysis is complete.
return s.auditOnTx(ctx, tx, servedScoreEvent("screenMATCH.opportunities", candidateID, res))
})
return err
})
return out, err
}
// rankOpenOpportunities scores a ready engine.Candidate against every OPEN requirement, calls auditFn
// for each served score (so the saved path and the ephemeral Mission Check path each record under their
// own AEDT action), ranks total descending with the requirement id as the stable tiebreak, and
// truncates to limit. It is the single ranking path shared by OpportunitiesForCandidate and Mission
// Check Mode A, so an unsaved resume and a saved candidate with identical fields rank identically.
func (s *Service) rankOpenOpportunities(ctx context.Context, tx pgx.Tx, ec engine.Candidate, limit int, auditFn func(res engine.MatchResult) error) ([]OpportunityFitView, error) {
q := dbgen.New(tx)
reqs, err := q.ListOpenRequirements(ctx, dbgen.ListOpenRequirementsParams{Limit: 500, Offset: 0})
if err != nil {
return nil, fmt.Errorf("list open requirements: %w", err)
}
type scored struct {
view OpportunityFitView
total decimal.Decimal
}
ranked := make([]scored, 0, len(reqs))
for _, rr := range reqs {
er, err := reqFromRow(ctx, q, rr)
if err != nil {
return nil, err
}
res := engine.ScoreCandidate(ctx, ec, er, s.matcher)
if err := auditFn(res); err != nil {
return nil, err
}
ranked = append(ranked, scored{
view: OpportunityFitView{
RequirementID: rr.ID, Title: rr.Title, CustomerAgency: deref(rr.CustomerAgency),
Score: res.Total.StringFixed(2), Band: res.Band.Code(),
Status: intelligence.MissionStatusLabel(res.Band),
},
total: res.Total,
})
}
sort.SliceStable(ranked, func(i, j int) bool {
if d := ranked[i].total.Cmp(ranked[j].total); d != 0 {
return d > 0
}
return ranked[i].view.RequirementID.String() < ranked[j].view.RequirementID.String()
})
if len(ranked) > limit {
ranked = ranked[:limit]
}
out := make([]OpportunityFitView, len(ranked))
for i, sc := range ranked {
out[i] = sc.view
}
return out, nil
}
func loadCandidate(ctx context.Context, q dbgen.Querier, c dbgen.Candidate, asOf time.Time) (engine.Candidate, error) {
skills, err := q.ListCandidateSkills(ctx, c.ID)
if err != nil {
return engine.Candidate{}, fmt.Errorf("list candidate skills: %w", err)
}
certs, err := q.ListCandidateCertifications(ctx, c.ID)
if err != nil {
return engine.Candidate{}, fmt.Errorf("list candidate certifications: %w", err)
}
cust, err := q.ListCandidateCustomerExperience(ctx, c.ID)
if err != nil {
return engine.Candidate{}, fmt.Errorf("list candidate customer experience: %w", err)
}
return ToEngineCandidate(c, skills, certs, cust, asOf)
}