Files
krow_backend/go-api/internal/owliver/context.go
2026-08-28 12:21:44 +05:30

178 lines
6.6 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}