aravind changes
This commit is contained in:
625
go-api/internal/owliver/catalog.go
Normal file
625
go-api/internal/owliver/catalog.go
Normal file
@@ -0,0 +1,625 @@
|
||||
// Package owliver answers "what could I usefully ask here?".
|
||||
//
|
||||
// It is the suggestion side of the Owliver panel and nothing else. It does not
|
||||
// answer questions, does not reach a database, does not call a model, and holds
|
||||
// no state: a request names a page and what the user has typed so far, and this
|
||||
// package ranks a static catalogue against it. That is deliberate — the panel
|
||||
// calls the endpoint on every keystroke, so the work per call has to be a few
|
||||
// string comparisons over a table built once at process start.
|
||||
//
|
||||
// WHERE THE CATALOGUE COMES FROM. Owliver's capabilities are declared in the
|
||||
// frontend, one manifest per page context, in
|
||||
// src/components/ai-assistant/capabilities/. Each entry there is an id, a label
|
||||
// and the function that answers it. This file transcribes the id and the page
|
||||
// it is offered on, and adds the two things a manifest does not carry because
|
||||
// the browser never needed them: the words that mean a user is reaching for
|
||||
// that reading, and the records it reads.
|
||||
//
|
||||
// Transcription rather than a second registry, on the pattern already set by
|
||||
// internal/definition/vocabulary.go: the frontend owns the vocabulary, this
|
||||
// side names keys from it, and TestIntentIDsAreFrontendCapabilities keeps the
|
||||
// two honest. An Intent.ID that no manifest declares is a bug here, not a new
|
||||
// capability — the id in a response is the id the panel dispatches on.
|
||||
//
|
||||
// WHAT MAKES A SUGGESTION SAFE TO SHOW. Offering someone a question is a claim
|
||||
// that they could ask it. Reads is that claim written down: the resources the
|
||||
// capability actually reads, checked against the one authorization table in
|
||||
// internal/domain/policy.go. No role list is repeated here, and no rule is
|
||||
// re-derived — a capability that reads staff is unavailable to talent because
|
||||
// policy.go says talent may not list staff, and for no other reason.
|
||||
package owliver
|
||||
|
||||
import "github.com/krow/krow-backend/go-api/internal/domain"
|
||||
|
||||
// Need is one record set a capability reads, and how much of it.
|
||||
//
|
||||
// OrgWide is the half a role check alone cannot express. Every role may list
|
||||
// job applications; a talent caller sees only their own, because the policy
|
||||
// attaches a row predicate rather than a refusal. So "which position has the
|
||||
// strongest pipeline?" is not forbidden to talent — it is unanswerable for
|
||||
// them, and offering it would promise a reading across records they will never
|
||||
// be shown. OrgWide asks policy.ScopeFor for exactly that: is this caller's
|
||||
// view of this resource narrowed, or is it the whole organization?
|
||||
type Need struct {
|
||||
Resource string // domain resource path, e.g. "job-applications"
|
||||
Op domain.Op
|
||||
OrgWide bool
|
||||
}
|
||||
|
||||
// permitted reports whether a role may perform this reading.
|
||||
//
|
||||
// Three gates, all sourced from the descriptors: the API must expose the
|
||||
// operation at all, the policy must allow the role, and — for an org-wide
|
||||
// reading — the policy must not narrow the role's rows.
|
||||
func (n Need) permitted(role domain.Role) bool {
|
||||
res, ok := domain.ResourceByPath[n.Resource]
|
||||
if !ok || !res.Supports(n.Op) {
|
||||
return false
|
||||
}
|
||||
if !res.Policy.Allows(n.Op, role) {
|
||||
return false
|
||||
}
|
||||
if n.OrgWide && res.Policy.ScopeFor(role).Kind != domain.ScopeNone {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Intent is one reading Owliver can offer on one page.
|
||||
type Intent struct {
|
||||
// ID is the frontend capability id, verbatim. It is what the panel
|
||||
// dispatches on, which is why it is not invented here.
|
||||
ID string
|
||||
|
||||
// Text is the suggestion as the user reads it: a question in their words,
|
||||
// not a label. Unique within a page.
|
||||
Text string
|
||||
|
||||
// Subject names what the reading is about — "hiring activity", "the
|
||||
// candidate pipeline" — and exists so a shaped variant can be phrased
|
||||
// ("Summarize hiring activity", "Show hiring activity as a flow") without
|
||||
// writing every combination out. An intent with no Subject is offered only
|
||||
// as its Text.
|
||||
Subject string
|
||||
|
||||
// Shapes are the section types this reading can be drawn as, named from the
|
||||
// closed OWLIVER_CAPABILITIES vocabulary in src/lib/skills/surfaces.js.
|
||||
// Empty means prose only. `summary` is never listed: it has no component,
|
||||
// so it applies to anything with a Subject.
|
||||
Shapes []string
|
||||
|
||||
// Terms are the words that mean a user is reaching for this reading. About
|
||||
// the subject, never about the shape — "as a flow" belongs to the shape
|
||||
// table, and putting it here would offer every page's intents to anyone who
|
||||
// typed "chart".
|
||||
Terms []string
|
||||
|
||||
// Reads is what the capability actually reads. Empty means it reads nothing
|
||||
// but the caller's own account, and is therefore available to anyone signed
|
||||
// in.
|
||||
Reads []Need
|
||||
}
|
||||
|
||||
// permitted reports whether a role may be offered this intent. Every reading
|
||||
// must be permitted: a suggestion that is half-answerable is not answerable.
|
||||
func (i Intent) permitted(role domain.Role) bool {
|
||||
for _, n := range i.Reads {
|
||||
if !n.permitted(role) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/* ── The readings each resource stands for ──────────────────────────────── */
|
||||
//
|
||||
// Named rather than repeated so that "this capability reads the organization's
|
||||
// applications" is written once and reads the same everywhere it appears.
|
||||
|
||||
var (
|
||||
postings = Need{Resource: "job-postings", Op: domain.OpList, OrgWide: true}
|
||||
applications = Need{Resource: "job-applications", Op: domain.OpList, OrgWide: true}
|
||||
interviews = Need{Resource: "ai-interviews", Op: domain.OpList, OrgWide: true}
|
||||
profiles = Need{Resource: "worker-profiles", Op: domain.OpList, OrgWide: true}
|
||||
staff = Need{Resource: "staff", Op: domain.OpList, OrgWide: true}
|
||||
orgActivity = Need{Resource: "user-activity", Op: domain.OpList, OrgWide: true}
|
||||
courses = Need{Resource: "courses", Op: domain.OpList, OrgWide: true}
|
||||
certs = Need{Resource: "certifications", Op: domain.OpList, OrgWide: true}
|
||||
evidence = Need{Resource: "evidence", Op: domain.OpList, OrgWide: true}
|
||||
|
||||
// The caller's own audit trail rather than the organization's — the Profile
|
||||
// page reads what *you* did, which every role may do for themselves.
|
||||
ownActivity = Need{Resource: "user-activity", Op: domain.OpList}
|
||||
)
|
||||
|
||||
/* ── The catalogue ──────────────────────────────────────────────────────── */
|
||||
|
||||
// catalogue is every intent, keyed by canonical page id.
|
||||
//
|
||||
// Keys are canonical SKILL_SURFACES ids — the same vocabulary
|
||||
// internal/definition validates a definition's `pages:` against. A page that is
|
||||
// a real surface but has no entry here is not an error: it answers with an
|
||||
// empty list, which is the honest reply for a screen holding no readings.
|
||||
//
|
||||
// The seven workspace and configuration surfaces are deliberately absent.
|
||||
// Their manifests explain the screen rather than read workforce records — they
|
||||
// have no data behind them to rank a typed query against, and the panel there
|
||||
// answers from the registries directly.
|
||||
//
|
||||
// Declaration order is the tie-break when two intents score equally, so the
|
||||
// order within a page is the order the frontend manifest lists them in.
|
||||
var catalogue = map[string][]Intent{
|
||||
|
||||
/* ── Control Center — the platform read as a whole ─────────────────── */
|
||||
|
||||
"control-center": {
|
||||
{
|
||||
ID: "platform-health", Text: "How healthy is the platform right now?",
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
},
|
||||
|
||||
/* ── Positions — the roles being filled ────────────────────────────── */
|
||||
|
||||
"positions": {
|
||||
{
|
||||
ID: "position-drafts", Text: "Which positions are still unfinished drafts?",
|
||||
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},
|
||||
},
|
||||
{
|
||||
ID: "position-strength", Text: "Which position has the strongest pipeline?",
|
||||
Subject: "pipeline strength by position", Shapes: []string{"table", "stats", "list", "card"},
|
||||
Terms: []string{"pipeline", "strength", "strongest", "healthiest", "best",
|
||||
"conversion", "which position", "compare", "position", "positions",
|
||||
"role", "roles"},
|
||||
Reads: []Need{postings, applications},
|
||||
},
|
||||
{
|
||||
ID: "positions-attention", Text: "Which positions need attention?",
|
||||
Subject: "positions needing attention", Shapes: []string{"list", "table", "insight"},
|
||||
Terms: []string{"attention", "risk", "risks", "at risk", "stalled", "stale",
|
||||
"ageing", "aging", "neglected", "urgent", "slipping", "behind",
|
||||
"position", "positions", "role", "roles"},
|
||||
Reads: []Need{postings, applications},
|
||||
},
|
||||
{
|
||||
ID: "hiring-priority", Text: "Which position should I fill first?",
|
||||
Subject: "what to fill first", Shapes: []string{"list", "table"},
|
||||
Terms: []string{"priority", "prioritise", "prioritize", "fill", "fill first",
|
||||
"first", "most important", "which position", "vacancy", "vacancies",
|
||||
"position", "positions", "role", "roles"},
|
||||
Reads: []Need{postings, applications},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
/* Deliberately NOT carrying "pipeline". This is the Positions page's
|
||||
reading of who is waiting on a decision, and it belongs here — the
|
||||
frontend manifest declares it on this page. But "pipeline" on
|
||||
Positions is a question about how the ROLES are converting, and
|
||||
answering it with a list of people is the page context being
|
||||
right and the ranking being wrong. Its own words are what it
|
||||
answers to. */
|
||||
ID: "candidates-waiting", Text: "Which candidates are waiting on a decision?",
|
||||
Subject: "the candidates waiting", Shapes: []string{"list", "table", "stats"},
|
||||
Terms: []string{"waiting", "candidate", "candidates", "applicant", "applicants",
|
||||
"review", "screen", "screening", "decision", "queue"},
|
||||
Reads: []Need{applications},
|
||||
},
|
||||
},
|
||||
|
||||
/* ── Candidates — the people in the funnel, as a triage queue ──────── */
|
||||
|
||||
"candidates": {
|
||||
{
|
||||
ID: "candidates-attention", Text: "Which candidates need attention?",
|
||||
Subject: "the candidates needing attention", Shapes: []string{"list", "table", "insight"},
|
||||
Terms: []string{"attention", "waiting", "stalled", "overdue", "action",
|
||||
"decision", "decide", "urgent", "candidate", "candidates", "applicant",
|
||||
"applicants"},
|
||||
Reads: []Need{applications},
|
||||
},
|
||||
{
|
||||
ID: "top-candidates", Text: "Who are the strongest candidates?",
|
||||
Subject: "the strongest candidates", Shapes: []string{"list", "table", "stats", "card"},
|
||||
Terms: []string{"strongest", "top", "best", "compare", "shortlist", "rank",
|
||||
"ranking", "highest", "score", "scores", "scored", "scoring", "who",
|
||||
"candidate", "candidates", "applicant", "applicants"},
|
||||
Reads: []Need{applications},
|
||||
},
|
||||
{
|
||||
ID: "interview-ready", Text: "Who is ready to interview?",
|
||||
Subject: "the interview-ready candidates", Shapes: []string{"list", "table", "stats"},
|
||||
Terms: []string{"interview", "interviews", "interviewed", "ready", "schedule",
|
||||
"next round", "shortlist", "candidate", "candidates", "applicant",
|
||||
"applicants"},
|
||||
Reads: []Need{applications, interviews},
|
||||
},
|
||||
{
|
||||
ID: "screening-gaps", Text: "Which candidates have not been scored yet?",
|
||||
Subject: "the screening gaps", Shapes: []string{"list", "table", "stats", "progress"},
|
||||
Terms: []string{"unscored", "score", "scores", "scoring", "screening", "screen",
|
||||
"gap", "gaps", "missing", "incomplete", "coverage", "candidate",
|
||||
"candidates", "applicant", "applicants"},
|
||||
Reads: []Need{applications},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
ID: "candidate-risk", Text: "Which candidates carry risk flags?",
|
||||
Subject: "the candidate risks", Shapes: []string{"list", "table", "insight"},
|
||||
Terms: []string{"risk", "risks", "risky", "flag", "flags", "flagged", "concern",
|
||||
"concerns", "integrity", "doubt", "decision", "candidate", "candidates",
|
||||
"applicant", "applicants"},
|
||||
Reads: []Need{applications, interviews},
|
||||
},
|
||||
},
|
||||
|
||||
/* ── Candidates Analysis — the same records read as a pool ─────────── */
|
||||
|
||||
"candidates-analysis": {
|
||||
{
|
||||
ID: "candidate-risk", Text: "Where is candidate risk concentrated?",
|
||||
Subject: "candidate risk", Shapes: []string{"table", "stats", "insight"},
|
||||
Terms: []string{"risk", "risks", "flag", "flags", "flagged", "concern",
|
||||
"integrity", "concentrated"},
|
||||
Reads: []Need{applications, interviews},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
},
|
||||
|
||||
/* ── Analytics — performance over time ─────────────────────────────── */
|
||||
|
||||
"analytics": {
|
||||
{
|
||||
ID: "hiring-trend", Text: "How has hiring trended over time?",
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
},
|
||||
|
||||
/* ── Activity — the audit log ──────────────────────────────────────── */
|
||||
|
||||
"activity": {
|
||||
{
|
||||
ID: "audit-summary", Text: "Summarize the audit log",
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
ID: "unusual-activity", Text: "Has anything unusual happened?",
|
||||
Subject: "the unusual activity", Shapes: []string{"list", "timeline", "insight"},
|
||||
Terms: []string{"unusual", "anomaly", "anomalies", "anomalous", "suspicious",
|
||||
"spike", "spikes", "burst", "odd", "strange", "out of hours"},
|
||||
Reads: []Need{orgActivity},
|
||||
},
|
||||
{
|
||||
ID: "security-insights", Text: "Are there any security concerns?",
|
||||
Subject: "the security findings", Shapes: []string{"list", "insight", "table"},
|
||||
Terms: []string{"security", "secure", "breach", "compliance", "compliant",
|
||||
"integrity", "trace", "traceable", "attributable", "risk", "risks"},
|
||||
Reads: []Need{orgActivity},
|
||||
},
|
||||
},
|
||||
|
||||
/* ── Talent Pool — supply, before anyone applies ───────────────────── */
|
||||
|
||||
"talent-pool": {
|
||||
{
|
||||
ID: "talent-priorities", Text: "Who should I prioritize in the talent pool?",
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
},
|
||||
|
||||
/* ── Hired History — what happened after the hire ───────────────────── */
|
||||
|
||||
"hired-history": {
|
||||
{
|
||||
ID: "hiring-outcomes", Text: "How have our hires worked out?",
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
},
|
||||
|
||||
/* ── KROW Forge — the skill library and the proof behind it ────────── */
|
||||
|
||||
"krow-forge": {
|
||||
{
|
||||
ID: "forge-library", Text: "What is in the skill library?",
|
||||
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},
|
||||
},
|
||||
{
|
||||
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},
|
||||
},
|
||||
{
|
||||
ID: "forge-evaluation", Text: "How is submitted proof evaluated?",
|
||||
Subject: "the evaluation criteria", Shapes: []string{"list", "table", "insight"},
|
||||
Terms: []string{"evaluate", "evaluated", "evaluation", "rubric", "rubrics",
|
||||
"criteria", "criterion", "grading", "graded", "proof", "verify",
|
||||
"verification", "assess"},
|
||||
Reads: []Need{courses, evidence},
|
||||
},
|
||||
{
|
||||
ID: "forge-workforce", Text: "How is the workforce using the Forge?",
|
||||
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},
|
||||
},
|
||||
{
|
||||
ID: "forge-gaps", Text: "Which skill gaps are still open?",
|
||||
Subject: "the skill gaps", Shapes: []string{"list", "table", "progress"},
|
||||
Terms: []string{"gap", "gaps", "missing", "uncovered", "close", "coverage",
|
||||
"needed", "shortfall", "certification", "certifications"},
|
||||
Reads: []Need{courses, certs, profiles},
|
||||
},
|
||||
},
|
||||
|
||||
/* ── Create Position — the authoring form ──────────────────────────── */
|
||||
|
||||
"create-position": {
|
||||
{
|
||||
ID: "vetting-weights", Text: "What do the vetting weights score?",
|
||||
Subject: "the vetting weights", Shapes: []string{"table", "insight"},
|
||||
Terms: []string{"weight", "weights", "weighting", "weightings", "vetting",
|
||||
"criteria", "criterion", "scoring", "score", "balance", "importance"},
|
||||
Reads: []Need{postings},
|
||||
},
|
||||
{
|
||||
ID: "position-benchmarks", Text: "How does this compare with similar roles?",
|
||||
Subject: "the benchmarks", Shapes: []string{"table", "stats", "insight"},
|
||||
Terms: []string{"compare", "comparison", "benchmark", "benchmarks", "similar",
|
||||
"typical", "average", "pay", "rate", "rates", "salary", "experience",
|
||||
"market"},
|
||||
Reads: []Need{postings},
|
||||
},
|
||||
{
|
||||
ID: "position-requirements", Text: "Which credentials are already in use?",
|
||||
Subject: "the credentials in use", Shapes: []string{"list", "table", "stats"},
|
||||
Terms: []string{"credential", "credentials", "certification", "certifications",
|
||||
"requirement", "requirements", "qualification", "qualifications",
|
||||
"licence", "license", "skill", "skills"},
|
||||
Reads: []Need{postings, certs},
|
||||
},
|
||||
{
|
||||
ID: "position-spec-steps", Text: "How does this form work?",
|
||||
Terms: []string{"form", "field", "fields", "step", "steps", "section",
|
||||
"sections", "how do i", "what do i", "explain", "fill in", "required"},
|
||||
},
|
||||
},
|
||||
|
||||
/* ── Profile — the account, not the workforce ──────────────────────── */
|
||||
|
||||
"profile": {
|
||||
{
|
||||
ID: "profile-permissions", Text: "What am I permitted to do?",
|
||||
Terms: []string{"permission", "permissions", "permitted", "allowed", "can i",
|
||||
"scope", "access", "role", "rights", "privilege", "privileges"},
|
||||
},
|
||||
{
|
||||
ID: "profile-identity", Text: "What are my account details?",
|
||||
Terms: []string{"account", "details", "name", "email", "identity", "profile",
|
||||
"who am i", "my details"},
|
||||
},
|
||||
{
|
||||
ID: "profile-security", Text: "How is my account secured?",
|
||||
Terms: []string{"security", "secure", "secured", "password", "two-factor",
|
||||
"two factor", "2fa", "session", "sessions", "sign out", "log out",
|
||||
"protect", "protected"},
|
||||
},
|
||||
{
|
||||
ID: "profile-preferences", Text: "Which preferences are set?",
|
||||
Terms: []string{"preference", "preferences", "setting", "settings", "digest",
|
||||
"density", "owliver", "default", "defaults", "toggle"},
|
||||
},
|
||||
{
|
||||
ID: "profile-activity", Text: "What have I done recently?",
|
||||
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},
|
||||
},
|
||||
{
|
||||
ID: "profile-actions", Text: "What can I do on this page?",
|
||||
Terms: []string{"do here", "what can i", "action", "actions", "edit", "change",
|
||||
"change my", "update", "manage"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Pages is every page the catalogue holds intents for, for tests and
|
||||
// diagnostics. It is not the set of valid pages — that is
|
||||
// definition.SupportedPages, and a valid page absent from here answers with an
|
||||
// empty list rather than a validation error.
|
||||
func Pages() []string {
|
||||
out := make([]string, 0, len(catalogue))
|
||||
for page := range catalogue {
|
||||
out = append(out, page)
|
||||
}
|
||||
return out
|
||||
}
|
||||
359
go-api/internal/owliver/suggest.go
Normal file
359
go-api/internal/owliver/suggest.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package owliver
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
)
|
||||
|
||||
// MaxSuggestions is the most a response may carry.
|
||||
//
|
||||
// Three, because the panel shows them under a composer the user is still typing
|
||||
// into. A fourth line pushes the input off a phone screen, and a ranked list
|
||||
// nobody reads to the bottom is a longer list, not a better one.
|
||||
const MaxSuggestions = 3
|
||||
|
||||
// MinQueryChars is the shortest query that is worth ranking.
|
||||
//
|
||||
// Counted in letters and digits after normalization, so " a " and "?!" are
|
||||
// both too short. One character matches a prefix of almost every term in the
|
||||
// catalogue, which would make the first keystroke return three arbitrary
|
||||
// readings and the second replace all three — noise that reads as a bug.
|
||||
const MinQueryChars = 2
|
||||
|
||||
// MaxQueryChars bounds the work one request can ask for. The panel sends what
|
||||
// is in the composer, and nothing about a suggestion improves past a couple of
|
||||
// sentences. Beyond this the query is truncated, never rejected: a long paste
|
||||
// should rank on its opening words, not fail.
|
||||
const MaxQueryChars = 200
|
||||
|
||||
// Suggestion is one offered question.
|
||||
//
|
||||
// Text is what the user reads. Intent is the frontend capability id the panel
|
||||
// dispatches on. Capability is the section type the answer should be drawn as,
|
||||
// present only when the query asked for one — nothing internal is exposed here:
|
||||
// no terms, no resource names, no policy detail, no scores.
|
||||
type Suggestion struct {
|
||||
Text string `json:"text"`
|
||||
Intent string `json:"intent"`
|
||||
Capability string `json:"capability,omitempty"`
|
||||
}
|
||||
|
||||
/* ── Shapes ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
// shape is one section type an answer can be drawn as.
|
||||
//
|
||||
// Transcribed from OWLIVER_CAPABILITIES in src/lib/skills/surfaces.js: the ids
|
||||
// and the terms are that table's, and `phrase` is how the id reads inside a
|
||||
// sentence. `summary` is first and has no component — it is prose, so it
|
||||
// applies to any intent with a subject.
|
||||
//
|
||||
// Terms here describe the SHAPE and never a subject, which is what keeps a
|
||||
// shape from dragging in another page's readings: "as a flow" belongs here,
|
||||
// "hiring activity" belongs to an intent.
|
||||
type shape struct {
|
||||
id string
|
||||
phrase string
|
||||
terms []string
|
||||
}
|
||||
|
||||
var shapes = []shape{
|
||||
{id: "summary", phrase: "", terms: []string{
|
||||
"summary", "summarise", "summarize", "summarised", "summarized",
|
||||
"summarising", "summarizing", "sum up", "recap", "overview", "brief me",
|
||||
"in short", "tell me about"}},
|
||||
{id: "flow", phrase: "as a flow", terms: []string{
|
||||
"flow", "as a flow", "chart", "graph", "diagram", "funnel", "visual",
|
||||
"visualise", "visualize", "step by step"}},
|
||||
{id: "stats", phrase: "as stats", terms: []string{
|
||||
"stats", "statistics", "figures", "numbers", "counts"}},
|
||||
{id: "list", phrase: "as a list", terms: []string{
|
||||
"list", "which ones", "show me the records"}},
|
||||
{id: "table", phrase: "as a table", terms: []string{
|
||||
"table", "as a table", "rows", "grid", "spreadsheet"}},
|
||||
{id: "timeline", phrase: "as a timeline", terms: []string{
|
||||
"timeline", "chronology", "over time", "what happened"}},
|
||||
{id: "progress", phrase: "as progress bars", terms: []string{
|
||||
"progress", "bars", "completion", "how far"}},
|
||||
{id: "weights", phrase: "as weights", terms: []string{
|
||||
"weighting", "weightings", "set the weights", "adjust the weights",
|
||||
"screening weight", "vetting weight"}},
|
||||
{id: "insight", phrase: "as an insight", terms: []string{
|
||||
"insight", "finding", "takeaway", "headline"}},
|
||||
{id: "card", phrase: "as a card", terms: []string{
|
||||
"card", "panel", "at a glance"}},
|
||||
}
|
||||
|
||||
/* ── Normalization ──────────────────────────────────────────────────────── */
|
||||
|
||||
// normalize reduces a raw query to the one form everything downstream matches
|
||||
// against: lower case, letters and digits only, single-spaced.
|
||||
//
|
||||
// Every other character — punctuation, quotes, brackets, control characters,
|
||||
// emoji, an SQL fragment, a script tag — becomes a space rather than being
|
||||
// stripped, so nothing can be glued into a token that was not typed as one.
|
||||
// The result is compared against a fixed table of literals and never reaches a
|
||||
// query, a template or a log message, so there is no construction to inject
|
||||
// into; this is about matching sanely, not about escaping.
|
||||
func normalize(raw string) (phrase string, tokens []string, meaningful int) {
|
||||
runes := []rune(raw)
|
||||
if len(runes) > MaxQueryChars {
|
||||
runes = runes[:MaxQueryChars]
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(runes))
|
||||
for _, r := range runes {
|
||||
switch {
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
meaningful++
|
||||
default:
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
|
||||
tokens = strings.Fields(b.String())
|
||||
return strings.Join(tokens, " "), tokens, meaningful
|
||||
}
|
||||
|
||||
/* ── Scoring ────────────────────────────────────────────────────────────── */
|
||||
|
||||
// Scores are small integers with a deliberate order:
|
||||
//
|
||||
// exact a token is the term — the user typed it
|
||||
// phrase a multi-word term appears in the query — the most specific hit
|
||||
// prefix the term begins with a token — mid-typing: "pipel"
|
||||
// extension a token begins with the term — "pipelines"
|
||||
// shaped the query named a section type — weakest on its own
|
||||
//
|
||||
// A shape hit is worth less than any subject hit, so typing "overview" can
|
||||
// surface a page's readings but can never outrank a reading the user named.
|
||||
const (
|
||||
scoreExact = 10
|
||||
scorePhrase = 12
|
||||
scorePrefix = 6
|
||||
scoreExtension = 5
|
||||
scoreShaped = 4
|
||||
|
||||
// minPrefixToken keeps one- and two-letter tokens from matching a term by
|
||||
// prefix. "a" begins nothing usefully; "at" would match "attention",
|
||||
// "audit" and "activity" at once.
|
||||
minPrefixToken = 3
|
||||
// minExtensionTerm keeps a short term from being found inside a longer
|
||||
// word: without it "list" matches "listen" and "score" matches "scoreboard".
|
||||
minExtensionTerm = 4
|
||||
)
|
||||
|
||||
// tokenScore is how well one typed token matches one single-word term.
|
||||
func tokenScore(token, term string) int {
|
||||
switch {
|
||||
case token == term:
|
||||
return scoreExact
|
||||
case len(token) >= minPrefixToken && strings.HasPrefix(term, token):
|
||||
return scorePrefix
|
||||
case len(term) >= minExtensionTerm && strings.HasPrefix(token, term):
|
||||
return scoreExtension
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// termsScore ranks a whole term list against the query.
|
||||
//
|
||||
// Multi-word terms are matched against the phrase, because "how many" means
|
||||
// something its two words do not. Single-word terms are scored per TYPED TOKEN,
|
||||
// taking that token's best term — so a query is rewarded for how much of what
|
||||
// the user typed the intent accounts for, and an intent cannot climb the
|
||||
// ranking by listing eight synonyms of one word.
|
||||
func termsScore(terms []string, tokens []string, phrase string) int {
|
||||
total := 0
|
||||
for _, term := range terms {
|
||||
if !strings.Contains(term, " ") {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(phrase, term) {
|
||||
total += scorePhrase + 2*strings.Count(term, " ")
|
||||
}
|
||||
}
|
||||
for _, token := range tokens {
|
||||
best := 0
|
||||
for _, term := range terms {
|
||||
if strings.Contains(term, " ") {
|
||||
continue
|
||||
}
|
||||
if s := tokenScore(token, term); s > best {
|
||||
best = s
|
||||
}
|
||||
}
|
||||
total += best
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// matchShape is the section type the query asked for, if it asked for one.
|
||||
// Highest scoring wins; ties go to declaration order, which puts `summary`
|
||||
// first.
|
||||
func matchShape(tokens []string, phrase string) (shape, bool) {
|
||||
best, bestScore := shape{}, 0
|
||||
for _, s := range shapes {
|
||||
if score := termsScore(s.terms, tokens, phrase); score > bestScore {
|
||||
best, bestScore = s, score
|
||||
}
|
||||
}
|
||||
return best, bestScore > 0
|
||||
}
|
||||
|
||||
// supportsShape reports whether an intent can be drawn as a section type.
|
||||
// `summary` needs only a subject, having no component of its own; every other
|
||||
// shape must be one the intent declares.
|
||||
func (i Intent) supportsShape(id string) bool {
|
||||
if i.Subject == "" {
|
||||
return false
|
||||
}
|
||||
if id == "summary" {
|
||||
return true
|
||||
}
|
||||
for _, s := range i.Shapes {
|
||||
if s == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shaped is the suggestion text for an intent asked for in a given shape.
|
||||
func (i Intent) shaped(s shape) string {
|
||||
if s.id == "summary" {
|
||||
return "Summarize " + i.Subject
|
||||
}
|
||||
return "Show " + i.Subject + " " + s.phrase
|
||||
}
|
||||
|
||||
/* ── The pipeline ───────────────────────────────────────────────────────── */
|
||||
|
||||
// filterOnTopic keeps the candidates the query actually named, or nothing if
|
||||
// it named none.
|
||||
func filterOnTopic(candidates []scored) []scored {
|
||||
out := make([]scored, 0, len(candidates))
|
||||
for _, c := range candidates {
|
||||
if c.onTopic {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// scored is one candidate on its way through ranking.
|
||||
type scored struct {
|
||||
suggestion Suggestion
|
||||
score int
|
||||
order int // declaration index, the tie-break
|
||||
|
||||
// onTopic records that the query matched this reading's own terms, rather
|
||||
// than only naming a section type it happens to support. See Suggest.
|
||||
onTopic bool
|
||||
}
|
||||
|
||||
// Suggest ranks the page's catalogue against what the user has typed.
|
||||
//
|
||||
// The order is fixed and each stage only ever removes: page context, then
|
||||
// permission, then relevance, then duplicates, then the cap. Permission comes
|
||||
// before relevance so a reading the caller cannot perform is never scored, and
|
||||
// therefore cannot be leaked by an ordering bug later.
|
||||
//
|
||||
// It returns an empty slice, never nil and never a filler suggestion: a query
|
||||
// that matches nothing on this page has no answer here, and saying so is more
|
||||
// useful than three questions the user did not ask.
|
||||
func Suggest(page, query string, role domain.Role) []Suggestion {
|
||||
out := []Suggestion{}
|
||||
|
||||
// Deny by default, as policy.go does. The service resolves the role before
|
||||
// calling, so an unrecognised one should be unreachable — but an intent
|
||||
// that reads nothing is permitted by every role it is asked about, so
|
||||
// without this line a caller whose role failed to parse would be offered
|
||||
// the account readings. The check belongs where the answer is decided.
|
||||
if _, known := domain.ParseRole(string(role)); !known {
|
||||
return out
|
||||
}
|
||||
|
||||
intents, ok := catalogue[page]
|
||||
if !ok {
|
||||
return out
|
||||
}
|
||||
|
||||
phrase, tokens, meaningful := normalize(query)
|
||||
if meaningful < MinQueryChars {
|
||||
return out
|
||||
}
|
||||
|
||||
requested, wantsShape := matchShape(tokens, phrase)
|
||||
|
||||
candidates := make([]scored, 0, len(intents))
|
||||
for order, intent := range intents {
|
||||
if !intent.permitted(role) {
|
||||
continue
|
||||
}
|
||||
|
||||
score := termsScore(intent.Terms, tokens, phrase)
|
||||
onTopic := score > 0
|
||||
|
||||
suggestion := Suggestion{Text: intent.Text, Intent: intent.ID}
|
||||
if wantsShape && intent.supportsShape(requested.id) {
|
||||
score += scoreShaped
|
||||
suggestion.Text = intent.shaped(requested)
|
||||
suggestion.Capability = requested.id
|
||||
}
|
||||
|
||||
if score == 0 {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, scored{
|
||||
suggestion: suggestion, score: score, order: order, onTopic: onTopic,
|
||||
})
|
||||
}
|
||||
|
||||
// A shape on its own is a weak signal, and what it means depends on what
|
||||
// else matched. "as a table" typed alone is a real request — draw this
|
||||
// page's readings that way — but the same words after "hiring activity" are
|
||||
// how the user asked for ONE reading, and offering two more that merely
|
||||
// support tables is the padding this endpoint is supposed to refuse.
|
||||
//
|
||||
// So the two are kept in separate tiers: if anything matched the query's
|
||||
// subject, only those compete. Shape-only matches answer for the whole page
|
||||
// or not at all.
|
||||
if onTopic := filterOnTopic(candidates); len(onTopic) > 0 {
|
||||
candidates = onTopic
|
||||
}
|
||||
|
||||
// Highest score first; declaration order breaks every tie, so the same
|
||||
// request 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
|
||||
})
|
||||
|
||||
// One suggestion per intent, and no two reading the same. The catalogue is
|
||||
// unique per page by construction — TestCatalogueIsWellFormed holds it that
|
||||
// way — so this guards the shaped rewrite, which can phrase two intents
|
||||
// identically only if two subjects ever collide.
|
||||
seenIntent := make(map[string]bool, MaxSuggestions)
|
||||
seenText := make(map[string]bool, MaxSuggestions)
|
||||
for _, c := range candidates {
|
||||
if len(out) == MaxSuggestions {
|
||||
break
|
||||
}
|
||||
key := strings.ToLower(c.suggestion.Text)
|
||||
if seenIntent[c.suggestion.Intent] || seenText[key] {
|
||||
continue
|
||||
}
|
||||
seenIntent[c.suggestion.Intent] = true
|
||||
seenText[key] = true
|
||||
out = append(out, c.suggestion)
|
||||
}
|
||||
return out
|
||||
}
|
||||
695
go-api/internal/owliver/suggest_test.go
Normal file
695
go-api/internal/owliver/suggest_test.go
Normal file
@@ -0,0 +1,695 @@
|
||||
package owliver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/definition"
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
)
|
||||
|
||||
// These tests need no database and no server: the catalogue is static and the
|
||||
// ranking is a pure function of (page, query, role). That is the property worth
|
||||
// protecting — an endpoint the panel calls on every keystroke should be
|
||||
// testable at the speed of a string comparison.
|
||||
|
||||
// intents is the ids Suggest returned, in order.
|
||||
func intents(got []Suggestion) []string {
|
||||
out := make([]string, len(got))
|
||||
for i, s := range got {
|
||||
out[i] = s.Intent
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ask is Suggest for an admin, the role the Owliver panel is placed for.
|
||||
func ask(page, query string) []Suggestion {
|
||||
return Suggest(page, query, domain.RoleAdmin)
|
||||
}
|
||||
|
||||
/* ── Control Center ─────────────────────────────────────────────────────── */
|
||||
|
||||
func TestControlCenterSuggestions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
query string
|
||||
want []string
|
||||
}{
|
||||
{"attention", "attention", []string{"attention-required"}},
|
||||
{"pipeline", "pipeline", []string{"pipeline-health"}},
|
||||
{"health", "health", []string{"platform-health"}},
|
||||
{"recommendation", "what should i do", []string{"recommendations"}},
|
||||
|
||||
// A question about a subject this page does not hold. The Control
|
||||
// Center reads the platform, not the training library.
|
||||
{"irrelevant", "forklift certification renewal", nil},
|
||||
{"empty", "", nil},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := intents(ask("control-center", c.query))
|
||||
if len(c.want) == 0 {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("query %q: want no suggestions, got %v", c.query, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("query %q: got %v, want %v", c.query, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Positions ──────────────────────────────────────────────────────────── */
|
||||
|
||||
func TestPositionsSuggestions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
query string
|
||||
want []string
|
||||
}{
|
||||
// The page's own noun offers the page's readings, in declaration order.
|
||||
{"position", "position", []string{"position-drafts", "position-strength", "positions-attention"}},
|
||||
|
||||
// Pipeline on Positions is a question about the ROLES: which one is
|
||||
// converting, and where it is stuck. Two answers, not three — the third
|
||||
// slot is left empty rather than filled with the page's list of people
|
||||
// waiting, which is a different question wearing a nearby word.
|
||||
{"pipeline", "pipeline", []string{"position-strength", "pipeline-health"}},
|
||||
|
||||
{"attention", "attention", []string{"positions-attention"}},
|
||||
{"risk", "risk", []string{"positions-attention"}},
|
||||
{"drafts", "draft", []string{"position-drafts"}},
|
||||
{"fill first", "what should i fill first", []string{"hiring-priority"}},
|
||||
|
||||
// Attendance is a workforce reading and belongs to another surface. It
|
||||
// must not fall through to this page's default report.
|
||||
{"irrelevant", "attendance last week", nil},
|
||||
{"empty", "", nil},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := intents(ask("positions", c.query))
|
||||
if len(c.want) == 0 {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("query %q: want no suggestions, got %v", c.query, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("query %q: got %v, want %v", c.query, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Candidates ─────────────────────────────────────────────────────────── */
|
||||
|
||||
func TestCandidatesSuggestions(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
query string
|
||||
want []string
|
||||
}{
|
||||
{"candidate", "candidate", []string{"candidates-attention", "top-candidates", "interview-ready"}},
|
||||
{"score", "score", []string{"top-candidates", "screening-gaps"}},
|
||||
{"interview", "interview", []string{"interview-ready"}},
|
||||
{"decision", "decision", []string{"candidates-attention", "candidate-risk"}},
|
||||
{"pipeline", "pipeline", []string{"pipeline-summary"}},
|
||||
{"risk", "risk", []string{"candidate-risk"}},
|
||||
|
||||
{"irrelevant", "payroll export", nil},
|
||||
{"empty", "", nil},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := intents(ask("candidates", c.query))
|
||||
if len(c.want) == 0 {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("query %q: want no suggestions, got %v", c.query, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("query %q: got %v, want %v", c.query, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Page context is the first filter ───────────────────────────────────── */
|
||||
|
||||
// One keyword, every page: the answers must differ, and none may name a
|
||||
// reading belonging to another surface.
|
||||
func TestSameKeywordDiffersByPage(t *testing.T) {
|
||||
const query = "pipeline"
|
||||
|
||||
seen := map[string][]string{}
|
||||
for _, page := range Pages() {
|
||||
got := intents(ask(page, query))
|
||||
if len(got) == 0 {
|
||||
continue
|
||||
}
|
||||
seen[page] = got
|
||||
|
||||
for _, id := range got {
|
||||
if !declaredOn(page, id) {
|
||||
t.Fatalf("page %q returned %q, which it does not declare", page, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(seen) < 2 {
|
||||
t.Fatalf("%q matched on %d pages; the comparison needs at least two", query, len(seen))
|
||||
}
|
||||
if reflect.DeepEqual(seen["positions"], seen["candidates"]) {
|
||||
t.Fatalf("positions and candidates both answered %q with %v", query, seen["positions"])
|
||||
}
|
||||
// The pipeline reading on Candidates is the candidates' own.
|
||||
if !reflect.DeepEqual(seen["candidates"], []string{"pipeline-summary"}) {
|
||||
t.Fatalf("candidates answered %q with %v", query, seen["candidates"])
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown page is not this package's error to raise — the service refuses it
|
||||
// during parsing. Reached directly it answers empty rather than borrowing
|
||||
// another page's readings.
|
||||
func TestUnknownPageIsEmpty(t *testing.T) {
|
||||
for _, page := range []string{"", "nowhere", "POSITIONS", "settings"} {
|
||||
if got := ask(page, "pipeline"); len(got) != 0 {
|
||||
t.Fatalf("page %q: got %v, want none", page, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func declaredOn(page, id string) bool {
|
||||
for _, i := range catalogue[page] {
|
||||
if i.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/* ── Query handling ─────────────────────────────────────────────────────── */
|
||||
|
||||
func TestQueryNormalization(t *testing.T) {
|
||||
want := intents(ask("positions", "pipeline"))
|
||||
if len(want) == 0 {
|
||||
t.Fatal("the baseline query matched nothing")
|
||||
}
|
||||
|
||||
// Every one of these is the same question typed differently: case,
|
||||
// surrounding whitespace, punctuation and control characters carry no
|
||||
// meaning, so all of them must rank identically.
|
||||
for _, query := range []string{
|
||||
" pipeline ", "PIPELINE", "PiPeLiNe", "\tpipeline\n",
|
||||
"pipeline?", "\"pipeline\"", "pipeline!!!", "…pipeline…",
|
||||
"(pipeline)", "**pipeline**", "pipeline\x00\x01", "\u200bpipeline",
|
||||
} {
|
||||
if got := intents(ask("positions", query)); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("query %q: got %v, want %v", query, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hostile input is data like any other. There is no query to inject into — the
|
||||
// normalized text is compared against a fixed table of literals and never
|
||||
// reaches SQL, a template or a shell — so the property under test is that such
|
||||
// a query is ranked rather than refused, and that it can only ever produce
|
||||
// entries this page declares.
|
||||
func TestHostileInputIsJustText(t *testing.T) {
|
||||
for _, query := range []string{
|
||||
"pipeline'; DROP TABLE job_postings; --",
|
||||
"pipeline\" OR 1=1 --",
|
||||
"<script>alert('pipeline')</script>",
|
||||
"{{7*7}} pipeline ${jndi:ldap://x/y}",
|
||||
"../../etc/passwd pipeline",
|
||||
"pipeline%00%0d%0aSet-Cookie:+x=1",
|
||||
strings.Repeat("' OR ''='", 40),
|
||||
} {
|
||||
for _, s := range ask("positions", query) {
|
||||
if !declaredOn("positions", s.Intent) {
|
||||
t.Errorf("query %q produced %q, which positions does not declare", query, s.Intent)
|
||||
}
|
||||
if !declaredText("positions", s) {
|
||||
t.Errorf("query %q produced unrecognised text %q", query, s.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// declaredText reports whether a suggestion's wording came from the catalogue —
|
||||
// either an intent's own Text, or its subject phrased in a shape it declares.
|
||||
// Nothing the caller typed may appear in a response.
|
||||
func declaredText(page string, got Suggestion) bool {
|
||||
for _, i := range catalogue[page] {
|
||||
if i.ID != got.Intent {
|
||||
continue
|
||||
}
|
||||
if got.Capability == "" {
|
||||
return got.Text == i.Text
|
||||
}
|
||||
for _, s := range shapes {
|
||||
if s.id == got.Capability {
|
||||
return got.Text == i.shaped(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Fewer than two meaningful characters is not a question yet. Punctuation and
|
||||
// whitespace are not meaningful.
|
||||
func TestShortQueriesAreEmpty(t *testing.T) {
|
||||
for _, query := range []string{"", " ", "\n\t ", "p", " p ", "?", "!!!", "-", "€", " , "} {
|
||||
if got := ask("positions", query); len(got) != 0 {
|
||||
t.Errorf("query %q: got %v, want none", query, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The panel calls this on every keystroke, so a half-typed word has to match.
|
||||
func TestPrefixMatchingWhileTyping(t *testing.T) {
|
||||
full := intents(ask("positions", "pipeline"))
|
||||
for _, query := range []string{"pip", "pipe", "pipel", "pipelin", "pipeline", "pipelines"} {
|
||||
got := intents(ask("positions", query))
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("query %q matched nothing; the panel would blank mid-word", query)
|
||||
}
|
||||
if query != "pipelines" && !reflect.DeepEqual(got, full) {
|
||||
t.Errorf("query %q: got %v, want %v", query, got, full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A long paste ranks on its opening words rather than being refused.
|
||||
func TestOverlongQueryIsTruncatedNotRejected(t *testing.T) {
|
||||
query := "pipeline " + strings.Repeat("x", 5000)
|
||||
got := intents(ask("positions", query))
|
||||
if len(got) == 0 {
|
||||
t.Fatal("an overlong query was refused instead of truncated")
|
||||
}
|
||||
if !reflect.DeepEqual(got, intents(ask("positions", "pipeline"))) {
|
||||
t.Fatalf("an overlong query ranked differently: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Shapes ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
// Asking for a section type names it in the answer and phrases the suggestion
|
||||
// in those terms — the brief's "Show hiring activity as a flow".
|
||||
func TestShapedSuggestions(t *testing.T) {
|
||||
got := ask("positions", "show hiring activity as a flow")
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d suggestions, want 1: %+v", len(got), got)
|
||||
}
|
||||
want := Suggestion{
|
||||
Text: "Show hiring activity as a flow",
|
||||
Intent: "hiring-operations",
|
||||
Capability: "flow",
|
||||
}
|
||||
if got[0] != want {
|
||||
t.Fatalf("got %+v, want %+v", got[0], want)
|
||||
}
|
||||
|
||||
summarized := ask("positions", "summarize hiring activity")
|
||||
if len(summarized) != 1 || summarized[0].Capability != "summary" ||
|
||||
summarized[0].Text != "Summarize hiring activity" {
|
||||
t.Fatalf("got %+v", summarized)
|
||||
}
|
||||
}
|
||||
|
||||
// A shape alone is a question about the page. A shape after a subject is a
|
||||
// question about that subject, and the other readings that merely support the
|
||||
// shape are padding — which this endpoint does not do.
|
||||
func TestShapeAloneAnswersThePageButNeverPads(t *testing.T) {
|
||||
alone := ask("control-center", "summarize")
|
||||
if len(alone) != MaxSuggestions {
|
||||
t.Fatalf("a bare shape returned %d suggestions, want %d: %+v",
|
||||
len(alone), MaxSuggestions, alone)
|
||||
}
|
||||
for _, s := range alone {
|
||||
if s.Capability != "summary" {
|
||||
t.Fatalf("got capability %q, want summary: %+v", s.Capability, s)
|
||||
}
|
||||
}
|
||||
|
||||
// "attention" names one reading; nothing else may ride along on the shape.
|
||||
withSubject := ask("control-center", "summarize what needs attention")
|
||||
if len(withSubject) != 1 || withSubject[0].Intent != "attention-required" {
|
||||
t.Fatalf("got %+v, want only attention-required", withSubject)
|
||||
}
|
||||
}
|
||||
|
||||
// A shape an intent cannot be drawn as leaves its wording alone.
|
||||
func TestUnsupportedShapeIsNotClaimed(t *testing.T) {
|
||||
for _, s := range ask("create-position", "adjust the weights") {
|
||||
if s.Capability == "weights" {
|
||||
t.Fatalf("offered a weights rendering nothing declares: %+v", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Response limits ────────────────────────────────────────────────────── */
|
||||
|
||||
func TestNeverMoreThanThreeAndNeverDuplicated(t *testing.T) {
|
||||
// A query broad enough to match everything the page has.
|
||||
queries := []string{
|
||||
"position pipeline attention risk draft hiring activity waiting candidates",
|
||||
"candidate score interview decision risk pipeline screening",
|
||||
"summarize", "attention risk", "who what how many",
|
||||
}
|
||||
|
||||
for _, page := range Pages() {
|
||||
for _, query := range queries {
|
||||
got := Suggest(page, query, domain.RoleAdmin)
|
||||
if len(got) > MaxSuggestions {
|
||||
t.Fatalf("page %q query %q: %d suggestions, cap is %d",
|
||||
page, query, len(got), MaxSuggestions)
|
||||
}
|
||||
|
||||
seenIntent, seenText := map[string]bool{}, map[string]bool{}
|
||||
for _, s := range got {
|
||||
if s.Text == "" || s.Intent == "" {
|
||||
t.Fatalf("page %q query %q: incomplete suggestion %+v", page, query, s)
|
||||
}
|
||||
if seenIntent[s.Intent] {
|
||||
t.Fatalf("page %q query %q: duplicate intent %q", page, query, s.Intent)
|
||||
}
|
||||
if seenText[strings.ToLower(s.Text)] {
|
||||
t.Fatalf("page %q query %q: duplicate text %q", page, query, s.Text)
|
||||
}
|
||||
seenIntent[s.Intent], seenText[strings.ToLower(s.Text)] = true, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The same request must answer the same way every time — the panel re-issues it
|
||||
// on every keystroke, and a list that reshuffles under the cursor is unusable.
|
||||
func TestSuggestIsDeterministic(t *testing.T) {
|
||||
for _, page := range Pages() {
|
||||
first := Suggest(page, "attention risk pipeline summary", domain.RoleAdmin)
|
||||
for i := 0; i < 20; i++ {
|
||||
again := Suggest(page, "attention risk pipeline summary", domain.RoleAdmin)
|
||||
if !reflect.DeepEqual(first, again) {
|
||||
t.Fatalf("page %q: run %d differed\n first: %+v\n again: %+v",
|
||||
page, i, first, again)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty, never nil: `{"suggestions": []}` and not `{"suggestions": null}`.
|
||||
func TestNoMatchIsAnEmptySliceNotNil(t *testing.T) {
|
||||
for _, c := range []struct{ page, query string }{
|
||||
{"positions", "sourdough"}, {"positions", ""}, {"nowhere", "pipeline"},
|
||||
} {
|
||||
got := ask(c.page, c.query)
|
||||
if got == nil {
|
||||
t.Fatalf("page %q query %q: got nil, want an empty slice", c.page, c.query)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("page %q query %q: got %v", c.page, c.query, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Authorization ──────────────────────────────────────────────────────── */
|
||||
|
||||
// Talent may list job applications, but only their own — so a reading across
|
||||
// the organization's pipeline is not theirs to be offered, even though the
|
||||
// operation itself is permitted. The same holds for postings, profiles, staff,
|
||||
// evidence and the audit log.
|
||||
//
|
||||
// The exception is stated rather than hidden: `courses` is the one resource in
|
||||
// the policy table that talent lists unscoped, because the training library is
|
||||
// shared platform-wide and everybody learns from it. So the two Forge readings
|
||||
// that ask only what the library holds survive, and every other Forge reading —
|
||||
// evaluation, workforce usage, gaps, all of which read evidence or profiles —
|
||||
// does not. If that ever widens, this test says exactly what widened.
|
||||
func TestTalentIsOfferedOnlyUnscopedReadings(t *testing.T) {
|
||||
pages := []string{
|
||||
"control-center", "positions", "candidates", "candidates-analysis",
|
||||
"analytics", "activity", "talent-pool", "hired-history", "krow-forge",
|
||||
"create-position",
|
||||
}
|
||||
queries := []string{
|
||||
"pipeline", "attention", "candidate", "position", "risk", "summarize",
|
||||
"hiring", "score", "activity", "who", "how many", "library", "skill",
|
||||
"published", "gaps", "evaluation", "weights", "credential",
|
||||
}
|
||||
|
||||
allowed := map[string]bool{"krow-forge/forge-library": true, "krow-forge/forge-published": true}
|
||||
|
||||
for _, page := range pages {
|
||||
for _, query := range queries {
|
||||
for _, s := range Suggest(page, query, domain.RoleTalent) {
|
||||
if !allowed[page+"/"+s.Intent] {
|
||||
t.Errorf("talent was offered %q on %q for %q", s.Intent, page, query)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And the operator's own Forge readings stay the operator's.
|
||||
for _, id := range []string{"forge-evaluation", "forge-workforce", "forge-gaps"} {
|
||||
for _, s := range Suggest("krow-forge", "evaluation workforce gaps", domain.RoleTalent) {
|
||||
if s.Intent == id {
|
||||
t.Errorf("talent was offered the operator reading %q", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(Suggest("krow-forge", "evaluation workforce gaps", domain.RoleAdmin)) == 0 {
|
||||
t.Error("admin was offered none of them either; the query no longer matches")
|
||||
}
|
||||
}
|
||||
|
||||
// The filter is not a blanket refusal: what a talent caller may genuinely ask —
|
||||
// about their own account — is still offered. Otherwise the test above would
|
||||
// pass with the role check stubbed out to "deny".
|
||||
func TestTalentIsStillOfferedTheirOwnReadings(t *testing.T) {
|
||||
for _, query := range []string{"permission", "password", "my recent activity"} {
|
||||
if got := Suggest("profile", query, domain.RoleTalent); len(got) == 0 {
|
||||
t.Fatalf("talent was offered nothing on profile for %q", query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A role the API does not recognise authorizes nothing, matching policy.go.
|
||||
func TestUnknownRoleIsOfferedNothing(t *testing.T) {
|
||||
for _, role := range []domain.Role{"", "root", "superuser", "Admin"} {
|
||||
for _, page := range Pages() {
|
||||
if got := Suggest(page, "attention pipeline permission", role); len(got) != 0 {
|
||||
t.Fatalf("role %q was offered %v on %q", role, intents(got), page)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every permission decision must come from the policy table, not from a list
|
||||
// kept here. This asserts the mechanism rather than a particular outcome: an
|
||||
// intent is offered exactly when policy.go allows every reading it declares.
|
||||
func TestPermissionsComeFromThePolicyTable(t *testing.T) {
|
||||
for _, role := range []domain.Role{domain.RoleAdmin, domain.RoleEmployer, domain.RoleTalent} {
|
||||
for page, list := range catalogue {
|
||||
for _, intent := range list {
|
||||
want := true
|
||||
for _, need := range intent.Reads {
|
||||
res, ok := domain.ResourceByPath[need.Resource]
|
||||
if !ok || !res.Supports(need.Op) || !res.Policy.Allows(need.Op, role) {
|
||||
want = false
|
||||
break
|
||||
}
|
||||
if need.OrgWide && res.Policy.ScopeFor(role).Kind != domain.ScopeNone {
|
||||
want = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if got := intent.permitted(role); got != want {
|
||||
t.Errorf("%s/%s for %s: permitted=%v, policy says %v",
|
||||
page, intent.ID, role, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── The catalogue itself ───────────────────────────────────────────────── */
|
||||
|
||||
func TestCatalogueIsWellFormed(t *testing.T) {
|
||||
for page, list := range catalogue {
|
||||
if definition.CanonicalPage(page) != page {
|
||||
t.Errorf("page key %q is not a canonical surface", page)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
t.Errorf("page %q has no intents; omit the key instead", page)
|
||||
}
|
||||
|
||||
ids, texts := map[string]bool{}, map[string]bool{}
|
||||
for _, intent := range list {
|
||||
where := fmt.Sprintf("%s/%s", page, intent.ID)
|
||||
|
||||
if intent.ID == "" || intent.Text == "" {
|
||||
t.Errorf("%s: an intent needs both an id and a text", where)
|
||||
}
|
||||
if ids[intent.ID] {
|
||||
t.Errorf("%s: duplicate intent id on this page", where)
|
||||
}
|
||||
if texts[strings.ToLower(intent.Text)] {
|
||||
t.Errorf("%s: duplicate suggestion text on this page", where)
|
||||
}
|
||||
ids[intent.ID], texts[strings.ToLower(intent.Text)] = true, true
|
||||
|
||||
if len(intent.Terms) == 0 {
|
||||
t.Errorf("%s: no terms, so it can never be suggested", where)
|
||||
}
|
||||
for _, term := range intent.Terms {
|
||||
if term != strings.ToLower(strings.TrimSpace(term)) || term == "" {
|
||||
t.Errorf("%s: term %q must be lower case and trimmed", where, term)
|
||||
}
|
||||
if _, _, meaningful := normalize(term); meaningful < MinQueryChars {
|
||||
t.Errorf("%s: term %q is shorter than the shortest query", where, term)
|
||||
}
|
||||
}
|
||||
|
||||
if len(intent.Shapes) > 0 && intent.Subject == "" {
|
||||
t.Errorf("%s: declares shapes but no subject to phrase them with", where)
|
||||
}
|
||||
for _, id := range intent.Shapes {
|
||||
if id == "summary" {
|
||||
t.Errorf("%s: `summary` applies to every subject and is never declared", where)
|
||||
}
|
||||
if !knownShape(id) {
|
||||
t.Errorf("%s: shape %q is not in the Owliver vocabulary", where, id)
|
||||
}
|
||||
}
|
||||
|
||||
for _, need := range intent.Reads {
|
||||
res, ok := domain.ResourceByPath[need.Resource]
|
||||
if !ok {
|
||||
t.Errorf("%s: reads %q, which is not a resource", where, need.Resource)
|
||||
continue
|
||||
}
|
||||
if !res.Supports(need.Op) {
|
||||
t.Errorf("%s: reads %q with an operation it does not serve", where, need.Resource)
|
||||
}
|
||||
// An intent nobody can be offered is dead weight, and usually a
|
||||
// typo in the resource path rather than a deliberate lockout.
|
||||
if !res.Policy.Allows(need.Op, domain.RoleAdmin) {
|
||||
t.Errorf("%s: reads %q, which not even admin may list", where, need.Resource)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every id in the catalogue must be a capability the frontend actually
|
||||
// declares, because the id in a response is what the panel dispatches on. The
|
||||
// list is the union of the manifests in
|
||||
// src/components/ai-assistant/capabilities/, transcribed alongside the
|
||||
// catalogue; an id here that is absent there would be a suggestion the panel
|
||||
// cannot run.
|
||||
func TestIntentIDsAreFrontendCapabilities(t *testing.T) {
|
||||
frontend := map[string]bool{}
|
||||
for _, id := range []string{
|
||||
// CONTROL_CENTER_CAPABILITIES
|
||||
"platform-health", "workforce-summary", "hiring-operations", "pipeline-health",
|
||||
"attention-required", "recommendations",
|
||||
// POSITIONS_CAPABILITIES
|
||||
"position-drafts", "position-strength", "positions-attention", "hiring-priority",
|
||||
"candidates-waiting",
|
||||
// CANDIDATE_LIST_CAPABILITIES
|
||||
"candidates-attention", "top-candidates", "interview-ready", "screening-gaps",
|
||||
"pipeline-summary", "candidate-risk",
|
||||
// ADMIN_CANDIDATE_CAPABILITIES
|
||||
"recruitment-insights", "hiring-recommendations",
|
||||
// ADMIN_ANALYTICS_CAPABILITIES
|
||||
"hiring-trend", "department-performance", "position-conversion",
|
||||
// ACTIVITY_CAPABILITIES
|
||||
"audit-summary", "user-activity", "unusual-activity", "security-insights",
|
||||
// TALENT_POOL_CAPABILITIES
|
||||
"talent-priorities", "talent-summary", "talent-verification", "talent-availability",
|
||||
// HIRED_HISTORY_CAPABILITIES
|
||||
"hiring-outcomes", "hiring-strongest", "hiring-patterns", "hiring-recent",
|
||||
// FORGE_CAPABILITIES
|
||||
"forge-library", "forge-published", "forge-evaluation", "forge-workforce", "forge-gaps",
|
||||
// CREATE_POSITION_CAPABILITIES
|
||||
"vetting-weights", "position-benchmarks", "position-requirements", "position-spec-steps",
|
||||
// PROFILE_CAPABILITIES
|
||||
"profile-permissions", "profile-identity", "profile-security", "profile-preferences",
|
||||
"profile-activity", "profile-actions",
|
||||
} {
|
||||
frontend[id] = true
|
||||
}
|
||||
|
||||
used := map[string]bool{}
|
||||
for page, list := range catalogue {
|
||||
for _, intent := range list {
|
||||
used[intent.ID] = true
|
||||
if !frontend[intent.ID] {
|
||||
t.Errorf("%s/%s names no frontend capability", page, intent.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
for id := range frontend {
|
||||
if !used[id] {
|
||||
t.Errorf("capability %q is declared here but suggested on no page", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func knownShape(id string) bool {
|
||||
for _, s := range shapes {
|
||||
if s.id == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The shape vocabulary is closed and mirrors OWLIVER_CAPABILITIES.
|
||||
func TestShapeVocabularyMatchesTheFrontend(t *testing.T) {
|
||||
want := []string{"summary", "flow", "stats", "list", "table", "timeline",
|
||||
"progress", "weights", "insight", "card"}
|
||||
|
||||
got := make([]string, len(shapes))
|
||||
for i, s := range shapes {
|
||||
got[i] = s.id
|
||||
if len(s.terms) == 0 {
|
||||
t.Errorf("shape %q has no terms", s.id)
|
||||
}
|
||||
if s.id != "summary" && s.phrase == "" {
|
||||
t.Errorf("shape %q has no phrase to read inside a sentence", s.id)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("shapes are %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing internal may reach a response: no terms, no resource names, no
|
||||
// scores. The struct is the whole contract, so this asserts its shape.
|
||||
func TestSuggestionExposesNothingInternal(t *testing.T) {
|
||||
fields := reflect.VisibleFields(reflect.TypeOf(Suggestion{}))
|
||||
if len(fields) != 3 {
|
||||
t.Fatalf("Suggestion has %d fields; the response contract is text, intent, capability", len(fields))
|
||||
}
|
||||
want := map[string]string{
|
||||
"Text": `json:"text"`,
|
||||
"Intent": `json:"intent"`,
|
||||
"Capability": `json:"capability,omitempty"`,
|
||||
}
|
||||
for _, f := range fields {
|
||||
if string(f.Tag) != want[f.Name] {
|
||||
t.Errorf("field %s has tag %q, want %q", f.Name, f.Tag, want[f.Name])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user