aravind changes
This commit is contained in:
94
README.md
94
README.md
@@ -4,13 +4,22 @@ The backend for Krow — a Go API over PostgreSQL, with a Python Owliver service
|
|||||||
to follow. The Krow frontend lives in a **separate repository** (`krow-demo`)
|
to follow. The Krow frontend lives in a **separate repository** (`krow-demo`)
|
||||||
and is not vendored, copied or modified here.
|
and is not vendored, copied or modified here.
|
||||||
|
|
||||||
**Status: Phase 3C — session authentication.** The API serves the endpoints in
|
**Status: authenticated, authorized, multi-tenant.** The API serves the
|
||||||
`docs/api-contract.md` against PostgreSQL, loaded with the frontend's own demo
|
endpoints in `docs/api-contract.md` against PostgreSQL, loaded with the
|
||||||
dataset. Every endpoint except `GET /health` and the two `/api/v1/auth/*` routes
|
frontend's own demo dataset. Every endpoint except `GET /health` and the two
|
||||||
requires a session: sign in with a password, hold an HttpOnly cookie, and the
|
`/api/v1/auth/*` routes requires a session: sign in with a password, hold an
|
||||||
server resolves it to a real user on every request. Authorization — which roles
|
HttpOnly cookie, and the server resolves it to a real user on every request.
|
||||||
may do what — is Phase 3D and is not implemented. No Owliver, no RAG, no Redis,
|
Roles are enforced — a deny-by-default policy table answers 403 before any query
|
||||||
NATS or S3.
|
runs, and organization scope and talent ownership are SQL predicates, so a row
|
||||||
|
you may not see answers 404. On top of that sits the agent and skill definition
|
||||||
|
surface: Markdown with YAML frontmatter, parsed and stored, with full CRUD.
|
||||||
|
|
||||||
|
Not built: no Owliver, no RAG, no Redis, no object storage. The agent/skill
|
||||||
|
runtime is an internal boundary with no executor behind it and no HTTP route
|
||||||
|
into it.
|
||||||
|
|
||||||
|
`docs/KROW_BACKEND_COMPLETE_SUMMARY.md` is the long-form technical account —
|
||||||
|
architecture, history, and a frank list of the current limitations.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -19,27 +28,39 @@ krow-backend/
|
|||||||
├── go-api/
|
├── go-api/
|
||||||
│ ├── cmd/
|
│ ├── cmd/
|
||||||
│ │ ├── api/ the HTTP service entrypoint
|
│ │ ├── api/ the HTTP service entrypoint
|
||||||
│ │ └── seed/ loads the demo dataset
|
│ │ ├── seed/ loads the demo dataset
|
||||||
|
│ │ └── setpassword/ the only way a password enters the database
|
||||||
│ ├── internal/
|
│ ├── internal/
|
||||||
│ │ ├── config/ environment loading + validation
|
│ │ ├── config/ environment loading + validation
|
||||||
│ │ ├── db/ pgx pool and the database health check
|
│ │ ├── db/ pgx pool and the database health check
|
||||||
│ │ ├── domain/ resource descriptors (generated from the schema)
|
│ │ ├── domain/ resource descriptors (generated) + the policy table
|
||||||
│ │ ├── repo/ pgx access layer — every statement built here
|
│ │ ├── repo/ pgx access layer — every statement built here
|
||||||
│ │ ├── service/ validation, org scoping, contract semantics
|
│ │ ├── service/ validation, org scoping, contract semantics
|
||||||
|
│ │ ├── auth/ argon2id passwords, sessions, the user store
|
||||||
|
│ │ ├── authctx/ the authenticated identity a request carries
|
||||||
│ │ ├── orgctx/ the organization a request runs as
|
│ │ ├── orgctx/ the organization a request runs as
|
||||||
|
│ │ ├── definition/ the agent/skill Markdown + YAML frontmatter parser
|
||||||
|
│ │ ├── runtime/ agent/skill load, validate, resolve, dispatch
|
||||||
│ │ ├── httpserver/ router, handlers, /health, graceful shutdown
|
│ │ ├── httpserver/ router, handlers, /health, graceful shutdown
|
||||||
│ │ ├── seeder/ fixture loading + the attendanceSeed.js port
|
│ │ ├── seeder/ fixture loading + the attendanceSeed.js port
|
||||||
│ │ └── testutil/ disposable migrated + seeded test database
|
│ │ └── testutil/ disposable migrated + seeded test database
|
||||||
│ └── go.mod
|
│ └── go.mod
|
||||||
├── migrations/ SQL migrations — the source of truth for the schema
|
├── migrations/ SQL migrations — the source of truth for the schema
|
||||||
├── seed/fixtures/ seed.json, generated from the frontend repository
|
├── seed/fixtures/ seed.json, generated from the frontend repository
|
||||||
├── docs/api-contract.md the frozen client contract
|
├── docs/
|
||||||
|
│ ├── api-contract.md the frozen client contract
|
||||||
|
│ └── KROW_BACKEND_COMPLETE_SUMMARY.md the long-form technical account
|
||||||
├── infrastructure/ deployment definitions (empty until later)
|
├── infrastructure/ deployment definitions (empty until later)
|
||||||
├── scripts/ verify_schema.sql, gen_resources.py
|
├── scripts/ verify_schema.sql, gen_resources.py, oracle.mjs
|
||||||
├── Makefile developer, migration and seed tasks
|
├── Makefile developer, migration and seed tasks
|
||||||
└── .env.example
|
└── .env.example
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`migrations/`, `seed/`, `scripts/` and `infrastructure/` sit beside `go-api/`
|
||||||
|
rather than inside it because none of them is Go: the migrations are run by the
|
||||||
|
`golang-migrate` CLI, the generator is Python, the parser oracle is Node, and a
|
||||||
|
second service (Owliver, Python) is expected to consume the same schema.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -82,10 +103,16 @@ make run # http://127.0.0.1:8080/health
|
|||||||
|
|
||||||
## The API
|
## The API
|
||||||
|
|
||||||
38 endpoints, specified in `docs/api-contract.md`. Every one exists because a
|
54 registered routes: 34 entity routes across 14 resources, 10 agent and skill
|
||||||
frontend call site exists — a table in the database is never a reason for an
|
definition routes, 4 `/me` routes, 2 `/auth` routes, 2 workflow routes, the
|
||||||
endpoint. `DELETE /job-postings/{id}` and `POST /shift-records` return 405
|
Owliver suggestion route and `GET /health`. The entity routes and the suggestion
|
||||||
because nothing in the frontend deletes a posting or creates a shift record.
|
route are specified in `docs/api-contract.md` (§2 and §2A); the definition and
|
||||||
|
workflow routes are not yet in the contract and are documented in the source and
|
||||||
|
its tests. Every
|
||||||
|
entity route exists because a frontend call site exists — a table in the
|
||||||
|
database is never a reason for an endpoint. `DELETE /job-postings/{id}` and
|
||||||
|
`POST /shift-records` return 405 because nothing in the frontend deletes a
|
||||||
|
posting or creates a shift record.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl localhost:8080/api/v1/job-postings
|
curl localhost:8080/api/v1/job-postings
|
||||||
@@ -110,8 +137,19 @@ Set a password before signing in: the seeded user's `password_hash` is NULL unti
|
|||||||
curl -b jar localhost:8080/api/v1/me
|
curl -b jar localhost:8080/api/v1/me
|
||||||
curl -b jar -X POST localhost:8080/api/v1/auth/logout
|
curl -b jar -X POST localhost:8080/api/v1/auth/logout
|
||||||
|
|
||||||
Authorization is **not** implemented. `users.role` is carried on the identity and
|
**Authorization is enforced.** `users.role` — `admin`, `employer` or `talent`,
|
||||||
consulted nowhere: any signed-in user reaches every endpoint. That is Phase 3D.
|
never the self-editable `account_type` — is the only authority. Entity routes
|
||||||
|
are gated by the deny-by-default policy table in `internal/domain/policy.go`:
|
||||||
|
a resource with no policy permits nothing to anyone, and `Server.authorize`
|
||||||
|
answers 403 before any query runs. Row visibility is a SQL predicate rather than
|
||||||
|
a filter — the organization scope always, plus an ownership clause for talent
|
||||||
|
callers — so a row outside it is never fetched and answers 404, which does not
|
||||||
|
distinguish "exists but not yours" from "does not exist".
|
||||||
|
|
||||||
|
The ten definition routes are the exception: they are not `domain.Resource`
|
||||||
|
values, so the descriptor machinery does not reach them and their role checks
|
||||||
|
are written inline in `internal/service/definitions.go`. Tenancy and ownership
|
||||||
|
*are* enforced for them, in the repository predicates.
|
||||||
|
|
||||||
## Seeding
|
## Seeding
|
||||||
|
|
||||||
@@ -135,16 +173,30 @@ records created through the API survive a re-seed.
|
|||||||
make test # go test ./...
|
make test # go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
55 tests. The database-backed ones build a disposable database — dropped,
|
175 test functions. The database-backed ones build a disposable database —
|
||||||
recreated, migrated and seeded per run, named `krow_backend_autotest_<pid>` so
|
dropped, recreated, migrated and seeded per run, named
|
||||||
concurrent test packages cannot collide. They skip rather than fail when
|
`krow_backend_autotest_<pid>` so concurrent test packages cannot collide. They
|
||||||
PostgreSQL is unreachable.
|
skip rather than fail when PostgreSQL is unreachable.
|
||||||
|
|
||||||
Seed assertions compare the database against the fixture field-by-field rather
|
Seed assertions compare the database against the fixture field-by-field rather
|
||||||
than against numbers typed into a test. The two ordering guarantees
|
than against numbers typed into a test. The two ordering guarantees
|
||||||
(`NULLS LAST` in both directions, and the `, id` tiebreaker) are covered by tests
|
(`NULLS LAST` in both directions, and the `, id` tiebreaker) are covered by tests
|
||||||
verified to fail when the guarantee is removed.
|
verified to fail when the guarantee is removed.
|
||||||
|
|
||||||
|
**Parser conformance.** The Go definition parser must agree with the frontend's
|
||||||
|
JavaScript one. `internal/definition/conformance_test.go` asserts against
|
||||||
|
`internal/definition/testdata/oracle.json`, which is not written by hand: it is
|
||||||
|
captured by running the real frontend module graph through Vite, so the fixture
|
||||||
|
records what the JS parser actually does rather than what anyone believes it
|
||||||
|
does. Regenerating it needs a `krow-demo` checkout, and is not part of
|
||||||
|
`make test`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/oracle.mjs go-api/internal/definition/testdata/oracle.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`scripts/cases.mjs` holds the adversarial corpus that file is captured over.
|
||||||
|
|
||||||
`GET /health` is public and says only whether traffic should be sent here:
|
`GET /health` is public and says only whether traffic should be sent here:
|
||||||
`{"status": "ok"}` with `200`, `{"status": "degraded"}` with `200` when the
|
`{"status": "ok"}` with `200`, `{"status": "degraded"}` with `200` when the
|
||||||
database is up but unmigrated or left dirty, and `{"status": "unavailable"}`
|
database is up but unmigrated or left dirty, and `{"status": "unavailable"}`
|
||||||
|
|||||||
@@ -1840,7 +1840,7 @@ Recorded in `docs/api-contract.md` §12 and still accurate against the current c
|
|||||||
| **GCS-compatible object storage** | — | **Future direction.** No reference in the repository; `infrastructure/README.md` names MinIO as a "later" candidate | — |
|
| **GCS-compatible object storage** | — | **Future direction.** No reference in the repository; `infrastructure/README.md` names MinIO as a "later" candidate | — |
|
||||||
| **Docker deployment** | — | **Future direction.** `infrastructure/` contains only a README; `Dockerfile.api` and `Dockerfile.owliver` are listed there as later work | — |
|
| **Docker deployment** | — | **Future direction.** `infrastructure/` contains only a README; `Dockerfile.api` and `Dockerfile.owliver` are listed there as later work | — |
|
||||||
| **Redis** | Shared state for rate limiting across instances | **Future direction.** `ratelimit.go` names Redis or the database as the seam; nothing is wired | Needed before multi-instance deployment for the limiter to mean anything |
|
| **Redis** | Shared state for rate limiting across instances | **Future direction.** `ratelimit.go` names Redis or the database as the seam; nothing is wired | Needed before multi-instance deployment for the limiter to mean anything |
|
||||||
| **NATS is not part of the target architecture** | — | **Future direction / stated rule.** Note the discrepancy: `infrastructure/README.md` currently lists NATS as a later docker-compose candidate. See §17. | Remove NATS from that table if the rule stands |
|
| **NATS is not part of the target architecture** | — | **Future direction / stated rule.** `infrastructure/README.md` previously listed NATS as a later docker-compose candidate; that table has been corrected. See §17.17. | Keep it out |
|
||||||
| **RAG / pgvector** | — | **Future direction.** `pgvector` is not installed in the local database and is named only once, in `infrastructure/README.md` | Introduce when the architecture calls for it |
|
| **RAG / pgvector** | — | **Future direction.** `pgvector` is not installed in the local database and is named only once, in `infrastructure/README.md` | Introduce when the architecture calls for it |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1850,35 +1850,31 @@ Recorded in `docs/api-contract.md` §12 and still accurate against the current c
|
|||||||
Everything below was checked against the current repository. Items the request asked
|
Everything below was checked against the current repository. Items the request asked
|
||||||
about that could not be verified are marked as such rather than guessed at.
|
about that could not be verified are marked as such rather than guessed at.
|
||||||
|
|
||||||
### 17.1 Documentation is behind the code
|
### 17.1 Documentation is behind the code — resolved
|
||||||
|
|
||||||
**Issue.** `README.md` describes the repository as being at an earlier stage than the
|
**Issue as recorded.** `README.md` described the repository as being at an earlier
|
||||||
code is. It states "Authorization — which roles may do what — is Phase 3D and is not
|
stage than the code is. It stated "Authorization — which roles may do what — is Phase
|
||||||
implemented", "Authorization is **not** implemented. `users.role` is carried on the
|
3D and is not implemented", "Authorization is **not** implemented. `users.role` is
|
||||||
identity and consulted nowhere: any signed-in user reaches every endpoint", "38
|
carried on the identity and consulted nowhere: any signed-in user reaches every
|
||||||
endpoints" and "55 tests".
|
endpoint", "38 endpoints" and "55 tests". The same staleness appeared in two source
|
||||||
|
comments: the `internal/httpserver/server.go` package documentation ("Authorization is
|
||||||
|
NOT here"), and `internal/authctx/authctx.go` ("It is NOT consulted anywhere in Phase
|
||||||
|
3C: authentication only").
|
||||||
|
|
||||||
**Cause.** Authorization, the definition tables, the CRUD surface and the runtime all
|
**Cause.** Authorization, the definition tables, the CRUD surface and the runtime all
|
||||||
landed after the README was last revised.
|
landed after the README was last revised.
|
||||||
|
|
||||||
**Impact.** A reader trusting the README would conclude that any signed-in user
|
|
||||||
reaches every endpoint, which is not what the code does.
|
|
||||||
|
|
||||||
**Current behaviour.** `internal/domain/policy.go`, `Server.authorize`,
|
**Current behaviour.** `internal/domain/policy.go`, `Server.authorize`,
|
||||||
`builder.ownership` and `internal/httpserver/rbac_test.go` (730 lines) all exist and
|
`builder.ownership` and `internal/httpserver/rbac_test.go` (730 lines) all exist and
|
||||||
run. 51 routes are registered. 175 test functions run.
|
run. 51 routes are registered. 175 test functions run. `docs/api-contract.md` §9A
|
||||||
`docs/api-contract.md` §9A *is* current and documents the authorization contract
|
*is* current and documents the authorization contract accurately.
|
||||||
accurately.
|
|
||||||
|
|
||||||
**Possible future handling.** Revise `README.md` against the code.
|
**Resolution.** `README.md` was revised against the code: the status paragraph, the
|
||||||
|
layout tree (which was missing `cmd/setpassword`, `internal/auth`, `internal/authctx`,
|
||||||
The same staleness appears in two source comments:
|
`internal/definition` and `internal/runtime`), the route count, the test count and the
|
||||||
|
authorization section. The `server.go` and `authctx.go` package comments were
|
||||||
- `internal/httpserver/server.go` package documentation: "Authorization is NOT here.
|
corrected to describe the authorization that exists. `internal/orgctx/orgctx.go`,
|
||||||
A signed-in user reaches every endpoint they could reach before".
|
which still framed itself as pre-authentication, was corrected in the same pass.
|
||||||
- `internal/authctx/authctx.go`: "It is NOT consulted anywhere in Phase 3C:
|
|
||||||
authentication only" — `Identity.Role` is now consulted by `Server.authorize`,
|
|
||||||
`builder.ownership`, `guardInsert` and `service/definitions.go`.
|
|
||||||
|
|
||||||
### 17.2 The definitions endpoints are not in the API contract
|
### 17.2 The definitions endpoints are not in the API contract
|
||||||
|
|
||||||
@@ -2000,23 +1996,26 @@ the effective limit multiplies by instance count, resets on restart, and degrade
|
|||||||
a global budget behind a reverse proxy. **Possible future handling:** move the
|
a global budget behind a reverse proxy. **Possible future handling:** move the
|
||||||
limiter behind shared state before deploying more than one instance.
|
limiter behind shared state before deploying more than one instance.
|
||||||
|
|
||||||
### 17.10 The repository has no commits
|
### 17.10 The repository has almost no history
|
||||||
|
|
||||||
**Issue.** `git log` reports `fatal: your current branch 'main' does not have any
|
**Issue as recorded.** `git log` reported `fatal: your current branch 'main' does not
|
||||||
commits yet`. Every file is untracked.
|
have any commits yet`, and every file was untracked.
|
||||||
|
|
||||||
**Impact.** There is no history, no recovery point, no blame, and no record of when
|
**Current behaviour.** The tree is now committed: a single commit on `main`
|
||||||
any of the work described in §2 happened. The chronology in this document was
|
(`7d12ebe`, "first commit") holds the whole repository.
|
||||||
reconstructed from migration headers, package documentation and the contract, not
|
|
||||||
from version control.
|
|
||||||
|
|
||||||
**Note.** This document does not change that; no commit was made.
|
**Remaining impact.** One commit is not history. There is still no blame, no
|
||||||
|
incremental recovery point, and no record of when any of the work described in §2
|
||||||
|
happened. The chronology in this document was reconstructed from migration headers,
|
||||||
|
package documentation and the contract, not from version control, and that remains
|
||||||
|
the only source for it.
|
||||||
|
|
||||||
### 17.11 A minor documentation defect in the source
|
### 17.11 A minor documentation defect in the source — resolved
|
||||||
|
|
||||||
`internal/httpserver/api.go` — the doc comment describing `decodeBody` sits
|
`internal/httpserver/api.go` — the doc comment describing `decodeBody` sat
|
||||||
immediately above `decodeInto`, so `decodeInto` carries two doc comments and
|
immediately above `decodeInto`, so `decodeInto` carried two doc comments and
|
||||||
`decodeBody` carries none.
|
`decodeBody` carried none. The `decodeBody` comment has been moved to sit above
|
||||||
|
`decodeBody`.
|
||||||
|
|
||||||
### 17.12 Badge endpoint mismatch — verified
|
### 17.12 Badge endpoint mismatch — verified
|
||||||
|
|
||||||
@@ -2091,8 +2090,9 @@ among the things that do not exist yet.
|
|||||||
|
|
||||||
**Impact.** A reader of `infrastructure/README.md` would take NATS to be planned.
|
**Impact.** A reader of `infrastructure/README.md` would take NATS to be planned.
|
||||||
|
|
||||||
**Possible future handling.** Amend that table if the rule stands. Nothing was changed
|
**Resolution.** The rule stands, so the table was amended: `infrastructure/README.md`
|
||||||
here.
|
no longer lists NATS as a candidate and says explicitly that it is not part of the
|
||||||
|
target architecture. `README.md` no longer names it either.
|
||||||
|
|
||||||
### 17.18 Deliberate contract behaviours that read as defects
|
### 17.18 Deliberate contract behaviours that read as defects
|
||||||
|
|
||||||
@@ -2169,8 +2169,8 @@ deliberately and should be done knowingly.
|
|||||||
|
|
||||||
### NATS
|
### NATS
|
||||||
|
|
||||||
**NATS is not part of the target architecture.** Note that `infrastructure/README.md`
|
**NATS is not part of the target architecture.** `infrastructure/README.md` no longer
|
||||||
currently lists it as a later candidate; see §17.17.
|
lists it as a candidate; see §17.17.
|
||||||
|
|
||||||
### Nearest-term work implied by the code itself
|
### Nearest-term work implied by the code itself
|
||||||
|
|
||||||
@@ -2281,9 +2281,9 @@ disposable `krow_backend_autotest_<pid>` database per test process.
|
|||||||
**Current Known Limitations:** no AI execution and no public runtime endpoint; login
|
**Current Known Limitations:** no AI execution and no public runtime endpoint; login
|
||||||
rate limiting is per-process and in-memory; CORS does not permit credentials;
|
rate limiting is per-process and in-memory; CORS does not permit credentials;
|
||||||
definitions endpoints bypass the policy table; the runtime package is unreachable
|
definitions endpoints bypass the policy table; the runtime package is unreachable
|
||||||
from the running service; `README.md` and two source comments are behind the code;
|
from the running service; the definitions endpoints are absent from the API contract;
|
||||||
the definitions endpoints are absent from the API contract; attendance data is empty
|
attendance data is empty without a rostering source; the repository has a single
|
||||||
without a rostering source; the repository has no commits.
|
commit and so no usable history.
|
||||||
|
|
||||||
**Current Development Focus:** the most recent work in the tree is the definition
|
**Current Development Focus:** the most recent work in the tree is the definition
|
||||||
system — schema, parser conformance, CRUD — and the runtime boundary that sits on top
|
system — schema, parser conformance, CRUD — and the runtime boundary that sits on top
|
||||||
@@ -2392,13 +2392,12 @@ is how this codebase would stop being trustworthy.
|
|||||||
- **The main current limitations** are: no AI execution and no runtime endpoint;
|
- **The main current limitations** are: no AI execution and no runtime endpoint;
|
||||||
login rate limiting that does not survive scale-out; CORS that does not yet permit
|
login rate limiting that does not survive scale-out; CORS that does not yet permit
|
||||||
credentials; ten definition endpoints that sit outside the policy table and outside
|
credentials; ten definition endpoints that sit outside the policy table and outside
|
||||||
the written contract; documentation that is behind the code; and a repository with
|
the written contract; and a repository with a single commit and so no usable
|
||||||
no commits.
|
history.
|
||||||
- **The direction** is real execution behind the existing executor interfaces
|
- **The direction** is real execution behind the existing executor interfaces
|
||||||
(Owliver / an LLM provider abstraction), then retrieval, then tooling, then
|
(Owliver / an LLM provider abstraction), then retrieval, then tooling, then
|
||||||
deployment infrastructure — none of which exists here today. **NATS is not part of
|
deployment infrastructure — none of which exists here today. **NATS is not part of
|
||||||
the target architecture**, and the one place in the repository that still names it
|
the target architecture**, and the documentation no longer implies otherwise.
|
||||||
should be corrected.
|
|
||||||
- **The rules that must not be broken** are in §21. The two that matter most in daily
|
- **The rules that must not be broken** are in §21. The two that matter most in daily
|
||||||
work: never trust a client-supplied identity or tenant, and never rewrite stored
|
work: never trust a client-supplied identity or tenant, and never rewrite stored
|
||||||
Markdown.
|
Markdown.
|
||||||
@@ -2409,3 +2408,9 @@ is how this codebase would stop being trustworthy.
|
|||||||
behaviour above was read from the current checkout or from read-only queries against
|
behaviour above was read from the current checkout or from read-only queries against
|
||||||
the local development database. Nothing in the repository was modified to produce
|
the local development database. Nothing in the repository was modified to produce
|
||||||
this document.*
|
this document.*
|
||||||
|
|
||||||
|
*Revised on 2026-08-24 by a structure-and-dead-code cleanup pass, which changed no
|
||||||
|
schema, no migration, no database row and no runtime behaviour. What it did change is
|
||||||
|
recorded in §17.1, §17.10, §17.11 and §17.17: documentation and source comments that
|
||||||
|
had fallen behind the code were corrected. The counts above were re-verified against
|
||||||
|
the checkout and are unchanged.*
|
||||||
|
|||||||
@@ -129,6 +129,125 @@ these would leave the shim with methods that 404. See §11 (D6).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2A. Owliver suggestions
|
||||||
|
|
||||||
|
`GET /api/v1/owliver/suggestions?page={surface}&query={typed}`
|
||||||
|
|
||||||
|
The one endpoint here that serves no resource. It answers "what could I usefully
|
||||||
|
ask on this page?" for the Owliver panel, which calls it while the user types —
|
||||||
|
so it reads no table, opens no transaction, calls no model, and its whole answer
|
||||||
|
is computed from a static catalogue in `internal/owliver`.
|
||||||
|
|
||||||
|
It is not on the public allowlist. Which readings exist depends on the caller's
|
||||||
|
role, so there is no anonymous answer to give.
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
| Parameter | Required | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `page` | yes | A surface id from the closed page vocabulary — the same one `internal/definition` validates a definition's `pages:` against. Aliases (`hired`, `forge`, `new-position`) and loose spellings (`Talent Pool`) resolve to the canonical id. |
|
||||||
|
| `query` | no | What the user has typed so far. |
|
||||||
|
|
||||||
|
Any other parameter is `invalid_query`. **Nothing about the caller is accepted
|
||||||
|
here** — role, organization and user are read from the session, and a request
|
||||||
|
that names one is refused rather than ignored.
|
||||||
|
|
||||||
|
An unknown `page` is `invalid_query`, with the frontend's own wording:
|
||||||
|
`Unsupported page: {value}. Supported pages: {…}.` An absent, blank or
|
||||||
|
too-short `query` is **not** an error — there is simply nothing to rank yet.
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"suggestions": [
|
||||||
|
{ "text": "Which position has the strongest pipeline?", "intent": "position-strength" },
|
||||||
|
{ "text": "Show hiring activity as a flow", "intent": "hiring-operations", "capability": "flow" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`data.suggestions` is always an array — `[]` when nothing matches, never `null`
|
||||||
|
and never an error. There is no `meta`: the list is capped rather than paged.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| `text` | The question, as the user reads it. |
|
||||||
|
| `intent` | The **frontend capability id** the panel dispatches on, verbatim from the manifests in `src/components/ai-assistant/capabilities/`. Not a backend identifier, and never invented here. |
|
||||||
|
| `capability` | The section type to draw the answer as, from the closed `OWLIVER_CAPABILITIES` vocabulary. Present only when the query asked for one ("as a flow", "summarize"); absent otherwise. |
|
||||||
|
|
||||||
|
Nothing internal is exposed: no matching terms, no resource names, no scores, no
|
||||||
|
policy detail. `text` is always catalogue wording — no part of the query is
|
||||||
|
echoed back into a suggestion.
|
||||||
|
|
||||||
|
### Selection
|
||||||
|
|
||||||
|
Five stages, each of which only ever removes:
|
||||||
|
|
||||||
|
```
|
||||||
|
the page's catalogue → permission → relevance → deduplicate → top 3
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Page.** Intents are keyed by surface, so a suggestion from another page
|
||||||
|
cannot appear. The same word answers differently per page by construction:
|
||||||
|
`pipeline` on `positions` is about which role converts, on `candidates` it is
|
||||||
|
the funnel the applicants are in.
|
||||||
|
- **Permission.** Each intent declares the resources it reads, and those are
|
||||||
|
checked against the policy table in §9A — *before* ranking, so a refused
|
||||||
|
reading is never scored. Two gates, not one: the role must be allowed the
|
||||||
|
operation, and for an organization-wide reading the role's rows must not be
|
||||||
|
narrowed. Talent may list job applications; talent may not be offered "which
|
||||||
|
position has the strongest pipeline?", because their view of that resource is
|
||||||
|
their own rows. No role list is written down here — see §9A.1.
|
||||||
|
- **Relevance.** Deterministic keyword ranking over the intent's own terms.
|
||||||
|
Exact token, then multi-word phrase, then prefix (so a half-typed word still
|
||||||
|
matches), then extension. Ties break on catalogue order, so the same request
|
||||||
|
always answers identically. A query naming only a section type ranks the
|
||||||
|
page's readings; once it names a subject, readings that merely *support* that
|
||||||
|
shape are dropped rather than used as padding.
|
||||||
|
- **Deduplicate.** One suggestion per intent id, and no two with the same text.
|
||||||
|
- **Cap.** Three. Nothing is added to reach three.
|
||||||
|
|
||||||
|
A query with fewer than two letters or digits after normalization returns `[]`.
|
||||||
|
|
||||||
|
### Normalization
|
||||||
|
|
||||||
|
The query is truncated to 200 characters, lower-cased, and reduced to letters,
|
||||||
|
digits and single spaces — every other character becomes a space rather than
|
||||||
|
being stripped, so nothing can be glued into a token that was not typed as one.
|
||||||
|
|
||||||
|
There is no injection surface to defend: the normalized text is compared against
|
||||||
|
a fixed table of literals and never reaches SQL, a template, a shell or a log
|
||||||
|
message. Hostile input is ranked like any other text, and can only ever produce
|
||||||
|
entries the catalogue already holds.
|
||||||
|
|
||||||
|
### Where the catalogue comes from
|
||||||
|
|
||||||
|
The frontend owns the vocabulary. Owliver's capabilities are declared per page
|
||||||
|
context in `src/components/ai-assistant/capabilities/`; `internal/owliver`
|
||||||
|
transcribes the id and the page, and adds the two things a manifest does not
|
||||||
|
carry — the words that mean a user is reaching for that reading, and the records
|
||||||
|
it reads. Same pattern as `internal/definition/vocabulary.go`, and for the same
|
||||||
|
reason: one vocabulary, named from both ends, with a test on this side that
|
||||||
|
fails when an id here names no capability there.
|
||||||
|
|
||||||
|
**No table, no migration.** The suggestions are derived from definitions that
|
||||||
|
already exist. Persistence would only be warranted if suggestions became
|
||||||
|
admin-managed, and nothing in the product asks for that today.
|
||||||
|
|
||||||
|
### Not covered
|
||||||
|
|
||||||
|
The seven workspace and configuration surfaces — `settings`, `workspace`,
|
||||||
|
`workspace-agents`, `workspace-skills`, `workspace-skill-configure`,
|
||||||
|
`skill-development`, `workspace-agent-configure` — are valid pages with no
|
||||||
|
entries. Their panel answers from the registries rather than from workforce
|
||||||
|
records, so there is nothing to rank a typed query against. They return `[]`,
|
||||||
|
which is the honest answer, not a validation error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 3. Request schemas
|
## 3. Request schemas
|
||||||
|
|
||||||
### 3.1 Create — `POST /{resource}`
|
### 3.1 Create — `POST /{resource}`
|
||||||
|
|||||||
1566
docs/backend-implementation-plan.md
Normal file
1566
docs/backend-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -28,10 +28,12 @@ var ErrNoIdentity = errors.New("no authenticated identity in context")
|
|||||||
|
|
||||||
// Identity is who the request is, as resolved from the session row.
|
// Identity is who the request is, as resolved from the session row.
|
||||||
//
|
//
|
||||||
// Role is carried because Phase 3D will need it, and because carrying it now
|
// Role is read once per request by the middleware, out of the user row, so an
|
||||||
// means the middleware reads it once per request instead of every future
|
// authorization check never has to re-query. It is the authorization authority:
|
||||||
// authorization check re-querying the user. It is NOT consulted anywhere in
|
// httpserver.Server.authorize gates operations on it, the repository's ownership
|
||||||
// Phase 3C: authentication only.
|
// predicate narrows a talent caller's rows by it, and service/definitions.go
|
||||||
|
// checks it on every definition write. AccountType is NOT an authority — a user
|
||||||
|
// can change their own through PATCH /me.
|
||||||
type Identity struct {
|
type Identity struct {
|
||||||
UserID string
|
UserID string
|
||||||
OrgID string
|
OrgID string
|
||||||
|
|||||||
@@ -259,8 +259,13 @@ func (c *Config) validate() error {
|
|||||||
// devCORSOrigins are the origins the Vite dev server can occupy. Vite binds
|
// devCORSOrigins are the origins the Vite dev server can occupy. Vite binds
|
||||||
// localhost by default and 127.0.0.1 when asked, and a browser treats those two
|
// localhost by default and 127.0.0.1 when asked, and a browser treats those two
|
||||||
// as different origins, so both are listed. 4173 is `vite preview`.
|
// as different origins, so both are listed. 4173 is `vite preview`.
|
||||||
|
// 5174 is where Vite lands when 5173 is already taken, which happens whenever a
|
||||||
|
// second dev server is started; an origin missing from this list is refused at
|
||||||
|
// the preflight with a bare 403 and no CORS headers, which reads as a server
|
||||||
|
// fault rather than a misconfigured port.
|
||||||
var devCORSOrigins = []string{
|
var devCORSOrigins = []string{
|
||||||
"http://localhost:5173", "http://127.0.0.1:5173",
|
"http://localhost:5173", "http://127.0.0.1:5173",
|
||||||
|
"http://localhost:5174", "http://127.0.0.1:5174",
|
||||||
"http://localhost:4173", "http://127.0.0.1:4173",
|
"http://localhost:4173", "http://127.0.0.1:4173",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package httpserver
|
package httpserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -130,7 +131,7 @@ func (s *Server) handleCreate(svc *service.Service) http.HandlerFunc {
|
|||||||
writeError(w, s.log, err)
|
writeError(w, s.log, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rec, err := svc.Create(r.Context(), ident, body)
|
rec, err := s.create(r.Context(), svc, ident, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, s.log, err)
|
writeError(w, s.log, err)
|
||||||
return
|
return
|
||||||
@@ -139,6 +140,25 @@ func (s *Server) handleCreate(svc *service.Service) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// create inserts through the resource's own service, except where creating a
|
||||||
|
// record has a consequence in another table.
|
||||||
|
//
|
||||||
|
// One resource has one: an AI interview is only half of completing an
|
||||||
|
// interview, and the application it names has to be linked in the same
|
||||||
|
// transaction — see internal/service/interviews.go for why the server performs
|
||||||
|
// that write and the caller may not. Routing it here rather than registering a
|
||||||
|
// second endpoint keeps POST /api/v1/ai-interviews the only way to write one,
|
||||||
|
// which is what the client already calls and what the ownership guard already
|
||||||
|
// covers.
|
||||||
|
func (s *Server) create(ctx context.Context, svc *service.Service,
|
||||||
|
ident authctx.Identity, body domain.Record) (domain.Record, error) {
|
||||||
|
|
||||||
|
if svc.Resource().Path == service.InterviewsPath {
|
||||||
|
return s.workflows.CreateInterview(ctx, ident, body)
|
||||||
|
}
|
||||||
|
return svc.Create(ctx, ident, body)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleUpdate(svc *service.Service) http.HandlerFunc {
|
func (s *Server) handleUpdate(svc *service.Service) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
ident, ok := s.authorize(w, r, svc, domain.OpUpdate)
|
ident, ok := s.authorize(w, r, svc, domain.OpUpdate)
|
||||||
@@ -174,11 +194,6 @@ func (s *Server) handleDelete(svc *service.Service) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeBody reads a JSON object body.
|
|
||||||
//
|
|
||||||
// DisallowUnknownFields is not used — the target is a map, so every field is
|
|
||||||
// "known" here. Unknown *columns* are rejected in the service, where the
|
|
||||||
// resource's schema is available to say which those are.
|
|
||||||
// decodeInto reads a JSON body into a typed struct.
|
// decodeInto reads a JSON body into a typed struct.
|
||||||
//
|
//
|
||||||
// Beside decodeBody rather than replacing it: the resource handlers genuinely
|
// Beside decodeBody rather than replacing it: the resource handlers genuinely
|
||||||
@@ -200,6 +215,11 @@ func decodeInto(r *http.Request, dst any) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeBody reads a JSON object body.
|
||||||
|
//
|
||||||
|
// DisallowUnknownFields is not used — the target is a map, so every field is
|
||||||
|
// "known" here. Unknown *columns* are rejected in the service, where the
|
||||||
|
// resource's schema is available to say which those are.
|
||||||
func decodeBody(r *http.Request) (domain.Record, error) {
|
func decodeBody(r *http.Request) (domain.Record, error) {
|
||||||
defer func() { _ = r.Body.Close() }()
|
defer func() { _ = r.Body.Close() }()
|
||||||
raw, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, maxBodyBytes))
|
raw, err := io.ReadAll(http.MaxBytesReader(nil, r.Body, maxBodyBytes))
|
||||||
|
|||||||
@@ -42,27 +42,30 @@ const sessionCookieName = "krow_session"
|
|||||||
// that never authenticate anything.
|
// that never authenticate anything.
|
||||||
func (s *Server) secureCookies() bool { return s.cfg.AppEnv != "development" }
|
func (s *Server) secureCookies() bool { return s.cfg.AppEnv != "development" }
|
||||||
|
|
||||||
// sameSite resolves the configured SameSite mode.
|
// sessionSameSite reports the SameSite mode the session cookie must carry.
|
||||||
//
|
//
|
||||||
// Lax remains the default and the recommendation. "none" exists for the one
|
// Lax is the default and the safer value: it closes the CSRF hole by refusing
|
||||||
// deployment shape that cannot work without it: a frontend on a different
|
// to travel on cross-site subresource requests. That is exactly right when the
|
||||||
// registrable domain from the API. In that case Lax withholds the cookie on
|
// page and the API share an origin, which is the supported deployment.
|
||||||
// every cross-site fetch, so the sign-in succeeds, the Set-Cookie arrives, and
|
|
||||||
// the next request carries nothing — which reads as a broken session rather
|
|
||||||
// than as a cookie policy.
|
|
||||||
//
|
//
|
||||||
// An unrecognised value falls back to Lax rather than to None. config.validate
|
// When the API is configured with a CORS allowlist, the deployment is by
|
||||||
// rejects those before startup, so this is only a belt-and-braces default in
|
// definition the other one: a page on some other origin calls this API
|
||||||
// the safe direction.
|
// directly. A Lax cookie is never sent on those requests, so login would
|
||||||
func (s *Server) sameSite() http.SameSite {
|
// succeed once and every request after it would arrive anonymous. None is the
|
||||||
switch s.cfg.HTTP.CookieSameSite {
|
// only mode a browser will send cross-site, and it requires Secure — which is
|
||||||
case "none":
|
// why an origin allowlist forces Secure on regardless of AppEnv.
|
||||||
|
func (s *Server) sessionSameSite() http.SameSite {
|
||||||
|
if len(s.cfg.HTTP.CORSOrigins) > 0 {
|
||||||
return http.SameSiteNoneMode
|
return http.SameSiteNoneMode
|
||||||
case "strict":
|
|
||||||
return http.SameSiteStrictMode
|
|
||||||
default:
|
|
||||||
return http.SameSiteLaxMode
|
|
||||||
}
|
}
|
||||||
|
return http.SameSiteLaxMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// crossSiteCookies reports whether the cookie must be marked Secure because it
|
||||||
|
// has to travel cross-site. SameSite=None without Secure is rejected outright
|
||||||
|
// by every current browser.
|
||||||
|
func (s *Server) crossSiteCookies() bool {
|
||||||
|
return s.sessionSameSite() == http.SameSiteNoneMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// setSessionCookie writes the raw token to the browser.
|
// setSessionCookie writes the raw token to the browser.
|
||||||
@@ -82,15 +85,13 @@ func (s *Server) setSessionCookie(w http.ResponseWriter, token string, lifetime
|
|||||||
Path: "/",
|
Path: "/",
|
||||||
// HttpOnly: script cannot read it.
|
// HttpOnly: script cannot read it.
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
// Lax by default, and Strict/None available through
|
// Lax, not Strict and not None. Strict would drop the cookie on any
|
||||||
// HTTP_COOKIE_SAMESITE. Strict would drop the cookie on any cross-site
|
// cross-site navigation, so following a link into the app would land on
|
||||||
// navigation, so following a link into the app would land on a login
|
// a login page despite a live session. None would require Secure and
|
||||||
// page despite a live session. None sends it on cross-site requests,
|
// would send the cookie on cross-site POSTs, which is the CSRF hole Lax
|
||||||
// which is the CSRF hole Lax exists to close — and is nonetheless the
|
// exists to close.
|
||||||
// only workable value when the frontend is on a different registrable
|
SameSite: s.sessionSameSite(),
|
||||||
// domain. See Server.sameSite.
|
Secure: s.secureCookies() || s.crossSiteCookies(),
|
||||||
SameSite: s.sameSite(),
|
|
||||||
Secure: s.secureCookies(),
|
|
||||||
MaxAge: int(lifetime.Seconds()),
|
MaxAge: int(lifetime.Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -107,10 +108,8 @@ func (s *Server) clearSessionCookie(w http.ResponseWriter) {
|
|||||||
Value: "",
|
Value: "",
|
||||||
Path: "/",
|
Path: "/",
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
// Must match the attributes it was set with, SameSite included, or the
|
SameSite: s.sessionSameSite(),
|
||||||
// browser treats this as a different cookie and leaves the original.
|
Secure: s.secureCookies() || s.crossSiteCookies(),
|
||||||
SameSite: s.sameSite(),
|
|
||||||
Secure: s.secureCookies(),
|
|
||||||
MaxAge: -1,
|
MaxAge: -1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ func cors(origins []string) func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
// The frontend sends `credentials: "include"`, and a browser
|
||||||
|
// discards any response to such a request that does not carry this
|
||||||
|
// header — preflight included. Safe only because the origin was
|
||||||
|
// matched exactly above and is echoed back one at a time; "*" is
|
||||||
|
// never sent, which is the pairing the spec forbids.
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
|
||||||
// Authentication is a cookie, so the browser will neither send it
|
// Authentication is a cookie, so the browser will neither send it
|
||||||
// nor expose the response without this. It is set for allowlisted
|
// nor expose the response without this. It is set for allowlisted
|
||||||
|
|||||||
239
go-api/internal/httpserver/interviews_test.go
Normal file
239
go-api/internal/httpserver/interviews_test.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package httpserver_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Completing an AI interview.
|
||||||
|
//
|
||||||
|
// The endpoint is unchanged — POST /api/v1/ai-interviews, the one the modal
|
||||||
|
// already calls — but finishing an interview is two writes, and the second one
|
||||||
|
// is a write the caller who most often makes the request may not perform. The
|
||||||
|
// tests below are about that seam: the interview and the link land together,
|
||||||
|
// they land for a talent user, and nothing about talent's own permissions has
|
||||||
|
// widened to make it possible.
|
||||||
|
|
||||||
|
// interviewCount counts the organization's interview rows.
|
||||||
|
func interviewCount(t *testing.T, r *rbac) int {
|
||||||
|
t.Helper()
|
||||||
|
return countRows(t, r, "ai_interviews")
|
||||||
|
}
|
||||||
|
|
||||||
|
// talentApplication files an application through the API as the talent user, so
|
||||||
|
// its email is whatever the server derived rather than what a test asked for.
|
||||||
|
func talentApplication(t *testing.T, r *rbac, who actor) string {
|
||||||
|
t.Helper()
|
||||||
|
return mustCreate(t, r, who, "/api/v1/job-applications", map[string]any{
|
||||||
|
"job_posting_id": r.activePosting,
|
||||||
|
"applicant_name": who.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func interviewBody(applicationID, postingID string, score any) map[string]any {
|
||||||
|
body := map[string]any{
|
||||||
|
"application_id": applicationID,
|
||||||
|
"job_posting_id": postingID,
|
||||||
|
"job_title": "Open Role",
|
||||||
|
"candidate_name": "Candidate",
|
||||||
|
"messages": []map[string]any{
|
||||||
|
{"role": "assistant", "content": "Tell me about a difficult shift."},
|
||||||
|
{"role": "user", "content": "We were two people short and I re-planned the passes."},
|
||||||
|
},
|
||||||
|
"verdict": "hire",
|
||||||
|
"hire_recommendation": "Hire",
|
||||||
|
"summary": "Composed under pressure.",
|
||||||
|
}
|
||||||
|
if score != nil {
|
||||||
|
body["overall_interview_score"] = score
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The RBAC break this fixes ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// A talent user completing their own interview is the whole talent flow, and it
|
||||||
|
// could not finish: ai-interviews:Create is open to everyone, job-applications:
|
||||||
|
// Update is operators only, so the interview was written and the application
|
||||||
|
// never learned about it. Both writes now happen server-side, in one
|
||||||
|
// transaction, on the row the interview already names.
|
||||||
|
func TestTalentCompletesTheirOwnInterview(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := talentApplication(t, r, r.talA)
|
||||||
|
|
||||||
|
got := r.as(r.talA, "POST", "/api/v1/ai-interviews",
|
||||||
|
interviewBody(app, r.activePosting, 88))
|
||||||
|
if got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("talent interview: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
interview := got.body["data"].(map[string]any)
|
||||||
|
interviewID, _ := interview["id"].(string)
|
||||||
|
if interviewID == "" {
|
||||||
|
t.Fatalf("the response carries no interview id: %v", got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The response is still the interview record, unchanged.
|
||||||
|
if interview["application_id"] != app {
|
||||||
|
t.Errorf("data.application_id = %v, want %s", interview["application_id"], app)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored := applicationByID(t, r, app)
|
||||||
|
if stored["status"] != "interview" {
|
||||||
|
t.Errorf("application.status = %v, want interview — the analytics count "+
|
||||||
|
"status === 'interview' || interview_id", stored["status"])
|
||||||
|
}
|
||||||
|
if stored["interview_id"] != interviewID {
|
||||||
|
t.Errorf("application.interview_id = %v, want %s", stored["interview_id"], interviewID)
|
||||||
|
}
|
||||||
|
if score, ok := stored["ai_score"].(float64); !ok || int(score) != 88 {
|
||||||
|
t.Errorf("application.ai_score = %v, want the interview's 88", stored["ai_score"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the permission itself has NOT widened. The server writes that one row on
|
||||||
|
// the caller's behalf; the caller still cannot patch an application.
|
||||||
|
func TestCompletingAnInterviewDoesNotWidenApplicationUpdate(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := talentApplication(t, r, r.talA)
|
||||||
|
|
||||||
|
if got := r.as(r.talA, "POST", "/api/v1/ai-interviews",
|
||||||
|
interviewBody(app, r.activePosting, 70)); got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("talent interview: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
if got := r.as(r.talA, "PATCH", "/api/v1/job-applications/"+app,
|
||||||
|
map[string]any{"status": "hired"}); got.code != http.StatusForbidden {
|
||||||
|
t.Fatalf("talent PATCH of their own application: got %d, want 403 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An operator's interview links the same way. The atomicity half of the fix is
|
||||||
|
// not talent-specific: a failure between the two writes left an interview
|
||||||
|
// attached to an application that did not know about it, whoever ran it.
|
||||||
|
func TestOperatorCompletingAnInterviewLinksTheApplication(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := applicationFor(t, r, r.activePosting, "Operator Candidate", "opcand@example.test")
|
||||||
|
|
||||||
|
got := r.as(r.empA, "POST", "/api/v1/ai-interviews",
|
||||||
|
interviewBody(app, r.activePosting, 64))
|
||||||
|
if got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("operator interview: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
interviewID := got.body["data"].(map[string]any)["id"].(string)
|
||||||
|
|
||||||
|
stored := applicationByID(t, r, app)
|
||||||
|
if stored["status"] != "interview" || stored["interview_id"] != interviewID {
|
||||||
|
t.Errorf("application = status %v, interview_id %v; want interview / %s",
|
||||||
|
stored["status"], stored["interview_id"], interviewID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── What the link must not do ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// A body that says nothing about the score must not overwrite the screening
|
||||||
|
// score with the interview column's default of 0. The field the caller never
|
||||||
|
// mentioned is not a value they asked to store.
|
||||||
|
func TestInterviewWithoutAScoreLeavesTheApplicationScore(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := applicationFor(t, r, r.activePosting, "Scored", "scored@example.test") // ai_score 77
|
||||||
|
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/ai-interviews",
|
||||||
|
interviewBody(app, r.activePosting, nil))
|
||||||
|
if got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("interview: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored := applicationByID(t, r, app)
|
||||||
|
if score, ok := stored["ai_score"].(float64); !ok || int(score) != 77 {
|
||||||
|
t.Errorf("application.ai_score = %v, want the screening score 77 left alone",
|
||||||
|
stored["ai_score"])
|
||||||
|
}
|
||||||
|
// The status and the link still move — those are what completing an
|
||||||
|
// interview means.
|
||||||
|
if stored["status"] != "interview" || stored["interview_id"] == nil {
|
||||||
|
t.Errorf("application = status %v, interview_id %v; want interview and a link",
|
||||||
|
stored["status"], stored["interview_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Somebody else's application is not a subject a talent user may interview for,
|
||||||
|
// and the refusal must leave nothing behind — not the interview, and not a
|
||||||
|
// changed application.
|
||||||
|
func TestInterviewForAnotherPersonsApplicationWritesNothing(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := talentApplication(t, r, r.talA)
|
||||||
|
before := interviewCount(t, r)
|
||||||
|
|
||||||
|
got := r.as(r.talB, "POST", "/api/v1/ai-interviews",
|
||||||
|
interviewBody(app, r.activePosting, 95))
|
||||||
|
if got.code != http.StatusNotFound {
|
||||||
|
t.Fatalf("interview for another person's application: got %d, want 404 (%v)",
|
||||||
|
got.code, got.body)
|
||||||
|
}
|
||||||
|
if after := interviewCount(t, r); after != before {
|
||||||
|
t.Errorf("ai_interviews: %d -> %d, want no row", before, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored := applicationByID(t, r, app)
|
||||||
|
if stored["status"] != "applied" || stored["interview_id"] != nil {
|
||||||
|
t.Errorf("application = status %v, interview_id %v; want it untouched",
|
||||||
|
stored["status"], stored["interview_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An interview that cannot be written must not move the application either.
|
||||||
|
// Both writes are in one transaction, so a refusal at the first is the whole
|
||||||
|
// request rolled back rather than a partial completion.
|
||||||
|
func TestARefusedInterviewLeavesTheApplicationAlone(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := applicationFor(t, r, r.activePosting, "Unfinished", "unfinished@example.test")
|
||||||
|
before := interviewCount(t, r)
|
||||||
|
|
||||||
|
body := interviewBody(app, r.activePosting, 80)
|
||||||
|
body["verdict"] = "definitely" // outside the interview_verdict enum
|
||||||
|
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/ai-interviews", body)
|
||||||
|
if got.code == http.StatusCreated {
|
||||||
|
t.Fatalf("an invalid verdict was accepted: %v", got.body)
|
||||||
|
}
|
||||||
|
if after := interviewCount(t, r); after != before {
|
||||||
|
t.Errorf("ai_interviews: %d -> %d, want no row", before, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored := applicationByID(t, r, app)
|
||||||
|
if stored["status"] != "shortlisted" || stored["interview_id"] != nil {
|
||||||
|
t.Errorf("application = status %v, interview_id %v; want it untouched",
|
||||||
|
stored["status"], stored["interview_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-tenant: the application is in another organization, so it is absent
|
||||||
|
// rather than forbidden, and no interview is written for it.
|
||||||
|
func TestInterviewCannotReachAnotherOrganizationsApplication(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := applicationFor(t, r, r.activePosting, "Ours", "ours-interview@example.test")
|
||||||
|
|
||||||
|
var before int
|
||||||
|
if err := r.h.Pool.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM ai_interviews`).Scan(&before); err != nil {
|
||||||
|
t.Fatalf("count interviews: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := r.as(r.outsider, "POST", "/api/v1/ai-interviews",
|
||||||
|
interviewBody(app, r.activePosting, 90))
|
||||||
|
if got.code == http.StatusCreated {
|
||||||
|
t.Fatalf("an outsider wrote an interview for our application: %v", got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var after int
|
||||||
|
if err := r.h.Pool.QueryRow(context.Background(),
|
||||||
|
`SELECT count(*) FROM ai_interviews`).Scan(&after); err != nil {
|
||||||
|
t.Fatalf("count interviews: %v", err)
|
||||||
|
}
|
||||||
|
if after != before {
|
||||||
|
t.Errorf("ai_interviews: %d -> %d, want no row", before, after)
|
||||||
|
}
|
||||||
|
if stored := applicationByID(t, r, app); stored["interview_id"] != nil {
|
||||||
|
t.Errorf("application.interview_id = %v, want it untouched", stored["interview_id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
61
go-api/internal/httpserver/owliver.go
Normal file
61
go-api/internal/httpserver/owliver.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/owliver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// routeOwliver registers the Owliver panel's suggestion endpoint.
|
||||||
|
//
|
||||||
|
// GET, and a query string rather than a body, because the request is a read
|
||||||
|
// with no side effect and the panel issues one per keystroke: a GET is what
|
||||||
|
// makes it retryable, cancellable and cacheable by anything in front of it.
|
||||||
|
//
|
||||||
|
// It is deliberately NOT on the publicPaths allowlist in auth.go. Which
|
||||||
|
// readings exist depends on the caller's role, so an anonymous suggestion has
|
||||||
|
// no meaning — and the allowlist's failure mode is a route that refuses
|
||||||
|
// everyone, which is the direction this should fall in.
|
||||||
|
func (s *Server) routeOwliver(mux *http.ServeMux) int {
|
||||||
|
mux.HandleFunc("GET /api/v1/owliver/suggestions", s.handleOwliverSuggestions)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestionsBody is the payload inside the standard data envelope.
|
||||||
|
//
|
||||||
|
// An object rather than a bare array, so the response has somewhere to grow — a
|
||||||
|
// future `truncated` or `context` field would otherwise be a breaking change to
|
||||||
|
// a client already reading `data` as a list.
|
||||||
|
type suggestionsBody struct {
|
||||||
|
Suggestions []owliver.Suggestion `json:"suggestions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleOwliverSuggestions answers what the caller could usefully ask here.
|
||||||
|
//
|
||||||
|
// There is no s.authorize call and no policy lookup in this handler, and that
|
||||||
|
// is the design rather than an omission: this endpoint exposes no resource, so
|
||||||
|
// there is no operation to gate. Authorization happens per suggestion, inside
|
||||||
|
// the catalogue, against the same domain.Policy table every other endpoint
|
||||||
|
// consults — a reading the caller could not perform is never ranked, so it
|
||||||
|
// cannot be returned. Authentication is upstream, in the middleware.
|
||||||
|
func (s *Server) handleOwliverSuggestions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ident, err := authctx.MustFrom(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
// Unreachable: the middleware refuses an unauthenticated request before
|
||||||
|
// the router sees it. A missing identity here is a wiring bug.
|
||||||
|
writeError(w, s.log, domain.Internal(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
params, err := s.suggestions.ParseParams(r.URL.Query())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, s.log, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, envelope{
|
||||||
|
Data: suggestionsBody{Suggestions: s.suggestions.Suggest(ident, params)},
|
||||||
|
})
|
||||||
|
}
|
||||||
315
go-api/internal/httpserver/owliver_test.go
Normal file
315
go-api/internal/httpserver/owliver_test.go
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
package httpserver_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET /api/v1/owliver/suggestions.
|
||||||
|
//
|
||||||
|
// The ranking itself is tested in internal/owliver, against no database and no
|
||||||
|
// server. What is tested here is only what the HTTP boundary adds: the session
|
||||||
|
// requirement, the query-string contract, the response envelope, and the fact
|
||||||
|
// that the role deciding which readings exist is the session's rather than
|
||||||
|
// anything the caller can set.
|
||||||
|
|
||||||
|
const suggestPath = "/api/v1/owliver/suggestions"
|
||||||
|
|
||||||
|
// suggestURL builds the endpoint's address, escaping as a browser would.
|
||||||
|
func suggestURL(page, query string) string {
|
||||||
|
v := url.Values{}
|
||||||
|
if page != "" {
|
||||||
|
v.Set("page", page)
|
||||||
|
}
|
||||||
|
if query != "" {
|
||||||
|
v.Set("query", query)
|
||||||
|
}
|
||||||
|
return suggestPath + "?" + v.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// suggestions reads the list out of the data envelope, failing the test if the
|
||||||
|
// response is not shaped as the contract says.
|
||||||
|
func suggestions(t *testing.T, r response) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
if r.code != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, body %v", r.code, r.body)
|
||||||
|
}
|
||||||
|
data, ok := r.body["data"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("data is not an object: %v", r.body)
|
||||||
|
}
|
||||||
|
raw, ok := data["suggestions"].([]any)
|
||||||
|
if !ok {
|
||||||
|
// json null decodes to nil, and an absent key to nothing at all. Both
|
||||||
|
// break a client that iterates the list without checking.
|
||||||
|
t.Fatalf("suggestions is not an array (got %#v)", data["suggestions"])
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, len(raw))
|
||||||
|
for i, item := range raw {
|
||||||
|
entry, ok := item.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("suggestion %d is not an object: %#v", i, item)
|
||||||
|
}
|
||||||
|
out[i] = entry
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Authentication ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// The endpoint is not on the public allowlist. Which readings exist depends on
|
||||||
|
// who is asking, so an anonymous suggestion has no meaning.
|
||||||
|
func TestOwliverSuggestionsRequireASession(t *testing.T) {
|
||||||
|
a := newAPI(t)
|
||||||
|
|
||||||
|
got := a.doAnon("GET", suggestURL("positions", "pipeline"), nil)
|
||||||
|
if got.code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status %d, want 401", got.code)
|
||||||
|
}
|
||||||
|
if code := got.codeOrEmpty(); code != "unauthorized" {
|
||||||
|
t.Fatalf("error code %q, want unauthorized", code)
|
||||||
|
}
|
||||||
|
// A refusal must not describe the catalogue it refused to rank.
|
||||||
|
if _, present := got.body["data"]; present {
|
||||||
|
t.Fatalf("an unauthenticated refusal carried data: %v", got.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The query string ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestOwliverSuggestionsValidation(t *testing.T) {
|
||||||
|
a := newAPI(t)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"no page", suggestPath, http.StatusBadRequest},
|
||||||
|
{"blank page", suggestPath + "?page=%20", http.StatusBadRequest},
|
||||||
|
{"unknown page", suggestURL("nowhere", "pipeline"), http.StatusBadRequest},
|
||||||
|
{"a route, not a surface", suggestURL("/admin/positions", "pipeline"), http.StatusBadRequest},
|
||||||
|
{"unknown parameter", suggestURL("positions", "pipeline") + "&role=admin", http.StatusBadRequest},
|
||||||
|
|
||||||
|
// A query is optional: with nothing typed there is nothing to rank, and
|
||||||
|
// that is an empty list rather than a refusal.
|
||||||
|
{"no query", suggestURL("positions", ""), http.StatusOK},
|
||||||
|
{"page alias", suggestURL("hired", "recent"), http.StatusOK},
|
||||||
|
{"page spelled loosely", suggestURL("Talent Pool", "availability"), http.StatusOK},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := a.do("GET", c.path, nil)
|
||||||
|
if got.code != c.want {
|
||||||
|
t.Fatalf("status %d, want %d (body %v)", got.code, c.want, got.body)
|
||||||
|
}
|
||||||
|
if c.want == http.StatusBadRequest && got.codeOrEmpty() != "invalid_query" {
|
||||||
|
t.Fatalf("error code %q, want invalid_query", got.codeOrEmpty())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mux answers anything but GET, so the endpoint cannot be reached with a
|
||||||
|
// body that might carry a page, a role or an identity.
|
||||||
|
func TestOwliverSuggestionsAreReadOnly(t *testing.T) {
|
||||||
|
a := newAPI(t)
|
||||||
|
for _, method := range []string{"POST", "PATCH", "DELETE", "PUT"} {
|
||||||
|
got := a.do(method, suggestURL("positions", "pipeline"), map[string]any{"page": "positions"})
|
||||||
|
if got.code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("%s: status %d, want 405", method, got.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── The response ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
func TestOwliverSuggestionsResponseShape(t *testing.T) {
|
||||||
|
a := newAPI(t) // the seeded user is an admin
|
||||||
|
|
||||||
|
got := suggestions(t, a.do("GET", suggestURL("positions", "pipeline"), nil))
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Fatal("pipeline on positions returned nothing")
|
||||||
|
}
|
||||||
|
if len(got) > 3 {
|
||||||
|
t.Fatalf("%d suggestions, the cap is 3", len(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
seenIntent, seenText := map[string]bool{}, map[string]bool{}
|
||||||
|
for i, s := range got {
|
||||||
|
text, _ := s["text"].(string)
|
||||||
|
intent, _ := s["intent"].(string)
|
||||||
|
if text == "" || intent == "" {
|
||||||
|
t.Fatalf("suggestion %d is incomplete: %v", i, s)
|
||||||
|
}
|
||||||
|
if seenIntent[intent] {
|
||||||
|
t.Fatalf("duplicate intent %q", intent)
|
||||||
|
}
|
||||||
|
if seenText[text] {
|
||||||
|
t.Fatalf("duplicate text %q", text)
|
||||||
|
}
|
||||||
|
seenIntent[intent], seenText[text] = true, true
|
||||||
|
|
||||||
|
// Nothing internal may ride along: no terms, no resource names, no
|
||||||
|
// scores, no page keys.
|
||||||
|
for key := range s {
|
||||||
|
switch key {
|
||||||
|
case "text", "intent", "capability":
|
||||||
|
default:
|
||||||
|
t.Fatalf("suggestion %d exposes %q: %v", i, key, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Asking for a rendering names it in the answer — and only where the reading
|
||||||
|
// can actually be drawn that way.
|
||||||
|
func TestOwliverSuggestionsCarryARequestedShape(t *testing.T) {
|
||||||
|
a := newAPI(t)
|
||||||
|
|
||||||
|
got := suggestions(t, a.do("GET", suggestURL("positions", "show hiring activity as a flow"), nil))
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("got %d suggestions, want 1: %v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0]["intent"] != "hiring-operations" || got[0]["capability"] != "flow" {
|
||||||
|
t.Fatalf("got %v", got[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// With no shape asked for, the field is absent rather than empty.
|
||||||
|
plain := suggestions(t, a.do("GET", suggestURL("positions", "draft"), nil))
|
||||||
|
if len(plain) == 0 {
|
||||||
|
t.Fatal("draft on positions returned nothing")
|
||||||
|
}
|
||||||
|
if _, present := plain[0]["capability"]; present {
|
||||||
|
t.Fatalf("capability was sent for an unshaped query: %v", plain[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No match is an empty array, not an error and not null.
|
||||||
|
func TestOwliverSuggestionsEmptyResults(t *testing.T) {
|
||||||
|
a := newAPI(t)
|
||||||
|
|
||||||
|
for _, c := range []struct{ name, query string }{
|
||||||
|
{"nothing typed", ""},
|
||||||
|
{"one character", "p"},
|
||||||
|
{"irrelevant", "sourdough starter recipe"},
|
||||||
|
} {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := suggestions(t, a.do("GET", suggestURL("positions", c.query), nil)); len(got) != 0 {
|
||||||
|
t.Fatalf("got %v, want none", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real surface the catalogue holds no readings for is the same answer.
|
||||||
|
if got := suggestions(t, a.do("GET", suggestURL("settings", "owliver"), nil)); len(got) != 0 {
|
||||||
|
t.Fatalf("settings returned %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page decides the answer, so the same word must not produce the same list
|
||||||
|
// everywhere.
|
||||||
|
func TestOwliverSuggestionsAreScopedToThePage(t *testing.T) {
|
||||||
|
a := newAPI(t)
|
||||||
|
|
||||||
|
read := func(page string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, s := range suggestions(t, a.do("GET", suggestURL(page, "pipeline"), nil)) {
|
||||||
|
out = append(out, s["intent"].(string))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
positions, candidates := read("positions"), read("candidates")
|
||||||
|
if len(positions) == 0 || len(candidates) == 0 {
|
||||||
|
t.Fatalf("positions=%v candidates=%v", positions, candidates)
|
||||||
|
}
|
||||||
|
if len(positions) == len(candidates) {
|
||||||
|
same := true
|
||||||
|
for i := range positions {
|
||||||
|
if positions[i] != candidates[i] {
|
||||||
|
same = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if same {
|
||||||
|
t.Fatalf("both pages answered pipeline with %v", positions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Authorization ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// Who is asking comes from the session, and it decides which readings exist.
|
||||||
|
//
|
||||||
|
// Talent may list job applications — but only their own, by a predicate in the
|
||||||
|
// repository — so the organization-wide readings the operator console offers
|
||||||
|
// are not theirs, and are absent rather than refused.
|
||||||
|
func TestOwliverSuggestionsFollowTheCallersRole(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
|
||||||
|
// A query each page can actually answer, so an empty list means the role
|
||||||
|
// was filtered rather than that the words matched nothing.
|
||||||
|
operatorPages := []struct{ page, query string }{
|
||||||
|
{"control-center", "pipeline attention"},
|
||||||
|
{"positions", "pipeline attention"},
|
||||||
|
{"candidates", "candidate score"},
|
||||||
|
{"hired-history", "recent hires outcomes"},
|
||||||
|
{"talent-pool", "talent pool availability"},
|
||||||
|
{"activity", "audit unusual activity"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range operatorPages {
|
||||||
|
for _, act := range []actor{r.admin, r.empA} {
|
||||||
|
got := suggestions(t, r.as(act, "GET", suggestURL(c.page, c.query), nil))
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Errorf("%s was offered nothing on %s for %q", act.name, c.page, c.query)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := suggestions(t, r.as(r.talA, "GET", suggestURL(c.page, c.query), nil)); len(got) != 0 {
|
||||||
|
t.Errorf("talent was offered %v on %s", got, c.page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still 200 with an empty list, never 403: refusing would tell a caller
|
||||||
|
// which pages hold readings they cannot have.
|
||||||
|
refused := r.as(r.talA, "GET", suggestURL("positions", "pipeline"), nil)
|
||||||
|
if refused.code != http.StatusOK {
|
||||||
|
t.Fatalf("talent got status %d, want 200 with an empty list", refused.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The role filter is not a blanket refusal for talent: what they may genuinely
|
||||||
|
// ask — about their own account — is still offered. Without this, the test
|
||||||
|
// above would pass with the permission check stubbed out to deny everything.
|
||||||
|
func TestOwliverSuggestionsStillServeTalentTheirOwnReadings(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
|
||||||
|
got := suggestions(t, r.as(r.talA, "GET", suggestURL("profile", "permission"), nil))
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Fatal("talent was offered nothing about their own account")
|
||||||
|
}
|
||||||
|
if got[0]["intent"] != "profile-permissions" {
|
||||||
|
t.Fatalf("got %v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A permission-sensitive reading: Hired History reads the staff table, which
|
||||||
|
// policy.go grants to operators only. Nothing about the request differs — only
|
||||||
|
// the session behind it.
|
||||||
|
func TestOwliverSuggestionsHideReadingsARoleCannotPerform(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
const path = suggestPath + "?page=hired-history&query=recent+hires"
|
||||||
|
|
||||||
|
for _, act := range []actor{r.admin, r.empA} {
|
||||||
|
if got := suggestions(t, r.as(act, "GET", path, nil)); len(got) == 0 {
|
||||||
|
t.Errorf("%s was offered no hiring outcomes", act.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := suggestions(t, r.as(r.talA, "GET", path, nil)); len(got) != 0 {
|
||||||
|
t.Fatalf("talent was offered readings of the staff table: %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -314,6 +314,50 @@ func mustCreate(t *testing.T, r *rbac, act actor, path string, body map[string]a
|
|||||||
|
|
||||||
/* ── 3. Mass assignment ─────────────────────────────────────────────────── */
|
/* ── 3. Mass assignment ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// The other half of a talent-only derivation: what an OPERATOR must supply.
|
||||||
|
//
|
||||||
|
// The server fills these columns from the session for a talent caller and for
|
||||||
|
// nobody else — an operator filing an application or logging evidence is
|
||||||
|
// writing about somebody who is not them. Treating the column as
|
||||||
|
// server-supplied for every role let an operator's request past validation and
|
||||||
|
// into SQL, where it came back as a not-null violation instead of the
|
||||||
|
// required-field message the contract promises. The two halves have to agree:
|
||||||
|
// what the repository will derive, and what validation stops asking for.
|
||||||
|
func TestTalentOnlyDerivedFieldsAreRequiredOfOperators(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name, path, column string
|
||||||
|
body map[string]any
|
||||||
|
}{
|
||||||
|
{"job_applications.email", "/api/v1/job-applications", "email",
|
||||||
|
map[string]any{"job_posting_id": r.activePosting, "applicant_name": "Nameless"}},
|
||||||
|
{"evidence.worker_email", "/api/v1/evidence", "worker_email",
|
||||||
|
map[string]any{"type": "photo_identify"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := r.as(r.admin, "POST", tc.path, tc.body)
|
||||||
|
if got.code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("operator create without %s: got %d, want 422 (%v)",
|
||||||
|
tc.column, got.code, got.body)
|
||||||
|
}
|
||||||
|
details, _ := got.body["error"].(map[string]any)["details"].(map[string]any)
|
||||||
|
if details[tc.column] != "required" {
|
||||||
|
t.Errorf("details = %v, want %s: required", details, tc.column)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same body from a talent caller is complete, because the
|
||||||
|
// server is about to fill the column in from their session.
|
||||||
|
if got := r.as(r.talA, "POST", tc.path, tc.body); got.code != http.StatusCreated {
|
||||||
|
t.Errorf("talent create without %s: got %d, want 201 (%v)",
|
||||||
|
tc.column, got.code, got.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Identity a caller supplies is ignored; identity the server derives wins.
|
// Identity a caller supplies is ignored; identity the server derives wins.
|
||||||
//
|
//
|
||||||
// This is the test that makes the ownership predicates above mean anything. If
|
// This is the test that makes the ownership predicates above mean anything. If
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
// Package httpserver holds the HTTP surface.
|
// Package httpserver holds the HTTP surface.
|
||||||
//
|
//
|
||||||
// It serves /health, the sign-in endpoints, the entity endpoints described in
|
// It serves /health, the sign-in endpoints, the entity endpoints described in
|
||||||
// docs/api-contract.md, and the current-user endpoints.
|
// docs/api-contract.md, the current-user endpoints, the agent and skill
|
||||||
|
// definition endpoints, and the Owliver panel's suggestion endpoint.
|
||||||
//
|
//
|
||||||
// Phase 3C replaced the development identity with real authentication. Every
|
// Authentication replaced the development identity: every request outside the
|
||||||
// request outside the small public allowlist in auth.go must carry a session
|
// small public allowlist in auth.go must carry a session cookie; the middleware
|
||||||
// cookie; the middleware resolves it to a user row and puts that user, and
|
// resolves it to a user row and puts that user, and their organization, on the
|
||||||
// their organization, on the request context. Nothing downstream changed —
|
// request context. Nothing downstream changed — every service and repository
|
||||||
// every service and repository already took the organization as a parameter,
|
// already took the organization as a parameter, which is what devOrgMiddleware
|
||||||
// which is what devOrgMiddleware existed to make true.
|
// existed to make true.
|
||||||
//
|
//
|
||||||
// Authorization is NOT here. A signed-in user reaches every endpoint they could
|
// Authorization is here, in Server.authorize: it reads the role off the
|
||||||
// reach before; deciding which roles may do what is Phase 3D.
|
// authenticated identity, consults the deny-by-default policy table in
|
||||||
|
// internal/domain/policy.go, and answers 403 before any query runs. Row
|
||||||
|
// visibility — organization scope, and ownership for talent callers — is a SQL
|
||||||
|
// predicate in internal/repo instead, so an invisible row answers 404 rather
|
||||||
|
// than 403. The definition endpoints are the exception: they are not
|
||||||
|
// domain.Resource values, so their role checks are written inline in
|
||||||
|
// internal/service/definitions.go rather than in the policy table.
|
||||||
package httpserver
|
package httpserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -38,6 +45,7 @@ type Server struct {
|
|||||||
api *service.Registry
|
api *service.Registry
|
||||||
definitions *service.DefinitionsService
|
definitions *service.DefinitionsService
|
||||||
workflows *service.WorkflowService
|
workflows *service.WorkflowService
|
||||||
|
suggestions *service.SuggestionsService
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
http *http.Server
|
http *http.Server
|
||||||
started time.Time
|
started time.Time
|
||||||
@@ -126,6 +134,7 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
|
|||||||
api: service.NewRegistry(database.Pool),
|
api: service.NewRegistry(database.Pool),
|
||||||
definitions: service.NewDefinitions(database.Pool),
|
definitions: service.NewDefinitions(database.Pool),
|
||||||
workflows: service.NewWorkflows(database.Pool).WithClock(o.now),
|
workflows: service.NewWorkflows(database.Pool).WithClock(o.now),
|
||||||
|
suggestions: service.NewSuggestions(),
|
||||||
started: o.now(),
|
started: o.now(),
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
users: users,
|
users: users,
|
||||||
@@ -138,7 +147,7 @@ func New(cfg *config.Config, database *db.DB, log *slog.Logger, opts ...Option)
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /health", s.handleHealth)
|
mux.HandleFunc("GET /health", s.handleHealth)
|
||||||
s.endpoints = s.routeAuth(mux) + s.routeResources(mux) + s.routeMe(mux) +
|
s.endpoints = s.routeAuth(mux) + s.routeResources(mux) + s.routeMe(mux) +
|
||||||
s.routeDefinitions(mux) + s.routeWorkflows(mux)
|
s.routeDefinitions(mux) + s.routeWorkflows(mux) + s.routeOwliver(mux)
|
||||||
|
|
||||||
handler := jsonErrors(mux)
|
handler := jsonErrors(mux)
|
||||||
// Authentication sits where devOrgMiddleware used to, so every route below
|
// Authentication sits where devOrgMiddleware used to, so every route below
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ func (s *Server) handleAssign(w http.ResponseWriter, r *http.Request) {
|
|||||||
ident, ok := s.authorizeAll(w, r,
|
ident, ok := s.authorizeAll(w, r,
|
||||||
requirement{"assignments", domain.OpCreate},
|
requirement{"assignments", domain.OpCreate},
|
||||||
requirement{"job-applications", domain.OpUpdate},
|
requirement{"job-applications", domain.OpUpdate},
|
||||||
|
// The workflow may now FILE an application as well as patch one, for a
|
||||||
|
// worker placed on a posting they never applied to. A write the handler
|
||||||
|
// performs has to appear in the list it is authorized against, even
|
||||||
|
// when — as here — the resulting permission set is unchanged.
|
||||||
|
requirement{"job-applications", domain.OpCreate},
|
||||||
requirement{"user-activity", domain.OpCreate},
|
requirement{"user-activity", domain.OpCreate},
|
||||||
)
|
)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -329,3 +329,326 @@ func TestWorkflowEndpointsRequireASession(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Activity vocabulary ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// activityTypes returns the event types written about one worker, newest first.
|
||||||
|
//
|
||||||
|
// Read straight from the table rather than through GET /user-activity so the
|
||||||
|
// assertion is about what was STORED. The frontend's anomaly detection reads
|
||||||
|
// these strings — PRIVILEGED_EVENTS is ['hire_candidate', 'create_position'] —
|
||||||
|
// and a value the vocabulary does not contain is not a different label, it is
|
||||||
|
// an event that silently stops counting.
|
||||||
|
func activityTypes(t *testing.T, r *rbac, workerEmail string) []string {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := r.h.Pool.Query(context.Background(),
|
||||||
|
`SELECT event_type FROM user_activity
|
||||||
|
WHERE org_id = $1::uuid AND worker_email = $2::citext
|
||||||
|
ORDER BY created_date DESC, id DESC`, r.orgID, workerEmail)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read user_activity: %v", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []string
|
||||||
|
for rows.Next() {
|
||||||
|
var s string
|
||||||
|
if err := rows.Scan(&s); err != nil {
|
||||||
|
t.Fatalf("scan user_activity: %v", err)
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
t.Fatalf("read user_activity: %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHireWritesTheFrontendsActivityEvent(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := applicationFor(t, r, r.activePosting, "Evented", "evented@example.test")
|
||||||
|
|
||||||
|
if got := r.as(r.admin, "POST", "/api/v1/job-applications/"+app+"/hire",
|
||||||
|
map[string]any{}); got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("hire: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := activityTypes(t, r, "evented@example.test")
|
||||||
|
if len(events) != 1 || events[0] != "hire_candidate" {
|
||||||
|
t.Errorf("activity = %v, want exactly [hire_candidate] — the vocabulary "+
|
||||||
|
"activitySignals.js reads, and the one the seed fixture uses", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssignWritesTheFrontendsActivityEvent(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
if got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
|
||||||
|
map[string]any{"workers": []map[string]any{
|
||||||
|
{"worker_email": "evented-assign@example.test", "worker_name": "Evented",
|
||||||
|
"starts_at": "2026-09-01T09:00:00Z"},
|
||||||
|
}}); got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
events := activityTypes(t, r, "evented-assign@example.test")
|
||||||
|
if len(events) != 1 || events[0] != "assign_employee" {
|
||||||
|
t.Errorf("activity = %v, want exactly [assign_employee]", events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Assign: source ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
// An unspecified source must mean what the column says it means. The default in
|
||||||
|
// 000001 is `owliver` and the frontend sends `owliver`; substituting `manual`
|
||||||
|
// made a row written through this endpoint disagree with a row written through
|
||||||
|
// POST /assignments about where the same action came from.
|
||||||
|
func TestAssignDefaultsSourceToTheColumnDefault(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
|
||||||
|
map[string]any{"workers": []map[string]any{
|
||||||
|
{"worker_email": "default-source@example.test", "starts_at": "2026-09-01T09:00:00Z"},
|
||||||
|
{"worker_email": "explicit-source@example.test", "starts_at": "2026-09-01T09:00:00Z",
|
||||||
|
"source": "manual"},
|
||||||
|
}})
|
||||||
|
if got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
created := assignmentsOf(t, got)
|
||||||
|
if created[0]["source"] != "owliver" {
|
||||||
|
t.Errorf("source = %v, want owliver", created[0]["source"])
|
||||||
|
}
|
||||||
|
// An explicit value is still the caller's.
|
||||||
|
if created[1]["source"] != "manual" {
|
||||||
|
t.Errorf("source = %v, want the supplied manual", created[1]["source"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignmentsOf reads the assignment records out of an assign response.
|
||||||
|
func assignmentsOf(t *testing.T, got response) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
data, _ := got.body["data"].(map[string]any)
|
||||||
|
raw, _ := data["assignments"].([]any)
|
||||||
|
if raw == nil {
|
||||||
|
t.Fatalf("response carries no assignments: %v", got.body)
|
||||||
|
}
|
||||||
|
out := make([]map[string]any, 0, len(raw))
|
||||||
|
for _, rec := range raw {
|
||||||
|
out = append(out, rec.(map[string]any))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// applicationByID reads one application as an operator, or fails.
|
||||||
|
func applicationByID(t *testing.T, r *rbac, id string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
list := r.as(r.admin, "GET", "/api/v1/job-applications?limit=500", nil)
|
||||||
|
if list.code != http.StatusOK {
|
||||||
|
t.Fatalf("list applications: %d (%v)", list.code, list.body)
|
||||||
|
}
|
||||||
|
for _, raw := range list.body["data"].([]any) {
|
||||||
|
rec := raw.(map[string]any)
|
||||||
|
if rec["id"] == id {
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("application %s not found", id)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Assign: the application a worker does not have yet ─────────────────── */
|
||||||
|
|
||||||
|
// The behaviour the frontend had and the endpoint did not.
|
||||||
|
//
|
||||||
|
// An application is what puts a person in the pipeline for a role: the
|
||||||
|
// candidate record is addressed by it and an interview takes one as its
|
||||||
|
// subject. A worker assigned from the talent pool has none, so the endpoint has
|
||||||
|
// to file one — in the same transaction as the assignment, which is the half
|
||||||
|
// the frontend could not do.
|
||||||
|
func TestAssignCreatesTheApplicationItNeeds(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
appsBefore := countRows(t, r, "job_applications")
|
||||||
|
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
|
||||||
|
map[string]any{"workers": []map[string]any{{
|
||||||
|
"worker_email": "from-pool@example.test",
|
||||||
|
"worker_name": "Pool Worker",
|
||||||
|
"starts_at": "2026-09-01T09:00:00Z",
|
||||||
|
"match_score": 88,
|
||||||
|
"application": map[string]any{
|
||||||
|
"job_title": "Open Role",
|
||||||
|
"phone": "555-0100",
|
||||||
|
"years_experience": 4,
|
||||||
|
"skills": []string{"service", "bar"},
|
||||||
|
"professional_summary": "Placed from the talent pool.",
|
||||||
|
"ai_score": 88,
|
||||||
|
},
|
||||||
|
}}})
|
||||||
|
if got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if after := countRows(t, r, "job_applications"); after != appsBefore+1 {
|
||||||
|
t.Fatalf("job_applications: %d -> %d, want exactly one more", appsBefore, after)
|
||||||
|
}
|
||||||
|
|
||||||
|
assignment := assignmentsOf(t, got)[0]
|
||||||
|
linked, _ := assignment["application_id"].(string)
|
||||||
|
if linked == "" {
|
||||||
|
t.Fatal("the assignment was not linked to the application that was created for it")
|
||||||
|
}
|
||||||
|
|
||||||
|
app := applicationByID(t, r, linked)
|
||||||
|
if app["status"] != "assigned" {
|
||||||
|
t.Errorf("application.status = %v, want assigned", app["status"])
|
||||||
|
}
|
||||||
|
if app["email"] != "from-pool@example.test" {
|
||||||
|
t.Errorf("application.email = %v, want the worker's email", app["email"])
|
||||||
|
}
|
||||||
|
if app["job_posting_id"] != r.activePosting {
|
||||||
|
t.Errorf("application.job_posting_id = %v, want the posting being assigned to", app["job_posting_id"])
|
||||||
|
}
|
||||||
|
// applicant_name falls back to the worker's name rather than being blank —
|
||||||
|
// the column has a not-blank check.
|
||||||
|
if app["applicant_name"] != "Pool Worker" {
|
||||||
|
t.Errorf("application.applicant_name = %v, want the worker's name", app["applicant_name"])
|
||||||
|
}
|
||||||
|
if app["phone"] != "555-0100" {
|
||||||
|
t.Errorf("application.phone = %v, want the supplied phone", app["phone"])
|
||||||
|
}
|
||||||
|
if score, ok := app["ai_score"].(float64); !ok || int(score) != 88 {
|
||||||
|
t.Errorf("application.ai_score = %v, want 88", app["ai_score"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audit entry names the application, so the feed can open it.
|
||||||
|
var activityApp *string
|
||||||
|
if err := r.h.Pool.QueryRow(context.Background(),
|
||||||
|
`SELECT application_id::text FROM user_activity
|
||||||
|
WHERE org_id = $1::uuid AND worker_email = $2::citext`,
|
||||||
|
r.orgID, "from-pool@example.test").Scan(&activityApp); err != nil {
|
||||||
|
t.Fatalf("read the activity entry: %v", err)
|
||||||
|
}
|
||||||
|
if activityApp == nil || *activityApp != linked {
|
||||||
|
t.Errorf("activity.application_id = %v, want %s", activityApp, linked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (job_posting_id, email) is UNIQUE, so the second assign of the same person to
|
||||||
|
// the same posting must find the application rather than try to file another —
|
||||||
|
// and the comparison is case-insensitive, because the column is citext and the
|
||||||
|
// frontend's own lookup lowercased both sides.
|
||||||
|
func TestAssignLinksAnExistingApplicationInsteadOfDuplicating(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
existing := applicationFor(t, r, r.activePosting, "Already Applied", "Already.Applied@example.test")
|
||||||
|
|
||||||
|
appsBefore := countRows(t, r, "job_applications")
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
|
||||||
|
map[string]any{"workers": []map[string]any{{
|
||||||
|
"worker_email": "already.applied@example.test",
|
||||||
|
"worker_name": "Already Applied",
|
||||||
|
"starts_at": "2026-09-01T09:00:00Z",
|
||||||
|
"application": map[string]any{"job_title": "Open Role"},
|
||||||
|
}}})
|
||||||
|
if got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if after := countRows(t, r, "job_applications"); after != appsBefore {
|
||||||
|
t.Errorf("job_applications: %d -> %d, want no new row for a person who already applied",
|
||||||
|
appsBefore, after)
|
||||||
|
}
|
||||||
|
if linked := assignmentsOf(t, got)[0]["application_id"]; linked != existing {
|
||||||
|
t.Errorf("assignment.application_id = %v, want the existing application %s", linked, existing)
|
||||||
|
}
|
||||||
|
if app := applicationByID(t, r, existing); app["status"] != "assigned" {
|
||||||
|
t.Errorf("application.status = %v, want assigned", app["status"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An id the caller already has still wins over the payload: it is a decision
|
||||||
|
// they have made, and honouring the payload instead could file a second
|
||||||
|
// application for the same placement.
|
||||||
|
func TestAssignPrefersTheSuppliedApplicationID(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
app := applicationFor(t, r, r.activePosting, "Named", "named@example.test")
|
||||||
|
appsBefore := countRows(t, r, "job_applications")
|
||||||
|
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
|
||||||
|
map[string]any{"workers": []map[string]any{{
|
||||||
|
"worker_email": "named@example.test",
|
||||||
|
"worker_name": "Named",
|
||||||
|
"starts_at": "2026-09-01T09:00:00Z",
|
||||||
|
"application_id": app,
|
||||||
|
"application": map[string]any{"applicant_name": "Ignored"},
|
||||||
|
}}})
|
||||||
|
if got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
if after := countRows(t, r, "job_applications"); after != appsBefore {
|
||||||
|
t.Errorf("job_applications: %d -> %d, want no new row", appsBefore, after)
|
||||||
|
}
|
||||||
|
if linked := assignmentsOf(t, got)[0]["application_id"]; linked != app {
|
||||||
|
t.Errorf("assignment.application_id = %v, want %s", linked, app)
|
||||||
|
}
|
||||||
|
if stored := applicationByID(t, r, app); stored["applicant_name"] != "Named" {
|
||||||
|
t.Errorf("applicant_name = %v — the payload overwrote a named application",
|
||||||
|
stored["applicant_name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No payload, no application. A worker placed straight from the workforce is
|
||||||
|
// legitimate, and one must not be invented for them.
|
||||||
|
func TestAssignWithoutAnApplicationPayloadLinksNothing(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
appsBefore := countRows(t, r, "job_applications")
|
||||||
|
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
|
||||||
|
map[string]any{"workers": []map[string]any{
|
||||||
|
{"worker_email": "unattached@example.test", "worker_name": "Unattached",
|
||||||
|
"starts_at": "2026-09-01T09:00:00Z"},
|
||||||
|
}})
|
||||||
|
if got.code != http.StatusCreated {
|
||||||
|
t.Fatalf("assign: got %d, want 201 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
if after := countRows(t, r, "job_applications"); after != appsBefore {
|
||||||
|
t.Errorf("job_applications: %d -> %d, want no application invented", appsBefore, after)
|
||||||
|
}
|
||||||
|
if linked := assignmentsOf(t, got)[0]["application_id"]; linked != nil {
|
||||||
|
t.Errorf("assignment.application_id = %v, want null", linked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The application payload is validated exactly as POST /job-applications would
|
||||||
|
// validate it, and the batch element that produced the complaint is named.
|
||||||
|
func TestAssignValidatesTheApplicationPayload(t *testing.T) {
|
||||||
|
r := newRBAC(t)
|
||||||
|
assignBefore := countRows(t, r, "assignments")
|
||||||
|
appsBefore := countRows(t, r, "job_applications")
|
||||||
|
|
||||||
|
got := r.as(r.admin, "POST", "/api/v1/job-postings/"+r.activePosting+"/assignments",
|
||||||
|
map[string]any{"workers": []map[string]any{
|
||||||
|
{"worker_email": "good@example.test", "worker_name": "Good",
|
||||||
|
"starts_at": "2026-09-01T09:00:00Z",
|
||||||
|
"application": map[string]any{"job_title": "Open Role"}},
|
||||||
|
{"worker_email": "bad@example.test", "worker_name": "Bad",
|
||||||
|
"starts_at": "2026-09-01T09:00:00Z",
|
||||||
|
"application": map[string]any{"english_level": "telepathic"}},
|
||||||
|
}})
|
||||||
|
if got.code != http.StatusUnprocessableEntity {
|
||||||
|
t.Fatalf("assign with an invalid application: got %d, want 422 (%v)", got.code, got.body)
|
||||||
|
}
|
||||||
|
details, _ := got.body["error"].(map[string]any)["details"].(map[string]any)
|
||||||
|
if details["workers[1].english_level"] == nil {
|
||||||
|
t.Errorf("details = %v, want the failure attributed to workers[1]", details)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the first worker — whose application WAS filed before the second
|
||||||
|
// failed — must be gone with it.
|
||||||
|
if after := countRows(t, r, "job_applications"); after != appsBefore {
|
||||||
|
t.Errorf("job_applications: %d -> %d — a rejected batch left an application behind",
|
||||||
|
appsBefore, after)
|
||||||
|
}
|
||||||
|
if after := countRows(t, r, "assignments"); after != assignBefore {
|
||||||
|
t.Errorf("assignments: %d -> %d — a rejected batch left an assignment behind",
|
||||||
|
assignBefore, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
// Package orgctx carries the organization a request operates on.
|
// Package orgctx carries the organization a request operates on.
|
||||||
//
|
//
|
||||||
// Phase 2C has no authentication, so there is nothing to derive a tenant from.
|
// It predates authentication. Before there was a session to derive a tenant
|
||||||
// Rather than defaulting org_id deep inside the SQL — where it would have to be
|
// from, org_id was put on the request context by one middleware rather than
|
||||||
// unpicked from fourteen repositories once auth arrives — the value is put on
|
// defaulted deep inside the SQL, so that it would not have to be unpicked from
|
||||||
// the request context by one middleware and threaded explicitly through the
|
// fourteen repositories later. That handover has since happened: the
|
||||||
// service and repository boundaries.
|
// authenticate middleware in httpserver reads the organization off the user's
|
||||||
|
// row, and authctx.Identity.OrgID is what the service and repository layers
|
||||||
|
// actually read.
|
||||||
//
|
//
|
||||||
// When authentication lands, DevMiddleware is replaced by one that reads the
|
// What is still load-bearing here is DevOrgSlug and DevOrgName, which name the
|
||||||
// organization off the authenticated session. Nothing below this package
|
// organization the seeder creates. With and From remain the read/write pair for
|
||||||
// changes: every caller already takes an org id as a parameter.
|
// the context key the middleware still sets; retiring them in favour of
|
||||||
|
// authctx.Identity.OrgID alone is a deliberate change, not a tidy-up.
|
||||||
package orgctx
|
package orgctx
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
go-api/internal/service/interviews.go
Normal file
119
go-api/internal/service/interviews.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
// Completing an AI interview, in one transaction.
|
||||||
|
//
|
||||||
|
// THE PROBLEM THIS SOLVES
|
||||||
|
//
|
||||||
|
// Finishing an interview is two writes: the interview record, and the
|
||||||
|
// application it was for — which has to carry the verdict forward as
|
||||||
|
// `status: interview`, `interview_id` and the score, because that is what the
|
||||||
|
// funnel and the analytics read. The frontend performed them as two independent
|
||||||
|
// requests (AIInterviewModal.finishInterview), and that had two consequences.
|
||||||
|
//
|
||||||
|
// The first is authorization. `ai-interviews:Create` is open to everyone and
|
||||||
|
// `job-applications:Update` is operators only, so a talent user sitting their
|
||||||
|
// own interview — which is the whole talent flow — got a 201 for the interview
|
||||||
|
// and a 403 for the link. The interview existed, the application still said
|
||||||
|
// `applied`, `interview_id` was never set, and every consumer that counts
|
||||||
|
// `status === 'interview' || interview_id` could not see it.
|
||||||
|
//
|
||||||
|
// The second is atomicity: even for an operator, a failure between the two left
|
||||||
|
// an interview attached to an application that did not know about it.
|
||||||
|
//
|
||||||
|
// WHY THE SERVER MAY WRITE WHAT THE CALLER MAY NOT
|
||||||
|
//
|
||||||
|
// The link is not a widening of `job-applications:Update`. A talent caller
|
||||||
|
// still cannot PATCH an application — the policy table is unchanged, and the
|
||||||
|
// role gate on that route still refuses them. What happens here is that the
|
||||||
|
// server updates the one row the interview it just wrote already names, and
|
||||||
|
// only after repo.guardInsert has proved that row belongs to the caller: a
|
||||||
|
// talent caller creating an interview for an application that is not theirs is
|
||||||
|
// answered 404 before anything is written. That guard is exactly the ownership
|
||||||
|
// proof this update needs.
|
||||||
|
//
|
||||||
|
// The alternative — adding `talent` to `job-applications:Update` with a
|
||||||
|
// per-column allowlist — was rejected in the plan for the reason the workflows
|
||||||
|
// file header gives: it would put a second authorization mechanism beside the
|
||||||
|
// per-operation one, and the two would eventually disagree.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InterviewsPath is the resource whose Create is routed through here.
|
||||||
|
// Exported so the HTTP layer names the same resource this file special-cases,
|
||||||
|
// rather than repeating a string literal that could drift.
|
||||||
|
const InterviewsPath = "ai-interviews"
|
||||||
|
|
||||||
|
// CreateInterview inserts an interview and links its application, atomically.
|
||||||
|
//
|
||||||
|
// The response is the interview record, unchanged: POST /api/v1/ai-interviews
|
||||||
|
// answered 201 with the created interview before this existed and answers 201
|
||||||
|
// with the created interview now. The application update is a consequence of
|
||||||
|
// the request, not a second thing in it.
|
||||||
|
func (s *WorkflowService) CreateInterview(ctx context.Context, ident authctx.Identity,
|
||||||
|
body domain.Record) (domain.Record, error) {
|
||||||
|
|
||||||
|
interviews, err := resourceByPath(InterviewsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
apps, err := resourceByPath("job-applications")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var out domain.Record
|
||||||
|
err = s.inTx(ctx, func(tx pgx.Tx) error {
|
||||||
|
// Through the resource's own service over the transaction, so the body
|
||||||
|
// is validated and the ownership guard runs exactly as they do on the
|
||||||
|
// plain create path. Nothing about the interview itself changes here.
|
||||||
|
created, err := New(interviews, tx).Create(ctx, ident, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out = created
|
||||||
|
|
||||||
|
applicationID, _ := created["application_id"].(string)
|
||||||
|
if applicationID == "" {
|
||||||
|
// Unreachable: application_id is NOT NULL and Required, so the
|
||||||
|
// create above would have refused. Checked rather than assumed
|
||||||
|
// because the alternative is an Update against an empty id.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
patch := domain.Record{
|
||||||
|
"status": "interview",
|
||||||
|
"interview_id": created["id"],
|
||||||
|
}
|
||||||
|
// The score moves onto the application only when the caller said
|
||||||
|
// something about it. Copying the column unconditionally would write
|
||||||
|
// the interview's default 0 over a real screening score, which is a
|
||||||
|
// loss caused by a field the request never mentioned.
|
||||||
|
if _, said := body["overall_interview_score"]; said {
|
||||||
|
patch["ai_score"] = created["overall_interview_score"]
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := repo.New(apps, tx).Update(ctx, ident, applicationID, patch)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
// Unreachable for the same reason the guard above passed: the row
|
||||||
|
// is in this organization and, for a talent caller, theirs. Kept so
|
||||||
|
// a silent no-op cannot pass for a completed interview.
|
||||||
|
return domain.NotFound(apps.Name, applicationID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
103
go-api/internal/service/owliver.go
Normal file
103
go-api/internal/service/owliver.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/definition"
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/owliver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// allowedSuggestionParams names the accepted query parameters, on the pattern
|
||||||
|
// of allowedDefinitionFilters: anything else is a caller mistake worth saying
|
||||||
|
// out loud rather than a filter to ignore. It also keeps the endpoint from
|
||||||
|
// quietly accepting a `role`, `org` or `user` parameter should one ever be
|
||||||
|
// added by a client — who is asking is read from the session and nowhere else.
|
||||||
|
var allowedSuggestionParams = map[string]bool{
|
||||||
|
"page": true,
|
||||||
|
"query": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxEchoedPage bounds how much of a rejected page value is quoted back. Long
|
||||||
|
// enough to name every real surface, short enough that the error cannot be used
|
||||||
|
// to reflect a payload.
|
||||||
|
const maxEchoedPage = 64
|
||||||
|
|
||||||
|
// SuggestionQuery is a validated suggestion request.
|
||||||
|
//
|
||||||
|
// Page is canonical: aliases are resolved here so nothing downstream has to
|
||||||
|
// know that `hired` and `hired-history` are the same surface.
|
||||||
|
type SuggestionQuery struct {
|
||||||
|
Page string
|
||||||
|
Query string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestionsService answers "what could I usefully ask on this page?".
|
||||||
|
//
|
||||||
|
// It holds no pool, opens no transaction and reads no table. That is not an
|
||||||
|
// omission — the panel calls it while the user types, and everything it needs
|
||||||
|
// is the static catalogue in internal/owliver plus the caller's role. It is a
|
||||||
|
// service rather than a function in the handler so that validation and
|
||||||
|
// authorization sit where every other endpoint's do.
|
||||||
|
type SuggestionsService struct{}
|
||||||
|
|
||||||
|
// NewSuggestions builds the suggestion service.
|
||||||
|
func NewSuggestions() *SuggestionsService { return &SuggestionsService{} }
|
||||||
|
|
||||||
|
// ParseParams validates the query string.
|
||||||
|
//
|
||||||
|
// `page` is required and must name a real surface — the same closed vocabulary
|
||||||
|
// internal/definition validates a definition's `pages:` against, so there is
|
||||||
|
// one answer to "is that a page" in this process. `query` is optional: an
|
||||||
|
// absent or too-short one is not an error, it is a request that has nothing to
|
||||||
|
// rank yet, and Suggest answers it with an empty list.
|
||||||
|
func (s *SuggestionsService) ParseParams(q url.Values) (SuggestionQuery, error) {
|
||||||
|
var out SuggestionQuery
|
||||||
|
|
||||||
|
for name := range q {
|
||||||
|
if !allowedSuggestionParams[name] {
|
||||||
|
return out, domain.Invalid(fmt.Sprintf("unknown parameter %q", name))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := strings.TrimSpace(q.Get("page"))
|
||||||
|
if raw == "" {
|
||||||
|
return out, domain.Invalid("page is required")
|
||||||
|
}
|
||||||
|
page := definition.CanonicalPage(raw)
|
||||||
|
if page == "" {
|
||||||
|
echoed := raw
|
||||||
|
if len(echoed) > maxEchoedPage {
|
||||||
|
echoed = echoed[:maxEchoedPage]
|
||||||
|
}
|
||||||
|
return out, domain.Invalid(fmt.Sprintf(
|
||||||
|
"Unsupported page: %s. Supported pages: %s.",
|
||||||
|
echoed, strings.Join(definition.SupportedPages, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
out.Page = page
|
||||||
|
out.Query = q.Get("query")
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suggest ranks the page's readings for this caller.
|
||||||
|
//
|
||||||
|
// The role comes off the session-resolved identity, exactly as Server.authorize
|
||||||
|
// reads it, and an unrecognised role is offered nothing — the same deny-by-
|
||||||
|
// default the policy table applies. Nothing else about the caller is consulted:
|
||||||
|
// there is no branch here on organization, account type or anything a request
|
||||||
|
// could set.
|
||||||
|
//
|
||||||
|
// No error case beyond parsing. A page with no readings for this caller, and a
|
||||||
|
// query that matches none of them, both answer with an empty list — an empty
|
||||||
|
// result is an answer, not a failure.
|
||||||
|
func (s *SuggestionsService) Suggest(ident authctx.Identity, q SuggestionQuery) []owliver.Suggestion {
|
||||||
|
role, known := domain.ParseRole(ident.Role)
|
||||||
|
if !known {
|
||||||
|
return []owliver.Suggestion{}
|
||||||
|
}
|
||||||
|
return owliver.Suggest(q.Page, q.Query, role)
|
||||||
|
}
|
||||||
@@ -150,7 +150,7 @@ func (s *Service) Get(ctx context.Context, ident authctx.Identity, id string) (d
|
|||||||
|
|
||||||
// Create validates and inserts, returning the complete stored record.
|
// Create validates and inserts, returning the complete stored record.
|
||||||
func (s *Service) Create(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) {
|
func (s *Service) Create(ctx context.Context, ident authctx.Identity, body domain.Record) (domain.Record, error) {
|
||||||
clean, err := s.validate(body, true)
|
clean, err := s.validate(ident, body, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -162,7 +162,7 @@ func (s *Service) Update(ctx context.Context, ident authctx.Identity, id string,
|
|||||||
if !isUUID(id) {
|
if !isUUID(id) {
|
||||||
return nil, domain.NotFound(s.res.Name, id)
|
return nil, domain.NotFound(s.res.Name, id)
|
||||||
}
|
}
|
||||||
clean, err := s.validate(patch, false)
|
clean, err := s.validate(ident, patch, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -201,7 +201,11 @@ func (s *Service) Delete(ctx context.Context, ident authctx.Identity, id string)
|
|||||||
// exactly how `interview_id`, `training_outline` and `score_breakdown` would
|
// exactly how `interview_id`, `training_outline` and `score_breakdown` would
|
||||||
// have been lost: the frontend would have written them, the API would have
|
// have been lost: the frontend would have written them, the API would have
|
||||||
// accepted the request, and the data would never have arrived.
|
// accepted the request, and the data would never have arrived.
|
||||||
func (s *Service) validate(in domain.Record, isCreate bool) (domain.Record, error) {
|
//
|
||||||
|
// The identity is a parameter because "the server will supply this column"
|
||||||
|
// is not a property of the column alone: a talent-only derivation supplies it
|
||||||
|
// for a talent caller and for nobody else. See serverSupplies.
|
||||||
|
func (s *Service) validate(ident authctx.Identity, in domain.Record, isCreate bool) (domain.Record, error) {
|
||||||
details := map[string]string{}
|
details := map[string]string{}
|
||||||
out := make(domain.Record, len(in))
|
out := make(domain.Record, len(in))
|
||||||
|
|
||||||
@@ -237,7 +241,7 @@ func (s *Service) validate(in domain.Record, isCreate bool) (domain.Record, erro
|
|||||||
if !col.Required {
|
if !col.Required {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if s.serverSupplies(col.Name) {
|
if s.serverSupplies(col.Name, ident) {
|
||||||
// The repository fills this from the session, so demanding it
|
// The repository fills this from the session, so demanding it
|
||||||
// from the caller would reject a request the server is about to
|
// from the caller would reject a request the server is about to
|
||||||
// complete correctly. evidence.worker_email is the live case.
|
// complete correctly. evidence.worker_email is the live case.
|
||||||
@@ -262,13 +266,24 @@ func (s *Service) validate(in domain.Record, isCreate bool) (domain.Record, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// serverSupplies reports whether a column is filled in from the authenticated
|
// serverSupplies reports whether a column is filled in from the authenticated
|
||||||
// session rather than from the request body.
|
// session rather than from the request body, FOR THIS CALLER.
|
||||||
func (s *Service) serverSupplies(name string) bool {
|
//
|
||||||
|
// The caller matters. A TalentOnly derivation records who the row is ABOUT, and
|
||||||
|
// the repository fills it for a talent caller only — when an operator files an
|
||||||
|
// application or logs evidence on somebody else's behalf, the subject is not
|
||||||
|
// the operator, so nothing is derived and the value has to come from the body.
|
||||||
|
// Treating those columns as server-supplied for every role was how an operator
|
||||||
|
// creating a job application without an email got as far as SQL and came back
|
||||||
|
// with a not-null violation instead of the required-field message the contract
|
||||||
|
// promises. It must agree with repo.derivedValues, which decides the same thing
|
||||||
|
// on the write path.
|
||||||
|
func (s *Service) serverSupplies(name string, ident authctx.Identity) bool {
|
||||||
if s.res.Policy == nil {
|
if s.res.Policy == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
isTalent := ident.Role == string(domain.RoleTalent)
|
||||||
for _, d := range s.res.Policy.Derived {
|
for _, d := range s.res.Policy.Derived {
|
||||||
if d.Column == name {
|
if d.Column == name && (!d.TalentOnly || isTalent) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The two callers validation distinguishes. Only the role is read.
|
||||||
|
var (
|
||||||
|
asOperator = authctx.Identity{Role: string(domain.RoleAdmin)}
|
||||||
|
asTalent = authctx.Identity{Role: string(domain.RoleTalent)}
|
||||||
|
)
|
||||||
|
|
||||||
// These exercise query parsing and validation without a database, so the
|
// These exercise query parsing and validation without a database, so the
|
||||||
// contract's defaults are pinned even when PostgreSQL is not available.
|
// contract's defaults are pinned even when PostgreSQL is not available.
|
||||||
|
|
||||||
@@ -139,24 +147,24 @@ func TestReservedParametersAreNotFilters(t *testing.T) {
|
|||||||
func TestValidateRequiredAndUnknownAndEnum(t *testing.T) {
|
func TestValidateRequiredAndUnknownAndEnum(t *testing.T) {
|
||||||
svc := New(resource(t, "job-postings"), nil)
|
svc := New(resource(t, "job-postings"), nil)
|
||||||
|
|
||||||
if _, err := svc.validate(domain.Record{}, true); err == nil {
|
if _, err := svc.validate(asOperator, domain.Record{}, true); err == nil {
|
||||||
t.Error("a create with no title was accepted")
|
t.Error("a create with no title was accepted")
|
||||||
}
|
}
|
||||||
if _, err := svc.validate(domain.Record{"title": " "}, true); err == nil {
|
if _, err := svc.validate(asOperator, domain.Record{"title": " "}, true); err == nil {
|
||||||
t.Error("a blank title was accepted")
|
t.Error("a blank title was accepted")
|
||||||
}
|
}
|
||||||
if _, err := svc.validate(domain.Record{"title": "X", "bogus": 1}, true); err == nil {
|
if _, err := svc.validate(asOperator, domain.Record{"title": "X", "bogus": 1}, true); err == nil {
|
||||||
t.Error("an unknown field was accepted")
|
t.Error("an unknown field was accepted")
|
||||||
}
|
}
|
||||||
if _, err := svc.validate(domain.Record{"title": "X", "status": "archived"}, true); err == nil {
|
if _, err := svc.validate(asOperator, domain.Record{"title": "X", "status": "archived"}, true); err == nil {
|
||||||
t.Error("an invalid enum value was accepted")
|
t.Error("an invalid enum value was accepted")
|
||||||
}
|
}
|
||||||
if _, err := svc.validate(domain.Record{"title": "X", "status": "active"}, true); err != nil {
|
if _, err := svc.validate(asOperator, domain.Record{"title": "X", "status": "active"}, true); err != nil {
|
||||||
t.Errorf("a valid payload was rejected: %v", err)
|
t.Errorf("a valid payload was rejected: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-owned fields are stripped, not rejected.
|
// Server-owned fields are stripped, not rejected.
|
||||||
out, err := svc.validate(domain.Record{"title": "X", "id": "abc", "org_id": "def"}, true)
|
out, err := svc.validate(asOperator, domain.Record{"title": "X", "id": "abc", "org_id": "def"}, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("server-owned fields caused a rejection: %v", err)
|
t.Fatalf("server-owned fields caused a rejection: %v", err)
|
||||||
}
|
}
|
||||||
@@ -168,11 +176,62 @@ func TestValidateRequiredAndUnknownAndEnum(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// An update needs no required fields — it is a partial by definition.
|
// An update needs no required fields — it is a partial by definition.
|
||||||
if _, err := svc.validate(domain.Record{"location": "Here"}, false); err != nil {
|
if _, err := svc.validate(asOperator, domain.Record{"location": "Here"}, false); err != nil {
|
||||||
t.Errorf("a partial update was rejected: %v", err)
|
t.Errorf("a partial update was rejected: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A talent-only derivation is only server-supplied for a talent caller.
|
||||||
|
//
|
||||||
|
// The column records who the row is ABOUT, and the repository fills it from the
|
||||||
|
// session for talent and for nobody else (repo.derivedValues). Validation has
|
||||||
|
// to agree: an operator filing an application for somebody else must be told
|
||||||
|
// `email` is required, rather than being let through to a not-null violation
|
||||||
|
// from SQL — and a talent caller must not be asked for the value the server is
|
||||||
|
// about to override anyway.
|
||||||
|
func TestValidateHonoursTalentOnlyDerivation(t *testing.T) {
|
||||||
|
apps := New(resource(t, "job-applications"), nil)
|
||||||
|
body := domain.Record{
|
||||||
|
"job_posting_id": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"applicant_name": "Someone",
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := apps.validate(asTalent, body, true); err != nil {
|
||||||
|
t.Errorf("a talent create without email was rejected: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := apps.validate(asOperator, body, true)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("an operator create without email was accepted")
|
||||||
|
}
|
||||||
|
var apiErr *domain.Error
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
t.Fatalf("error is not an API error: %v", err)
|
||||||
|
}
|
||||||
|
if apiErr.Details["email"] != "required" {
|
||||||
|
t.Errorf("details = %v, want email: required", apiErr.Details)
|
||||||
|
}
|
||||||
|
|
||||||
|
// evidence.worker_email is the same shape, and the case the original
|
||||||
|
// comment in serverSupplies was written for.
|
||||||
|
ev := New(resource(t, "evidence"), nil)
|
||||||
|
if _, err := ev.validate(asTalent, domain.Record{"type": "photo_identify"}, true); err != nil {
|
||||||
|
t.Errorf("a talent evidence create without worker_email was rejected: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ev.validate(asOperator, domain.Record{"type": "photo_identify"}, true); err == nil {
|
||||||
|
t.Error("an operator evidence create without worker_email was accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A derivation that is NOT talent-only stays server-supplied for everyone:
|
||||||
|
// user_activity records who acted, whoever that is.
|
||||||
|
act := New(resource(t, "user-activity"), nil)
|
||||||
|
for name, ident := range map[string]authctx.Identity{"operator": asOperator, "talent": asTalent} {
|
||||||
|
if _, err := act.validate(ident, domain.Record{"event_type": "x"}, true); err != nil {
|
||||||
|
t.Errorf("%s: an activity create was rejected: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsUUID(t *testing.T) {
|
func TestIsUUID(t *testing.T) {
|
||||||
valid := []string{
|
valid := []string{
|
||||||
"00000000-0000-0000-0000-000000000000",
|
"00000000-0000-0000-0000-000000000000",
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ func (s *WorkflowService) Hire(ctx context.Context, ident authctx.Identity,
|
|||||||
// happened without a log entry, or a log entry for a hire that rolled
|
// happened without a log entry, or a log entry for a hire that rolled
|
||||||
// back, are both worse than neither.
|
// back, are both worse than neither.
|
||||||
if _, err := repo.New(activity, tx).Insert(ctx, ident, domain.Record{
|
if _, err := repo.New(activity, tx).Insert(ctx, ident, domain.Record{
|
||||||
"event_type": "candidate_hired",
|
"event_type": "hire_candidate",
|
||||||
"details": fmt.Sprintf("%s was hired", name),
|
"details": fmt.Sprintf("%s was hired", name),
|
||||||
"application_id": applicationID,
|
"application_id": applicationID,
|
||||||
"position_id": app["job_posting_id"],
|
"position_id": app["job_posting_id"],
|
||||||
@@ -230,6 +230,11 @@ func (s *WorkflowService) Hire(ctx context.Context, ident authctx.Identity,
|
|||||||
/* ── Assign ─────────────────────────────────────────────────────────────── */
|
/* ── Assign ─────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
// AssignWorker is one worker in an assign request.
|
// AssignWorker is one worker in an assign request.
|
||||||
|
//
|
||||||
|
// Three ways of naming the application this placement belongs to, in order of
|
||||||
|
// precedence: an id the caller already has, an `application` payload to find or
|
||||||
|
// file one from, and neither — a worker placed straight from the talent pool,
|
||||||
|
// who has no application and is not given an invented one.
|
||||||
type AssignWorker struct {
|
type AssignWorker struct {
|
||||||
WorkerEmail string `json:"worker_email"`
|
WorkerEmail string `json:"worker_email"`
|
||||||
WorkerName string `json:"worker_name"`
|
WorkerName string `json:"worker_name"`
|
||||||
@@ -239,6 +244,17 @@ type AssignWorker struct {
|
|||||||
WorkerProfileID *string `json:"worker_profile_id"`
|
WorkerProfileID *string `json:"worker_profile_id"`
|
||||||
MatchScore *int `json:"match_score"`
|
MatchScore *int `json:"match_score"`
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
|
|
||||||
|
// Application is the application to attach this worker to when no
|
||||||
|
// ApplicationID is supplied: the same fields POST /job-applications takes.
|
||||||
|
//
|
||||||
|
// It exists because an application is what puts a person in the pipeline
|
||||||
|
// for a role, and every downstream step keys on it — the candidate record
|
||||||
|
// is addressed by it, and an AI interview takes one as its subject. A
|
||||||
|
// worker assigned without one is unreachable: nothing to open, and nobody
|
||||||
|
// to interview. The frontend was creating it in a second, untransacted
|
||||||
|
// request; this carries it into the same transaction as the assignment.
|
||||||
|
Application *domain.Record `json:"application"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AssignRequest is the body of POST /job-postings/{id}/assignments.
|
// AssignRequest is the body of POST /job-postings/{id}/assignments.
|
||||||
@@ -258,6 +274,11 @@ type AssignResult struct {
|
|||||||
// request and one transaction. All-or-nothing across the whole batch: assigning
|
// request and one transaction. All-or-nothing across the whole batch: assigning
|
||||||
// six workers and having the fourth fail should not leave three assigned, three
|
// six workers and having the fourth fail should not leave three assigned, three
|
||||||
// not, and the caller unsure which.
|
// not, and the caller unsure which.
|
||||||
|
//
|
||||||
|
// Per worker the flow is: settle the application (see settleApplication —
|
||||||
|
// patch the one named, find-or-file the one described, or neither), insert the
|
||||||
|
// assignment linked to whatever that produced, and write the audit entry. The
|
||||||
|
// application comes first because the assignment references it.
|
||||||
func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
|
func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
|
||||||
postingID string, req AssignRequest) (*AssignResult, error) {
|
postingID string, req AssignRequest) (*AssignResult, error) {
|
||||||
|
|
||||||
@@ -324,22 +345,43 @@ func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
|
|||||||
|
|
||||||
assignRepo := repo.New(assignments, tx)
|
assignRepo := repo.New(assignments, tx)
|
||||||
appRepo := repo.New(apps, tx)
|
appRepo := repo.New(apps, tx)
|
||||||
|
// The application is filed through the service rather than the
|
||||||
|
// repository so that a payload the caller sent is validated exactly as
|
||||||
|
// POST /job-applications would validate it — unknown fields rejected,
|
||||||
|
// enums checked, required fields demanded — instead of reaching SQL and
|
||||||
|
// coming back as a constraint violation.
|
||||||
|
appSvc := New(apps, tx)
|
||||||
activityRepo := repo.New(activity, tx)
|
activityRepo := repo.New(activity, tx)
|
||||||
|
|
||||||
for i, w := range req.Workers {
|
for i, w := range req.Workers {
|
||||||
|
// The application is settled BEFORE the assignment is written,
|
||||||
|
// because the assignment carries the reference to it and a row
|
||||||
|
// cannot point at one that does not exist yet. An empty id means
|
||||||
|
// this worker legitimately has no application.
|
||||||
|
applicationID, err := s.settleApplication(ctx, ident, appRepo, appSvc, postingID, w)
|
||||||
|
if err != nil {
|
||||||
|
return annotate(err, i)
|
||||||
|
}
|
||||||
|
|
||||||
record := domain.Record{
|
record := domain.Record{
|
||||||
"job_posting_id": postingID,
|
"job_posting_id": postingID,
|
||||||
"worker_email": w.WorkerEmail,
|
"worker_email": w.WorkerEmail,
|
||||||
"worker_name": w.WorkerName,
|
"worker_name": w.WorkerName,
|
||||||
"starts_at": w.StartsAt,
|
"starts_at": w.StartsAt,
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"source": orDefault(w.Source, "manual"),
|
// `owliver` is the column's own default (000001:511) and what
|
||||||
|
// the frontend sends. Substituting `manual` here made the API
|
||||||
|
// and the schema disagree about what an unspecified source
|
||||||
|
// means, so a row written through this endpoint and a row
|
||||||
|
// written through POST /assignments recorded different origins
|
||||||
|
// for the same action.
|
||||||
|
"source": orDefault(w.Source, "owliver"),
|
||||||
}
|
}
|
||||||
if w.EndsAt != nil {
|
if w.EndsAt != nil {
|
||||||
record["ends_at"] = *w.EndsAt
|
record["ends_at"] = *w.EndsAt
|
||||||
}
|
}
|
||||||
if w.ApplicationID != nil {
|
if applicationID != "" {
|
||||||
record["application_id"] = *w.ApplicationID
|
record["application_id"] = applicationID
|
||||||
}
|
}
|
||||||
if w.WorkerProfileID != nil {
|
if w.WorkerProfileID != nil {
|
||||||
record["worker_profile_id"] = *w.WorkerProfileID
|
record["worker_profile_id"] = *w.WorkerProfileID
|
||||||
@@ -354,29 +396,14 @@ func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
|
|||||||
}
|
}
|
||||||
out.Assignments = append(out.Assignments, created)
|
out.Assignments = append(out.Assignments, created)
|
||||||
|
|
||||||
// The application moves to `assigned` only when one was named. A
|
|
||||||
// worker can be placed without having applied — that is what the
|
|
||||||
// talent pool is for — and inventing an application for them would
|
|
||||||
// be worse than leaving the link absent.
|
|
||||||
if w.ApplicationID != nil {
|
|
||||||
updated, err := appRepo.Update(ctx, ident, *w.ApplicationID,
|
|
||||||
domain.Record{"status": "assigned"})
|
|
||||||
if err != nil {
|
|
||||||
return annotate(err, i)
|
|
||||||
}
|
|
||||||
if updated == nil {
|
|
||||||
return domain.NotFound("JobApplication", *w.ApplicationID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := domain.Record{
|
entry := domain.Record{
|
||||||
"event_type": "worker_assigned",
|
"event_type": "assign_employee",
|
||||||
"details": fmt.Sprintf("%s was assigned", orDefault(w.WorkerName, w.WorkerEmail)),
|
"details": fmt.Sprintf("%s was assigned", orDefault(w.WorkerName, w.WorkerEmail)),
|
||||||
"position_id": postingID,
|
"position_id": postingID,
|
||||||
"worker_email": w.WorkerEmail,
|
"worker_email": w.WorkerEmail,
|
||||||
}
|
}
|
||||||
if w.ApplicationID != nil {
|
if applicationID != "" {
|
||||||
entry["application_id"] = *w.ApplicationID
|
entry["application_id"] = applicationID
|
||||||
}
|
}
|
||||||
if _, err := activityRepo.Insert(ctx, ident, entry); err != nil {
|
if _, err := activityRepo.Insert(ctx, ident, entry); err != nil {
|
||||||
return annotate(err, i)
|
return annotate(err, i)
|
||||||
@@ -391,6 +418,122 @@ func (s *WorkflowService) Assign(ctx context.Context, ident authctx.Identity,
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// settleApplication resolves the application an assignment belongs to and
|
||||||
|
// returns its id, or "" when this worker has none.
|
||||||
|
//
|
||||||
|
// Three cases, and the middle one is why this exists:
|
||||||
|
//
|
||||||
|
// - an id was supplied — move that application to `assigned`. Unchanged
|
||||||
|
// behaviour, and it still wins over any payload, because an id is a
|
||||||
|
// decision the caller has already made.
|
||||||
|
// - an `application` payload was supplied — find the application on this
|
||||||
|
// posting for this email, and move it to `assigned` if there is one or file
|
||||||
|
// it if there is not. (job_posting_id, email) is UNIQUE
|
||||||
|
// (job_applications_posting_email_key, 000001), so there is at most one to
|
||||||
|
// find and the insert cannot produce a second.
|
||||||
|
// - neither — nothing. A worker placed straight from the talent pool has no
|
||||||
|
// application, and inventing one for them would be worse than leaving the
|
||||||
|
// link absent.
|
||||||
|
//
|
||||||
|
// Everything here runs inside the caller's transaction, which is what makes the
|
||||||
|
// find-or-create safe: a concurrent assign of the same person to the same
|
||||||
|
// posting blocks on the unique index and is then reported as a conflict, rather
|
||||||
|
// than racing past the lookup and writing a duplicate.
|
||||||
|
func (s *WorkflowService) settleApplication(ctx context.Context, ident authctx.Identity,
|
||||||
|
appRepo *repo.Repo, appSvc *Service, postingID string, w AssignWorker) (string, error) {
|
||||||
|
|
||||||
|
if w.ApplicationID != nil {
|
||||||
|
updated, err := appRepo.Update(ctx, ident, *w.ApplicationID,
|
||||||
|
domain.Record{"status": "assigned"})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
return "", domain.NotFound("JobApplication", *w.ApplicationID)
|
||||||
|
}
|
||||||
|
return *w.ApplicationID, nil
|
||||||
|
}
|
||||||
|
if w.Application == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := findApplication(ctx, ident, appRepo, postingID, w.WorkerEmail)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
id, _ := existing["id"].(string)
|
||||||
|
updated, err := appRepo.Update(ctx, ident, id, domain.Record{"status": "assigned"})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if updated == nil {
|
||||||
|
return "", domain.NotFound("JobApplication", id)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The posting and the person come from the assignment, not from the
|
||||||
|
// payload. A body naming a different posting or a different email would
|
||||||
|
// file an application about somebody other than the worker being placed,
|
||||||
|
// and the lookup above would never find it again.
|
||||||
|
record := domain.Record{}
|
||||||
|
for k, v := range *w.Application {
|
||||||
|
record[k] = v
|
||||||
|
}
|
||||||
|
record["job_posting_id"] = postingID
|
||||||
|
record["email"] = w.WorkerEmail
|
||||||
|
if _, ok := record["applicant_name"]; !ok {
|
||||||
|
record["applicant_name"] = w.WorkerName
|
||||||
|
}
|
||||||
|
if _, ok := record["status"]; !ok {
|
||||||
|
record["status"] = "assigned"
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := appSvc.Create(ctx, ident, record)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
id, _ := created["id"].(string)
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findApplication returns this posting's application for this email, or nil.
|
||||||
|
//
|
||||||
|
// The read goes through the repository so it carries the same organization
|
||||||
|
// scope and ownership predicate every other read does — an application in
|
||||||
|
// another tenant is not found, rather than found and then refused. The email
|
||||||
|
// column is citext, so the comparison is case-insensitive: the same equality
|
||||||
|
// the frontend performed with toLowerCase before it had this endpoint.
|
||||||
|
func findApplication(ctx context.Context, ident authctx.Identity, appRepo *repo.Repo,
|
||||||
|
postingID, email string) (domain.Record, error) {
|
||||||
|
|
||||||
|
res := appRepo.Resource()
|
||||||
|
postingCol, ok := res.Column("job_posting_id")
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.Internal(errors.New("service: job_applications has no job_posting_id column"))
|
||||||
|
}
|
||||||
|
emailCol, ok := res.Column("email")
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.Internal(errors.New("service: job_applications has no email column"))
|
||||||
|
}
|
||||||
|
|
||||||
|
page, err := appRepo.List(ctx, ident, domain.ListParams{
|
||||||
|
Limit: 1,
|
||||||
|
Filters: []domain.Filter{
|
||||||
|
{Column: postingCol, Values: []string{postingID}},
|
||||||
|
{Column: emailCol, Values: []string{email}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(page.Records) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return page.Records[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
// pick takes the caller's value for a key when they supplied a usable one, and
|
// pick takes the caller's value for a key when they supplied a usable one, and
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ What lands here in later phases, once each is actually approved:
|
|||||||
| File | Phase | Contents |
|
| File | Phase | Contents |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `docker-compose.dev.yml` | 2 | PostgreSQL + pgvector, so the dev database stops being a machine-local install |
|
| `docker-compose.dev.yml` | 2 | PostgreSQL + pgvector, so the dev database stops being a machine-local install |
|
||||||
| `docker-compose.dev.yml` (extended) | later | Redis, NATS, MinIO — each only when the phase that needs it starts |
|
| `docker-compose.dev.yml` (extended) | later | Redis, MinIO — each only when the phase that needs it starts |
|
||||||
| `Dockerfile.api` | later | Multi-stage build for `go-api` |
|
| `Dockerfile.api` | later | Multi-stage build for `go-api` |
|
||||||
| `Dockerfile.owliver` | later | The Python service |
|
| `Dockerfile.owliver` | later | The Python service |
|
||||||
| `otel-collector.yaml` | later | OpenTelemetry collector config |
|
| `otel-collector.yaml` | later | OpenTelemetry collector config |
|
||||||
|
|
||||||
|
NATS is deliberately absent from that table: it is not part of the target
|
||||||
|
architecture, and nothing here should reintroduce it.
|
||||||
|
|
||||||
Adding any of these before its phase would be speculative, so the directory
|
Adding any of these before its phase would be speculative, so the directory
|
||||||
holds only this note for now.
|
holds only this note for now.
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
*
|
*
|
||||||
* Runs the REAL frontend module graph through Vite — `import.meta.glob`, the
|
* Runs the REAL frontend module graph through Vite — `import.meta.glob`, the
|
||||||
* `@/` alias and raw Markdown loading behave exactly as they do in the app, the
|
* `@/` alias and raw Markdown loading behave exactly as they do in the app, the
|
||||||
* same technique `scripts/skill-check.mjs` uses. A mock of the registry would
|
* same technique the frontend's own `scripts/skill-check.mjs` uses (that file
|
||||||
* reproduce none of the behaviour this file exists to capture.
|
* lives in krow-demo, not here). A mock of the registry would reproduce none of
|
||||||
|
* the behaviour this file exists to capture.
|
||||||
*
|
*
|
||||||
* Emits one JSON document: for every shipped definition and every adversarial
|
* Emits one JSON document: for every shipped definition and every adversarial
|
||||||
* case, what the JS parser did with it. That document is the fixture the Go
|
* case, what the JS parser did with it. That document is the fixture the Go
|
||||||
|
|||||||
Reference in New Issue
Block a user