agent build
This commit is contained in:
@@ -98,6 +98,22 @@ type Intent struct {
|
||||
// but the caller's own account, and is therefore available to anyone signed
|
||||
// in.
|
||||
Reads []Need
|
||||
|
||||
// Signal is how loudly the organization's current state asks for this
|
||||
// reading, from the counts in Context. Zero — and a nil Signal — mean "not
|
||||
// worth raising unprompted", which is what an intent whose subject nothing
|
||||
// in the database is doing should say.
|
||||
//
|
||||
// It is only consulted by Highlights, the no-query path. Suggest never
|
||||
// looks at it: a reading the user typed the words for is wanted whether or
|
||||
// not the data is remarkable, and letting a count veto a typed query would
|
||||
// make the panel refuse to answer questions it can answer.
|
||||
//
|
||||
// Declared here rather than in a table beside the catalogue so that an
|
||||
// intent and the thing that makes it relevant stay one entry. An intent
|
||||
// that names no count is never offered unprompted, which is the honest
|
||||
// default for a reading with nothing to measure.
|
||||
Signal func(Context) int
|
||||
}
|
||||
|
||||
// permitted reports whether a role may be offered this intent. Every reading
|
||||
@@ -158,42 +174,48 @@ var catalogue = map[string][]Intent{
|
||||
Subject: "platform health", Shapes: []string{"stats", "card", "insight"},
|
||||
Terms: []string{"health", "healthy", "platform", "integrity", "data quality",
|
||||
"status", "wrong", "broken", "degraded"},
|
||||
Reads: []Need{postings, applications, interviews, profiles},
|
||||
Reads: []Need{postings, applications, interviews, profiles},
|
||||
Signal: func(c Context) int { return when(c.StarvedPositions+c.FlaggedRisks, 9) },
|
||||
},
|
||||
{
|
||||
ID: "workforce-summary", Text: "Summarize the workforce across the platform",
|
||||
Subject: "the workforce", Shapes: []string{"stats", "table", "progress", "card"},
|
||||
Terms: []string{"workforce", "scale", "how big", "composition", "headcount",
|
||||
"people", "how many"},
|
||||
Reads: []Need{postings, profiles, staff},
|
||||
Reads: []Need{postings, profiles, staff},
|
||||
Signal: func(c Context) int { return when(c.Staff, 3) },
|
||||
},
|
||||
{
|
||||
ID: "hiring-operations", Text: "How is hiring operating overall?",
|
||||
Subject: "hiring activity", Shapes: []string{"flow", "stats", "timeline", "table", "card"},
|
||||
Terms: []string{"hiring", "operations", "activity", "velocity", "speed",
|
||||
"throughput", "how fast", "time to hire"},
|
||||
Reads: []Need{applications, postings},
|
||||
Reads: []Need{applications, postings},
|
||||
Signal: func(c Context) int { return when(c.Applications, 2) },
|
||||
},
|
||||
{
|
||||
ID: "pipeline-health", Text: "Where is the hiring pipeline getting stuck?",
|
||||
Subject: "the hiring pipeline", Shapes: []string{"flow", "stats", "progress", "table", "card"},
|
||||
Terms: []string{"pipeline", "funnel", "bottleneck", "stuck", "blocked",
|
||||
"conversion", "stage", "stages", "drop off", "dropoff"},
|
||||
Reads: []Need{applications},
|
||||
Reads: []Need{applications},
|
||||
Signal: func(c Context) int { return when(c.Unscreened+c.Shortlisted+c.Interviewing, 5) },
|
||||
},
|
||||
{
|
||||
ID: "attention-required", Text: "What needs attention right now?",
|
||||
Subject: "what needs attention", Shapes: []string{"list", "table", "insight"},
|
||||
Terms: []string{"attention", "urgent", "priority", "action", "unusual",
|
||||
"anomaly", "anomalous", "risk", "risks", "problem", "problems", "issue"},
|
||||
Reads: []Need{applications, postings},
|
||||
Reads: []Need{applications, postings},
|
||||
Signal: func(c Context) int { return when(c.StarvedPositions+c.Unscreened, 8) },
|
||||
},
|
||||
{
|
||||
ID: "recommendations", Text: "What should I do next?",
|
||||
Subject: "the recommendations", Shapes: []string{"list", "insight"},
|
||||
Terms: []string{"recommend", "recommendation", "recommendations", "suggest",
|
||||
"should", "advice", "next step", "next"},
|
||||
Reads: []Need{applications, postings},
|
||||
Reads: []Need{applications, postings},
|
||||
Signal: func(c Context) int { return when(c.DraftPositions+c.StarvedPositions+c.Unscreened, 6) },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -205,7 +227,8 @@ var catalogue = map[string][]Intent{
|
||||
Subject: "the unfinished drafts", Shapes: []string{"list", "table"},
|
||||
Terms: []string{"draft", "drafts", "unfinished", "incomplete", "unpublished",
|
||||
"not posted", "half", "position", "positions", "role", "roles"},
|
||||
Reads: []Need{postings},
|
||||
Reads: []Need{postings},
|
||||
Signal: func(c Context) int { return when(c.DraftPositions, 7) },
|
||||
},
|
||||
{
|
||||
ID: "position-strength", Text: "Which position has the strongest pipeline?",
|
||||
@@ -213,7 +236,8 @@ var catalogue = map[string][]Intent{
|
||||
Terms: []string{"pipeline", "strength", "strongest", "healthiest", "best",
|
||||
"conversion", "which position", "compare", "position", "positions",
|
||||
"role", "roles"},
|
||||
Reads: []Need{postings, applications},
|
||||
Reads: []Need{postings, applications},
|
||||
Signal: func(c Context) int { return when(atLeastTwo(c.ActivePositions), 3) },
|
||||
},
|
||||
{
|
||||
ID: "positions-attention", Text: "Which positions need attention?",
|
||||
@@ -221,7 +245,8 @@ var catalogue = map[string][]Intent{
|
||||
Terms: []string{"attention", "risk", "risks", "at risk", "stalled", "stale",
|
||||
"ageing", "aging", "neglected", "urgent", "slipping", "behind",
|
||||
"position", "positions", "role", "roles"},
|
||||
Reads: []Need{postings, applications},
|
||||
Reads: []Need{postings, applications},
|
||||
Signal: func(c Context) int { return when(c.StarvedPositions, 9) },
|
||||
},
|
||||
{
|
||||
ID: "hiring-priority", Text: "Which position should I fill first?",
|
||||
@@ -229,21 +254,24 @@ var catalogue = map[string][]Intent{
|
||||
Terms: []string{"priority", "prioritise", "prioritize", "fill", "fill first",
|
||||
"first", "most important", "which position", "vacancy", "vacancies",
|
||||
"position", "positions", "role", "roles"},
|
||||
Reads: []Need{postings, applications},
|
||||
Reads: []Need{postings, applications},
|
||||
Signal: func(c Context) int { return when(c.UnderfilledActive, 5) },
|
||||
},
|
||||
{
|
||||
ID: "pipeline-health", Text: "Where are the hiring bottlenecks?",
|
||||
Subject: "the hiring bottlenecks", Shapes: []string{"flow", "stats", "progress", "table"},
|
||||
Terms: []string{"bottleneck", "bottlenecks", "funnel", "stuck", "blocked",
|
||||
"stage", "stages", "waiting", "backlog", "pipeline"},
|
||||
Reads: []Need{applications},
|
||||
Reads: []Need{applications},
|
||||
Signal: func(c Context) int { return when(c.Unscreened+c.Interviewing, 4) },
|
||||
},
|
||||
{
|
||||
ID: "hiring-operations", Text: "Summarize hiring activity across all positions",
|
||||
Subject: "hiring activity", Shapes: []string{"flow", "stats", "timeline", "table", "card"},
|
||||
Terms: []string{"hiring", "activity", "operations", "throughput", "velocity",
|
||||
"how many", "posting", "postings", "role", "roles", "position", "positions"},
|
||||
Reads: []Need{applications, postings},
|
||||
Reads: []Need{applications, postings},
|
||||
Signal: func(c Context) int { return when(c.Applications, 2) },
|
||||
},
|
||||
{
|
||||
/* Deliberately NOT carrying "pipeline". This is the Positions page's
|
||||
@@ -257,7 +285,8 @@ var catalogue = map[string][]Intent{
|
||||
Subject: "the candidates waiting", Shapes: []string{"list", "table", "stats"},
|
||||
Terms: []string{"waiting", "candidate", "candidates", "applicant", "applicants",
|
||||
"review", "screen", "screening", "decision", "queue"},
|
||||
Reads: []Need{applications},
|
||||
Reads: []Need{applications},
|
||||
Signal: func(c Context) int { return when(c.Unscreened, 6) },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -270,7 +299,8 @@ var catalogue = map[string][]Intent{
|
||||
Terms: []string{"attention", "waiting", "stalled", "overdue", "action",
|
||||
"decision", "decide", "urgent", "candidate", "candidates", "applicant",
|
||||
"applicants"},
|
||||
Reads: []Need{applications},
|
||||
Reads: []Need{applications},
|
||||
Signal: func(c Context) int { return when(c.Unscreened+c.Shortlisted, 6) },
|
||||
},
|
||||
{
|
||||
ID: "top-candidates", Text: "Who are the strongest candidates?",
|
||||
@@ -278,7 +308,8 @@ var catalogue = map[string][]Intent{
|
||||
Terms: []string{"strongest", "top", "best", "compare", "shortlist", "rank",
|
||||
"ranking", "highest", "score", "scores", "scored", "scoring", "who",
|
||||
"candidate", "candidates", "applicant", "applicants"},
|
||||
Reads: []Need{applications},
|
||||
Reads: []Need{applications},
|
||||
Signal: func(c Context) int { return when(c.Applications-c.Unscreened, 3) },
|
||||
},
|
||||
{
|
||||
ID: "interview-ready", Text: "Who is ready to interview?",
|
||||
@@ -286,7 +317,8 @@ var catalogue = map[string][]Intent{
|
||||
Terms: []string{"interview", "interviews", "interviewed", "ready", "schedule",
|
||||
"next round", "shortlist", "candidate", "candidates", "applicant",
|
||||
"applicants"},
|
||||
Reads: []Need{applications, interviews},
|
||||
Reads: []Need{applications, interviews},
|
||||
Signal: func(c Context) int { return when(c.Shortlisted, 5) },
|
||||
},
|
||||
{
|
||||
ID: "screening-gaps", Text: "Which candidates have not been scored yet?",
|
||||
@@ -294,14 +326,16 @@ var catalogue = map[string][]Intent{
|
||||
Terms: []string{"unscored", "score", "scores", "scoring", "screening", "screen",
|
||||
"gap", "gaps", "missing", "incomplete", "coverage", "candidate",
|
||||
"candidates", "applicant", "applicants"},
|
||||
Reads: []Need{applications},
|
||||
Reads: []Need{applications},
|
||||
Signal: func(c Context) int { return when(c.Unscreened, 7) },
|
||||
},
|
||||
{
|
||||
ID: "pipeline-summary", Text: "Summarize the candidate pipeline",
|
||||
Subject: "the candidate pipeline", Shapes: []string{"flow", "stats", "progress", "table", "card"},
|
||||
Terms: []string{"pipeline", "funnel", "stage", "stages", "breakdown",
|
||||
"how many", "where are", "conversion"},
|
||||
Reads: []Need{applications},
|
||||
Reads: []Need{applications},
|
||||
Signal: func(c Context) int { return when(c.Applications, 2) },
|
||||
},
|
||||
{
|
||||
ID: "candidate-risk", Text: "Which candidates carry risk flags?",
|
||||
@@ -309,7 +343,8 @@ var catalogue = map[string][]Intent{
|
||||
Terms: []string{"risk", "risks", "risky", "flag", "flags", "flagged", "concern",
|
||||
"concerns", "integrity", "doubt", "decision", "candidate", "candidates",
|
||||
"applicant", "applicants"},
|
||||
Reads: []Need{applications, interviews},
|
||||
Reads: []Need{applications, interviews},
|
||||
Signal: func(c Context) int { return when(c.FlaggedRisks, 9) },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -321,35 +356,40 @@ var catalogue = map[string][]Intent{
|
||||
Subject: "candidate risk", Shapes: []string{"table", "stats", "insight"},
|
||||
Terms: []string{"risk", "risks", "flag", "flags", "flagged", "concern",
|
||||
"integrity", "concentrated"},
|
||||
Reads: []Need{applications, interviews},
|
||||
Reads: []Need{applications, interviews},
|
||||
Signal: func(c Context) int { return when(c.FlaggedRisks, 9) },
|
||||
},
|
||||
{
|
||||
ID: "recruitment-insights", Text: "What do the recruitment numbers show?",
|
||||
Subject: "the recruitment insights", Shapes: []string{"stats", "table", "insight", "card"},
|
||||
Terms: []string{"insight", "insights", "recruitment", "quality", "pool",
|
||||
"stands out", "numbers", "trend", "trends"},
|
||||
Reads: []Need{applications, profiles},
|
||||
Reads: []Need{applications, profiles},
|
||||
Signal: func(c Context) int { return when(c.Applications, 2) },
|
||||
},
|
||||
{
|
||||
ID: "screening-gaps", Text: "Where are the screening gaps?",
|
||||
Subject: "the screening gaps", Shapes: []string{"table", "stats", "progress"},
|
||||
Terms: []string{"screening", "screen", "gap", "gaps", "unscored", "coverage",
|
||||
"missing", "incomplete", "score", "scores"},
|
||||
Reads: []Need{applications},
|
||||
Reads: []Need{applications},
|
||||
Signal: func(c Context) int { return when(c.Unscreened, 7) },
|
||||
},
|
||||
{
|
||||
ID: "hiring-recommendations", Text: "Who should we hire?",
|
||||
Subject: "the hiring recommendations", Shapes: []string{"list", "table", "insight"},
|
||||
Terms: []string{"recommend", "recommendation", "recommendations", "hire",
|
||||
"hiring", "should", "advice", "decision", "who"},
|
||||
Reads: []Need{applications, profiles},
|
||||
Reads: []Need{applications, profiles},
|
||||
Signal: func(c Context) int { return when(c.Shortlisted, 5) },
|
||||
},
|
||||
{
|
||||
ID: "top-candidates", Text: "Compare the top candidates",
|
||||
Subject: "the top candidates", Shapes: []string{"table", "list", "stats"},
|
||||
Terms: []string{"compare", "comparison", "top", "best", "strongest",
|
||||
"shortlist", "rank", "ranking", "side by side"},
|
||||
Reads: []Need{applications},
|
||||
Reads: []Need{applications},
|
||||
Signal: func(c Context) int { return when(c.Applications-c.Unscreened, 3) },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -361,42 +401,48 @@ var catalogue = map[string][]Intent{
|
||||
Subject: "the hiring trend", Shapes: []string{"timeline", "flow", "stats", "table"},
|
||||
Terms: []string{"trend", "trends", "trending", "over time", "month", "monthly",
|
||||
"week", "weekly", "history", "growth", "change"},
|
||||
Reads: []Need{applications, postings},
|
||||
Reads: []Need{applications, postings},
|
||||
Signal: func(c Context) int { return when(c.Applications, 2) },
|
||||
},
|
||||
{
|
||||
ID: "department-performance", Text: "How is each department performing?",
|
||||
Subject: "department performance", Shapes: []string{"table", "stats", "progress"},
|
||||
Terms: []string{"department", "departments", "team", "teams", "category",
|
||||
"categories", "performance", "performing", "breakdown", "compare"},
|
||||
Reads: []Need{applications, postings},
|
||||
Reads: []Need{applications, postings},
|
||||
Signal: func(c Context) int { return when(atLeastTwo(c.ActivePositions), 3) },
|
||||
},
|
||||
{
|
||||
ID: "pipeline-health", Text: "Where are the hiring bottlenecks?",
|
||||
Subject: "the hiring bottlenecks", Shapes: []string{"flow", "stats", "progress", "table"},
|
||||
Terms: []string{"bottleneck", "bottlenecks", "funnel", "pipeline", "conversion",
|
||||
"stuck", "stage", "stages"},
|
||||
Reads: []Need{applications},
|
||||
Reads: []Need{applications},
|
||||
Signal: func(c Context) int { return when(c.Unscreened+c.Interviewing, 5) },
|
||||
},
|
||||
{
|
||||
ID: "position-conversion", Text: "Which positions convert best?",
|
||||
Subject: "position conversion", Shapes: []string{"table", "stats", "list"},
|
||||
Terms: []string{"conversion", "convert", "converts", "position", "positions",
|
||||
"role", "roles", "rate", "rates", "ratio", "yield"},
|
||||
Reads: []Need{postings, applications},
|
||||
Reads: []Need{postings, applications},
|
||||
Signal: func(c Context) int { return when(atLeastTwo(c.ActivePositions)+c.Hired, 4) },
|
||||
},
|
||||
{
|
||||
ID: "hiring-operations", Text: "How is hiring performing overall?",
|
||||
Subject: "hiring performance", Shapes: []string{"stats", "flow", "timeline", "card"},
|
||||
Terms: []string{"hiring", "performance", "operations", "velocity", "speed",
|
||||
"average", "averages", "time to hire", "throughput"},
|
||||
Reads: []Need{applications, postings},
|
||||
Reads: []Need{applications, postings},
|
||||
Signal: func(c Context) int { return when(c.Applications, 2) },
|
||||
},
|
||||
{
|
||||
ID: "attention-required", Text: "What needs attention in the numbers?",
|
||||
Subject: "what needs attention", Shapes: []string{"list", "insight", "table"},
|
||||
Terms: []string{"attention", "outlier", "outliers", "anomaly", "unusual",
|
||||
"risk", "risks", "worst", "falling"},
|
||||
Reads: []Need{applications, postings},
|
||||
Reads: []Need{applications, postings},
|
||||
Signal: func(c Context) int { return when(c.StarvedPositions, 8) },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -408,14 +454,16 @@ var catalogue = map[string][]Intent{
|
||||
Subject: "the audit log", Shapes: []string{"stats", "table", "timeline", "card"},
|
||||
Terms: []string{"audit", "log", "logs", "event", "events", "activity",
|
||||
"trail", "record", "records", "how many"},
|
||||
Reads: []Need{orgActivity},
|
||||
Reads: []Need{orgActivity},
|
||||
Signal: func(c Context) int { return when(c.ActivityEvents, 3) },
|
||||
},
|
||||
{
|
||||
ID: "user-activity", Text: "Who has been most active?",
|
||||
Subject: "activity by user", Shapes: []string{"table", "list", "stats"},
|
||||
Terms: []string{"user", "users", "who", "account", "accounts", "busiest",
|
||||
"most active", "behaviour", "behavior", "person"},
|
||||
Reads: []Need{orgActivity},
|
||||
Reads: []Need{orgActivity},
|
||||
Signal: func(c Context) int { return when(c.ActivityEvents, 2) },
|
||||
},
|
||||
{
|
||||
ID: "unusual-activity", Text: "Has anything unusual happened?",
|
||||
@@ -441,28 +489,32 @@ var catalogue = map[string][]Intent{
|
||||
Subject: "the talent priorities", Shapes: []string{"list", "table", "stats"},
|
||||
Terms: []string{"prioritise", "prioritize", "priority", "priorities", "who",
|
||||
"best", "top", "strongest", "elite", "star", "shortlist", "score", "scores"},
|
||||
Reads: []Need{profiles},
|
||||
Reads: []Need{profiles},
|
||||
Signal: func(c Context) int { return when(c.Profiles, 3) },
|
||||
},
|
||||
{
|
||||
ID: "talent-summary", Text: "Summarize the talent pool",
|
||||
Subject: "the talent pool", Shapes: []string{"stats", "table", "progress", "card"},
|
||||
Terms: []string{"pool", "talent", "worker", "workers", "profile", "profiles",
|
||||
"segment", "segments", "composition", "supply", "how many"},
|
||||
Reads: []Need{profiles},
|
||||
Reads: []Need{profiles},
|
||||
Signal: func(c Context) int { return when(c.Profiles, 2) },
|
||||
},
|
||||
{
|
||||
ID: "talent-verification", Text: "Which profiles are missing verification?",
|
||||
Subject: "the verification gaps", Shapes: []string{"list", "table", "progress", "stats"},
|
||||
Terms: []string{"verification", "verify", "verified", "unverified", "gap",
|
||||
"gaps", "missing", "credential", "credentials", "proof", "evidence"},
|
||||
Reads: []Need{profiles, evidence},
|
||||
Reads: []Need{profiles, evidence},
|
||||
Signal: func(c Context) int { return when(c.UnverifiedProfiles, 7) },
|
||||
},
|
||||
{
|
||||
ID: "talent-availability", Text: "Who is available to start?",
|
||||
Subject: "availability", Shapes: []string{"list", "table", "stats"},
|
||||
Terms: []string{"available", "availability", "unavailable", "free", "start",
|
||||
"capacity", "when", "now", "notice"},
|
||||
Reads: []Need{profiles},
|
||||
Reads: []Need{profiles},
|
||||
Signal: func(c Context) int { return when(c.Profiles, 2) },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -474,28 +526,32 @@ var catalogue = map[string][]Intent{
|
||||
Subject: "the hiring outcomes", Shapes: []string{"stats", "table", "progress", "card"},
|
||||
Terms: []string{"outcome", "outcomes", "result", "results", "quality",
|
||||
"retention", "worked out", "performance", "department", "departments"},
|
||||
Reads: []Need{staff, applications},
|
||||
Reads: []Need{staff, applications},
|
||||
Signal: func(c Context) int { return when(c.Staff, 3) },
|
||||
},
|
||||
{
|
||||
ID: "hiring-strongest", Text: "Who are our strongest hires?",
|
||||
Subject: "the strongest hires", Shapes: []string{"list", "table", "stats"},
|
||||
Terms: []string{"strongest", "best", "top", "star", "highest", "score",
|
||||
"scores", "standout"},
|
||||
Reads: []Need{staff, applications},
|
||||
Reads: []Need{staff, applications},
|
||||
Signal: func(c Context) int { return when(c.Staff, 2) },
|
||||
},
|
||||
{
|
||||
ID: "hiring-patterns", Text: "What stands out about who we hire?",
|
||||
Subject: "the hiring patterns", Shapes: []string{"table", "stats", "insight"},
|
||||
Terms: []string{"pattern", "patterns", "stands out", "trend", "trends",
|
||||
"common", "typical", "breakdown", "profile"},
|
||||
Reads: []Need{staff, applications},
|
||||
Reads: []Need{staff, applications},
|
||||
Signal: func(c Context) int { return when(c.Staff, 2) },
|
||||
},
|
||||
{
|
||||
ID: "hiring-recent", Text: "Who did we hire recently?",
|
||||
Subject: "the recent hires", Shapes: []string{"list", "timeline", "table"},
|
||||
Terms: []string{"recent", "recently", "latest", "last", "new hire", "new hires",
|
||||
"who did we hire", "this month", "hired"},
|
||||
Reads: []Need{staff},
|
||||
Reads: []Need{staff},
|
||||
Signal: func(c Context) int { return when(c.Hired, 5) },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -507,14 +563,16 @@ var catalogue = map[string][]Intent{
|
||||
Subject: "the skill library", Shapes: []string{"stats", "table", "list", "card"},
|
||||
Terms: []string{"library", "skill", "skills", "catalogue", "catalog", "course",
|
||||
"courses", "challenge", "challenges", "what do we have", "how many"},
|
||||
Reads: []Need{courses},
|
||||
Reads: []Need{courses},
|
||||
Signal: func(c Context) int { return when(c.Courses, 3) },
|
||||
},
|
||||
{
|
||||
ID: "forge-published", Text: "Which skills are published?",
|
||||
Subject: "the published skills", Shapes: []string{"list", "table", "stats"},
|
||||
Terms: []string{"published", "publish", "live", "draft", "drafts", "archived",
|
||||
"archive", "status", "in service"},
|
||||
Reads: []Need{courses},
|
||||
Reads: []Need{courses},
|
||||
Signal: func(c Context) int { return when(c.Courses, 2) },
|
||||
},
|
||||
{
|
||||
ID: "forge-evaluation", Text: "How is submitted proof evaluated?",
|
||||
@@ -529,7 +587,8 @@ var catalogue = map[string][]Intent{
|
||||
Subject: "workforce usage", Shapes: []string{"stats", "progress", "table"},
|
||||
Terms: []string{"workforce", "usage", "using", "uptake", "adoption", "progress",
|
||||
"completion", "completed", "training", "learning"},
|
||||
Reads: []Need{courses, evidence, profiles},
|
||||
Reads: []Need{courses, evidence, profiles},
|
||||
Signal: func(c Context) int { return when(c.Profiles, 2) },
|
||||
},
|
||||
{
|
||||
ID: "forge-gaps", Text: "Which skill gaps are still open?",
|
||||
@@ -556,7 +615,8 @@ var catalogue = map[string][]Intent{
|
||||
Terms: []string{"compare", "comparison", "benchmark", "benchmarks", "similar",
|
||||
"typical", "average", "pay", "rate", "rates", "salary", "experience",
|
||||
"market"},
|
||||
Reads: []Need{postings},
|
||||
Reads: []Need{postings},
|
||||
Signal: func(c Context) int { return when(c.ActivePositions, 3) },
|
||||
},
|
||||
{
|
||||
ID: "position-requirements", Text: "Which credentials are already in use?",
|
||||
@@ -564,7 +624,8 @@ var catalogue = map[string][]Intent{
|
||||
Terms: []string{"credential", "credentials", "certification", "certifications",
|
||||
"requirement", "requirements", "qualification", "qualifications",
|
||||
"licence", "license", "skill", "skills"},
|
||||
Reads: []Need{postings, certs},
|
||||
Reads: []Need{postings, certs},
|
||||
Signal: func(c Context) int { return when(c.ActivePositions, 2) },
|
||||
},
|
||||
{
|
||||
ID: "position-spec-steps", Text: "How does this form work?",
|
||||
@@ -602,7 +663,8 @@ var catalogue = map[string][]Intent{
|
||||
Subject: "my recent activity", Shapes: []string{"timeline", "list", "table"},
|
||||
Terms: []string{"activity", "recent", "recently", "history", "what have i",
|
||||
"my actions", "audit", "did i"},
|
||||
Reads: []Need{ownActivity},
|
||||
Reads: []Need{ownActivity},
|
||||
Signal: func(c Context) int { return when(c.ActivityEvents, 3) },
|
||||
},
|
||||
{
|
||||
ID: "profile-actions", Text: "What can I do on this page?",
|
||||
|
||||
177
go-api/internal/owliver/context.go
Normal file
177
go-api/internal/owliver/context.go
Normal file
@@ -0,0 +1,177 @@
|
||||
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
|
||||
}
|
||||
286
go-api/internal/owliver/context_test.go
Normal file
286
go-api/internal/owliver/context_test.go
Normal file
@@ -0,0 +1,286 @@
|
||||
package owliver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
)
|
||||
|
||||
// Highlights — what is worth asking when nothing has been typed.
|
||||
//
|
||||
// Everything here is a pure function of a Context written out by hand, so the
|
||||
// assertions are about the ranking itself rather than about a fixture. The
|
||||
// database read that produces a real Context is exercised over HTTP, in
|
||||
// internal/httpserver.
|
||||
|
||||
// ids is the intents a result names, in order.
|
||||
func ids(list []Suggestion) []string {
|
||||
out := make([]string, len(list))
|
||||
for i, s := range list {
|
||||
out[i] = s.Intent
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// has reports whether an intent was offered.
|
||||
func has(list []Suggestion, intent string) bool {
|
||||
for _, s := range list {
|
||||
if s.Intent == intent {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// A workspace with nothing in it is asked nothing.
|
||||
//
|
||||
// The alternative — three questions about empty sets — is the padding this
|
||||
// whole path is supposed to refuse. "You have no positions, would you like to
|
||||
// know which position has the strongest pipeline?" is worse than silence.
|
||||
func TestHighlightsOfAnEmptyOrganizationAreEmpty(t *testing.T) {
|
||||
for _, page := range []string{"positions", "candidates", "control-center", "analytics",
|
||||
"talent-pool", "hired-history", "krow-forge", "activity"} {
|
||||
if got := Highlights(page, Context{}, domain.RoleAdmin); len(got) != 0 {
|
||||
t.Errorf("%s offered %v for an empty organization", page, ids(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The counts decide, and the loudest one leads.
|
||||
func TestHighlightsRankByTheData(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
page string
|
||||
ctx Context
|
||||
wantTop string
|
||||
}{
|
||||
{
|
||||
name: "unfinished drafts dominate a quiet workspace",
|
||||
page: "positions",
|
||||
ctx: Context{DraftPositions: 4, ActivePositions: 1},
|
||||
wantTop: "position-drafts",
|
||||
},
|
||||
{
|
||||
name: "a role nobody applied to outranks the drafts",
|
||||
page: "positions",
|
||||
ctx: Context{DraftPositions: 1, ActivePositions: 3, StarvedPositions: 2},
|
||||
wantTop: "positions-attention",
|
||||
},
|
||||
{
|
||||
// The tier, not the pile. This is the case weight × count gets
|
||||
// wrong: forty applications outnumber one abandoned role, and the
|
||||
// abandoned role is still the finding.
|
||||
name: "one starved role outranks a large pile of everything else",
|
||||
page: "positions",
|
||||
ctx: Context{ActivePositions: 9, StarvedPositions: 1, Applications: 40, Unscreened: 22},
|
||||
wantTop: "positions-attention",
|
||||
},
|
||||
{
|
||||
// And the thing that just happened is heard, however small.
|
||||
name: "a single unfinished draft is heard over a busy funnel",
|
||||
page: "positions",
|
||||
ctx: Context{ActivePositions: 9, DraftPositions: 1, Applications: 40, Unscreened: 22},
|
||||
wantTop: "position-drafts",
|
||||
},
|
||||
{
|
||||
name: "unscored candidates are the screening gap",
|
||||
page: "candidates",
|
||||
ctx: Context{Applications: 12, Unscreened: 9},
|
||||
wantTop: "screening-gaps",
|
||||
},
|
||||
{
|
||||
name: "a flagged interview is a risk before it is anything else",
|
||||
page: "candidates",
|
||||
ctx: Context{Applications: 12, Unscreened: 2, FlaggedRisks: 4},
|
||||
wantTop: "candidate-risk",
|
||||
},
|
||||
{
|
||||
name: "unverified profiles are the talent pool's gap",
|
||||
page: "talent-pool",
|
||||
ctx: Context{Profiles: 20, UnverifiedProfiles: 8},
|
||||
wantTop: "talent-verification",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := Highlights(c.page, c.ctx, domain.RoleAdmin)
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("%s offered nothing", c.page)
|
||||
}
|
||||
if got[0].Intent != c.wantTop {
|
||||
t.Fatalf("led with %q, want %q (whole list %v)", got[0].Intent, c.wantTop, ids(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A comparison needs two things to compare.
|
||||
//
|
||||
// "Which position has the strongest pipeline?" is not a question about a
|
||||
// workspace holding one position: the answer is a table of one row, which is
|
||||
// the same padding as a question about an empty set.
|
||||
func TestHighlightsDoNotOfferAComparisonOfOne(t *testing.T) {
|
||||
one := Highlights("positions", Context{ActivePositions: 1, Applications: 3}, domain.RoleAdmin)
|
||||
if has(one, "position-strength") {
|
||||
t.Fatalf("offered a pipeline comparison across one position: %v", ids(one))
|
||||
}
|
||||
|
||||
two := Highlights("positions", Context{ActivePositions: 2, Applications: 3}, domain.RoleAdmin)
|
||||
if !has(two, "position-strength") {
|
||||
t.Fatalf("two positions is a comparison and was not offered: %v", ids(two))
|
||||
}
|
||||
}
|
||||
|
||||
// Never more than three, and never the same reading twice.
|
||||
func TestHighlightsAreCappedAndDistinct(t *testing.T) {
|
||||
loud := Context{
|
||||
ActivePositions: 9, DraftPositions: 7, StarvedPositions: 5, UnderfilledActive: 6,
|
||||
Applications: 40, Unscreened: 22, Shortlisted: 9, Interviewing: 6, Hired: 11,
|
||||
FlaggedRisks: 4, Staff: 30, Profiles: 60, UnverifiedProfiles: 25, Courses: 14,
|
||||
ActivityEvents: 900,
|
||||
}
|
||||
|
||||
for page := range catalogue {
|
||||
got := Highlights(page, loud, domain.RoleAdmin)
|
||||
if len(got) > MaxSuggestions {
|
||||
t.Errorf("%s returned %d, the cap is %d", page, len(got), MaxSuggestions)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, s := range got {
|
||||
if seen[s.Intent] {
|
||||
t.Errorf("%s repeated %q", page, s.Intent)
|
||||
}
|
||||
seen[s.Intent] = true
|
||||
if s.Text == "" || s.Intent == "" {
|
||||
t.Errorf("%s returned an incomplete suggestion: %+v", page, s)
|
||||
}
|
||||
// Nothing was typed, so nothing asked for a rendering.
|
||||
if s.Capability != "" {
|
||||
t.Errorf("%s carried a shape nobody asked for: %+v", page, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Permission is decided before relevance, exactly as it is in Suggest.
|
||||
//
|
||||
// A count cannot promote a reading the caller may not perform: talent's rows
|
||||
// are narrowed by the policy table, so an org-wide figure is not theirs to be
|
||||
// told even as a ranking input.
|
||||
func TestHighlightsRefuseWhatARoleCannotRead(t *testing.T) {
|
||||
loud := Context{ActivePositions: 9, DraftPositions: 7, Applications: 40, Unscreened: 22,
|
||||
Staff: 30, Profiles: 60}
|
||||
|
||||
for _, page := range []string{"positions", "candidates", "hired-history", "control-center"} {
|
||||
if got := Highlights(page, loud, domain.RoleTalent); len(got) != 0 {
|
||||
t.Errorf("%s offered talent %v", page, ids(got))
|
||||
}
|
||||
if got := Highlights(page, loud, domain.RoleAdmin); len(got) == 0 {
|
||||
t.Errorf("%s offered an admin nothing — the assertion above proves nothing", page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An unrecognised role is offered nothing, as everywhere else.
|
||||
func TestHighlightsDenyAnUnknownRole(t *testing.T) {
|
||||
loud := Context{ActivePositions: 9, Applications: 40, Unscreened: 22}
|
||||
for _, role := range []domain.Role{"", "root", "superuser", "Admin"} {
|
||||
if got := Highlights("positions", loud, role); len(got) != 0 {
|
||||
t.Errorf("role %q was offered %v", role, ids(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A page the catalogue does not hold answers with an empty slice, not nil.
|
||||
func TestHighlightsOfAnUnknownPageAreAnEmptySlice(t *testing.T) {
|
||||
loud := Context{ActivePositions: 9, Applications: 40}
|
||||
for _, page := range []string{"", "nowhere", "POSITIONS", "settings"} {
|
||||
got := Highlights(page, loud, domain.RoleAdmin)
|
||||
if got == nil {
|
||||
t.Fatalf("page %q returned nil, which a client cannot range over", page)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("page %q returned %v", page, ids(got))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same state always produces the same three in the same order.
|
||||
func TestHighlightsAreDeterministic(t *testing.T) {
|
||||
ctx := Context{ActivePositions: 5, DraftPositions: 3, StarvedPositions: 2,
|
||||
Applications: 20, Unscreened: 7, Shortlisted: 4}
|
||||
|
||||
first := ids(Highlights("positions", ctx, domain.RoleAdmin))
|
||||
for range 25 {
|
||||
again := ids(Highlights("positions", ctx, domain.RoleAdmin))
|
||||
if len(again) != len(first) {
|
||||
t.Fatalf("got %v, first run was %v", again, first)
|
||||
}
|
||||
for i := range first {
|
||||
if again[i] != first[i] {
|
||||
t.Fatalf("got %v, first run was %v", again, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every declared Signal names an intent the catalogue actually holds, and is
|
||||
// monotonic: more of the thing it counts can never make the reading less
|
||||
// relevant. A signal that fell as its subject grew would be a sign error, and
|
||||
// the symptom would be a suggestion that disappears exactly when it matters.
|
||||
func TestSignalsAreMonotonic(t *testing.T) {
|
||||
small := Context{
|
||||
ActivePositions: 2, DraftPositions: 1, StarvedPositions: 1, UnderfilledActive: 1,
|
||||
Applications: 5, Unscreened: 2, Shortlisted: 1, Interviewing: 1, Hired: 1,
|
||||
FlaggedRisks: 1, Staff: 2, Profiles: 3, UnverifiedProfiles: 1, Courses: 1,
|
||||
ActivityEvents: 10,
|
||||
}
|
||||
large := Context{
|
||||
ActivePositions: 20, DraftPositions: 10, StarvedPositions: 10, UnderfilledActive: 10,
|
||||
Applications: 50, Unscreened: 20, Shortlisted: 10, Interviewing: 10, Hired: 10,
|
||||
FlaggedRisks: 10, Staff: 20, Profiles: 30, UnverifiedProfiles: 10, Courses: 10,
|
||||
ActivityEvents: 100,
|
||||
}
|
||||
|
||||
for page, intents := range catalogue {
|
||||
for _, intent := range intents {
|
||||
if intent.Signal == nil {
|
||||
continue
|
||||
}
|
||||
lo, hi := intent.Signal(small), intent.Signal(large)
|
||||
if lo < 0 || hi < 0 {
|
||||
t.Errorf("%s/%s: a negative signal (%d, %d)", page, intent.ID, lo, hi)
|
||||
}
|
||||
if hi < lo {
|
||||
t.Errorf("%s/%s: signal fell as the workspace grew (%d → %d)", page, intent.ID, lo, hi)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A tier decides before a count does, everywhere.
|
||||
//
|
||||
// Stated as a property rather than as a list of pairs, because it is the whole
|
||||
// design: a signal is "how much does this matter when it is happening at all",
|
||||
// and a count that could climb into the tier above would make a large
|
||||
// organization's biggest pile outrank its most urgent finding.
|
||||
func TestATierAlwaysOutranksACount(t *testing.T) {
|
||||
for tier := 1; tier <= 9; tier++ {
|
||||
lowestAbove := when(1, tier+1)
|
||||
highestWithin := when(maxTiebreak*10, tier) // deliberately over the cap
|
||||
if highestWithin >= lowestAbove {
|
||||
t.Fatalf("tier %d saturates into tier %d: %d >= %d",
|
||||
tier, tier+1, highestWithin, lowestAbove)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing happening scores nothing, whatever the tier claims.
|
||||
for tier := 1; tier <= 9; tier++ {
|
||||
for _, count := range []int{0, -1, -1000} {
|
||||
if got := when(count, tier); got != 0 {
|
||||
t.Fatalf("when(%d, %d) = %d, want 0", count, tier, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user