178 lines
6.6 KiB
Go
178 lines
6.6 KiB
Go
package owliver
|
||
|
||
import (
|
||
"sort"
|
||
|
||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||
)
|
||
|
||
// Context is the state of one organization's hiring, as counts.
|
||
//
|
||
// It is the answer to "what is actually going on here right now", read from
|
||
// PostgreSQL by service.SuggestionsService and handed to Highlights. Nothing in
|
||
// this package fetches it: the ranking stays a pure function of its inputs, and
|
||
// the one place that touches a database stays in the service layer where every
|
||
// other query lives.
|
||
//
|
||
// Counts rather than records, deliberately. A suggestion is a question, and
|
||
// deciding whether a question is worth asking needs to know that eleven
|
||
// candidates are waiting on a decision — never who they are. Nothing here can
|
||
// leak a name, and a zero-valued Context is a valid one: it means the reading
|
||
// has nothing to report, and Highlights answers with nothing rather than with a
|
||
// question about an empty set.
|
||
type Context struct {
|
||
// Positions.
|
||
ActivePositions int // status = 'active'
|
||
DraftPositions int // status = 'draft'
|
||
StarvedPositions int // active postings nobody has applied to
|
||
UnderfilledActive int // active postings with fewer hires than headcount
|
||
|
||
// Candidates, by where they are in the funnel.
|
||
Applications int
|
||
Unscreened int // status = 'applied' — nobody has scored them
|
||
Shortlisted int
|
||
Interviewing int
|
||
Hired int
|
||
FlaggedRisks int // interviews carrying at least one ai_flag
|
||
|
||
// Supply and the record behind it.
|
||
Staff int
|
||
Profiles int
|
||
UnverifiedProfiles int // profiles with no evidence filed
|
||
Courses int
|
||
ActivityEvents int
|
||
}
|
||
|
||
// Empty reports that nothing in this organization is worth remarking on.
|
||
//
|
||
// Used to tell "the database says there is nothing here" apart from "the
|
||
// database was not consulted": both produce no highlights, but only the second
|
||
// is a reason to fall back to anything.
|
||
func (c Context) Empty() bool { return c == Context{} }
|
||
|
||
/* ── Highlights ─────────────────────────────────────────────────────────── */
|
||
|
||
// Highlights is what is worth asking on this page given the state of the data.
|
||
//
|
||
// The counterpart to Suggest, and deliberately a separate function rather than
|
||
// a mode of it. Suggest answers "the user typed this, what did they mean" and
|
||
// is a pure string match; this answers "the user typed nothing, what should
|
||
// they know" and is a pure read of the organization. Merging them would make
|
||
// every keystroke pay for a database round trip in order to serve the one
|
||
// request per page that has nothing typed.
|
||
//
|
||
// The stages are the same and in the same order: page context, then permission,
|
||
// then relevance, then the cap. An intent the caller may not perform is never
|
||
// scored, so no ordering bug can surface one; an intent whose signal is zero is
|
||
// dropped rather than padded in, so a quiet workspace is offered nothing rather
|
||
// than three questions about empty sets.
|
||
func Highlights(page string, ctx Context, role domain.Role) []Suggestion {
|
||
out := []Suggestion{}
|
||
|
||
// Deny by default, exactly as Suggest does. An intent that reads nothing is
|
||
// permitted to every role, so without this an unparseable role would be
|
||
// offered the account readings.
|
||
if _, known := domain.ParseRole(string(role)); !known {
|
||
return out
|
||
}
|
||
|
||
intents, ok := catalogue[page]
|
||
if !ok {
|
||
return out
|
||
}
|
||
|
||
candidates := make([]scored, 0, len(intents))
|
||
for order, intent := range intents {
|
||
if !intent.permitted(role) {
|
||
continue
|
||
}
|
||
if intent.Signal == nil {
|
||
continue
|
||
}
|
||
signal := intent.Signal(ctx)
|
||
if signal <= 0 {
|
||
continue
|
||
}
|
||
candidates = append(candidates, scored{
|
||
suggestion: Suggestion{Text: intent.Text, Intent: intent.ID},
|
||
score: signal,
|
||
order: order,
|
||
onTopic: true,
|
||
})
|
||
}
|
||
|
||
// Strongest signal first; declaration order breaks every tie, so the same
|
||
// database state always produces the same three in the same sequence.
|
||
sort.SliceStable(candidates, func(a, b int) bool {
|
||
if candidates[a].score != candidates[b].score {
|
||
return candidates[a].score > candidates[b].score
|
||
}
|
||
return candidates[a].order < candidates[b].order
|
||
})
|
||
|
||
seenIntent := make(map[string]bool, MaxSuggestions)
|
||
for _, c := range candidates {
|
||
if len(out) == MaxSuggestions {
|
||
break
|
||
}
|
||
if seenIntent[c.suggestion.Intent] {
|
||
continue
|
||
}
|
||
seenIntent[c.suggestion.Intent] = true
|
||
out = append(out, c.suggestion)
|
||
}
|
||
return out
|
||
}
|
||
|
||
/* ── Signal helpers ─────────────────────────────────────────────────────── */
|
||
|
||
// maxTiebreak bounds the count half of a signal, so a very large organization
|
||
// cannot let a tie-break spill into the tier above it.
|
||
const maxTiebreak = 999
|
||
|
||
// when scores a reading as "how much does this matter when it is happening at
|
||
// all", with the count breaking ties inside a tier.
|
||
//
|
||
// The obvious formulation — weight × count — is wrong here, and wrong in a way
|
||
// that gets worse as an organization grows: a workspace with forty applications
|
||
// and one role nobody has applied to would be asked about the forty, because
|
||
// forty of anything outscores one of anything else. But the single starved role
|
||
// is the finding. Nobody needs to be told there are applications.
|
||
//
|
||
// So the tier dominates and the count only orders readings within it. `tier` is
|
||
// a judgement about the SUBJECT, made once where the intent is declared:
|
||
//
|
||
// 9 something is at risk and nobody is on it — a role with no applicants,
|
||
// an interview carrying a flag
|
||
// 7 work is queued on a person's decision — unscored candidates, unfinished
|
||
// drafts, unverified profiles
|
||
// 5 a state worth reviewing — a shortlist waiting, a role under-filled,
|
||
// recent hires
|
||
// 3 what exists, as a figure — headcounts, comparisons, trends
|
||
//
|
||
// A count of zero scores zero whatever the tier, which is what stops a page
|
||
// being asked an urgent-sounding question about an empty set.
|
||
func when(count, tier int) int {
|
||
if count <= 0 {
|
||
return 0
|
||
}
|
||
if count > maxTiebreak {
|
||
count = maxTiebreak
|
||
}
|
||
return tier*(maxTiebreak+1) + count
|
||
}
|
||
|
||
// atLeastTwo is the count, or zero below two.
|
||
//
|
||
// A comparison needs something to compare. "Which position has the strongest
|
||
// pipeline?" is not a question about a workspace holding one position, and
|
||
// "how is each department performing?" is not a question about one department —
|
||
// both would answer with a table of a single row, which is the padding
|
||
// Highlights exists to refuse.
|
||
func atLeastTwo(n int) int {
|
||
if n < 2 {
|
||
return 0
|
||
}
|
||
return n
|
||
}
|