agnets done
This commit is contained in:
54
.env
54
.env
@@ -1,41 +1,19 @@
|
|||||||
# ============================================================================
|
# Local development against the deployed backend.
|
||||||
# Krow frontend — example environment
|
|
||||||
#
|
#
|
||||||
# Copy to .env and adjust. .env is gitignored.
|
# The dev server proxies /api and /health to VITE_API_PROXY_TARGET, so the app
|
||||||
#
|
# talks to the deployment through its own origin — which is what keeps the
|
||||||
# cp .env.example .env
|
# session cookie (Secure, SameSite=Lax, host-scoped) working in the browser.
|
||||||
#
|
|
||||||
# Vite only exposes variables prefixed with VITE_ to client code, and it reads
|
|
||||||
# these at build/dev-server start — changing one needs a restart, not a reload.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
# Where the browser sends API requests.
|
|
||||||
#
|
|
||||||
# A SAME-ORIGIN PATH, not a host. The browser asks its own origin for
|
|
||||||
# /api/v1/..., and something on that origin forwards it to the Go API:
|
|
||||||
# in development the Vite proxy (see `server.proxy` in vite.config.js), in
|
|
||||||
# production the same web server that serves the built assets (see nginx.conf).
|
|
||||||
#
|
|
||||||
# browser → localhost:5173/api/v1 → Vite proxy → 127.0.0.1:8080/api/v1
|
|
||||||
#
|
|
||||||
# THIS MUST STAY A PATH. Pointing it at http://127.0.0.1:8080/api/v1 makes every
|
|
||||||
# request cross-site, and the session cookie stops working in two separate ways:
|
|
||||||
#
|
|
||||||
# 1. The cookie is SameSite=Lax, and a Lax cookie is not sent on a cross-site
|
|
||||||
# subresource request. A browser treats localhost:5173 and 127.0.0.1:8080
|
|
||||||
# as different sites, so the cookie would be set at login and then never
|
|
||||||
# sent again.
|
|
||||||
# 2. Because the transport sends `credentials: 'include'`, the browser
|
|
||||||
# requires `Access-Control-Allow-Credentials: true` on the preflight
|
|
||||||
# response. The API does not send it — deliberately, because the supported
|
|
||||||
# arrangement is same-origin — so Chrome discards the preflight and never
|
|
||||||
# dispatches the real request. The symptom is an OPTIONS that answers 204
|
|
||||||
# followed by a POST that never reaches the server at all.
|
|
||||||
#
|
|
||||||
# Both failures are silent from the page's point of view, which is why this
|
|
||||||
# comment is longer than the value.
|
|
||||||
VITE_API_BASE_URL=/api/v1
|
VITE_API_BASE_URL=/api/v1
|
||||||
|
|
||||||
# Where the Vite dev proxy forwards /api. Only read by vite.config.js, never by
|
|
||||||
# client code.
|
|
||||||
VITE_API_PROXY_TARGET=https://mcp.krowforce.com
|
VITE_API_PROXY_TARGET=https://mcp.krowforce.com
|
||||||
|
|
||||||
|
# Owliver's agent endpoint.
|
||||||
|
#
|
||||||
|
# Relative FOR DEVELOPMENT ONLY: vite proxies it to VITE_API_PROXY_TARGET above.
|
||||||
|
# The production image cannot use a relative value — nginx serves it as a static
|
||||||
|
# path and answers a run POST with 405 — so Dockerfile passes an absolute URL as
|
||||||
|
# a build arg instead. Changing this file does not change what ships.
|
||||||
|
#
|
||||||
|
# Empty is what it was, and an empty value is not a broken panel: the provider
|
||||||
|
# answers every question with "Owliver is not configured on this deployment",
|
||||||
|
# deliberately, because a panel that quietly does nothing is harder to diagnose.
|
||||||
|
VITE_AGENT_API=/api/v1
|
||||||
|
|||||||
19
.env.example
19
.env.example
@@ -56,3 +56,22 @@ VITE_API_BASE_URL=/api/v1
|
|||||||
# Where the Vite dev proxy forwards /api. Only read by vite.config.js, never by
|
# Where the Vite dev proxy forwards /api. Only read by vite.config.js, never by
|
||||||
# client code.
|
# client code.
|
||||||
VITE_API_PROXY_TARGET=https://mcp.krowforce.com
|
VITE_API_PROXY_TARGET=https://mcp.krowforce.com
|
||||||
|
|
||||||
|
# ── The agent runtime ───────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Set this and the assistant panel stops simulating and starts calling the real
|
||||||
|
# backend: a model, the tool layer, permission-aware retrieval, and a write path
|
||||||
|
# that cannot act without a person approving it.
|
||||||
|
#
|
||||||
|
# VITE_AGENT_API=/api/v1
|
||||||
|
#
|
||||||
|
# Same-origin path, like VITE_API_BASE — the proxy above forwards it. Unset by
|
||||||
|
# default, deliberately: with no value the panel uses the local simulator, which
|
||||||
|
# is deterministic, costs nothing, and is what every check in `npm test` runs
|
||||||
|
# against. Making the real API the default would make the whole suite depend on
|
||||||
|
# a credential.
|
||||||
|
#
|
||||||
|
# The backend must be running with ANTHROPIC_API_KEY set. Without one it does
|
||||||
|
# not register the run routes at all, and the panel will report that no agent is
|
||||||
|
# available — which is true, rather than a broken-looking 500.
|
||||||
|
VITE_AGENT_API=
|
||||||
|
|||||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,4 +1,9 @@
|
|||||||
#env
|
# Local configuration is never committed. This rule read "#env" — commented out,
|
||||||
|
# so it matched nothing and every .env in this directory was committable. The
|
||||||
|
# `!.env.example` negation below only makes sense against a rule that excludes
|
||||||
|
# them, which is what gave it away.
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
# ...but the template belongs in the repository: it is the one place the API's
|
# ...but the template belongs in the repository: it is the one place the API's
|
||||||
# location is documented, and a checkout with no .env needs it.
|
# location is documented, and a checkout with no .env needs it.
|
||||||
|
|||||||
15
Dockerfile
15
Dockerfile
@@ -33,6 +33,21 @@ COPY . .
|
|||||||
ARG VITE_API_BASE_URL=https://mcp.krowforce.com/api/v1
|
ARG VITE_API_BASE_URL=https://mcp.krowforce.com/api/v1
|
||||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||||
|
|
||||||
|
# Owliver's agent endpoint, for exactly the same reason and with exactly the
|
||||||
|
# same failure if it is missing.
|
||||||
|
#
|
||||||
|
# In development this is left relative and vite's proxy carries it to the
|
||||||
|
# backend, which is why the .env in the repository sets `/api/v1`. That value
|
||||||
|
# cannot reach this build — .dockerignore excludes .env* — and a relative path
|
||||||
|
# here would be served by the nginx stage as a static file, so a run POST would
|
||||||
|
# come back 405 Method Not Allowed rather than an answer.
|
||||||
|
#
|
||||||
|
# Empty is not neutral either: with no value the panel reports that no agent is
|
||||||
|
# configured, which is honest but is not what a deployment carrying agents
|
||||||
|
# should say.
|
||||||
|
ARG VITE_AGENT_API=https://mcp.krowforce.com/api/v1
|
||||||
|
ENV VITE_AGENT_API=$VITE_AGENT_API
|
||||||
|
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Runtime stage ----
|
# ---- Runtime stage ----
|
||||||
|
|||||||
@@ -263,14 +263,21 @@ ids, so they are suppression preferences over a namespace, not definitions.
|
|||||||
`GET /api/v1/owliver/suggestions?page={surface}&query={typed}` — specified in
|
`GET /api/v1/owliver/suggestions?page={surface}&query={typed}` — specified in
|
||||||
the backend contract's §2A.
|
the backend contract's §2A.
|
||||||
|
|
||||||
**Called, registered in backend source, and NOT deployed.** This is the one row
|
**Called, registered in backend source, and NOT deployed on every host.** This
|
||||||
in this document where those three come apart, so it is worth stating plainly:
|
is the one row in this document where those three come apart, so it is worth
|
||||||
|
stating plainly:
|
||||||
|
|
||||||
| | |
|
| | |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Frontend calls it | `src/api/base44Client.js:300`, via `useSuggestions.js` |
|
| Frontend calls it | `src/api/base44Client.js` (`base44.owliver.suggestions`) → `src/lib/krowHooks.js` (`fetchOwliverSuggestions`, `useOwliverSuggestions`) → `src/lib/skills/serverSuggestions.js` → `KrowAssistant.jsx` |
|
||||||
| Backend source registers it | `go-api/internal/httpserver/owliver.go:22`, commit `b6f8655`, pushed to `origin/main` |
|
| Backend source registers it | `go-api/internal/httpserver/owliver.go:22` |
|
||||||
| `https://mcp.krowforce.com` serves it | **No — 404** |
|
| `https://mcp.krowforce.com` serves it | **No — 404 as of this writing** |
|
||||||
|
|
||||||
|
The endpoint now also builds real context: with no `query`, the backend counts
|
||||||
|
the organization's positions, applications, interviews, staff, profiles,
|
||||||
|
courses and activity in one statement and ranks the page's readings against
|
||||||
|
them, so a suggestion changes when the data does. See the backend contract's
|
||||||
|
§2A.
|
||||||
|
|
||||||
So a signed-in dev server pointed at `mcp.krowforce.com` answers this route
|
So a signed-in dev server pointed at `mcp.krowforce.com` answers this route
|
||||||
`404 Not Found`, and no prompt chips appear. **That is a deployment lag, not a
|
`404 Not Found`, and no prompt chips appear. **That is a deployment lag, not a
|
||||||
@@ -291,10 +298,12 @@ reaching for the frontend code again:
|
|||||||
means the session is not signed in; `404` means the session is fine and the
|
means the session is not signed in; `404` means the session is fine and the
|
||||||
route is not on that backend.
|
route is not on that backend.
|
||||||
|
|
||||||
The panel treats the two differently as of `useSuggestions.js`: a `404` closes a
|
The panel treats the two differently, in `fetchOwliverSuggestions`
|
||||||
circuit breaker and it stops asking for the rest of the page load, because no
|
(`src/lib/krowHooks.js`): a `404` closes a circuit breaker and it stops asking
|
||||||
further keystroke can change the answer. A `401`, a `500` and an unreachable API
|
for the rest of the page load, because no further keystroke can change the
|
||||||
are all still retried.
|
answer. A `401`, a `500` and an unreachable API are all still retried. Against a
|
||||||
|
host that does not serve the route the chip row is simply empty — the panel, the
|
||||||
|
composer and every answer are unaffected.
|
||||||
|
|
||||||
The endpoint replaces the *selection*, not
|
The endpoint replaces the *selection*, not
|
||||||
the answering: it returns at most three `{ text, intent }` pairs, where `intent`
|
the answering: it returns at most three `{ text, intent }` pairs, where `intent`
|
||||||
@@ -636,8 +645,8 @@ Recorded, not fixed — each belongs in its own repo.
|
|||||||
| Where | Issue |
|
| Where | Issue |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `krow-backend/docs/api-contract.md` §1 | Says "Auth: None in v1. Every endpoint is unauthenticated." Sessions and cookie middleware have been implemented since. |
|
| `krow-backend/docs/api-contract.md` §1 | Says "Auth: None in v1. Every endpoint is unauthenticated." Sessions and cookie middleware have been implemented since. |
|
||||||
| `krow-demo/src/api/store.js` | 173 lines, **imported by nothing**. Superseded by `httpClient.js`. Dead. |
|
| ~~`krow-demo/src/api/store.js`~~ | **Fixed.** The file is deleted. It was 173 lines and imported by nothing. |
|
||||||
| `krow-demo/src/api/seed.js` | 1,928 lines, retained for one export — `DEMO_USER.preferences`, the default shape the synchronous accessor needs. Entity data lives in PostgreSQL. |
|
| ~~`krow-demo/src/api/seed.js`~~ | **Fixed.** The default user shape moved to `src/api/demoUser.js`, so no production module imports the fixture and its 1,928 lines are out of the bundle. `seed.js` remains, serving `scripts/skill-check.mjs` and `scripts/owliver-capture.mjs` as the test fixture it now is. |
|
||||||
| `krow-demo/src/lib/krowHooks.js:537` | `useBadges` has no consumers and no route. |
|
| `krow-demo/src/lib/krowHooks.js:537` | `useBadges` has no consumers and no route. |
|
||||||
| `krow-demo/src/api/httpClient.js:96` | `User: 'users'` names a resource the backend does not have. No call site, so harmless today. |
|
| `krow-demo/src/api/httpClient.js:96` | `User: 'users'` names a resource the backend does not have. No call site, so harmless today. |
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
"lint": "eslint . --quiet",
|
"lint": "eslint . --quiet",
|
||||||
"lint:fix": "eslint . --fix",
|
"lint:fix": "eslint . --fix",
|
||||||
"test": "node scripts/skill-check.mjs",
|
"test": "node scripts/skill-check.mjs",
|
||||||
|
"seed:fixture": "node scripts/seed-fixture.mjs --write",
|
||||||
|
"seed:check": "node scripts/seed-fixture.mjs",
|
||||||
"typecheck": "tsc -p ./jsconfig.json",
|
"typecheck": "tsc -p ./jsconfig.json",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
65
scripts/__baseline__/README.md
Normal file
65
scripts/__baseline__/README.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# Owliver behaviour baseline
|
||||||
|
|
||||||
|
`owliver-baseline.json` records how the panel routed and answered before the
|
||||||
|
agent layer existed. `skill-check.mjs` asserts against it on every run.
|
||||||
|
|
||||||
|
Regenerating it is a deliberate act, and the reason belongs here.
|
||||||
|
|
||||||
|
## 2026-08-27 — the seed gained the three statuses nothing exercised
|
||||||
|
|
||||||
|
`application_status` has seven values. The fixture produced four: `applied`,
|
||||||
|
`ai_screened`, `interview`, `hired`. `shortlisted`, `rejected` and `assigned`
|
||||||
|
existed only in the schema, and `Assignment` shipped empty by design.
|
||||||
|
|
||||||
|
That gap was hiding real defects, all found the same week and none visible
|
||||||
|
against the old data:
|
||||||
|
|
||||||
|
- `atOrBeyond` ranks a status by its index in `STAGE_ORDER`, which listed five
|
||||||
|
of the seven. `rejected` and `assigned` scored -1 and dropped out of *every*
|
||||||
|
bucket including `applied`, so a position's funnel lost people and a role
|
||||||
|
whose candidates had all been assigned read as unfilled.
|
||||||
|
- The agent's `candidates_awaiting` excluded `hired` and `rejected` from "still
|
||||||
|
in the running", so somebody already working a shift was offered as a person
|
||||||
|
to chase.
|
||||||
|
- `insights.stalled` counted "screened and waiting on a decision" as
|
||||||
|
`status === 'ai_screened'` only, silently dropping the shortlisted — the
|
||||||
|
people that phrase most describes. Caught by this regeneration, not before it.
|
||||||
|
|
||||||
|
**The change:** three existing applications were converted, not added, so every
|
||||||
|
regression anchor holds — 24 applications, 9 scored averaging 76, 3 staff,
|
||||||
|
8 postings, all unchanged.
|
||||||
|
|
||||||
|
app_kevin applied → rejected (never scored; a hard requirement)
|
||||||
|
app_sofia ai_screened → shortlisted (the strongest screened candidate)
|
||||||
|
app_marco hired → assigned (hired, then rostered)
|
||||||
|
|
||||||
|
Plus one `Assignment` for Marco — the minimum that makes `assigned` real. Eight
|
||||||
|
of the nine positions still have none, so every reader still meets the empty
|
||||||
|
case.
|
||||||
|
|
||||||
|
**What drifted, verified before regenerating:** one number, in four prompts.
|
||||||
|
Kevin left `applied`, so the unscored count reads 9 where it read 10:
|
||||||
|
|
||||||
|
"Show the 10 applications waiting for review" → "…9 applications…"
|
||||||
|
"10 have no score" / "10 unscored applicants" / "10 unscored applications"
|
||||||
|
|
||||||
|
Nothing else moved. `1 waiting on a decision` briefly became a fallback prompt
|
||||||
|
and that was the `stalled` defect above — fixed in `insights.js` rather than
|
||||||
|
absorbed into the baseline, and the prompt came back on its own.
|
||||||
|
|
||||||
|
## 2026-08-27 — the local simulator was removed
|
||||||
|
|
||||||
|
The panel used to answer from two places: an agent, and ~3,300 lines of
|
||||||
|
browser-side templates. Two paths behind one avatar meant the same question got
|
||||||
|
different answers depending on phrasing, so the templates were deleted.
|
||||||
|
|
||||||
|
**What actually drifted, verified before regenerating:** one thing. The
|
||||||
|
"I do not have that on this page" decline used to list the page's capability
|
||||||
|
labels as bullets — those labels were the simulator's canned readings and went
|
||||||
|
with it. It now names the page's topics in a sentence.
|
||||||
|
|
||||||
|
doc shape: ["text","list","note"] → ["text","text","note"]
|
||||||
|
|
||||||
|
Every intent `kind` was unchanged. No routing moved, no skill matching changed.
|
||||||
|
That is why this regeneration was safe: the diff was read first, and it was one
|
||||||
|
cosmetic change on a path that only runs when no agent is configured at all.
|
||||||
@@ -2,16 +2,29 @@
|
|||||||
"schema": 1,
|
"schema": 1,
|
||||||
"today": "2026-08-20T09:00:00.000Z",
|
"today": "2026-08-20T09:00:00.000Z",
|
||||||
"skillIds": [
|
"skillIds": [
|
||||||
|
"activity-analysis",
|
||||||
"analytics-insights",
|
"analytics-insights",
|
||||||
|
"anomaly-detection",
|
||||||
|
"attendance-analysis",
|
||||||
"bartending-training",
|
"bartending-training",
|
||||||
|
"candidate-analysis",
|
||||||
"candidate-search",
|
"candidate-search",
|
||||||
"create-position",
|
"create-position",
|
||||||
"customer-service-training",
|
"customer-service-training",
|
||||||
|
"executive-summary",
|
||||||
"food-safety-training",
|
"food-safety-training",
|
||||||
"forge-skill-management",
|
"forge-skill-management",
|
||||||
"hiring-activity-assistant",
|
"hiring-activity-assistant",
|
||||||
|
"hiring-history-analysis",
|
||||||
|
"hiring-pulse-analysis",
|
||||||
"leadership-training",
|
"leadership-training",
|
||||||
"server-training"
|
"learning-analysis",
|
||||||
|
"operational-risk",
|
||||||
|
"overtime-analysis",
|
||||||
|
"server-training",
|
||||||
|
"staffing-risk",
|
||||||
|
"talent-pool-analysis",
|
||||||
|
"workforce-analytics"
|
||||||
],
|
],
|
||||||
"diagnostics": [],
|
"diagnostics": [],
|
||||||
"routes": {
|
"routes": {
|
||||||
@@ -24,15 +37,35 @@
|
|||||||
"/admin/positions": "admin.positions",
|
"/admin/positions": "admin.positions",
|
||||||
"/admin/positions/new": "admin.createPosition",
|
"/admin/positions/new": "admin.createPosition",
|
||||||
"/admin/profile": "admin.profile",
|
"/admin/profile": "admin.profile",
|
||||||
|
"/admin/settings": "admin.settings",
|
||||||
"/admin/talent-pool": "admin.talentPool",
|
"/admin/talent-pool": "admin.talentPool",
|
||||||
"/admin/university": "admin.forge"
|
"/admin/university": "admin.forge",
|
||||||
|
"/admin/workspace": "admin.workspace",
|
||||||
|
"/admin/workspace/agents": "admin.workspaceAgents",
|
||||||
|
"/admin/workspace/agents/new": "admin.agentConfigure",
|
||||||
|
"/admin/workspace/skill-development": "admin.skillDevelopment",
|
||||||
|
"/admin/workspace/skills": "admin.workspaceSkills",
|
||||||
|
"/admin/workspace/skills/new": "admin.skillConfigure"
|
||||||
},
|
},
|
||||||
"contexts": {
|
"contexts": {
|
||||||
"admin.controlCenter": {
|
"admin.controlCenter": {
|
||||||
"pageKey": "control-center",
|
"pageKey": "control-center",
|
||||||
"route": "/admin",
|
"route": "/admin",
|
||||||
"skills": [],
|
"skills": [
|
||||||
"suggestions": [],
|
"anomaly-detection",
|
||||||
|
"attendance-analysis",
|
||||||
|
"executive-summary",
|
||||||
|
"hiring-pulse-analysis",
|
||||||
|
"operational-risk",
|
||||||
|
"overtime-analysis",
|
||||||
|
"staffing-risk"
|
||||||
|
],
|
||||||
|
"suggestions": [
|
||||||
|
"Compare attendance by person",
|
||||||
|
"How is attendance?",
|
||||||
|
"Is anything unusual?",
|
||||||
|
"Show the signals"
|
||||||
|
],
|
||||||
"prompts": [
|
"prompts": [
|
||||||
"What needs attention?",
|
"What needs attention?",
|
||||||
"Platform health",
|
"Platform health",
|
||||||
@@ -118,7 +151,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -129,16 +162,19 @@
|
|||||||
"route": "/admin/positions",
|
"route": "/admin/positions",
|
||||||
"skills": [
|
"skills": [
|
||||||
"create-position",
|
"create-position",
|
||||||
"hiring-activity-assistant"
|
"hiring-activity-assistant",
|
||||||
|
"staffing-risk"
|
||||||
],
|
],
|
||||||
"suggestions": [
|
"suggestions": [
|
||||||
"Show hiring activity as a flow",
|
"Show hiring activity as a flow",
|
||||||
"Summarize hiring activity for this position"
|
"Summarize hiring activity for this position",
|
||||||
|
"Summarize staffing risk",
|
||||||
|
"Which roles are at risk?"
|
||||||
],
|
],
|
||||||
"prompts": [
|
"prompts": [
|
||||||
"What needs attention?",
|
"What needs attention?",
|
||||||
"Which position has the strongest pipeline?",
|
"Which position has the strongest pipeline?",
|
||||||
"Show the 10 applications waiting for review",
|
"Show the 9 applications waiting for review",
|
||||||
"Summarize hiring activity across all positions"
|
"Summarize hiring activity across all positions"
|
||||||
],
|
],
|
||||||
"intents": [
|
"intents": [
|
||||||
@@ -162,7 +198,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -226,7 +262,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -254,7 +290,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -278,7 +314,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -328,7 +364,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -338,14 +374,19 @@
|
|||||||
"pageKey": "candidates",
|
"pageKey": "candidates",
|
||||||
"route": "/admin/candidates",
|
"route": "/admin/candidates",
|
||||||
"skills": [
|
"skills": [
|
||||||
|
"candidate-analysis",
|
||||||
"candidate-search"
|
"candidate-search"
|
||||||
],
|
],
|
||||||
"suggestions": [],
|
"suggestions": [
|
||||||
|
"How strong is the candidate pool?",
|
||||||
|
"Score bands",
|
||||||
|
"Show candidates by score"
|
||||||
|
],
|
||||||
"prompts": [
|
"prompts": [
|
||||||
"1 waiting on a decision",
|
"1 waiting on a decision",
|
||||||
"Who are the strongest candidates?",
|
"Who are the strongest candidates?",
|
||||||
"Which candidates are ready for interview?",
|
"Which candidates are ready for interview?",
|
||||||
"10 have no score",
|
"9 have no score",
|
||||||
"Summarize the candidate pipeline",
|
"Summarize the candidate pipeline",
|
||||||
"Identify candidate risks"
|
"Identify candidate risks"
|
||||||
],
|
],
|
||||||
@@ -380,7 +421,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -427,7 +468,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -437,13 +478,18 @@
|
|||||||
"pageKey": "candidates-analysis",
|
"pageKey": "candidates-analysis",
|
||||||
"route": "/admin/candidates-analysis",
|
"route": "/admin/candidates-analysis",
|
||||||
"skills": [
|
"skills": [
|
||||||
|
"candidate-analysis",
|
||||||
"candidate-search"
|
"candidate-search"
|
||||||
],
|
],
|
||||||
"suggestions": [],
|
"suggestions": [
|
||||||
|
"How strong is the candidate pool?",
|
||||||
|
"Score bands",
|
||||||
|
"Show candidates by score"
|
||||||
|
],
|
||||||
"prompts": [
|
"prompts": [
|
||||||
"Compare Chef & Marcus",
|
"Compare Chef & Marcus",
|
||||||
"1 missing credentials",
|
"1 missing credentials",
|
||||||
"10 unscored applicants",
|
"9 unscored applicants",
|
||||||
"Recommend next actions",
|
"Recommend next actions",
|
||||||
"Recruitment insights"
|
"Recruitment insights"
|
||||||
],
|
],
|
||||||
@@ -458,7 +504,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -472,7 +518,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -486,7 +532,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -536,7 +582,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -545,8 +591,13 @@
|
|||||||
"admin.hiredHistory": {
|
"admin.hiredHistory": {
|
||||||
"pageKey": "hired-history",
|
"pageKey": "hired-history",
|
||||||
"route": "/admin/hired",
|
"route": "/admin/hired",
|
||||||
"skills": [],
|
"skills": [
|
||||||
"suggestions": [],
|
"hiring-history-analysis"
|
||||||
|
],
|
||||||
|
"suggestions": [
|
||||||
|
"How is hiring performance?",
|
||||||
|
"Who did we hire recently?"
|
||||||
|
],
|
||||||
"prompts": [
|
"prompts": [
|
||||||
"3 hires unrated",
|
"3 hires unrated",
|
||||||
"Bartender leads on hires",
|
"Bartender leads on hires",
|
||||||
@@ -565,7 +616,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -579,7 +630,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -593,7 +644,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -646,7 +697,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -655,8 +706,14 @@
|
|||||||
"admin.talentPool": {
|
"admin.talentPool": {
|
||||||
"pageKey": "talent-pool",
|
"pageKey": "talent-pool",
|
||||||
"route": "/admin/talent-pool",
|
"route": "/admin/talent-pool",
|
||||||
"skills": [],
|
"skills": [
|
||||||
"suggestions": [],
|
"talent-pool-analysis"
|
||||||
|
],
|
||||||
|
"suggestions": [
|
||||||
|
"How healthy is the talent pool?",
|
||||||
|
"Strongest people in the pool",
|
||||||
|
"Who is in the pool?"
|
||||||
|
],
|
||||||
"prompts": [
|
"prompts": [
|
||||||
"Why does Maria rank first?",
|
"Why does Maria rank first?",
|
||||||
"4 profiles unscored",
|
"4 profiles unscored",
|
||||||
@@ -675,7 +732,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -699,7 +756,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -752,7 +809,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -762,9 +819,13 @@
|
|||||||
"pageKey": "krow-forge",
|
"pageKey": "krow-forge",
|
||||||
"route": "/admin/university",
|
"route": "/admin/university",
|
||||||
"skills": [
|
"skills": [
|
||||||
"forge-skill-management"
|
"forge-skill-management",
|
||||||
|
"learning-analysis"
|
||||||
|
],
|
||||||
|
"suggestions": [
|
||||||
|
"How is training progressing?",
|
||||||
|
"What does the library hold?"
|
||||||
],
|
],
|
||||||
"suggestions": [],
|
|
||||||
"prompts": [
|
"prompts": [
|
||||||
"Create a skill training",
|
"Create a skill training",
|
||||||
"40 published",
|
"40 published",
|
||||||
@@ -783,7 +844,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -797,7 +858,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -811,7 +872,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -864,7 +925,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -874,16 +935,25 @@
|
|||||||
"pageKey": "analytics",
|
"pageKey": "analytics",
|
||||||
"route": "/admin/analytics",
|
"route": "/admin/analytics",
|
||||||
"skills": [
|
"skills": [
|
||||||
"analytics-insights"
|
"analytics-insights",
|
||||||
|
"attendance-analysis",
|
||||||
|
"hiring-pulse-analysis",
|
||||||
|
"overtime-analysis",
|
||||||
|
"workforce-analytics"
|
||||||
|
],
|
||||||
|
"suggestions": [
|
||||||
|
"Attendance over recent periods",
|
||||||
|
"Compare attendance by person",
|
||||||
|
"How is attendance?",
|
||||||
|
"What is the hiring pulse?"
|
||||||
],
|
],
|
||||||
"suggestions": [],
|
|
||||||
"prompts": [
|
"prompts": [
|
||||||
"What is the hiring trend?",
|
"What is the hiring trend?",
|
||||||
"Which department is performing best?",
|
"Which department is performing best?",
|
||||||
"Biggest drop at Hired",
|
"Biggest drop at Hired",
|
||||||
"Which positions are converting best?",
|
"Which positions are converting best?",
|
||||||
"Summarize hiring performance",
|
"Summarize hiring performance",
|
||||||
"10 unscored applications"
|
"9 unscored applications"
|
||||||
],
|
],
|
||||||
"intents": [
|
"intents": [
|
||||||
{
|
{
|
||||||
@@ -906,7 +976,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -920,7 +990,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -970,7 +1040,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -979,8 +1049,17 @@
|
|||||||
"admin.activity": {
|
"admin.activity": {
|
||||||
"pageKey": "activity",
|
"pageKey": "activity",
|
||||||
"route": "/admin/activity",
|
"route": "/admin/activity",
|
||||||
"skills": [],
|
"skills": [
|
||||||
"suggestions": [],
|
"activity-analysis",
|
||||||
|
"anomaly-detection",
|
||||||
|
"operational-risk"
|
||||||
|
],
|
||||||
|
"suggestions": [
|
||||||
|
"Activity over recent periods",
|
||||||
|
"Is anything unusual?",
|
||||||
|
"Summarize workspace activity",
|
||||||
|
"What kinds of event are there?"
|
||||||
|
],
|
||||||
"prompts": [
|
"prompts": [
|
||||||
"3 unusual patterns",
|
"3 unusual patterns",
|
||||||
"Summarize today's activity",
|
"Summarize today's activity",
|
||||||
@@ -999,7 +1078,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1013,7 +1092,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1076,7 +1155,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1105,7 +1184,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1119,7 +1198,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1179,7 +1258,7 @@
|
|||||||
"destination": null,
|
"destination": null,
|
||||||
"doc": [
|
"doc": [
|
||||||
"text",
|
"text",
|
||||||
"list",
|
"text",
|
||||||
"note"
|
"note"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
72
scripts/seed-fixture.mjs
Normal file
72
scripts/seed-fixture.mjs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Writes the backend's seed fixture from the frontend's seed module.
|
||||||
|
*
|
||||||
|
* node scripts/seed-fixture.mjs # check: exits non-zero if stale
|
||||||
|
* node scripts/seed-fixture.mjs --write # regenerate
|
||||||
|
*
|
||||||
|
* `src/api/seed.js` is the authored source: it has the helpers, the reasoning in
|
||||||
|
* comments, and the values a person edits. `seed/fixtures/seed.json` is what the
|
||||||
|
* Go seeder loads. They used to be maintained in parallel with nothing checking
|
||||||
|
* them, which is a duplication that only fails quietly — the demo and the API
|
||||||
|
* would answer the same question differently, and the first symptom would be a
|
||||||
|
* number on a page disagreeing with a number from an agent.
|
||||||
|
*
|
||||||
|
* `ShiftRecord` is deliberately not carried across. Its dates are anchored to
|
||||||
|
* the day the seeder runs rather than to this file's fixed calendar, so the Go
|
||||||
|
* side generates it (internal/seeder/shifts.go, a port of attendanceSeed.js).
|
||||||
|
* A frozen snapshot of it would read as permanently empty a fortnight later.
|
||||||
|
*/
|
||||||
|
import { createServer } from 'vite';
|
||||||
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
|
export const FIXTURE_PATH = join(process.cwd(), '..', 'krow-backend', 'seed', 'fixtures', 'seed.json');
|
||||||
|
|
||||||
|
/** The fixture the frontend seed implies. Pure — takes the loaded module. */
|
||||||
|
export const GENERATED_NOTE =
|
||||||
|
'Generated from krow-demo/src/api/seed.js — do not edit by hand. ' +
|
||||||
|
'Regenerate with: npm run seed:fixture';
|
||||||
|
|
||||||
|
export function fixtureFrom(seedModule) {
|
||||||
|
const { ShiftRecord, ...entities } = seedModule.seedData;
|
||||||
|
/* First key, so it is the first thing anyone opening the file reads. The Go
|
||||||
|
loader unmarshals into a struct of demoUser + entities and ignores the
|
||||||
|
rest, so this costs nothing on the reading side. */
|
||||||
|
return { _generated: GENERATED_NOTE, demoUser: seedModule.DEMO_USER, entities };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialised exactly as the committed file is: 2-space indent, no trailing newline. */
|
||||||
|
export const serialise = (fixture) => JSON.stringify(fixture, null, 2);
|
||||||
|
|
||||||
|
export async function buildFixture(server) {
|
||||||
|
return serialise(fixtureFrom(await server.ssrLoadModule('/src/api/seed.js')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── CLI ──────────────────────────────────────────────────────────────────── */
|
||||||
|
/* pathToFileURL, not string concatenation: this repo lives under a path with a
|
||||||
|
space in it, and import.meta.url percent-encodes it while `file://${path}`
|
||||||
|
does not — the guard silently never fires and the script does nothing. */
|
||||||
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
const server = await createServer({
|
||||||
|
root: process.cwd(), server: { middlewareMode: true }, appType: 'custom', logLevel: 'error',
|
||||||
|
});
|
||||||
|
const built = await buildFixture(server);
|
||||||
|
await server.close();
|
||||||
|
|
||||||
|
if (process.argv.includes('--write')) {
|
||||||
|
writeFileSync(FIXTURE_PATH, built);
|
||||||
|
const { entities } = JSON.parse(built);
|
||||||
|
const n = Object.values(entities).reduce((s, v) => s + v.length, 0);
|
||||||
|
console.log(`seed.json written — ${Object.keys(entities).length} collections, ${n} records.`);
|
||||||
|
} else if (!existsSync(FIXTURE_PATH)) {
|
||||||
|
console.error('seed.json is missing. Run: node scripts/seed-fixture.mjs --write');
|
||||||
|
process.exit(1);
|
||||||
|
} else if (readFileSync(FIXTURE_PATH, 'utf8') !== built) {
|
||||||
|
console.error('seed.json is stale — it no longer matches src/api/seed.js.');
|
||||||
|
console.error('Run: node scripts/seed-fixture.mjs --write');
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
console.log('seed.json is in step with src/api/seed.js.');
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,9 @@ starters:
|
|||||||
permissions:
|
permissions:
|
||||||
owner: demo@krow.app
|
owner: demo@krow.app
|
||||||
access: all
|
access: all
|
||||||
|
tools:
|
||||||
|
- activity_breakdown
|
||||||
|
- activity_signals
|
||||||
---
|
---
|
||||||
|
|
||||||
# Activity Agent
|
# Activity Agent
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ starters:
|
|||||||
permissions:
|
permissions:
|
||||||
owner: demo@krow.app
|
owner: demo@krow.app
|
||||||
access: all
|
access: all
|
||||||
|
tools:
|
||||||
|
- workspace_summary
|
||||||
|
- workforce_attendance
|
||||||
|
- workforce_overtime
|
||||||
|
- workforce_coverage
|
||||||
|
- candidates_quality
|
||||||
|
- hires_performance
|
||||||
|
- activity_breakdown
|
||||||
---
|
---
|
||||||
|
|
||||||
# Analytics Agent
|
# Analytics Agent
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ starters:
|
|||||||
permissions:
|
permissions:
|
||||||
owner: demo@krow.app
|
owner: demo@krow.app
|
||||||
access: all
|
access: all
|
||||||
|
tools:
|
||||||
|
- candidates_quality
|
||||||
|
- talent_pool
|
||||||
|
- hires_recent
|
||||||
|
- candidates_awaiting
|
||||||
|
- move_application
|
||||||
---
|
---
|
||||||
|
|
||||||
# Candidates Agent
|
# Candidates Agent
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ starters:
|
|||||||
permissions:
|
permissions:
|
||||||
owner: demo@krow.app
|
owner: demo@krow.app
|
||||||
access: all
|
access: all
|
||||||
|
tools:
|
||||||
|
- knowledge_search
|
||||||
|
- workspace_summary
|
||||||
|
- operations_risk
|
||||||
|
- activity_signals
|
||||||
|
- positions_risk
|
||||||
|
- workforce_coverage
|
||||||
|
- candidates_awaiting
|
||||||
|
sources:
|
||||||
|
- policy_docs
|
||||||
---
|
---
|
||||||
|
|
||||||
# Control Center Agent
|
# Control Center Agent
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ starters:
|
|||||||
permissions:
|
permissions:
|
||||||
owner: demo@krow.app
|
owner: demo@krow.app
|
||||||
access: all
|
access: all
|
||||||
|
tools:
|
||||||
|
- hires_recent
|
||||||
|
- hires_performance
|
||||||
---
|
---
|
||||||
|
|
||||||
# Hired History Agent
|
# Hired History Agent
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ starters:
|
|||||||
permissions:
|
permissions:
|
||||||
owner: demo@krow.app
|
owner: demo@krow.app
|
||||||
access: all
|
access: all
|
||||||
|
tools:
|
||||||
|
- workforce_training
|
||||||
|
- talent_pool
|
||||||
---
|
---
|
||||||
|
|
||||||
# KROW Forge Agent
|
# KROW Forge Agent
|
||||||
|
|||||||
@@ -78,6 +78,16 @@ permissions:
|
|||||||
people:
|
people:
|
||||||
- user: demo@krow.app
|
- user: demo@krow.app
|
||||||
role: manager
|
role: manager
|
||||||
|
tools:
|
||||||
|
- workspace_summary
|
||||||
|
- operations_risk
|
||||||
|
- positions_risk
|
||||||
|
- workforce_attendance
|
||||||
|
- workforce_coverage
|
||||||
|
- candidates_quality
|
||||||
|
- talent_pool
|
||||||
|
sources:
|
||||||
|
- policy_docs
|
||||||
---
|
---
|
||||||
|
|
||||||
# Krow Workforce Agent
|
# Krow Workforce Agent
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ starters:
|
|||||||
permissions:
|
permissions:
|
||||||
owner: demo@krow.app
|
owner: demo@krow.app
|
||||||
access: all
|
access: all
|
||||||
|
tools:
|
||||||
|
- positions_risk
|
||||||
|
- open_positions
|
||||||
|
- available_workers
|
||||||
|
- workforce_coverage
|
||||||
|
- candidates_quality
|
||||||
|
- assign_worker
|
||||||
|
- candidates_awaiting
|
||||||
|
- move_application
|
||||||
---
|
---
|
||||||
|
|
||||||
# Positions Agent
|
# Positions Agent
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ starters:
|
|||||||
permissions:
|
permissions:
|
||||||
owner: demo@krow.app
|
owner: demo@krow.app
|
||||||
access: all
|
access: all
|
||||||
|
tools:
|
||||||
|
- talent_pool
|
||||||
|
- workforce_training
|
||||||
|
- available_workers
|
||||||
---
|
---
|
||||||
|
|
||||||
# Talent Pool Agent
|
# Talent Pool Agent
|
||||||
|
|||||||
@@ -11,16 +11,19 @@
|
|||||||
*
|
*
|
||||||
* React → base44Client.js → HTTP → Go API → PostgreSQL
|
* React → base44Client.js → HTTP → Go API → PostgreSQL
|
||||||
*
|
*
|
||||||
* The entity surface is unchanged. `store.js` and `seed.js` are no longer the
|
* The entity surface is unchanged. The seeded dataset lives in PostgreSQL,
|
||||||
* source of data — the seeded dataset now lives in PostgreSQL, loaded by the
|
* loaded by the backend's `make seed`, and nothing in the running app carries a
|
||||||
* backend's `make seed`. `seed.js` is still imported for one thing: the shape
|
* copy of it: `store.js` — the localStorage database this replaced — is gone,
|
||||||
* of the demo user's default preferences, which the synchronous accessor below
|
* and `seed.js` is now a test fixture that no production module imports.
|
||||||
* needs before the first response arrives.
|
*
|
||||||
|
* The one thing still needed before the first response arrives is the *shape*
|
||||||
|
* of the user's default preferences, because the accessor that reads them is
|
||||||
|
* synchronous. That is `api/demoUser.js`: a default shape, not a record.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
|
import { createEntity, request, isUnauthenticated, API_BASE_URL } from './httpClient';
|
||||||
import { invokeLLM, uploadFile } from './aiEngine';
|
import { invokeLLM, uploadFile } from './aiEngine';
|
||||||
import { DEMO_USER } from './seed';
|
import { DEMO_USER } from './demoUser';
|
||||||
|
|
||||||
const ENTITY_NAMES = [
|
const ENTITY_NAMES = [
|
||||||
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile',
|
||||||
@@ -33,6 +36,14 @@ const ENTITY_NAMES = [
|
|||||||
/* Shifts worked, missed and overrun. The operational record behind
|
/* Shifts worked, missed and overrun. The operational record behind
|
||||||
attendance and overtime analysis — see `api/attendanceSeed.js`. */
|
attendance and overtime analysis — see `api/attendanceSeed.js`. */
|
||||||
'ShiftRecord',
|
'ShiftRecord',
|
||||||
|
/* The agent and skill registry the runtime resolves from.
|
||||||
|
Authored definitions used to be written into the account's preferences,
|
||||||
|
which the backend stores faithfully and the runtime never reads — so an
|
||||||
|
agent created in the editor showed as "published" and answered every
|
||||||
|
request with 404. These are the endpoints that make an authored agent a
|
||||||
|
real one. */
|
||||||
|
'AgentDefinition',
|
||||||
|
'SkillDefinition',
|
||||||
];
|
];
|
||||||
|
|
||||||
const entities = Object.fromEntries(
|
const entities = Object.fromEntries(
|
||||||
@@ -210,7 +221,7 @@ const auth = {
|
|||||||
* Preferences, read synchronously.
|
* Preferences, read synchronously.
|
||||||
*
|
*
|
||||||
* A plain read of the same record `me()` returns, defaulted with the shape
|
* A plain read of the same record `me()` returns, defaulted with the shape
|
||||||
* from `seed.js` so a key the server has never stored still resolves. See the
|
* from `demoUser.js` so a key the server has never stored still resolves. See the
|
||||||
* note on `currentUser` for why this must not become async.
|
* note on `currentUser` for why this must not become async.
|
||||||
*/
|
*/
|
||||||
preferences() {
|
preferences() {
|
||||||
@@ -327,6 +338,41 @@ const workflows = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── Owliver ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What could usefully be asked on this page.
|
||||||
|
*
|
||||||
|
* The one read in this file that is not a table. `GET /owliver/suggestions`
|
||||||
|
* answers with at most three questions, and the server decides all three: it
|
||||||
|
* filters them against the caller's role through the same policy table every
|
||||||
|
* other endpoint consults, and — when nothing has been typed — ranks them
|
||||||
|
* against the organization's actual state in PostgreSQL. A workspace with
|
||||||
|
* unfinished drafts is asked about drafts; one with unscored candidates is
|
||||||
|
* asked about screening.
|
||||||
|
*
|
||||||
|
* Nothing is ranked, scored, filtered or reordered on this side. That is the
|
||||||
|
* point: a suggestion is a claim that the reader could usefully ask something,
|
||||||
|
* and the only thing that knows whether that is true is the thing holding the
|
||||||
|
* data and the permissions. The panel requests, renders, and runs whichever one
|
||||||
|
* is chosen through the capability it names.
|
||||||
|
*
|
||||||
|
* `query` is what is in the composer. Sent only when it is non-empty: an absent
|
||||||
|
* query asks the data what to suggest, and an empty string would be a different
|
||||||
|
* request from the one the caller means.
|
||||||
|
*/
|
||||||
|
const owliver = {
|
||||||
|
/** @param {any} request */
|
||||||
|
async suggestions({ page, query = '' } = {}) {
|
||||||
|
if (!page) return [];
|
||||||
|
const typed = String(query || '').trim();
|
||||||
|
const data = await request('GET', '/owliver/suggestions', {
|
||||||
|
query: { page, ...(typed ? { query: typed } : {}) },
|
||||||
|
});
|
||||||
|
return Array.isArray(data?.suggestions) ? data.suggestions : [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/* ── Integrations & analytics ───────────────────────────────────────────── */
|
/* ── Integrations & analytics ───────────────────────────────────────────── */
|
||||||
|
|
||||||
const integrations = {
|
const integrations = {
|
||||||
@@ -344,7 +390,7 @@ const analytics = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const base44 = { entities, auth, integrations, analytics, workflows };
|
export const base44 = { entities, auth, integrations, analytics, workflows, owliver };
|
||||||
|
|
||||||
/** Where the entity data actually comes from, for diagnostics. */
|
/** Where the entity data actually comes from, for diagnostics. */
|
||||||
export { API_BASE_URL };
|
export { API_BASE_URL };
|
||||||
|
|||||||
39
src/api/demoUser.js
Normal file
39
src/api/demoUser.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* The shape of a signed-in user, before the server has answered.
|
||||||
|
*
|
||||||
|
* This is a **default shape, not a record**. The user lives in PostgreSQL and
|
||||||
|
* arrives from `GET /me`; what is here is the set of keys that must resolve
|
||||||
|
* during the first render, in the fraction of a second before that response
|
||||||
|
* lands.
|
||||||
|
*
|
||||||
|
* It exists as its own module for one reason: `base44Client.js` needs exactly
|
||||||
|
* this, and it used to reach into `api/seed.js` to get it — a 1,900-line
|
||||||
|
* fixture of demo positions, candidates, interviews, staff and shift records,
|
||||||
|
* every byte of which was then in the production bundle so that three booleans
|
||||||
|
* could be defaulted. The fixture is still the right thing for the test scripts
|
||||||
|
* that read it (`scripts/skill-check.mjs`, `scripts/owliver-capture.mjs`); it
|
||||||
|
* was never the right thing for the running app. `seed.js` re-exports this
|
||||||
|
* constant, so those scripts are unchanged and the production import chain no
|
||||||
|
* longer reaches them.
|
||||||
|
*
|
||||||
|
* Nothing here is a source of truth for anything. `preferences` is the one part
|
||||||
|
* that is read: `auth.preferences()` is synchronous — `AssistantPanelContext`
|
||||||
|
* decides whether Owliver starts open in a `useState` initialiser — so a key
|
||||||
|
* the server has never stored still has to resolve to something rather than to
|
||||||
|
* `undefined`.
|
||||||
|
*/
|
||||||
|
export const DEMO_USER = {
|
||||||
|
id: 'user_demo',
|
||||||
|
full_name: 'Alex Rivera',
|
||||||
|
email: 'demo@krow.app',
|
||||||
|
role: 'admin',
|
||||||
|
account_type: 'employer',
|
||||||
|
created_date: '2026-06-01T09:00:00.000Z',
|
||||||
|
/* Product preferences travel with the account rather than in a store of their
|
||||||
|
own, so there is one record to persist and one thing to read. */
|
||||||
|
preferences: {
|
||||||
|
owliverDefault: true,
|
||||||
|
compactDensity: false,
|
||||||
|
emailDigest: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -109,6 +109,11 @@ const RESOURCE_PATHS = {
|
|||||||
User: 'users',
|
User: 'users',
|
||||||
Assignment: 'assignments',
|
Assignment: 'assignments',
|
||||||
ShiftRecord: 'shift-records',
|
ShiftRecord: 'shift-records',
|
||||||
|
/* Authored agents and skills. These are the registry the BACKEND runs from:
|
||||||
|
an agent saved here is one Owliver can be asked to run, which is the whole
|
||||||
|
difference between this and the preferences blob these used to live in. */
|
||||||
|
AgentDefinition: 'agent-definitions',
|
||||||
|
SkillDefinition: 'skill-definitions',
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ── Request ────────────────────────────────────────────────────────────── */
|
/* ── Request ────────────────────────────────────────────────────────────── */
|
||||||
|
|||||||
199
src/api/seed.js
199
src/api/seed.js
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { SHIFT_RECORDS } from './attendanceSeed';
|
import { SHIFT_RECORDS } from './attendanceSeed';
|
||||||
|
import { DEMO_USER } from './demoUser';
|
||||||
|
|
||||||
const iso = (date) => new Date(`${date}T09:00:00.000Z`).toISOString();
|
const iso = (date) => new Date(`${date}T09:00:00.000Z`).toISOString();
|
||||||
|
|
||||||
@@ -530,9 +531,14 @@ const JOB_APPLICATIONS = [
|
|||||||
job_posting_id: 'job_security', job_title: 'Event Security Officer',
|
job_posting_id: 'job_security', job_title: 'Event Security Officer',
|
||||||
}),
|
}),
|
||||||
unscreened({
|
unscreened({
|
||||||
|
/* The rejected outcome, and the only one reached without a score: turned
|
||||||
|
down on the licence requirement before screening ran. Rejection overwrites
|
||||||
|
whatever stage it was reached from, which is why the funnel can place him
|
||||||
|
no further than screened. */
|
||||||
id: 'app_kevin', applicant_name: 'Kevin Boyle', email: 'kevin.boyle@email.com',
|
id: 'app_kevin', applicant_name: 'Kevin Boyle', email: 'kevin.boyle@email.com',
|
||||||
phone: '+1-415-555-0113', years_experience: 7, english_level: 'native',
|
phone: '+1-415-555-0113', years_experience: 7, english_level: 'native',
|
||||||
job_posting_id: 'job_security', job_title: 'Event Security Officer',
|
job_posting_id: 'job_security', job_title: 'Event Security Officer',
|
||||||
|
status: 'rejected', updated_date: iso('2026-08-07'),
|
||||||
}),
|
}),
|
||||||
unscreened({
|
unscreened({
|
||||||
id: 'app_rosa', applicant_name: 'Rosa Delgado', email: 'rosa.delgado@email.com',
|
id: 'app_rosa', applicant_name: 'Rosa Delgado', email: 'rosa.delgado@email.com',
|
||||||
@@ -563,7 +569,11 @@ const JOB_APPLICATIONS = [
|
|||||||
selfie_url: 'https://i.pravatar.cc/240?img=33',
|
selfie_url: 'https://i.pravatar.cc/240?img=33',
|
||||||
job_posting_id: 'job_bartender_corp',
|
job_posting_id: 'job_bartender_corp',
|
||||||
job_title: 'Experienced Bartender – Corporate Events',
|
job_title: 'Experienced Bartender – Corporate Events',
|
||||||
status: 'hired',
|
/* Hired and then rostered. `assigned` is the seventh application status and
|
||||||
|
the last one nothing exercised: it counts as hired everywhere the product
|
||||||
|
asks how many people a role has, but takes the candidate out of the
|
||||||
|
running, and it is the only status backed by a row in Assignment. */
|
||||||
|
status: 'assigned',
|
||||||
ai_score: 92,
|
ai_score: 92,
|
||||||
ai_match_label: 'Excellent Match',
|
ai_match_label: 'Excellent Match',
|
||||||
ai_summary:
|
ai_summary:
|
||||||
@@ -583,6 +593,9 @@ const JOB_APPLICATIONS = [
|
|||||||
|
|
||||||
/* Event Server – Fine Dining: 3 applicants · 3 screened · 0 hired */
|
/* Event Server – Fine Dining: 3 applicants · 3 screened · 0 hired */
|
||||||
{
|
{
|
||||||
|
/* Shortlisted: the strongest screened candidate on this role, moved forward
|
||||||
|
but not yet interviewed. Without her the shortlisted stage was empty and
|
||||||
|
every funnel that counts it reported a permanent zero. */
|
||||||
id: 'app_sofia',
|
id: 'app_sofia',
|
||||||
applicant_name: 'Sofia Mendez',
|
applicant_name: 'Sofia Mendez',
|
||||||
email: 'sofia.mendez@email.com',
|
email: 'sofia.mendez@email.com',
|
||||||
@@ -599,7 +612,7 @@ const JOB_APPLICATIONS = [
|
|||||||
selfie_url: 'https://i.pravatar.cc/240?img=47',
|
selfie_url: 'https://i.pravatar.cc/240?img=47',
|
||||||
job_posting_id: 'job_server_fine',
|
job_posting_id: 'job_server_fine',
|
||||||
job_title: 'Event Server – Fine Dining',
|
job_title: 'Event Server – Fine Dining',
|
||||||
status: 'ai_screened',
|
status: 'shortlisted',
|
||||||
ai_score: 89,
|
ai_score: 89,
|
||||||
ai_match_label: 'Excellent Match',
|
ai_match_label: 'Excellent Match',
|
||||||
ai_summary:
|
ai_summary:
|
||||||
@@ -1426,9 +1439,18 @@ const WORKER_PROFILES = [
|
|||||||
salary_expectations: '$30–$36/hr',
|
salary_expectations: '$30–$36/hr',
|
||||||
leadership_potential: 92,
|
leadership_potential: 92,
|
||||||
ai_interview_score: 93,
|
ai_interview_score: 93,
|
||||||
krow_score: 94,
|
krow_score: 95,
|
||||||
reliability_score: 95,
|
reliability_score: 96,
|
||||||
profile_completion: 100,
|
profile_completion: 94,
|
||||||
|
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||||
|
this profile. Seeding the engine's own output is what makes the
|
||||||
|
"auto-recalculated" claim true — completing one course no longer
|
||||||
|
jumps the score to a different number than the one on screen. */
|
||||||
|
score_breakdown: {
|
||||||
|
attendance: 98, performance: 93, education: 100,
|
||||||
|
clientReviews: 98, supervisorReviews: 96,
|
||||||
|
growth: 100, experience: 80,
|
||||||
|
},
|
||||||
xp: 1480,
|
xp: 1480,
|
||||||
/* Seven years on the floor, as a completed ladder: Server and Leadership to
|
/* Seven years on the floor, as a completed ladder: Server and Leadership to
|
||||||
Advanced, Customer Service to Advanced, Food Safety to Beginner. */
|
Advanced, Customer Service to Advanced, Food Safety to Beginner. */
|
||||||
@@ -1479,9 +1501,18 @@ const WORKER_PROFILES = [
|
|||||||
industries: ['Hospitality'],
|
industries: ['Hospitality'],
|
||||||
leadership_potential: 24,
|
leadership_potential: 24,
|
||||||
ai_interview_score: 0,
|
ai_interview_score: 0,
|
||||||
krow_score: 12,
|
krow_score: 30,
|
||||||
reliability_score: 38,
|
reliability_score: 37,
|
||||||
profile_completion: 62,
|
profile_completion: 88,
|
||||||
|
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||||
|
this profile. Seeding the engine's own output is what makes the
|
||||||
|
"auto-recalculated" claim true — completing one course no longer
|
||||||
|
jumps the score to a different number than the one on screen. */
|
||||||
|
score_breakdown: {
|
||||||
|
attendance: 92, performance: 0, education: 80,
|
||||||
|
clientReviews: 0, supervisorReviews: 0,
|
||||||
|
growth: 12, experience: 15,
|
||||||
|
},
|
||||||
xp: 120,
|
xp: 120,
|
||||||
/* One year in: the Beginner rung of Server, and one Intermediate module
|
/* One year in: the Beginner rung of Server, and one Intermediate module
|
||||||
started — so her card reads "Beginner, advancing" rather than a flat
|
started — so her card reads "Beginner, advancing" rather than a flat
|
||||||
@@ -1537,9 +1568,18 @@ const WORKER_PROFILES = [
|
|||||||
salary_expectations: '$26–$32/hr',
|
salary_expectations: '$26–$32/hr',
|
||||||
leadership_potential: 68,
|
leadership_potential: 68,
|
||||||
ai_interview_score: 86,
|
ai_interview_score: 86,
|
||||||
krow_score: 82,
|
krow_score: 87,
|
||||||
reliability_score: 91,
|
reliability_score: 91,
|
||||||
profile_completion: 94,
|
profile_completion: 88,
|
||||||
|
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||||
|
this profile. Seeding the engine's own output is what makes the
|
||||||
|
"auto-recalculated" claim true — completing one course no longer
|
||||||
|
jumps the score to a different number than the one on screen. */
|
||||||
|
score_breakdown: {
|
||||||
|
attendance: 96, performance: 88, education: 100,
|
||||||
|
clientReviews: 94, supervisorReviews: 92,
|
||||||
|
growth: 100, experience: 45,
|
||||||
|
},
|
||||||
xp: 1180,
|
xp: 1180,
|
||||||
/* Server through Intermediate, plus two of the three Advanced modules —
|
/* Server through Intermediate, plus two of the three Advanced modules —
|
||||||
Fine Dining Service Standards is the one thing between him and Advanced,
|
Fine Dining Service Standards is the one thing between him and Advanced,
|
||||||
@@ -1585,9 +1625,18 @@ const WORKER_PROFILES = [
|
|||||||
industries: ['Hospitality'],
|
industries: ['Hospitality'],
|
||||||
leadership_potential: 45,
|
leadership_potential: 45,
|
||||||
ai_interview_score: 74,
|
ai_interview_score: 74,
|
||||||
krow_score: 68,
|
krow_score: 78,
|
||||||
reliability_score: 84,
|
reliability_score: 86,
|
||||||
profile_completion: 80,
|
profile_completion: 88,
|
||||||
|
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||||
|
this profile. Seeding the engine's own output is what makes the
|
||||||
|
"auto-recalculated" claim true — completing one course no longer
|
||||||
|
jumps the score to a different number than the one on screen. */
|
||||||
|
score_breakdown: {
|
||||||
|
attendance: 93, performance: 81, education: 100,
|
||||||
|
clientReviews: 90, supervisorReviews: 88,
|
||||||
|
growth: 76, experience: 25,
|
||||||
|
},
|
||||||
xp: 760,
|
xp: 760,
|
||||||
completed_courses: completions(
|
completed_courses: completions(
|
||||||
[...SERVER_BEGINNER, ...SERVER_INTERMEDIATE, ...CS_BEGINNER, ...CS_INTERMEDIATE, ...FOOD_BEGINNER],
|
[...SERVER_BEGINNER, ...SERVER_INTERMEDIATE, ...CS_BEGINNER, ...CS_INTERMEDIATE, ...FOOD_BEGINNER],
|
||||||
@@ -1624,9 +1673,18 @@ const WORKER_PROFILES = [
|
|||||||
industries: ['Hospitality'],
|
industries: ['Hospitality'],
|
||||||
leadership_potential: 20,
|
leadership_potential: 20,
|
||||||
ai_interview_score: 0,
|
ai_interview_score: 0,
|
||||||
krow_score: 41,
|
krow_score: 35,
|
||||||
reliability_score: 72,
|
reliability_score: 42,
|
||||||
profile_completion: 58,
|
profile_completion: 63,
|
||||||
|
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||||
|
this profile. Seeding the engine's own output is what makes the
|
||||||
|
"auto-recalculated" claim true — completing one course no longer
|
||||||
|
jumps the score to a different number than the one on screen. */
|
||||||
|
score_breakdown: {
|
||||||
|
attendance: 100, performance: 0, education: 100,
|
||||||
|
clientReviews: 0, supervisorReviews: 0,
|
||||||
|
growth: 32, experience: 0,
|
||||||
|
},
|
||||||
xp: 320,
|
xp: 320,
|
||||||
completed_courses: completions(
|
completed_courses: completions(
|
||||||
[...SERVER_BEGINNER, ...CS_BEGINNER, ...FOOD_BEGINNER, ...LEAD_BEGINNER],
|
[...SERVER_BEGINNER, ...CS_BEGINNER, ...FOOD_BEGINNER, ...LEAD_BEGINNER],
|
||||||
@@ -1665,7 +1723,16 @@ const WORKER_PROFILES = [
|
|||||||
ai_interview_score: 0,
|
ai_interview_score: 0,
|
||||||
krow_score: 0,
|
krow_score: 0,
|
||||||
reliability_score: 0,
|
reliability_score: 0,
|
||||||
profile_completion: 30,
|
profile_completion: 56,
|
||||||
|
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||||
|
this profile. Seeding the engine's own output is what makes the
|
||||||
|
"auto-recalculated" claim true — completing one course no longer
|
||||||
|
jumps the score to a different number than the one on screen. */
|
||||||
|
score_breakdown: {
|
||||||
|
attendance: null, performance: 0, education: 0,
|
||||||
|
clientReviews: 0, supervisorReviews: 0,
|
||||||
|
growth: 0, experience: 0,
|
||||||
|
},
|
||||||
xp: 0,
|
xp: 0,
|
||||||
completed_courses: [],
|
completed_courses: [],
|
||||||
earned_badges: [],
|
earned_badges: [],
|
||||||
@@ -1700,7 +1767,16 @@ const WORKER_PROFILES = [
|
|||||||
ai_interview_score: 0,
|
ai_interview_score: 0,
|
||||||
krow_score: 0,
|
krow_score: 0,
|
||||||
reliability_score: 0,
|
reliability_score: 0,
|
||||||
profile_completion: 10,
|
profile_completion: 31,
|
||||||
|
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||||
|
this profile. Seeding the engine's own output is what makes the
|
||||||
|
"auto-recalculated" claim true — completing one course no longer
|
||||||
|
jumps the score to a different number than the one on screen. */
|
||||||
|
score_breakdown: {
|
||||||
|
attendance: null, performance: 0, education: 0,
|
||||||
|
clientReviews: 0, supervisorReviews: 0,
|
||||||
|
growth: 0, experience: 0,
|
||||||
|
},
|
||||||
xp: 0,
|
xp: 0,
|
||||||
completed_courses: [],
|
completed_courses: [],
|
||||||
earned_badges: [],
|
earned_badges: [],
|
||||||
@@ -1735,7 +1811,16 @@ const WORKER_PROFILES = [
|
|||||||
ai_interview_score: 0,
|
ai_interview_score: 0,
|
||||||
krow_score: 0,
|
krow_score: 0,
|
||||||
reliability_score: 0,
|
reliability_score: 0,
|
||||||
profile_completion: 25,
|
profile_completion: 56,
|
||||||
|
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||||
|
this profile. Seeding the engine's own output is what makes the
|
||||||
|
"auto-recalculated" claim true — completing one course no longer
|
||||||
|
jumps the score to a different number than the one on screen. */
|
||||||
|
score_breakdown: {
|
||||||
|
attendance: null, performance: 0, education: 0,
|
||||||
|
clientReviews: 0, supervisorReviews: 0,
|
||||||
|
growth: 0, experience: 0,
|
||||||
|
},
|
||||||
xp: 0,
|
xp: 0,
|
||||||
completed_courses: [],
|
completed_courses: [],
|
||||||
earned_badges: [],
|
earned_badges: [],
|
||||||
@@ -1770,7 +1855,16 @@ const WORKER_PROFILES = [
|
|||||||
ai_interview_score: 0,
|
ai_interview_score: 0,
|
||||||
krow_score: 0,
|
krow_score: 0,
|
||||||
reliability_score: 0,
|
reliability_score: 0,
|
||||||
profile_completion: 20,
|
profile_completion: 56,
|
||||||
|
/* Derived, not authored: exactly what recalcProfilePatch produces for
|
||||||
|
this profile. Seeding the engine's own output is what makes the
|
||||||
|
"auto-recalculated" claim true — completing one course no longer
|
||||||
|
jumps the score to a different number than the one on screen. */
|
||||||
|
score_breakdown: {
|
||||||
|
attendance: null, performance: 0, education: 0,
|
||||||
|
clientReviews: 0, supervisorReviews: 0,
|
||||||
|
growth: 0, experience: 0,
|
||||||
|
},
|
||||||
xp: 0,
|
xp: 0,
|
||||||
completed_courses: [],
|
completed_courses: [],
|
||||||
earned_badges: [],
|
earned_badges: [],
|
||||||
@@ -1878,33 +1972,56 @@ const USER_ACTIVITY = ACTIVITY_EVENTS.map(([event_type, user_email, user_name, a
|
|||||||
|
|
||||||
/* ── Signed-in demo user ───────────────────────────────────────────────── */
|
/* ── Signed-in demo user ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
export const DEMO_USER = {
|
/**
|
||||||
id: 'user_demo',
|
* Re-exported, not declared.
|
||||||
full_name: 'Alex Rivera',
|
*
|
||||||
email: 'demo@krow.app',
|
* The default user shape moved to `api/demoUser.js` so that `base44Client.js`
|
||||||
role: 'admin',
|
* can have it without importing this file — and with it every position,
|
||||||
account_type: 'employer',
|
* candidate, interview and shift record below — into the production bundle.
|
||||||
created_date: iso('2026-06-01'),
|
* The fixtures here are for the test scripts, and they still read
|
||||||
/* Product preferences travel with the account rather than in a store of their
|
* `seedData.User` and `DEMO_USER` from this module, so both keep working.
|
||||||
own, so there is one record to persist and one thing to read. */
|
*/
|
||||||
preferences: {
|
export { DEMO_USER } from './demoUser';
|
||||||
owliverDefault: true,
|
|
||||||
compactDensity: false,
|
|
||||||
emailDigest: true,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── Assignments ───────────────────────────────────────────────────────────
|
/* ── Assignments ───────────────────────────────────────────────────────────
|
||||||
Who is on which position, and until when — the record that turns a hire into
|
Who is on which position, and until when — the record that turns a hire into
|
||||||
workforce allocation, and the one that says who is therefore not free for
|
workforce allocation, and the one that says who is therefore not free for
|
||||||
anything else.
|
anything else.
|
||||||
|
|
||||||
Empty by design. This deployment has no assignment records yet: they are
|
Was empty by design, on the reasoning that assignments are written by the
|
||||||
written by the assignment flow, not shipped as seed. The engine reads an
|
assignment flow rather than shipped. That held until it turned out to be the
|
||||||
empty set correctly — every position simply reports nobody assigned — and
|
reason nothing exercised `assigned`: the funnel dropped that status out of
|
||||||
the UI omits the demand figures rather than inventing coverage that does
|
every stage bucket, and the agent's own candidate lookup offered somebody
|
||||||
not exist. */
|
already working a shift as a person to chase. Neither was visible against a
|
||||||
const ASSIGNMENTS = [];
|
fixture that never produced one.
|
||||||
|
|
||||||
|
So: exactly one, the minimum that makes the status real. Every reader still
|
||||||
|
has to handle the empty case, because eight of the nine positions have no
|
||||||
|
assignment and report nobody assigned. */
|
||||||
|
const ASSIGNMENTS = [
|
||||||
|
/* One assignment, for the one application at `assigned`.
|
||||||
|
Marco is the right person to carry it: he is the control in the attendance
|
||||||
|
data, with a recurring corporate bartending pattern across the whole
|
||||||
|
window, so an open-ended assignment to that position is what his shift
|
||||||
|
records already describe. `ends_at` is null — the assignment is ongoing,
|
||||||
|
which is also the case the readers have to handle.
|
||||||
|
`worker_profile_id` is absent by design: Marco is Staff, and the talent
|
||||||
|
pool and the roster are deliberately different people. */
|
||||||
|
{
|
||||||
|
id: 'assign_marco_bartender',
|
||||||
|
job_posting_id: 'job_bartender_corp',
|
||||||
|
application_id: 'app_marco',
|
||||||
|
worker_email: 'marco.rivera@email.com',
|
||||||
|
worker_name: 'Marco Rivera',
|
||||||
|
starts_at: iso('2026-07-01'),
|
||||||
|
ends_at: null,
|
||||||
|
status: 'active',
|
||||||
|
source: 'seed',
|
||||||
|
match_score: 92,
|
||||||
|
created_date: iso('2026-07-01'),
|
||||||
|
updated_date: iso('2026-07-01'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
/* Attendance lives in its own module: it is generated rather than written out,
|
/* Attendance lives in its own module: it is generated rather than written out,
|
||||||
and its dates are anchored to now rather than to this file's fixed calendar.
|
and its dates are anchored to now rather than to this file's fixed calendar.
|
||||||
|
|||||||
173
src/api/store.js
173
src/api/store.js
@@ -1,173 +0,0 @@
|
|||||||
/**
|
|
||||||
* In-memory entity store backing the KROW demo.
|
|
||||||
*
|
|
||||||
* Mirrors the Base44 entity API the app was built against
|
|
||||||
* (`list` / `filter` / `get` / `create` / `update` / `delete`) so every hook,
|
|
||||||
* page and component consumes data exactly as it did against the real backend.
|
|
||||||
* Records live in memory and are mirrored to localStorage, so demo edits —
|
|
||||||
* screening a candidate, hiring, creating a position — survive a reload.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'krow_demo_db';
|
|
||||||
const STORAGE_VERSION = 8;
|
|
||||||
|
|
||||||
/** Simulated network latency, in ms, so loading states are real. */
|
|
||||||
const LATENCY = { read: 140, write: 220 };
|
|
||||||
|
|
||||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
/** Structured clone with a JSON fallback for older Safari. */
|
|
||||||
const clone = (value) =>
|
|
||||||
typeof structuredClone === 'function'
|
|
||||||
? structuredClone(value)
|
|
||||||
: JSON.parse(JSON.stringify(value));
|
|
||||||
|
|
||||||
let _counter = 0;
|
|
||||||
export function makeId(prefix = 'rec') {
|
|
||||||
_counter += 1;
|
|
||||||
return `${prefix}_${Date.now().toString(36)}${_counter.toString(36).padStart(3, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Persistence ───────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
let db = {};
|
|
||||||
|
|
||||||
function persist() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: STORAGE_VERSION, db }));
|
|
||||||
} catch {
|
|
||||||
// Private browsing / quota — the demo still works from memory.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readPersisted() {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
// A seed-data change bumps STORAGE_VERSION, which invalidates stale copies.
|
|
||||||
return parsed?.version === STORAGE_VERSION ? parsed.db : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the store: persisted snapshot if one matches the current seed version,
|
|
||||||
* otherwise the seed itself.
|
|
||||||
*/
|
|
||||||
export function initStore(seed) {
|
|
||||||
db = readPersisted() || clone(seed);
|
|
||||||
// A seed that gained an entity after the snapshot was written still resolves.
|
|
||||||
for (const [name, records] of Object.entries(seed)) {
|
|
||||||
if (!db[name]) db[name] = clone(records);
|
|
||||||
}
|
|
||||||
persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Drops all local edits and restores the shipped demo data. */
|
|
||||||
export function resetStore(seed) {
|
|
||||||
db = clone(seed);
|
|
||||||
persist();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Query helpers ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a Base44-style sort string: `'-ai_score'` descending,
|
|
||||||
* `'created_date'` ascending. Unknown fields leave order untouched.
|
|
||||||
*/
|
|
||||||
function applySort(records, sort) {
|
|
||||||
if (!sort) return records;
|
|
||||||
const desc = sort.startsWith('-');
|
|
||||||
const field = desc ? sort.slice(1) : sort;
|
|
||||||
return [...records].sort((a, b) => {
|
|
||||||
const av = a?.[field];
|
|
||||||
const bv = b?.[field];
|
|
||||||
if (av === bv) return 0;
|
|
||||||
if (av === undefined || av === null) return 1;
|
|
||||||
if (bv === undefined || bv === null) return -1;
|
|
||||||
const result = typeof av === 'number' && typeof bv === 'number'
|
|
||||||
? av - bv
|
|
||||||
: String(av).localeCompare(String(bv));
|
|
||||||
return desc ? -result : result;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shallow equality match across every key of `query`, array fields included. */
|
|
||||||
function matches(record, query) {
|
|
||||||
return Object.entries(query).every(([key, want]) => {
|
|
||||||
const got = record?.[key];
|
|
||||||
if (Array.isArray(want)) return want.includes(got);
|
|
||||||
return got === want;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Entity API ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the client surface for one entity. Every method is async and returns
|
|
||||||
* cloned records, so callers can never mutate the store by reference.
|
|
||||||
*/
|
|
||||||
export function createEntity(name) {
|
|
||||||
const table = () => (db[name] ||= []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
entityName: name,
|
|
||||||
|
|
||||||
async list(sort = '-created_date', limit = 100) {
|
|
||||||
await delay(LATENCY.read);
|
|
||||||
return clone(applySort(table(), sort).slice(0, limit));
|
|
||||||
},
|
|
||||||
|
|
||||||
async filter(query = {}, sort = '-created_date', limit = 100) {
|
|
||||||
await delay(LATENCY.read);
|
|
||||||
const found = table().filter((record) => matches(record, query));
|
|
||||||
return clone(applySort(found, sort).slice(0, limit));
|
|
||||||
},
|
|
||||||
|
|
||||||
async get(id) {
|
|
||||||
await delay(LATENCY.read);
|
|
||||||
const found = table().find((record) => record.id === id);
|
|
||||||
if (!found) throw new Error(`${name} ${id} not found`);
|
|
||||||
return clone(found);
|
|
||||||
},
|
|
||||||
|
|
||||||
async create(data) {
|
|
||||||
await delay(LATENCY.write);
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const record = {
|
|
||||||
id: makeId(name.toLowerCase()),
|
|
||||||
created_date: now,
|
|
||||||
updated_date: now,
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
table().unshift(record);
|
|
||||||
persist();
|
|
||||||
return clone(record);
|
|
||||||
},
|
|
||||||
|
|
||||||
async update(id, data) {
|
|
||||||
await delay(LATENCY.write);
|
|
||||||
const index = table().findIndex((record) => record.id === id);
|
|
||||||
if (index === -1) throw new Error(`${name} ${id} not found`);
|
|
||||||
const next = { ...table()[index], ...data, updated_date: new Date().toISOString() };
|
|
||||||
table()[index] = next;
|
|
||||||
persist();
|
|
||||||
return clone(next);
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(id) {
|
|
||||||
await delay(LATENCY.write);
|
|
||||||
db[name] = table().filter((record) => record.id !== id);
|
|
||||||
persist();
|
|
||||||
return { id };
|
|
||||||
},
|
|
||||||
|
|
||||||
async bulkCreate(records = []) {
|
|
||||||
const created = [];
|
|
||||||
for (const record of records) created.push(await this.create(record));
|
|
||||||
return created;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {
|
import {
|
||||||
BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus,
|
BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus,
|
||||||
SlidersHorizontal, Trash2, X,
|
SlidersHorizontal, Trash2, Wrench, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger,
|
Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger,
|
||||||
SelectValue, Switch, Textarea,
|
SelectValue, Switch, Textarea,
|
||||||
} from '@/components/ds';
|
} from '@/components/ds';
|
||||||
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
|
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
|
||||||
|
import { getSkillsForPage } from '@/lib/skills/registry';
|
||||||
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
|
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
|
||||||
import {
|
import {
|
||||||
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
|
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
|
||||||
@@ -96,6 +97,16 @@ function useActiveSection(ids) {
|
|||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
export function AgentConfigure({
|
export function AgentConfigure({
|
||||||
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
|
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
|
||||||
|
/* Attaching and detaching a skill goes through the caller's existing write
|
||||||
|
path, NOT through `set`. Every other control here writes a draft that Save
|
||||||
|
persists; a skill persists immediately, on purpose — the whole point of the
|
||||||
|
catalog is that the agent gains the capability there and then. Two write
|
||||||
|
paths for one field would mean the Configure screen and the catalog
|
||||||
|
disagreed about when a skill takes effect. */
|
||||||
|
onToggleSkill = null, pendingSkill = null,
|
||||||
|
/* The tools this deployment registers, from GET /api/v1/tools. Passed in
|
||||||
|
rather than fetched here so this component stays a form over `fields`. */
|
||||||
|
toolCatalogue = [],
|
||||||
}) {
|
}) {
|
||||||
/* Sections are open by default: this is a document, and one that greets its
|
/* Sections are open by default: this is a document, and one that greets its
|
||||||
author with four closed headers hides the thing they came to write. Closing
|
author with four closed headers hides the thing they came to write. Closing
|
||||||
@@ -107,6 +118,32 @@ export function AgentConfigure({
|
|||||||
|
|
||||||
const [active, setActive] = useActiveSection(SECTION_IDS);
|
const [active, setActive] = useActiveSection(SECTION_IDS);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The skills these pages offer, and the ones not yet attached.
|
||||||
|
*
|
||||||
|
* Derived at render from `fields.pages` rather than held in state, for the
|
||||||
|
* same reason the rail is: a second copy of a list that is already on screen
|
||||||
|
* is a second thing to keep correct, and the one that drifts is always the
|
||||||
|
* copy.
|
||||||
|
*
|
||||||
|
* Deduplicated by id because an agent covering several pages will be offered
|
||||||
|
* the same skill by each of them.
|
||||||
|
*/
|
||||||
|
const availableSkills = React.useMemo(() => {
|
||||||
|
const seen = new Map();
|
||||||
|
for (const page of fields.pages) {
|
||||||
|
for (const skill of getSkillsForPage(page, { customSources: customSkills })) {
|
||||||
|
if (!seen.has(skill.id)) seen.set(skill.id, skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}, [fields.pages, customSkills]);
|
||||||
|
|
||||||
|
const attachableSkills = React.useMemo(
|
||||||
|
() => availableSkills.filter((sk) => !fields.skills.includes(sk.id)),
|
||||||
|
[availableSkills, fields.skills]
|
||||||
|
);
|
||||||
|
|
||||||
const set = (patch) => onChange({ ...fields, ...patch });
|
const set = (patch) => onChange({ ...fields, ...patch });
|
||||||
|
|
||||||
const instructionWords = React.useMemo(
|
const instructionWords = React.useMemo(
|
||||||
@@ -176,9 +213,10 @@ export function AgentConfigure({
|
|||||||
id: 'capabilities',
|
id: 'capabilities',
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
label: 'Capabilities',
|
label: 'Capabilities',
|
||||||
meta: `${fields.pages.length + fields.knowledge.length}`,
|
meta: `${fields.pages.length + fields.skills.length + fields.knowledge.length}`,
|
||||||
items: [
|
items: [
|
||||||
{ id: 'capabilities-pages', label: 'Pages', meta: fields.pages.length },
|
{ id: 'capabilities-pages', label: 'Pages', meta: fields.pages.length },
|
||||||
|
{ id: 'capabilities-skills', label: 'Skills', meta: fields.skills.length },
|
||||||
{ id: 'capabilities-knowledge', label: 'Knowledge', meta: fields.knowledge.length },
|
{ id: 'capabilities-knowledge', label: 'Knowledge', meta: fields.knowledge.length },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -384,6 +422,176 @@ export function AgentConfigure({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Skills ── what it can actually do.
|
||||||
|
|
||||||
|
The section this screen was missing. Skills were attachable from
|
||||||
|
the catalog and invisible here, on the one page that lists
|
||||||
|
everything else an agent carries — so the reading was that this
|
||||||
|
agent had none.
|
||||||
|
|
||||||
|
Availability is bounded by the pages above, which is the rule the
|
||||||
|
copy under Pages already states: a page decides which skills
|
||||||
|
exist there, and an agent chooses among them. It can narrow that
|
||||||
|
list, never widen it. */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<GroupHead
|
||||||
|
id="capabilities-skills"
|
||||||
|
icon={Boxes}
|
||||||
|
title="Skills"
|
||||||
|
count={fields.skills.length}
|
||||||
|
action={onOpenSkills && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onOpenSkills}>
|
||||||
|
Browse catalog
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!fields.pages.length ? (
|
||||||
|
<p className="mt-2 text-body-sm text-ink-3">
|
||||||
|
Choose a page first. Skills belong to pages, so there are none to offer until
|
||||||
|
this agent has somewhere to answer.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-2 space-y-3">
|
||||||
|
{fields.skills.length > 0 && (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{fields.skills.map((id) => {
|
||||||
|
const skill = availableSkills.find((sk) => sk.id === id);
|
||||||
|
return (
|
||||||
|
<ItemRow
|
||||||
|
key={id}
|
||||||
|
icon={Boxes}
|
||||||
|
title={skill?.name || id}
|
||||||
|
detail={skill?.description}
|
||||||
|
/* A skill the pages no longer offer is still
|
||||||
|
attached and still listed — silently dropping it
|
||||||
|
would edit the definition behind the author's
|
||||||
|
back. Flagged instead, so removing it is their
|
||||||
|
decision. */
|
||||||
|
warning={skill ? null : 'Not available on the pages above.'}
|
||||||
|
removeLabel={`Remove ${skill?.name || id}`}
|
||||||
|
onRemove={onToggleSkill ? () => onToggleSkill(id) : undefined}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fields.skills.length && (
|
||||||
|
<p className="text-body-sm text-ink-3">
|
||||||
|
No skills attached. This agent answers from the page's own reader only.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value=""
|
||||||
|
disabled={!onToggleSkill || Boolean(pendingSkill)}
|
||||||
|
onValueChange={(id) => !fields.skills.includes(id) && onToggleSkill?.(id)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-72">
|
||||||
|
<SelectValue placeholder={
|
||||||
|
pendingSkill ? 'Saving...' : 'Add a skill...'
|
||||||
|
} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{attachableSkills.map((sk) => (
|
||||||
|
<SelectItem key={sk.id} value={sk.id}>{sk.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<p className="text-caption leading-relaxed text-ink-4">
|
||||||
|
{attachableSkills.length
|
||||||
|
? 'Attaching a skill takes effect immediately — it does not wait for Save.'
|
||||||
|
: 'Every skill these pages offer is already attached.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tools ── what it can actually do.
|
||||||
|
|
||||||
|
Skills are guidance the model reads; tools are the calls it may
|
||||||
|
make. An agent with skills and no tools discusses the work and
|
||||||
|
looks nothing up, which is what every agent authored here was
|
||||||
|
before this section existed — the editor had no field for it and
|
||||||
|
the parser dropped `tools:` on the way in.
|
||||||
|
|
||||||
|
The catalogue is served by the backend rather than listed here,
|
||||||
|
so a tool renamed or withdrawn cannot leave a stale option in
|
||||||
|
this form and an agent built from one that no longer exists. */}
|
||||||
|
<div className="pt-4">
|
||||||
|
<GroupHead
|
||||||
|
id="capabilities-tools"
|
||||||
|
icon={Wrench}
|
||||||
|
title="Tools"
|
||||||
|
count={fields.tools.length}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-3">
|
||||||
|
{fields.tools.length > 0 && (
|
||||||
|
<ul className="divide-y divide-border">
|
||||||
|
{fields.tools.map((name) => {
|
||||||
|
const tool = toolCatalogue.find((t) => t.name === name);
|
||||||
|
return (
|
||||||
|
<ItemRow
|
||||||
|
key={name}
|
||||||
|
icon={Wrench}
|
||||||
|
title={tool?.name || name}
|
||||||
|
detail={tool?.description}
|
||||||
|
/* A tool this deployment no longer registers is kept
|
||||||
|
and flagged rather than dropped: the backend now
|
||||||
|
refuses to save a definition naming one, so the
|
||||||
|
author needs to see which. */
|
||||||
|
warning={tool
|
||||||
|
? (tool.effect === 'write'
|
||||||
|
? 'Proposes changes — a person is asked to approve each one.'
|
||||||
|
: null)
|
||||||
|
: 'Not available on this deployment.'}
|
||||||
|
removeLabel={`Remove ${name}`}
|
||||||
|
onRemove={() => set({ tools: fields.tools.filter((t) => t !== name) })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!fields.tools.length && (
|
||||||
|
<p className="text-body-sm text-ink-3">
|
||||||
|
No tools attached. This agent can discuss its subject but cannot look
|
||||||
|
anything up.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value=""
|
||||||
|
onValueChange={(name) =>
|
||||||
|
!fields.tools.includes(name) && set({ tools: [...fields.tools, name] })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full sm:w-72">
|
||||||
|
<SelectValue placeholder={
|
||||||
|
toolCatalogue.length ? 'Add a tool...' : 'Loading tools...'
|
||||||
|
} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{toolCatalogue
|
||||||
|
.filter((t) => !fields.tools.includes(t.name))
|
||||||
|
.map((t) => (
|
||||||
|
<SelectItem key={t.name} value={t.name}>
|
||||||
|
{t.name}{t.effect === 'write' ? ' — writes' : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<p className="text-caption leading-relaxed text-ink-4">
|
||||||
|
A tool marked <em>writes</em> can propose a change. Nothing is written until
|
||||||
|
somebody approves it, and the agent can only reach what you could reach
|
||||||
|
yourself.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Knowledge ── what it has been told. */}
|
{/* Knowledge ── what it has been told. */}
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<GroupHead
|
<GroupHead
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ function FeedbackControls({ feedback, onFeedback }) {
|
|||||||
|
|
||||||
export const Message = React.memo(
|
export const Message = React.memo(
|
||||||
/** @param {any} props */
|
/** @param {any} props */
|
||||||
({ role, text, blocks, streaming, stopped, onPrompt, feedback = null, onFeedback = null }) => {
|
({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null,
|
||||||
|
feedback = null, onFeedback = null }) => {
|
||||||
if (role === 'user') {
|
if (role === 'user') {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
@@ -105,7 +106,7 @@ export const Message = React.memo(
|
|||||||
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} />
|
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} onConfirm={onConfirm} />
|
||||||
|
|
||||||
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
|
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
|
ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -9,28 +10,28 @@ import { IconButton } from '@/components/ds/IconButton';
|
|||||||
import { Alert } from '@/components/ds/Alert';
|
import { Alert } from '@/components/ds/Alert';
|
||||||
import {
|
import {
|
||||||
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
|
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
|
||||||
useMarkInterviewReady, useShiftRecords, useUpdateJobPosting,
|
fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords,
|
||||||
|
useUpdateJobPosting,
|
||||||
usePreferences, useRoleCategories,
|
usePreferences, useRoleCategories,
|
||||||
} from '@/lib/krowHooks';
|
} from '@/lib/krowHooks';
|
||||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||||
import { runAction } from '@/lib/skills/actions';
|
import { runAction } from '@/lib/skills/actions';
|
||||||
import { allSkills, skillsForContext } from '@/lib/skills/registry';
|
import { allSkills, pageKeyForContext } from '@/lib/skills/registry';
|
||||||
import { owliverSuggestions } from '@/lib/skills/owliverResolver';
|
import { suggestionChips } from '@/lib/skills/serverSuggestions';
|
||||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||||
import { profileForEmail } from '@/lib/skillGraph';
|
import { profileForEmail } from '@/lib/skillGraph';
|
||||||
import { groupByRecency } from './history';
|
import { groupByRecency } from './history';
|
||||||
import { useAssistantPanel } from './AssistantPanelContext';
|
import { useAssistantPanel } from './AssistantPanelContext';
|
||||||
import { usePageContext } from './PageContext';
|
import { usePageContext } from './PageContext';
|
||||||
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
|
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
|
||||||
import { buildIntro, buildPrompts } from './dynamic';
|
import { buildIntro } from './dynamic';
|
||||||
import { agentScopedDisabled, agentStarters } from '@/lib/agents/runtime';
|
import { agentScopedDisabled } from '@/lib/agents/runtime';
|
||||||
import { buildOwliverContext } from '@/lib/agents/context';
|
import { buildOwliverContext } from '@/lib/agents/context';
|
||||||
import { AgentBadge } from './AgentBadge';
|
import { AgentBadge } from './AgentBadge';
|
||||||
import { useActiveAgent } from './AgentContext';
|
import { useActiveAgent } from './AgentContext';
|
||||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||||
import { PromptInput } from './PromptInput';
|
import { PromptInput } from './PromptInput';
|
||||||
import { PromptChips } from './PromptChips';
|
import { PromptChips } from './PromptChips';
|
||||||
import { rankPrompts } from './matchPrompts';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chip row's "nothing to offer", as one shared array.
|
* The chip row's "nothing to offer", as one shared array.
|
||||||
@@ -41,6 +42,51 @@ import { rankPrompts } from './matchPrompts';
|
|||||||
*/
|
*/
|
||||||
const EMPTY_PROMPTS = [];
|
const EMPTY_PROMPTS = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same, for a suggestion response that has not arrived.
|
||||||
|
*
|
||||||
|
* Separate from EMPTY_PROMPTS because they are different kinds of empty: one is
|
||||||
|
* "this page offers nothing", the other is "the server has not answered yet",
|
||||||
|
* and sharing an array between them would make a pending request and a
|
||||||
|
* considered refusal indistinguishable in a dependency list.
|
||||||
|
*/
|
||||||
|
const EMPTY_SUGGESTIONS = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How long a pause counts as having finished typing.
|
||||||
|
*
|
||||||
|
* Short enough that the chips feel like they are keeping up, long enough that a
|
||||||
|
* word typed at speed is one request rather than eight. The endpoint is cached
|
||||||
|
* per query, so a reader deleting back to something already asked pays nothing
|
||||||
|
* either way.
|
||||||
|
*/
|
||||||
|
const SUGGEST_DEBOUNCE_MS = 180;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A value, held still until it stops changing.
|
||||||
|
*
|
||||||
|
* Deliberately generic and local: it debounces the composer's contents and
|
||||||
|
* nothing else, and the alternative — debouncing inside the query hook — would
|
||||||
|
* make every other caller of that hook pay for a delay it did not ask for.
|
||||||
|
*/
|
||||||
|
function useDebounced(value, delay) {
|
||||||
|
const [settled, setSettled] = React.useState(value);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
/* An emptied composer settles immediately. Waiting would leave the previous
|
||||||
|
query's chips under a blank input for a fifth of a second, which reads as
|
||||||
|
the panel not having noticed. */
|
||||||
|
if (!value) {
|
||||||
|
setSettled(value);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(() => setSettled(value), delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return settled;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owliver History — the conversations that came before.
|
* Owliver History — the conversations that came before.
|
||||||
*
|
*
|
||||||
@@ -294,10 +340,6 @@ export default function KrowAssistant({
|
|||||||
() => preferences.customSkills || [],
|
() => preferences.customSkills || [],
|
||||||
[preferences.customSkills]
|
[preferences.customSkills]
|
||||||
);
|
);
|
||||||
const skills = React.useMemo(
|
|
||||||
() => skillsForContext(context.id, disabledSkills, customSkills),
|
|
||||||
[context.id, disabledSkills, customSkills]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a declared skill reads from.
|
* What a declared skill reads from.
|
||||||
@@ -307,6 +349,17 @@ export default function KrowAssistant({
|
|||||||
* the card on the page and the answer in the panel are two readings of the
|
* the card on the page and the answer in the panel are two readings of the
|
||||||
* same data rather than two queries that happen to agree.
|
* same data rather than two queries that happen to agree.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* The page, as the API names it.
|
||||||
|
*
|
||||||
|
* `pageKeyForContext` is the existing translation from an assistant context
|
||||||
|
* id to a surface key — `admin.positions` → `positions` — and the surface
|
||||||
|
* keys are the same closed vocabulary the backend validates `page` against.
|
||||||
|
* So the panel asks about the page it is on without a second table mapping
|
||||||
|
* one to the other.
|
||||||
|
*/
|
||||||
|
const owliverPage = React.useMemo(() => pageKeyForContext(context.id), [context.id]);
|
||||||
|
|
||||||
const pageContext = usePageContext();
|
const pageContext = usePageContext();
|
||||||
const { statesFor } = useWorkforcePaths();
|
const { statesFor } = useWorkforcePaths();
|
||||||
const trainingPaths = React.useMemo(
|
const trainingPaths = React.useMemo(
|
||||||
@@ -380,6 +433,37 @@ export default function KrowAssistant({
|
|||||||
return createJob.mutateAsync(result.data);
|
return createJob.mutateAsync(result.data);
|
||||||
}, [createJob]);
|
}, [createJob]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What to ask next, from the server, after something has been written.
|
||||||
|
*
|
||||||
|
* The untyped form of the suggestions endpoint: nothing has been typed, so
|
||||||
|
* the API ranks against the organization's actual state — the rows the
|
||||||
|
* mutation just changed. Asking it here rather than deriving an answer in the
|
||||||
|
* panel is the whole point. The panel knows a position now exists; only the
|
||||||
|
* server knows whether that makes drafts, starved roles or an unscreened
|
||||||
|
* queue the thing worth raising, and only it knows what this caller's role
|
||||||
|
* permits.
|
||||||
|
*
|
||||||
|
* `fetchQuery` with `staleTime: 0` rather than a bare request: it resolves
|
||||||
|
* with the fresh answer *and* leaves it in the cache under the key the hook
|
||||||
|
* reads, so the chips this reply carries and the chips the composer would
|
||||||
|
* offer are one answer rather than two requests apart. Zero rather than the
|
||||||
|
* hook's thirty seconds because a cached answer is exactly what must not be
|
||||||
|
* used here — it was computed before the row existed.
|
||||||
|
*/
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const refreshSuggestions = React.useCallback(async () => {
|
||||||
|
if (!owliverPage) return [];
|
||||||
|
const fresh = await queryClient.fetchQuery({
|
||||||
|
/* The empty query string is the untyped request — the same key
|
||||||
|
`useOwliverSuggestions` uses when the composer is empty. */
|
||||||
|
queryKey: ['owliverSuggestions', owliverPage, ''],
|
||||||
|
queryFn: () => fetchOwliverSuggestions({ page: owliverPage }),
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
return suggestionChips(fresh || [], context.id);
|
||||||
|
}, [queryClient, owliverPage, context.id]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finishing a draft, through the mutations the Create Position form calls.
|
* Finishing a draft, through the mutations the Create Position form calls.
|
||||||
*
|
*
|
||||||
@@ -463,7 +547,7 @@ export default function KrowAssistant({
|
|||||||
const {
|
const {
|
||||||
messages, pending, error, busy, send, announce, stop, reset,
|
messages, pending, error, busy, send, announce, stop, reset,
|
||||||
history, conversationId, openConversation, forgetConversation,
|
history, conversationId, openConversation, forgetConversation,
|
||||||
submitFeedback, feedback,
|
submitFeedback, feedback, confirm,
|
||||||
} = useConversation({
|
} = useConversation({
|
||||||
contextId: context.id,
|
contextId: context.id,
|
||||||
facts,
|
facts,
|
||||||
@@ -471,6 +555,7 @@ export default function KrowAssistant({
|
|||||||
onNavigate: goToPage,
|
onNavigate: goToPage,
|
||||||
onAction: performAction,
|
onAction: performAction,
|
||||||
onCreatePosition: createPosition,
|
onCreatePosition: createPosition,
|
||||||
|
onRefreshSuggestions: refreshSuggestions,
|
||||||
onUpdatePosition,
|
onUpdatePosition,
|
||||||
onGenerateDescription,
|
onGenerateDescription,
|
||||||
onAssignWorkers,
|
onAssignWorkers,
|
||||||
@@ -532,103 +617,26 @@ export default function KrowAssistant({
|
|||||||
() => buildIntro(context.id, facts, userName),
|
() => buildIntro(context.id, facts, userName),
|
||||||
[context.id, facts, userName]
|
[context.id, facts, userName]
|
||||||
);
|
);
|
||||||
/**
|
|
||||||
* Everything this page can be asked, in the chips that already know how to
|
|
||||||
* ask it.
|
|
||||||
*
|
|
||||||
* Unchanged in what it collects and in what order: the agent's starters, the
|
|
||||||
* declared suggestions of every attached skill, the skills that carry a
|
|
||||||
* prompt, and the page's own derived prompts, de-duplicated by intent. Each
|
|
||||||
* chip keeps the metadata that makes it executable — a page `capability`, a
|
|
||||||
* `skillId`/`skillCapability` pair, a `positionId`, a `route` — because that
|
|
||||||
* is what `runPrompt` dispatches on.
|
|
||||||
*
|
|
||||||
* What changed is only that this is no longer what the panel renders. It is
|
|
||||||
* the set that `prompts` below chooses from, so an opening panel can offer
|
|
||||||
* nothing while a typed query can still reach any of it. Building it eagerly
|
|
||||||
* costs nothing — it is derived from data already in hand and memoized on it
|
|
||||||
* — and building it lazily would mean the first keystroke paid for the whole
|
|
||||||
* catalogue.
|
|
||||||
*/
|
|
||||||
const catalogue = React.useMemo(() => {
|
|
||||||
/* A definition's own suggestions come first: they are the only ones written
|
|
||||||
for this workspace rather than derived from the page, and they are capped
|
|
||||||
by the resolver so a workspace with several skills attached cannot bury
|
|
||||||
the page's own. */
|
|
||||||
const declared = owliverSuggestions(context.id, disabledSkills, customSkills, pageContext);
|
|
||||||
const skillPrompts = skills
|
|
||||||
.filter((s) => s.prompt)
|
|
||||||
.map((s) => ({ label: s.prompt, prompt: s.prompt }));
|
|
||||||
|
|
||||||
/* Three sources can propose the same question — a skill's declared
|
|
||||||
suggestion and the page's own derived one often word it identically —
|
|
||||||
and two chips reading "Summarize hiring activity" is both a duplicate
|
|
||||||
React key and a duplicate offer. First wins, so the definition's own
|
|
||||||
wording survives and the derived copy drops out. */
|
|
||||||
/* A declared suggestion that cannot answer yet — it reads one record and
|
|
||||||
none is selected — ranks behind the page's own offers rather than
|
|
||||||
leading with a question. The lifecycle reads correctly either way:
|
|
||||||
before a position exists the page's actions lead; once one is open or
|
|
||||||
has just been created, the readings about it come first. */
|
|
||||||
const ready = declared.filter((c) => !c.deferred);
|
|
||||||
const asking = declared.filter((c) => c.deferred);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One chip per *intent*, not per wording.
|
|
||||||
*
|
|
||||||
* De-duplicating on the label alone let two chips through whenever the same
|
|
||||||
* answer was worded twice — a skill's "Show hiring activity" and its
|
|
||||||
* "Summarize hiring activity" both resolved to that skill's `summary`
|
|
||||||
* capability, so the reader was offered the same reading under two names and
|
|
||||||
* had no way to tell them apart. What a chip *resolves to* is the thing that
|
|
||||||
* must be unique: a skill capability, the page capability, or, for a chip
|
|
||||||
* that is neither, the question it sends. The label is compared too, so two
|
|
||||||
* differently-routed chips still cannot arrive reading identically.
|
|
||||||
*/
|
|
||||||
const seen = new Set();
|
|
||||||
const intentOf = (chip) => {
|
|
||||||
if (chip?.skillId && chip?.skillCapability) return `skill:${chip.skillId}:${chip.skillCapability}`;
|
|
||||||
if (chip?.capability) return `page:${chip.capability}`;
|
|
||||||
return `ask:${String(chip?.prompt ?? '').trim().toLowerCase()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
/* The agent's own starters lead: they were written for this agent, and they
|
|
||||||
pass through the same de-duplication below, so a starter worded like a
|
|
||||||
skill suggestion still yields one chip rather than two. An agent that
|
|
||||||
does not cover this page offers none. */
|
|
||||||
return [...agentStarters(agent, context.id), ...ready, ...skillPrompts,
|
|
||||||
...buildPrompts(context.id, facts, workforce), ...asking]
|
|
||||||
.filter((chip) => {
|
|
||||||
const label = String(chip?.label ?? '').trim().toLowerCase();
|
|
||||||
if (!label) return false;
|
|
||||||
const intent = intentOf(chip);
|
|
||||||
if (seen.has(intent) || seen.has(`label:${label}`)) return false;
|
|
||||||
seen.add(intent);
|
|
||||||
seen.add(`label:${label}`);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
/* `pageContext` decides which suggestions can answer without asking, so the
|
|
||||||
chips re-rank when a position is opened or closed. */
|
|
||||||
}, [skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext, agent]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the chip row actually shows, which is one of three separate things.
|
* What the chip row actually shows, which is one of three separate things.
|
||||||
*
|
*
|
||||||
* They are separate states, not one merged list, because they answer to
|
* They are separate states, not one merged list, because they answer to
|
||||||
* different owners. Follow-ups belong to the answer that raised them; typed
|
* different owners. Follow-ups belong to the answer that raised them; the
|
||||||
* matches belong to the composer; the catalogue belongs to the page. Only one
|
* suggestions belong to the server. Only one of them can be true at a time,
|
||||||
* of them can be true at a time, and the order below is that precedence.
|
* and the order below is that precedence.
|
||||||
*
|
*
|
||||||
* 1. Follow-ups. When an answer ends by asking something, its chips *are*
|
* 1. Follow-ups. When an answer ends by asking something, its chips *are*
|
||||||
* the answers to it — the role list after "create a position". They are
|
* the answers to it — the role list after "create a position". They are
|
||||||
* never capped and never filtered, and they stand until the next turn or
|
* never capped and never filtered, and they stand until the next turn or
|
||||||
* until the reader starts typing something else.
|
* until the reader starts typing something else.
|
||||||
*
|
*
|
||||||
* 2. Typed matches. From the second meaningful character, the catalogue
|
* 2. The server's suggestions. From the moment there is something in the
|
||||||
* above is ranked against the query and the best three are offered.
|
* composer, `GET /api/v1/owliver/suggestions` is asked what this page
|
||||||
* These are the catalogue's own chip objects, so clicking one runs the
|
* can usefully answer for this query, and its reply is rendered in the
|
||||||
* same capability or skill it would have run when the panel offered the
|
* order it arrived. The panel does not rank, score, filter or reorder
|
||||||
* whole list outright.
|
* it: which readings exist depends on the caller's role and on what is
|
||||||
|
* actually in the database, and neither of those is knowable here.
|
||||||
*
|
*
|
||||||
* 3. Nothing. An empty composer offers no chips at all. The panel used to
|
* 3. Nothing. An empty composer offers no chips at all. The panel used to
|
||||||
* open on a dozen of them, which taught the range of what could be asked
|
* open on a dozen of them, which taught the range of what could be asked
|
||||||
@@ -639,10 +647,32 @@ export default function KrowAssistant({
|
|||||||
*/
|
*/
|
||||||
const followUp = messages[messages.length - 1]?.followUp;
|
const followUp = messages[messages.length - 1]?.followUp;
|
||||||
const typed = input.trim();
|
const typed = input.trim();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The request behind (2), debounced.
|
||||||
|
*
|
||||||
|
* The endpoint is cheap and cached per query, but a keystroke is not a
|
||||||
|
* decision — a reader typing "positions" would otherwise fire nine requests
|
||||||
|
* to see the answer to the ninth. A short delay means one request per pause,
|
||||||
|
* and `placeholderData` in the hook keeps the previous answer on screen
|
||||||
|
* meanwhile so the row does not empty and refill.
|
||||||
|
*
|
||||||
|
* Only asked while there is something in the composer. An empty one offers no
|
||||||
|
* chips, so there would be nothing to render the answer into — and a reader
|
||||||
|
* who starts typing has left the follow-up behind, which is why typing
|
||||||
|
* supersedes it rather than being ranked against it.
|
||||||
|
*/
|
||||||
|
const debouncedQuery = useDebounced(typed, SUGGEST_DEBOUNCE_MS);
|
||||||
|
const { data: suggested = EMPTY_SUGGESTIONS } = useOwliverSuggestions({
|
||||||
|
page: owliverPage,
|
||||||
|
query: debouncedQuery,
|
||||||
|
enabled: Boolean(debouncedQuery),
|
||||||
|
});
|
||||||
|
|
||||||
const prompts = React.useMemo(() => {
|
const prompts = React.useMemo(() => {
|
||||||
if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
||||||
return rankPrompts(catalogue, typed);
|
return suggestionChips(suggested, context.id);
|
||||||
}, [typed, followUp, catalogue]);
|
}, [typed, followUp, suggested, context.id]);
|
||||||
|
|
||||||
/* The newest assistant turn, which is the one that carries the rating. */
|
/* The newest assistant turn, which is the one that carries the rating. */
|
||||||
const lastAnswerIndex = React.useMemo(
|
const lastAnswerIndex = React.useMemo(
|
||||||
@@ -936,6 +966,10 @@ export default function KrowAssistant({
|
|||||||
blocks={message.blocks}
|
blocks={message.blocks}
|
||||||
stopped={message.stopped}
|
stopped={message.stopped}
|
||||||
onPrompt={askOwliver}
|
onPrompt={askOwliver}
|
||||||
|
/* Approving a proposed write. Offered on every assistant turn
|
||||||
|
that carries one, not just the newest: a proposal scrolled
|
||||||
|
past is still a decision somebody has to make. */
|
||||||
|
onConfirm={confirm}
|
||||||
/* A rating belongs to the conversation, so it is offered on
|
/* A rating belongs to the conversation, so it is offered on
|
||||||
the newest answer only — and never while one streams. */
|
the newest answer only — and never while one streams. */
|
||||||
feedback={message.role === 'assistant' && i === lastAnswerIndex ? feedback : null}
|
feedback={message.role === 'assistant' && i === lastAnswerIndex ? feedback : null}
|
||||||
|
|||||||
@@ -491,8 +491,107 @@ const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => {
|
|||||||
});
|
});
|
||||||
SkillSectionBlock.displayName = 'SkillSectionBlock';
|
SkillSectionBlock.displayName = 'SkillSectionBlock';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A write the agent has proposed. The only block a person can act on.
|
||||||
|
*
|
||||||
|
* Three things it must do, and they are all about not being clicked past:
|
||||||
|
*
|
||||||
|
* - **Say what will happen, in the server's words.** The title, summary and
|
||||||
|
* details are rendered as they arrived. A browser paraphrasing them would
|
||||||
|
* be describing a different act than the one the token authorises.
|
||||||
|
* - **Put warnings above the button.** A clash or an over-headcount is
|
||||||
|
* exactly what somebody is about to approve without noticing, and a warning
|
||||||
|
* underneath the decision is a warning read afterwards.
|
||||||
|
* - **Not pretend to be finished.** Once approved, the block stays visible
|
||||||
|
* and says so. Replacing it with a tick would lose what was agreed to.
|
||||||
|
*/
|
||||||
|
const ConfirmationBlock = React.memo(/** @param {any} props */ ({ block, onConfirm }) => {
|
||||||
|
const [state, setState] = React.useState('pending');
|
||||||
|
|
||||||
|
const approve = React.useCallback(() => {
|
||||||
|
/* Guarded rather than debounced. The token is single-use server-side, so a
|
||||||
|
double click costs a confusing refusal rather than a duplicate write —
|
||||||
|
but a button that visibly does nothing the second time is kinder than
|
||||||
|
one that reports an error somebody caused by being quick. */
|
||||||
|
if (state !== 'pending') return;
|
||||||
|
setState('approved');
|
||||||
|
onConfirm?.(block);
|
||||||
|
}, [state, block, onConfirm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-krow-blue/30 bg-krow-blue/5 p-3.5">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-krow-blue text-white">
|
||||||
|
<TriangleAlert className="h-3 w-3" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-body-sm font-semibold text-ink-1">{block.title}</p>
|
||||||
|
{block.summary && (
|
||||||
|
<p className="mt-1 text-caption leading-relaxed text-ink-2">{block.summary}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{block.details?.length > 0 && (
|
||||||
|
<dl className="mt-2.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
|
||||||
|
{block.details.map((d, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
<dt className="text-caption text-ink-3">{d.label}</dt>
|
||||||
|
<dd className="text-caption font-medium text-ink-1">{d.value}</dd>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Above the button, deliberately. */}
|
||||||
|
{block.warnings?.length > 0 && (
|
||||||
|
<ul className="mt-2.5 space-y-1">
|
||||||
|
{block.warnings.map((w, i) => (
|
||||||
|
<li key={i} className="flex gap-1.5 text-caption text-amber-700 dark:text-amber-400">
|
||||||
|
<TriangleAlert className="mt-0.5 h-3 w-3 shrink-0" aria-hidden="true" />
|
||||||
|
<span>{w}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
{state === 'pending' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={approve}
|
||||||
|
className="rounded-lg bg-krow-blue px-3 py-1.5 text-caption font-semibold text-white
|
||||||
|
transition hover:opacity-90 focus-visible:outline focus-visible:outline-2
|
||||||
|
focus-visible:outline-offset-2 focus-visible:outline-krow-blue"
|
||||||
|
>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setState('dismissed')}
|
||||||
|
className="rounded-lg px-3 py-1.5 text-caption font-medium text-ink-3
|
||||||
|
transition hover:text-ink-1 focus-visible:outline focus-visible:outline-2
|
||||||
|
focus-visible:outline-offset-2 focus-visible:outline-krow-blue"
|
||||||
|
>
|
||||||
|
Not now
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="flex items-center gap-1.5 text-caption font-medium text-ink-3">
|
||||||
|
<Check className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{state === 'approved' ? 'Approved — carrying on.' : 'Left for now. Nothing was changed.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ConfirmationBlock.displayName = 'ConfirmationBlock';
|
||||||
|
|
||||||
const RENDERERS = {
|
const RENDERERS = {
|
||||||
text: TextBlock,
|
text: TextBlock,
|
||||||
|
confirmation: ConfirmationBlock,
|
||||||
skillSection: SkillSectionBlock,
|
skillSection: SkillSectionBlock,
|
||||||
heading: HeadingBlock,
|
heading: HeadingBlock,
|
||||||
kpis: KpisBlock,
|
kpis: KpisBlock,
|
||||||
@@ -515,7 +614,7 @@ const RENDERERS = {
|
|||||||
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
* blocks has consistent rhythm — a heading hugs what follows it, everything
|
||||||
* else breathes.
|
* else breathes.
|
||||||
*/
|
*/
|
||||||
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt }) => (
|
export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = [], streaming = false, onPrompt, onConfirm }) => (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{blocks.map((block, i) => {
|
{blocks.map((block, i) => {
|
||||||
const Renderer = RENDERERS[block.type];
|
const Renderer = RENDERERS[block.type];
|
||||||
@@ -525,7 +624,7 @@ export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
|
<div key={i} className={cn(hugsNext && '-mb-1.5', 'animate-fade-in')}>
|
||||||
<Renderer block={block} onPrompt={onPrompt} />
|
<Renderer block={block} onPrompt={onPrompt} onConfirm={onConfirm} />
|
||||||
{/* The cursor trails the final block only while text is still arriving. */}
|
{/* The cursor trails the final block only while text is still arriving. */}
|
||||||
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
|
{streaming && isLast && (block.type === 'text' || block.type === 'heading') && (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -90,6 +90,25 @@ export const skillSection = (section, data) =>
|
|||||||
/** A caveat. Always the last word on a claim, never the headline. */
|
/** A caveat. Always the last word on a claim, never the headline. */
|
||||||
export const note = (value) => value && { type: 'note', text: value };
|
export const note = (value) => value && { type: 'note', text: value };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A write the agent has proposed and NOT performed.
|
||||||
|
*
|
||||||
|
* The only block in this file that carries a decision rather than information,
|
||||||
|
* and the only one whose renderer has a button. Everything else here describes
|
||||||
|
* something that already happened; this describes something that will happen if
|
||||||
|
* a person says so.
|
||||||
|
*
|
||||||
|
* The payload comes from the server verbatim — title, summary, details,
|
||||||
|
* warnings, token — and is rendered rather than reformatted. The wording was
|
||||||
|
* composed beside the code that will do the writing, so a browser paraphrasing
|
||||||
|
* it would be describing a different act than the one the token authorises.
|
||||||
|
*
|
||||||
|
* `token` is what the approval sends back. It authorises exactly one call, with
|
||||||
|
* exactly those arguments, and it expires.
|
||||||
|
*/
|
||||||
|
export const confirmation = (payload) =>
|
||||||
|
payload?.token && { type: 'confirmation', ...payload };
|
||||||
|
|
||||||
/** A single sentence answer — used by short free-text replies. */
|
/** A single sentence answer — used by short free-text replies. */
|
||||||
export const answer = (value) => doc(text(value));
|
export const answer = (value) => doc(text(value));
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,592 +0,0 @@
|
|||||||
import {
|
|
||||||
AlertTriangle, BarChart3, ClipboardCheck, FileSpreadsheet, FileText, GitCompare,
|
|
||||||
HeartPulse, Layers, LineChart, ListChecks, MessageSquareQuote, Sparkles, Target,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
|
||||||
actions, answer, badges, doc, funnel, heading, insights, kpis, list, meters, note,
|
|
||||||
status, table, text,
|
|
||||||
} from '../blocks';
|
|
||||||
import { plural, verb } from '../insights';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Employer capabilities — Overview, Candidates, Analytics.
|
|
||||||
*
|
|
||||||
* Each returns a block document rather than prose. The shape is chosen by what
|
|
||||||
* the information is: a comparison is a table, a health check is a status list,
|
|
||||||
* a pipeline is a funnel. Prose is reserved for the judgement a table cannot
|
|
||||||
* carry.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ── Overview ───────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const hiringSummary = (f) => {
|
|
||||||
if (!f.total) return answer('Nothing in the pipeline yet. Publish a position and I will start tracking it.');
|
|
||||||
|
|
||||||
return doc(
|
|
||||||
kpis([
|
|
||||||
{ label: 'Applicants', value: f.total, sub: `${f.openPositions.length} open roles` },
|
|
||||||
{ label: 'Screened', value: f.screened.length, sub: `${f.standardizedPct}% coverage`, tone: f.standardizedPct >= 80 ? 'success' : 'warning' },
|
|
||||||
{ label: 'Interviewing', value: f.interviewing.length },
|
|
||||||
{ label: 'Hired', value: f.hired.length, sub: f.hiredAvgScore ? `avg ${f.hiredAvgScore}/100` : undefined, tone: 'info' },
|
|
||||||
]),
|
|
||||||
heading('Pipeline'),
|
|
||||||
funnel(f.funnel.map((stage, i) => ({
|
|
||||||
label: stage.label,
|
|
||||||
count: stage.count,
|
|
||||||
rate: i === 0 ? undefined : f.transitions[i - 1]?.rate,
|
|
||||||
}))),
|
|
||||||
text(
|
|
||||||
f.stalled.length
|
|
||||||
? `The first thing I would fix: ${plural(f.stalled.length, 'candidate')} scored 80+ and ${verb(f.stalled.length, 'is', 'are')} still at AI Screened.`
|
|
||||||
: f.unscreened.length
|
|
||||||
? `The first thing I would fix: ${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored.`
|
|
||||||
: 'Nothing is stuck — every applicant is screened and every strong candidate has been actioned.'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hiringHealth = (f) => {
|
|
||||||
const checks = [
|
|
||||||
{
|
|
||||||
label: 'Screening coverage',
|
|
||||||
value: `${f.standardizedPct}%`,
|
|
||||||
ok: f.standardizedPct >= 80,
|
|
||||||
note: f.standardizedPct >= 80
|
|
||||||
? 'Applicants are scored consistently.'
|
|
||||||
: `${plural(f.unscreened.length, 'applicant')} never got a score, so those decisions are unstandardized.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Response speed',
|
|
||||||
value: f.timeToHire ? `${f.timeToHire}d` : '—',
|
|
||||||
ok: f.timeToHire > 0 && f.timeToHire <= 3,
|
|
||||||
note: f.timeToHire && f.timeToHire <= 3
|
|
||||||
? 'Inside the window where good candidates are still available.'
|
|
||||||
: 'Event staff accept other work within days — past 3 days you lose people.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Quality of hire',
|
|
||||||
value: f.hiredAvgScore ? `${f.hiredAvgScore}/100` : '—',
|
|
||||||
ok: f.hiredAvgScore >= 80,
|
|
||||||
note: f.hiredAvgScore >= 80
|
|
||||||
? 'You are hiring from the top of your own pool.'
|
|
||||||
: 'Hires are scoring mid-pack — widen the funnel before lowering the bar.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Pipeline supply',
|
|
||||||
value: `${f.openPositions.length} open`,
|
|
||||||
ok: f.starvedPositions.length === 0,
|
|
||||||
note: f.starvedPositions.length
|
|
||||||
? `${f.starvedPositions.map((p) => p.title).join(', ')} ${verb(f.starvedPositions.length, 'has', 'have')} no applicants.`
|
|
||||||
: 'Every open position has applicants.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const passing = checks.filter((c) => c.ok).length;
|
|
||||||
|
|
||||||
return doc(
|
|
||||||
heading(`${passing} of ${checks.length} signals healthy`),
|
|
||||||
status(checks),
|
|
||||||
note('Thresholds are event-staffing norms, not universal benchmarks.')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildActions = (f) => [
|
|
||||||
f.stalled.length && {
|
|
||||||
title: 'Move your 80+ candidates',
|
|
||||||
body: `${f.stalled.slice(0, 3).map((a) => `**${a.applicant_name}** (${a.ai_score})`).join(', ')} ${verb(f.stalled.length, 'is', 'are')} screened and waiting on you.`,
|
|
||||||
},
|
|
||||||
f.unscreened.length && {
|
|
||||||
title: `Screen ${plural(f.unscreened.length, 'applicant')}`,
|
|
||||||
body: 'One pass clears the backlog and costs nothing.',
|
|
||||||
},
|
|
||||||
f.starvedPositions.length && {
|
|
||||||
title: 'Fix the postings nobody applies to',
|
|
||||||
body: `${f.starvedPositions.map((p) => p.title).join(' and ')} — usually pay range or reach, not the description.`,
|
|
||||||
},
|
|
||||||
f.unratedStaff.length && {
|
|
||||||
title: `Rate ${plural(f.unratedStaff.length, 'hire')}`,
|
|
||||||
body: 'Ratings feed KROW scores and sharpen future matching.',
|
|
||||||
},
|
|
||||||
f.elite.length && {
|
|
||||||
title: 'Reach out to the pool directly',
|
|
||||||
body: `${f.elite.map((p) => p.full_name).join(', ')} ${verb(f.elite.length, 'is', 'are')} Elite and ${verb(f.elite.length, 'has', 'have')} not applied to anything.`,
|
|
||||||
},
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
const pendingActions = (f) => {
|
|
||||||
const items = buildActions(f);
|
|
||||||
if (!items.length) {
|
|
||||||
return answer('Nothing pending. Everyone is screened, strong candidates are actioned, and every open role has applicants.');
|
|
||||||
}
|
|
||||||
return doc(heading('Pending actions', 'Ordered by what costs you most'), actions(items.slice(0, 4)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const hiringRisks = (f) => {
|
|
||||||
const risks = [
|
|
||||||
f.singleCandidateRoles.length && {
|
|
||||||
tone: 'risk',
|
|
||||||
title: 'Single-candidate roles',
|
|
||||||
body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. If they withdraw, the role reopens from zero.`,
|
|
||||||
},
|
|
||||||
f.stalled.length && {
|
|
||||||
tone: 'warning',
|
|
||||||
title: 'Losing strong candidates to delay',
|
|
||||||
body: `${plural(f.stalled.length, 'candidate')} at 80+ screened but not moved. This is where good hires quietly disappear.`,
|
|
||||||
},
|
|
||||||
f.missingCredentials.length && {
|
|
||||||
tone: 'risk',
|
|
||||||
title: 'Compliance exposure',
|
|
||||||
body: `${plural(f.missingCredentials.length, 'scored candidate')} ${verb(f.missingCredentials.length, 'has', 'have')} no certification on file. Placing them on a role that requires one is a real liability.`,
|
|
||||||
},
|
|
||||||
f.narrowAvailability.length && {
|
|
||||||
tone: 'warning',
|
|
||||||
title: 'Availability risk',
|
|
||||||
body: `${plural(f.narrowAvailability.length, 'candidate')} listed one slot or none — most likely to fall through on the day.`,
|
|
||||||
},
|
|
||||||
f.starvedPositions.length && {
|
|
||||||
tone: 'warning',
|
|
||||||
title: 'Unfillable as posted',
|
|
||||||
body: `${plural(f.starvedPositions.length, 'open role')} with zero applicants after going live.`,
|
|
||||||
},
|
|
||||||
f.flagged.length && {
|
|
||||||
tone: 'risk',
|
|
||||||
title: 'Interview integrity',
|
|
||||||
body: `${plural(f.flagged.length, 'interview')} scored below full integrity. Worth a human re-interview before progressing.`,
|
|
||||||
},
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
if (!risks.length) {
|
|
||||||
return answer('No material risks: coverage is good, no role depends on a single candidate, and credentials check out.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return doc(
|
|
||||||
heading(`${plural(risks.length, 'risk')} to watch`),
|
|
||||||
insights(risks),
|
|
||||||
note('Flags to check, not conclusions. Each is a question for a human.')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── Candidates ─────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability'];
|
|
||||||
|
|
||||||
const resumeAnalysis = (f) => {
|
|
||||||
const c = f.top[0];
|
|
||||||
if (!c) return answer('No scored candidates yet. Screen the pipeline and I will analyse the strongest.');
|
|
||||||
|
|
||||||
return doc(
|
|
||||||
heading(c.applicant_name, `${c.job_title} · ${plural(c.years_experience, 'year')} · ${c.english_level} English`),
|
|
||||||
kpis([
|
|
||||||
{ label: 'AI score', value: `${c.ai_score}`, tone: c.ai_score >= 80 ? 'success' : c.ai_score >= 60 ? 'info' : 'warning' },
|
|
||||||
{ label: 'Verdict', value: c.ai_match_label || '—' },
|
|
||||||
]),
|
|
||||||
c.ai_summary && text(c.ai_summary),
|
|
||||||
c.certifications?.length
|
|
||||||
? [heading('Credentials'), badges(c.certifications.map((label) => ({ label, tone: 'success' })))]
|
|
||||||
: [heading('Credentials'), badges([{ label: 'None on file', tone: 'risk' }])],
|
|
||||||
heading('Score breakdown'),
|
|
||||||
meters(DIMENSIONS.map((d) => ({ label: d[0].toUpperCase() + d.slice(1), value: c.score_breakdown?.[d] ?? 0 }))),
|
|
||||||
c.ai_strengths?.length && [heading('Strengths'), list(c.ai_strengths)],
|
|
||||||
c.ai_gaps?.length && [heading('Probe these'), list(c.ai_gaps)],
|
|
||||||
note(
|
|
||||||
c.certifications?.length
|
|
||||||
? 'Verify credentials before the first shift — a lapsed card is the most common surprise.'
|
|
||||||
: 'Request credentials before scheduling.'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const candidateComparison = (f) => {
|
|
||||||
const [a, b] = f.ranked;
|
|
||||||
if (!a || !b) return answer('I need at least two scored candidates to compare.');
|
|
||||||
|
|
||||||
const gap = a.ai_score - b.ai_score;
|
|
||||||
const first = (n) => n.split(' ')[0];
|
|
||||||
|
|
||||||
const rows = DIMENSIONS.map((d) => {
|
|
||||||
const av = a.score_breakdown?.[d] ?? 0;
|
|
||||||
const bv = b.score_breakdown?.[d] ?? 0;
|
|
||||||
return {
|
|
||||||
dimension: d[0].toUpperCase() + d.slice(1),
|
|
||||||
a: { value: av, tone: av > bv ? 'success' : av < bv ? 'neutral' : undefined },
|
|
||||||
b: { value: bv, tone: bv > av ? 'success' : bv < av ? 'neutral' : undefined },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const biggest = DIMENSIONS.reduce((best, d) =>
|
|
||||||
Math.abs((a.score_breakdown?.[d] ?? 0) - (b.score_breakdown?.[d] ?? 0)) >
|
|
||||||
Math.abs((a.score_breakdown?.[best] ?? 0) - (b.score_breakdown?.[best] ?? 0)) ? d : best, DIMENSIONS[0]);
|
|
||||||
|
|
||||||
return doc(
|
|
||||||
heading(`${a.applicant_name} vs ${b.applicant_name}`),
|
|
||||||
table(
|
|
||||||
[
|
|
||||||
{ key: 'dimension', label: '' },
|
|
||||||
{ key: 'a', label: first(a.applicant_name), align: 'right' },
|
|
||||||
{ key: 'b', label: first(b.applicant_name), align: 'right' },
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{ dimension: 'Overall', a: { value: a.ai_score, tone: 'info' }, b: { value: b.ai_score, tone: 'info' } },
|
|
||||||
...rows,
|
|
||||||
],
|
|
||||||
{ caption: 'Score comparison by dimension' }
|
|
||||||
),
|
|
||||||
heading('Where each one wins'),
|
|
||||||
insights([
|
|
||||||
{
|
|
||||||
tone: 'success',
|
|
||||||
title: `${first(a.applicant_name)} leads on ${biggest}`,
|
|
||||||
body: a.ai_strengths?.[0] || `Scores ${a.score_breakdown?.[biggest] ?? 0} against ${b.score_breakdown?.[biggest] ?? 0}.`,
|
|
||||||
},
|
|
||||||
b.ai_strengths?.[0] && {
|
|
||||||
tone: 'info',
|
|
||||||
title: `${first(b.applicant_name)} brings`,
|
|
||||||
body: b.ai_strengths[0],
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
text(
|
|
||||||
gap <= 3
|
|
||||||
? `Effectively tied — ${plural(gap, 'point')} apart is inside the noise. Decide on availability and the interview, not the score.`
|
|
||||||
: `${a.applicant_name} leads by ${gap} points, driven mostly by ${biggest}.`
|
|
||||||
),
|
|
||||||
note('Recommendation only. Neither has been met in person.')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const interviewQuestions = (f) => {
|
|
||||||
const c = f.stalled[0] || f.ranked[0];
|
|
||||||
if (!c) return answer('Screen a candidate and I will build questions around their specific gaps.');
|
|
||||||
|
|
||||||
const gaps = c.ai_gaps || [];
|
|
||||||
|
|
||||||
return doc(
|
|
||||||
heading(`Questions for ${c.applicant_name}`, 'Built from their gaps, not a generic list'),
|
|
||||||
list([
|
|
||||||
`Walk me through your busiest ${c.job_title?.toLowerCase() || 'shift'}. What happened, and what did you decide?`,
|
|
||||||
gaps[0]
|
|
||||||
? `I noticed ${gaps[0].toLowerCase()}. How have you handled that in practice?`
|
|
||||||
: 'Tell me about a shift that went wrong. What was your part in it?',
|
|
||||||
'Something breaks mid-service and your lead is unreachable. What are your next three moves?',
|
|
||||||
c.certifications?.length
|
|
||||||
? `Your ${c.certifications[0]} is on file — when did you last apply it on a live shift?`
|
|
||||||
: 'Which certifications are you working toward, and why that one?',
|
|
||||||
'What does your real availability look like over the next eight weeks?',
|
|
||||||
], { ordered: true }),
|
|
||||||
note('The scenario question is the one that separates candidates. Let them think.')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const skillGapAnalysis = (f) => {
|
|
||||||
if (!f.scored.length) return answer('No scored candidates yet, so there are no gaps to analyse.');
|
|
||||||
|
|
||||||
const tally = {};
|
|
||||||
f.scored.forEach((a) => (a.ai_gaps || []).forEach((gap) => {
|
|
||||||
const key = gap.replace(/^Missing certifications?:\s*/i, 'Missing credential: ');
|
|
||||||
tally[key] = (tally[key] || 0) + 1;
|
|
||||||
}));
|
|
||||||
|
|
||||||
const ranked = Object.entries(tally).sort((a, b) => b[1] - a[1]).slice(0, 5);
|
|
||||||
if (!ranked.length) return answer('No recurring gaps — the pool matches your postings well.');
|
|
||||||
|
|
||||||
return doc(
|
|
||||||
heading('Recurring gaps', `Across ${plural(f.scored.length, 'scored candidate')}`),
|
|
||||||
table(
|
|
||||||
[{ key: 'gap', label: 'Gap' }, { key: 'count', label: 'Candidates', align: 'right' }],
|
|
||||||
ranked.map(([gap, count]) => ({
|
|
||||||
gap,
|
|
||||||
count: { value: `${count}/${f.scored.length}`, tone: count > f.scored.length / 2 ? 'risk' : undefined },
|
|
||||||
}))
|
|
||||||
),
|
|
||||||
text(
|
|
||||||
f.missingCredentials.length >= 2
|
|
||||||
? `${f.missingCredentials.length} candidates are short a required credential. That is usually a posting problem: if the certification is trainable, requiring it up front filters out people you could hire this week.`
|
|
||||||
: 'Gaps are spread thin, which means your requirements are well matched to who is applying.'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hiringRecommendation = (f) => {
|
|
||||||
if (!f.scored.length) return answer('Nothing scored yet, so I have no basis for a recommendation.');
|
|
||||||
|
|
||||||
const open = (a) => !['hired', 'rejected'].includes(a.status);
|
|
||||||
const groups = [
|
|
||||||
{ verdict: 'Shortlist', tone: 'success', people: f.ranked.filter((a) => a.ai_score >= 80 && open(a)) },
|
|
||||||
{ verdict: 'Interview', tone: 'info', people: f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && open(a)) },
|
|
||||||
{ verdict: 'Likely pass', tone: 'risk', people: f.weak.filter(open) },
|
|
||||||
].filter((g) => g.people.length);
|
|
||||||
|
|
||||||
if (!groups.length) return answer('Every scored candidate has already been actioned.');
|
|
||||||
|
|
||||||
return doc(
|
|
||||||
heading('Recommendation'),
|
|
||||||
table(
|
|
||||||
[
|
|
||||||
{ key: 'name', label: 'Candidate' },
|
|
||||||
{ key: 'score', label: 'Score', align: 'right' },
|
|
||||||
{ key: 'verdict', label: 'Verdict', align: 'right' },
|
|
||||||
],
|
|
||||||
groups.flatMap((g) => g.people.map((p) => ({
|
|
||||||
name: p.applicant_name,
|
|
||||||
score: { value: p.ai_score, tone: g.tone },
|
|
||||||
verdict: { badge: g.verdict, tone: g.tone },
|
|
||||||
})))
|
|
||||||
),
|
|
||||||
note('A recommendation, not a decision. I score what is on file — I have not met these people.')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── Analytics ──────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const explainCharts = (f) => doc(
|
|
||||||
heading('What these charts are saying'),
|
|
||||||
insights([
|
|
||||||
{
|
|
||||||
tone: 'info',
|
|
||||||
title: 'Pipeline Stages',
|
|
||||||
body: `${f.total} applied, ${f.screened.length} screened, ${f.hired.length} hired. Bars are cumulative, so "AI Screened" includes everyone who moved past it.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tone: 'info',
|
|
||||||
title: 'Candidate Scores',
|
|
||||||
body: `${plural(f.scored.length, 'candidate')} scored, averaging ${f.avgScore}. The 80–100 bucket holds ${f.ranked.filter((a) => a.ai_score >= 80).length} — that bucket is your real hiring pool.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tone: f.interviewCompletion >= 70 ? 'success' : 'warning',
|
|
||||||
title: 'Interview Completion',
|
|
||||||
body: `${f.completedInterviews.length} of ${f.interviews.length} fully scored (${f.interviewCompletion}%). Unscored means started and abandoned.`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
tone: 'success',
|
|
||||||
title: 'Cost Saved',
|
|
||||||
body: `$${f.costSaved.toLocaleString()} from ${plural(f.scored.length, 'screen')} at $45 and ${plural(f.interviews.length, 'interview')} at $80 — industry per-unit costs for doing it manually, so treat it as a floor.`,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
f.bottleneck && [
|
|
||||||
heading('Biggest drop-off'),
|
|
||||||
funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
|
|
||||||
text(`Most people are lost between **${f.bottleneck.from}** and **${f.bottleneck.to}** — only ${f.bottleneck.rate}% pass through, costing ${plural(f.bottleneck.lost, 'candidate')}.`),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
const forecastHiring = (f) => {
|
|
||||||
const available = f.ranked.filter((a) => a.ai_score >= 60 && !['hired', 'rejected'].includes(a.status));
|
|
||||||
const conversion = f.hireRate || 14;
|
|
||||||
const expected = Math.round((available.length * conversion) / 100) || (available.length >= 3 ? 1 : 0);
|
|
||||||
const short = f.openPositions.length - expected;
|
|
||||||
|
|
||||||
return doc(
|
|
||||||
heading('Forecast'),
|
|
||||||
kpis([
|
|
||||||
{ label: 'In play at 60+', value: available.length },
|
|
||||||
{ label: 'Projected hires', value: expected, tone: short > 0 ? 'warning' : 'success' },
|
|
||||||
{ label: 'Open roles', value: f.openPositions.length },
|
|
||||||
{ label: 'Conversion', value: `${conversion}%` },
|
|
||||||
]),
|
|
||||||
short > 0
|
|
||||||
? insights([{
|
|
||||||
tone: 'warning',
|
|
||||||
title: `Roughly ${plural(short, 'role')} short`,
|
|
||||||
body: `Two ways to close it: screen the ${f.unscreened.length} unscored applicants, or source directly from the ${plural(f.profiles.length, 'profile')} in the talent pool.`,
|
|
||||||
}])
|
|
||||||
: insights([{
|
|
||||||
tone: 'success',
|
|
||||||
title: 'Current pool covers your open roles',
|
|
||||||
body: 'Assuming the strong candidates do not go elsewhere first.',
|
|
||||||
}]),
|
|
||||||
note('Projected from your own conversion rate, not a promise — small pools move a lot.')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const departmentInsights = (f) => {
|
|
||||||
if (!f.byRole.length) return answer('No open positions to break down.');
|
|
||||||
|
|
||||||
const byCategory = {};
|
|
||||||
f.byRole.forEach((r) => {
|
|
||||||
byCategory[r.category] ||= { applied: 0, hired: 0, qualified: 0, roles: 0 };
|
|
||||||
byCategory[r.category].applied += r.applied;
|
|
||||||
byCategory[r.category].hired += r.hired;
|
|
||||||
byCategory[r.category].qualified += r.qualified;
|
|
||||||
byCategory[r.category].roles += 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const entries = Object.entries(byCategory).sort((a, b) => b[1].applied - a[1].applied);
|
|
||||||
const starved = entries.filter(([, d]) => d.applied === 0);
|
|
||||||
|
|
||||||
return doc(
|
|
||||||
heading('By role category'),
|
|
||||||
table(
|
|
||||||
[
|
|
||||||
{ key: 'category', label: 'Category' },
|
|
||||||
{ key: 'applied', label: 'Applied', align: 'right' },
|
|
||||||
{ key: 'qualified', label: '70+', align: 'right' },
|
|
||||||
{ key: 'hired', label: 'Hired', align: 'right' },
|
|
||||||
],
|
|
||||||
entries.map(([category, d]) => ({
|
|
||||||
category,
|
|
||||||
applied: { value: d.applied, tone: d.applied === 0 ? 'risk' : undefined },
|
|
||||||
qualified: d.qualified,
|
|
||||||
hired: d.hired,
|
|
||||||
}))
|
|
||||||
),
|
|
||||||
text(
|
|
||||||
starved.length
|
|
||||||
? `**${starved.map(([c]) => c).join(' and ')}** ${verb(starved.length, 'is', 'are')} attracting nobody. Compare the pay range against the categories that are filling.`
|
|
||||||
: 'Every category has applicants, so your reach is working across the board.'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateReports = (f) => doc(
|
|
||||||
// Local date, not toISOString — UTC would show yesterday for anyone behind it.
|
|
||||||
heading('Hiring report', f.today.toLocaleDateString(undefined, {
|
|
||||||
year: 'numeric', month: 'long', day: 'numeric',
|
|
||||||
})),
|
|
||||||
kpis([
|
|
||||||
{ label: 'Applicants', value: f.total },
|
|
||||||
{ label: 'Screened', value: `${f.standardizedPct}%` },
|
|
||||||
{ label: 'Hires', value: f.hired.length },
|
|
||||||
{ label: 'Hire rate', value: `${f.hireRate}%` },
|
|
||||||
{ label: 'Avg hire score', value: f.hiredAvgScore || '—', tone: 'success' },
|
|
||||||
{ label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' },
|
|
||||||
]),
|
|
||||||
heading('Funnel'),
|
|
||||||
funnel(f.funnel.map((s, i) => ({ label: s.label, count: s.count, rate: i === 0 ? undefined : f.transitions[i - 1]?.rate }))),
|
|
||||||
heading('By role'),
|
|
||||||
table(
|
|
||||||
[
|
|
||||||
{ key: 'title', label: 'Role' },
|
|
||||||
{ key: 'applied', label: 'Applied', align: 'right' },
|
|
||||||
{ key: 'qualified', label: '70+', align: 'right' },
|
|
||||||
{ key: 'hired', label: 'Hired', align: 'right' },
|
|
||||||
],
|
|
||||||
f.byRole.map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified, hired: r.hired }))
|
|
||||||
),
|
|
||||||
heading('Efficiency'),
|
|
||||||
kpis([
|
|
||||||
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
|
|
||||||
{ label: 'Recruiter hours', value: `${f.hoursSaved}h`, tone: 'success' },
|
|
||||||
]),
|
|
||||||
heading('Headline finding'),
|
|
||||||
insights([
|
|
||||||
f.bottleneck
|
|
||||||
? {
|
|
||||||
tone: 'warning',
|
|
||||||
title: `${f.bottleneck.from} → ${f.bottleneck.to} passes only ${f.bottleneck.rate}%`,
|
|
||||||
body: `Losing ${plural(f.bottleneck.lost, 'candidate')} — the single biggest recoverable loss in the funnel.`,
|
|
||||||
}
|
|
||||||
: { tone: 'success', title: 'No single bottleneck', body: 'The funnel is passing through evenly.' },
|
|
||||||
]),
|
|
||||||
buildActions(f).length && [heading('Recommended actions'), actions(buildActions(f).slice(0, 3))],
|
|
||||||
note('Copy this into your own template — PDF and CSV export are not wired up in this build.')
|
|
||||||
);
|
|
||||||
|
|
||||||
/* ── Free-text responders ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const has = (q, ...words) => words.some((w) => q.includes(w));
|
|
||||||
|
|
||||||
const stateOfPlay = (f) => doc(
|
|
||||||
text('I do not have a specific read on that. Here is where things stand:'),
|
|
||||||
kpis([
|
|
||||||
{ label: 'Applicants', value: f.total },
|
|
||||||
{ label: 'Screened', value: f.screened.length },
|
|
||||||
{ label: 'Interviewing', value: f.interviewing.length },
|
|
||||||
{ label: 'Hired', value: f.hired.length },
|
|
||||||
]),
|
|
||||||
text('Ask me about any of those and I will go deeper.')
|
|
||||||
);
|
|
||||||
|
|
||||||
const bottleneckAnswer = (f) => {
|
|
||||||
if (!f.bottleneck) return answer('Not enough pipeline movement yet to locate a bottleneck.');
|
|
||||||
const b = f.bottleneck;
|
|
||||||
const diagnosis = {
|
|
||||||
'AI Screened': `${plural(f.unscreened.length, 'applicant')} ${verb(f.unscreened.length, 'has', 'have')} never been scored — the cheapest gap to close.`,
|
|
||||||
Shortlisted: 'Candidates are screened but not shortlisted. Either the scores are not being read, or the bar is above what the pool can deliver.',
|
|
||||||
Interviewed: 'Shortlisted candidates are not reaching interview — usually scheduling friction rather than a decision.',
|
|
||||||
Hired: 'Interviews happen but offers are not closing. Check pay against market and how long the decision takes.',
|
|
||||||
}[b.to];
|
|
||||||
|
|
||||||
return doc(
|
|
||||||
heading(`Bottleneck: ${b.from} → ${b.to}`, `${b.rate}% pass through`),
|
|
||||||
funnel(f.transitions.map((t) => ({ label: `${t.from} → ${t.to}`, count: t.rate, rate: t.rate }))),
|
|
||||||
insights([{ tone: 'warning', title: `Losing ${plural(b.lost, 'candidate')} here`, body: diagnosis }])
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const respondOverview = (question, f) => {
|
|
||||||
const q = question.toLowerCase();
|
|
||||||
if (has(q, 'risk', 'exposure', 'worry', 'concern')) return hiringRisks(f);
|
|
||||||
if (has(q, 'attention', 'urgent', 'priorit', 'pending', 'what should i', 'next', 'slow')) return pendingActions(f);
|
|
||||||
if (has(q, 'health', 'how are we', 'how is hiring', 'doing')) return hiringHealth(f);
|
|
||||||
if (has(q, 'summar', 'today', 'brief', 'catch me up', 'overview', 'morning', 'afternoon', 'evening')) return hiringSummary(f);
|
|
||||||
if (has(q, 'pipeline', 'funnel', 'stuck', 'bottleneck', 'drop')) return bottleneckAnswer(f);
|
|
||||||
if (has(q, 'position', 'posting', 'role', 'opening')) return departmentInsights(f);
|
|
||||||
return stateOfPlay(f);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const respondCandidates = (question, f) => {
|
|
||||||
const q = question.toLowerCase();
|
|
||||||
if (has(q, 'compare', 'versus', ' vs ', 'between', '&')) return candidateComparison(f);
|
|
||||||
if (has(q, 'question', 'ask them', 'interview prep')) return interviewQuestions(f);
|
|
||||||
if (has(q, 'gap', 'missing', 'weakness', 'skill')) return skillGapAnalysis(f);
|
|
||||||
if (has(q, 'recommend', 'should i hire', 'who should', 'shortlist', 'pass', 'interview next')) return hiringRecommendation(f);
|
|
||||||
if (has(q, 'resume', 'résumé', 'cv', 'analyse', 'analyze', 'review')) return resumeAnalysis(f);
|
|
||||||
if (has(q, 'unscreened', 'not screened', 'pending')) {
|
|
||||||
return f.unscreened.length
|
|
||||||
? doc(
|
|
||||||
heading(`${plural(f.unscreened.length, 'applicant')} unscreened`),
|
|
||||||
table(
|
|
||||||
[{ key: 'name', label: 'Candidate' }, { key: 'role', label: 'Applied for' }],
|
|
||||||
f.unscreened.slice(0, 8).map((a) => ({ name: a.applicant_name, role: a.job_title }))
|
|
||||||
),
|
|
||||||
f.unscreened.length > 8 && text(`…and ${f.unscreened.length - 8} more.`)
|
|
||||||
)
|
|
||||||
: answer('Everyone has been screened.');
|
|
||||||
}
|
|
||||||
return stateOfPlay(f);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const respondAnalytics = (question, f) => {
|
|
||||||
const q = question.toLowerCase();
|
|
||||||
if (has(q, 'forecast', 'predict', 'projection', 'will i', 'expect')) return forecastHiring(f);
|
|
||||||
if (has(q, 'report', 'export', 'executive', 'download')) return generateReports(f);
|
|
||||||
if (has(q, 'bottleneck', 'drop', 'stuck', 'lose', 'why do')) return bottleneckAnswer(f);
|
|
||||||
if (has(q, 'department', 'category', 'role', 'team', 'break down')) return departmentInsights(f);
|
|
||||||
if (has(q, 'explain', 'what does', 'mean', 'chart', 'graph')) return explainCharts(f);
|
|
||||||
if (has(q, 'hire rate', 'conversion')) {
|
|
||||||
return answer(`${f.hireRate}% — ${plural(f.hired.length, 'hire')} from ${plural(f.total, 'applicant')}. Healthy for event staffing; the norm sits between 2% and 8% because most applicants are never properly screened.`);
|
|
||||||
}
|
|
||||||
if (has(q, 'cost', 'saved', 'roi', 'money')) {
|
|
||||||
return doc(
|
|
||||||
kpis([
|
|
||||||
{ label: 'Cost saved', value: `$${f.costSaved.toLocaleString()}`, tone: 'success' },
|
|
||||||
{ label: 'Hours returned', value: `${f.hoursSaved}h`, tone: 'success' },
|
|
||||||
]),
|
|
||||||
text(`From ${plural(f.scored.length, 'AI screen')} at the $45 a manual screen costs, plus ${plural(f.interviews.length, 'automated interview')} at $80 each.`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return stateOfPlay(f);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── Capability manifests ───────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export const OVERVIEW_CAPABILITIES = [
|
|
||||||
{ id: 'hiring-summary', label: 'Hiring Summary', icon: Sparkles, run: hiringSummary },
|
|
||||||
{ id: 'hiring-health', label: 'Hiring Health', icon: HeartPulse, run: hiringHealth },
|
|
||||||
{ id: 'pending-actions', label: 'Pending Actions', icon: ListChecks, run: pendingActions },
|
|
||||||
{ id: 'hiring-risks', label: 'Hiring Risks', icon: AlertTriangle, run: hiringRisks },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const CANDIDATE_CAPABILITIES = [
|
|
||||||
{ id: 'resume-analysis', label: 'Resume Analysis', icon: FileText, run: resumeAnalysis },
|
|
||||||
{ id: 'candidate-comparison', label: 'Candidate Comparison', icon: GitCompare, run: candidateComparison },
|
|
||||||
{ id: 'interview-questions', label: 'Interview Question Generator', icon: MessageSquareQuote, run: interviewQuestions },
|
|
||||||
{ id: 'skill-gap', label: 'Skill Gap Analysis', icon: Target, run: skillGapAnalysis },
|
|
||||||
{ id: 'hiring-recommendation', label: 'Hiring Recommendation', icon: ClipboardCheck, run: hiringRecommendation },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const ANALYTICS_CAPABILITIES = [
|
|
||||||
{ id: 'explain-charts', label: 'Explain Charts', icon: BarChart3, run: explainCharts },
|
|
||||||
{ id: 'forecast-hiring', label: 'Forecast Hiring', icon: LineChart, run: forecastHiring },
|
|
||||||
{ id: 'department-insights', label: 'Department Insights', icon: Layers, run: departmentInsights },
|
|
||||||
{ id: 'generate-reports', label: 'Generate Reports', icon: FileSpreadsheet, run: generateReports },
|
|
||||||
];
|
|
||||||
@@ -1,552 +0,0 @@
|
|||||||
import { doc, heading, list, note, text } from '../blocks';
|
|
||||||
/* `vocabulary.js` is a leaf — it imports nothing — so naming the reasoning
|
|
||||||
modes here costs no dependency.
|
|
||||||
|
|
||||||
The registries are deliberately **not** imported. `contexts.js` is reached
|
|
||||||
from `placement.js`, which `skills/registry.js` already depends on, so
|
|
||||||
importing a registry back into a context closes a cycle and leaves
|
|
||||||
`PLACEMENT_ROUTES` undefined at module-evaluation time. Counting agents here
|
|
||||||
would also duplicate what the screen behind this panel already shows. */
|
|
||||||
import { REASONING_MODES } from '@/lib/agents/vocabulary';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Owliver on the agent configuration screen.
|
|
||||||
*
|
|
||||||
* This page is a **workspace** page, not an operational one. It shows no
|
|
||||||
* positions, no candidates and no shifts, and Owliver must not behave as though
|
|
||||||
* it does. What it can genuinely answer from is the two registries — what
|
|
||||||
* agents exist, what skills exist, what the configuration options mean — which
|
|
||||||
* is metadata about the workspace itself rather than a reading of workforce
|
|
||||||
* records.
|
|
||||||
*
|
|
||||||
* The distinction matters most when the agent being *edited* is an operational
|
|
||||||
* one. Configuring the Analytics Agent does not put the reader on Analytics:
|
|
||||||
* the edited agent is a record being changed, not the page they are standing
|
|
||||||
* on. So nothing here reaches for analytics data, and no skill declares this
|
|
||||||
* page — `skillsForContext` returns an empty list, which is the honest answer.
|
|
||||||
*
|
|
||||||
* Questions outside these topics are declined by the routing layer rather than
|
|
||||||
* answered with whatever this page happens to know.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** What this page's answers are actually about. */
|
|
||||||
/**
|
|
||||||
* What this page's answers are actually about.
|
|
||||||
*
|
|
||||||
* Deliberately narrow, and it was not narrow enough at first: `what is` and
|
|
||||||
* `page` were on this list, so "What is our attendance rate?" matched and was
|
|
||||||
* answered from a screen that holds no attendance records. A topic list on a
|
|
||||||
* page with no operational data has to name *subjects*, never sentence
|
|
||||||
* openings — a generic phrase turns the honest decline into a confident answer
|
|
||||||
* about the wrong thing.
|
|
||||||
*
|
|
||||||
* Anything not named here is declined by `routing.js` and pointed at the page
|
|
||||||
* that holds the records.
|
|
||||||
*/
|
|
||||||
export const AGENT_CONFIGURE_TOPICS = [
|
|
||||||
'agent', 'agents', 'subagent', 'subagents',
|
|
||||||
'skill', 'skills', 'knowledge',
|
|
||||||
'reasoning', 'starter', 'starters', 'web search',
|
|
||||||
'instruction', 'instructions', 'configure', 'configuration', 'setting', 'settings',
|
|
||||||
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
|
|
||||||
'workspace',
|
|
||||||
];
|
|
||||||
|
|
||||||
/** What this screen is, and what it can be asked. */
|
|
||||||
function explainScreen() {
|
|
||||||
return doc(
|
|
||||||
heading('Configuring an agent'),
|
|
||||||
text('An agent decides how Owliver answers on a page: which of that page\u2019s skills it may use, what it knows, and how much work an answer is worth.'),
|
|
||||||
list([
|
|
||||||
'It narrows what a page offers. It can never widen it.',
|
|
||||||
'The same skill can be attached to several agents.',
|
|
||||||
'Changes are saved to this workspace; publishing puts them into service.',
|
|
||||||
]),
|
|
||||||
note('The agents in this workspace are listed on the Agents page, and skills in Workspace \u2192 Skills.')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Where skills come from. */
|
|
||||||
function skillOverview() {
|
|
||||||
return doc(
|
|
||||||
heading('Where skills come from'),
|
|
||||||
text('Skills are authored once in **Workspace \u2192 Skills** and attached to as many agents as you like. There is one library, so a skill behaves identically wherever it is used.'),
|
|
||||||
list([
|
|
||||||
'Add skills \u2014 attaches from the shared library.',
|
|
||||||
'A skill still only answers on the pages it itself declares.',
|
|
||||||
'Attaching a skill an agent\u2019s pages do not cover changes nothing on those pages.',
|
|
||||||
]),
|
|
||||||
note('Removing a skill here detaches it from this agent. It stays in the library for everything else.')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What the configuration options mean. */
|
|
||||||
function explainOptions() {
|
|
||||||
return doc(
|
|
||||||
heading('What each setting does'),
|
|
||||||
list([
|
|
||||||
'Pages it covers — where this agent may answer. It narrows what the page offers; it can never widen it.',
|
|
||||||
'Skills — what it can do. Attached from the shared library.',
|
|
||||||
'Knowledge — reference material it can quote, with the source named.',
|
|
||||||
`Reasoning — how much work an answer is worth: ${REASONING_MODES.map((m) => m.label).join(', ')}.`,
|
|
||||||
'Conversation starters — the questions offered as chips when this agent opens.',
|
|
||||||
'Web search — whether it may look outside the workspace.',
|
|
||||||
'Subagents — other agents whose skills it may also use, still bounded by the current page.',
|
|
||||||
]),
|
|
||||||
note('Changes are saved to this workspace. Publishing is what puts them into service.')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The lifecycle, in the words the buttons use. */
|
|
||||||
function explainLifecycle() {
|
|
||||||
return doc(
|
|
||||||
heading('Draft, published, archived'),
|
|
||||||
list([
|
|
||||||
'Draft — saved, but not answering anywhere yet.',
|
|
||||||
'Published — in service, and offered in the Owliver switcher.',
|
|
||||||
'Archived — taken out of service, definition kept. It can be restored as a draft.',
|
|
||||||
]),
|
|
||||||
note('Editing an agent that ships with Krow saves your own version alongside it. The shipped definition is never altered, and reverting brings it back.')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AGENT_CONFIGURE_CAPABILITIES = [
|
|
||||||
{ id: 'screen', label: 'What this screen configures', run: explainScreen },
|
|
||||||
{ id: 'skills', label: 'Where skills come from', run: skillOverview },
|
|
||||||
{ id: 'options', label: 'What each setting does', run: explainOptions },
|
|
||||||
{ id: 'lifecycle', label: 'Draft, published, archived', run: explainLifecycle },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A free-text question on the configuration screen.
|
|
||||||
*
|
|
||||||
* Answers from the registries, or says plainly that this page cannot answer it.
|
|
||||||
* The one thing it must never do is reach for operational data because the
|
|
||||||
* agent being edited happens to be an operational agent.
|
|
||||||
*/
|
|
||||||
export function respondAgentConfigure(question) {
|
|
||||||
const q = String(question || '').toLowerCase();
|
|
||||||
|
|
||||||
if (/\bskill/.test(q)) return skillOverview();
|
|
||||||
if (/\b(draft|publish|archive|version|revert|shipped)/.test(q)) return explainLifecycle();
|
|
||||||
if (/\b(reasoning|knowledge|starter|subagent|web search|instruction|setting|option|page)/.test(q)) {
|
|
||||||
return explainOptions();
|
|
||||||
}
|
|
||||||
if (/\bagent/.test(q)) return explainScreen();
|
|
||||||
|
|
||||||
return doc(
|
|
||||||
text('This is the agent configuration screen, so I can answer about **agents, skills and what each setting does** here.'),
|
|
||||||
list([
|
|
||||||
'What agents exist in this workspace',
|
|
||||||
'What skills I can attach, and where they come from',
|
|
||||||
'What pages, reasoning, knowledge and subagents mean',
|
|
||||||
'What draft, published and archived do',
|
|
||||||
]),
|
|
||||||
note('For workforce questions — positions, candidates, attendance — open the page that holds those records and ask me there.')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── The rest of the workspace, and Settings ──────────────────────────────
|
|
||||||
*
|
|
||||||
* Every page below is a configuration surface: it holds no positions, no
|
|
||||||
* candidates and no shifts. That does not make Owliver useless there, and
|
|
||||||
* treating it as though it did is the bug this section exists to correct — the
|
|
||||||
* panel did not mount on these pages at all, on a product where Owliver had
|
|
||||||
* answered perfectly well everywhere before specialised agents existed.
|
|
||||||
*
|
|
||||||
* What each page can honestly answer from is the screen itself: what it
|
|
||||||
* configures, what the controls mean, and which page holds the records a
|
|
||||||
* workforce question is really about. That is what these produce.
|
|
||||||
*
|
|
||||||
* **No skill is invented to make this work.** A skill is a capability over
|
|
||||||
* records; none of these pages has records, so a skill here would be a fake one
|
|
||||||
* whose only purpose was to populate a chip. What answers instead is a page
|
|
||||||
* responder — the same mechanism every context in this table has always used,
|
|
||||||
* including the eight operational ones.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One configuration page's answers, from a description of the page.
|
|
||||||
*
|
|
||||||
* A factory rather than five near-identical copies. Each section carries the
|
|
||||||
* questions it answers (`match`), the label the panel offers it under, and the
|
|
||||||
* document itself — so the capability list shown when a question is declined
|
|
||||||
* and the routing behind a typed question come from one declaration and cannot
|
|
||||||
* drift apart.
|
|
||||||
*
|
|
||||||
* **Sections are matched in order, specific before general.** The section that
|
|
||||||
* explains the screen as a whole is deliberately last on every page: it is the
|
|
||||||
* one whose words appear in every other question, and put first it would answer
|
|
||||||
* all of them. The same lesson `AGENT_CONFIGURE_TOPICS` records, one level down.
|
|
||||||
*
|
|
||||||
* `topics` is what `routing.js` gates on, and it names *subjects* rather than
|
|
||||||
* sentence openings — a generic opener on a page with no operational data turns
|
|
||||||
* an honest decline into a confident answer about the wrong thing.
|
|
||||||
*/
|
|
||||||
function workspacePage({ label, topics, sections, elsewhere }) {
|
|
||||||
const capabilities = sections.map((section) => ({
|
|
||||||
id: section.id,
|
|
||||||
label: section.label,
|
|
||||||
run: () => doc(
|
|
||||||
heading(section.heading),
|
|
||||||
...(section.text ? [text(section.text)] : []),
|
|
||||||
...(section.bullets?.length ? [list(section.bullets)] : []),
|
|
||||||
...(section.note ? [note(section.note)] : [])
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const byId = new Map(capabilities.map((c) => [c.id, c]));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A typed question, answered from this page or offered what it can answer.
|
|
||||||
*
|
|
||||||
* The default is not "here is the page's report" — these pages have no report.
|
|
||||||
* It is the offer itself: what can be asked here, and where the records live
|
|
||||||
* for what cannot. `routing.js` has already declined anything outside
|
|
||||||
* `topics`, so this only ever sees a question the page is plausibly about.
|
|
||||||
*/
|
|
||||||
const respond = (question) => {
|
|
||||||
const q = String(question || '').toLowerCase();
|
|
||||||
const matched = sections.find((section) => section.match.test(q));
|
|
||||||
if (matched) return byId.get(matched.id).run();
|
|
||||||
|
|
||||||
return doc(
|
|
||||||
text(`This is **${label}**, so I can answer about the screen itself — what it configures and what each control does.`),
|
|
||||||
list(sections.map((section) => section.label)),
|
|
||||||
note(elsewhere)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return { topics, capabilities, respond };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** What every configuration page says about where the records are. */
|
|
||||||
const RECORDS_ELSEWHERE =
|
|
||||||
'For workforce questions — positions, candidates, attendance, training — open the page that holds those records and ask me there.';
|
|
||||||
|
|
||||||
/* ── Settings ───────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const SETTINGS = workspacePage({
|
|
||||||
label: 'Settings',
|
|
||||||
topics: [
|
|
||||||
'setting', 'settings', 'account', 'password', 'credential', 'security', 'two-factor',
|
|
||||||
'two factor', '2fa', 'permission', 'access', 'organization', 'organisation',
|
|
||||||
'client', 'user', 'users', 'team', 'audit', 'notification', 'digest', 'email',
|
|
||||||
'density', 'compact', 'preference', 'automation', 'toggle', 'configure', 'configuration',
|
|
||||||
'workspace', 'owliver', 'assistant',
|
|
||||||
],
|
|
||||||
elsewhere: RECORDS_ELSEWHERE,
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: 'automation',
|
|
||||||
label: 'What the automation toggles do',
|
|
||||||
heading: 'Automation controls',
|
|
||||||
match: /\b(automation|toggle|toggles|digest|notification|notifications|density|compact|assistant)\b/,
|
|
||||||
bullets: [
|
|
||||||
'Owliver Workspace Assistant — whether this panel opens by default on the pages that carry it.',
|
|
||||||
'Compact UI Density — tighter table rows, for high-density monitoring.',
|
|
||||||
'Daily Executive Email Digest — a scheduled summary to the account address.',
|
|
||||||
],
|
|
||||||
note: 'Turning the assistant off closes the panel; it never removes it. The docked control brings it back.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'access',
|
|
||||||
label: 'Permissions and security',
|
|
||||||
heading: 'Who may do what',
|
|
||||||
match: /\b(permission|permissions|access|secure|secured|security|password|credential|credentials|audit|2fa|two.factor|user|users|team)\b/,
|
|
||||||
text: 'Access is configured here and recorded on Activity. The two are deliberately separate: this page states the policy, and the audit trail states what happened under it.',
|
|
||||||
bullets: [
|
|
||||||
'Users & Permissions — the roles this workspace grants.',
|
|
||||||
'Account & Security — credentials and protection for your own account.',
|
|
||||||
'Audit & System Activity Log — the record of events, read in full on Activity.',
|
|
||||||
],
|
|
||||||
note: 'Ask me on Activity for who did what, and when — that page holds the events.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'screen',
|
|
||||||
label: 'What this screen configures',
|
|
||||||
heading: 'Settings',
|
|
||||||
match: /\b(configure|configuration|configures|screen|settings|manage|set up|do here|organization|organisation|client|clients)\b/,
|
|
||||||
text: 'Settings is the account and system side of Krow. It configures who you are and how the workspace behaves — never the workforce records themselves.',
|
|
||||||
bullets: [
|
|
||||||
'Account & Security — your profile details and how this account is protected.',
|
|
||||||
'Organization — the clients, positions and people this workspace is structured around.',
|
|
||||||
'Users & Permissions — who may do what.',
|
|
||||||
'Audit & System Activity — the log of what has happened.',
|
|
||||||
'Automation — the behaviours below, including whether Owliver opens by default.',
|
|
||||||
],
|
|
||||||
note: 'Changes here are saved to this account.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SETTINGS_TOPICS = SETTINGS.topics;
|
|
||||||
export const SETTINGS_CAPABILITIES = SETTINGS.capabilities;
|
|
||||||
export const respondSettings = SETTINGS.respond;
|
|
||||||
|
|
||||||
/* ── Workspace hub ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const WORKSPACE = workspacePage({
|
|
||||||
label: 'Workspace',
|
|
||||||
topics: [
|
|
||||||
'workspace', 'agent', 'agents', 'skill', 'skills', 'capability', 'capabilities',
|
|
||||||
'training', 'path', 'paths', 'library', 'registry', 'owliver', 'extend', 'configure',
|
|
||||||
'configuration', 'govern', 'development',
|
|
||||||
],
|
|
||||||
elsewhere: RECORDS_ELSEWHERE,
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: 'agents',
|
|
||||||
label: 'What an agent is',
|
|
||||||
heading: 'Agents',
|
|
||||||
match: /\b(agent|agents|subagent|subagents|reasoning)\b/,
|
|
||||||
text: 'An agent decides how Owliver answers on a page: which of that page’s skills it may use, what it knows, and how much work an answer is worth.',
|
|
||||||
bullets: [
|
|
||||||
'It narrows what a page offers. It can never widen it.',
|
|
||||||
'A page written for an agent opens on that agent.',
|
|
||||||
'A page with no agent of its own opens on the general Krow Workforce Agent.',
|
|
||||||
],
|
|
||||||
note: 'Agents are listed and edited in Workspace → Agents.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'skills',
|
|
||||||
label: 'Where skills come from',
|
|
||||||
heading: 'Skills',
|
|
||||||
match: /\b(skill|skills|library|registry|capability|capabilities)\b/,
|
|
||||||
text: 'Skills are authored once and attached to as many agents as you like. There is one library, so a skill behaves identically wherever it is used.',
|
|
||||||
bullets: [
|
|
||||||
'An Owliver skill answers questions.',
|
|
||||||
'A Board skill extends a Krow page with a section.',
|
|
||||||
'A skill only applies on the pages it itself declares.',
|
|
||||||
],
|
|
||||||
note: 'Both lists live in Workspace → Skills.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'screen',
|
|
||||||
label: 'What the workspace governs',
|
|
||||||
heading: 'Workspace',
|
|
||||||
match: /\b(workspace|govern|governs|overview|configure|configuration|extend)\b/,
|
|
||||||
text: 'The workspace is where Krow’s capabilities are configured: the agents that answer, the skills they carry, and the training paths the workforce is measured against.',
|
|
||||||
bullets: [
|
|
||||||
'Agents — who answers on which page, and how.',
|
|
||||||
'Skills — what can be answered or drawn, authored once and shared.',
|
|
||||||
'Skill Development — the training paths the workforce progresses along.',
|
|
||||||
],
|
|
||||||
note: 'Nothing configured here reads workforce records on its own. A page decides what is in reach; configuration decides how much of that reach is used.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const WORKSPACE_TOPICS = WORKSPACE.topics;
|
|
||||||
export const WORKSPACE_CAPABILITIES = WORKSPACE.capabilities;
|
|
||||||
export const respondWorkspace = WORKSPACE.respond;
|
|
||||||
|
|
||||||
/* ── Agents list ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const WORKSPACE_AGENTS = workspacePage({
|
|
||||||
label: 'Agents',
|
|
||||||
topics: [
|
|
||||||
'agent', 'agents', 'subagent', 'subagents', 'skill', 'skills', 'page', 'pages',
|
|
||||||
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
|
|
||||||
'configure', 'configuration', 'reasoning', 'workspace', 'owliver',
|
|
||||||
'constrained', 'cover', 'covers', 'fallback', 'default',
|
|
||||||
],
|
|
||||||
elsewhere: RECORDS_ELSEWHERE,
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: 'coverage',
|
|
||||||
label: 'How a page picks its agent',
|
|
||||||
heading: 'Which agent answers where',
|
|
||||||
match: /\b(cover|covers|covered|page|pages|default|fallback|constrained|picks|pick|answers|resolve|resolves)\b/,
|
|
||||||
bullets: [
|
|
||||||
'A page with an agent written for it opens on that agent.',
|
|
||||||
'A page with none opens on the general Krow Workforce Agent.',
|
|
||||||
'Choosing an agent that does not cover the page you are on shows it as constrained here, and it declines rather than answering.',
|
|
||||||
],
|
|
||||||
note: 'An agent is a lens on the page you are standing on. It is never a way to reach a different page.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lifecycle',
|
|
||||||
label: 'Draft, published, archived',
|
|
||||||
heading: 'Draft, published, archived',
|
|
||||||
match: /\b(draft|publish|published|publishing|archive|archived|revert|shipped|version|lifecycle)\b/,
|
|
||||||
bullets: [
|
|
||||||
'Draft — saved, but not answering anywhere yet.',
|
|
||||||
'Published — in service, and offered in the Owliver switcher.',
|
|
||||||
'Archived — taken out of service, definition kept. It can be restored as a draft.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'list',
|
|
||||||
label: 'What this list holds',
|
|
||||||
heading: 'The agents in this workspace',
|
|
||||||
match: /\b(list|lists|registered|exist|exists|agent|agents|screen|workspace)\b/,
|
|
||||||
text: 'Every agent Krow ships, plus anything this account has authored. Opening one configures it; nothing on this screen changes how Owliver is answering right now.',
|
|
||||||
bullets: [
|
|
||||||
'A shipped agent can be edited — your version is saved alongside it, and reverting brings the original back.',
|
|
||||||
'A published agent is offered in the Owliver switcher.',
|
|
||||||
'An archived agent is out of service, and its definition is kept.',
|
|
||||||
],
|
|
||||||
note: 'Configuring an agent never puts you on the page that agent covers.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const WORKSPACE_AGENTS_TOPICS = WORKSPACE_AGENTS.topics;
|
|
||||||
export const WORKSPACE_AGENTS_CAPABILITIES = WORKSPACE_AGENTS.capabilities;
|
|
||||||
export const respondWorkspaceAgents = WORKSPACE_AGENTS.respond;
|
|
||||||
|
|
||||||
/* ── Skills list ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const WORKSPACE_SKILLS = workspacePage({
|
|
||||||
label: 'Skills',
|
|
||||||
topics: [
|
|
||||||
'skill', 'skills', 'library', 'registry', 'owliver', 'board', 'section', 'surface',
|
|
||||||
'placement', 'markdown', 'definition', 'upload', 'author', 'attach',
|
|
||||||
'active', 'draft', 'archive', 'archived', 'category', 'capability', 'capabilities',
|
|
||||||
'workspace', 'agent', 'agents', 'page', 'pages',
|
|
||||||
],
|
|
||||||
elsewhere: RECORDS_ELSEWHERE,
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: 'attaching',
|
|
||||||
label: 'How skills reach an agent',
|
|
||||||
heading: 'Attaching a skill',
|
|
||||||
match: /\b(attach|attached|attaching|agent|agents|reach|reaches|scope)\b/,
|
|
||||||
bullets: [
|
|
||||||
'Attaching a skill lets an agent use it — on the pages the skill itself declares.',
|
|
||||||
'Attaching a skill an agent’s pages do not cover changes nothing on those pages.',
|
|
||||||
'Removing a skill from an agent leaves it in the library for everything else.',
|
|
||||||
],
|
|
||||||
note: 'Agents are configured in Workspace → Agents.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'authoring',
|
|
||||||
label: 'How a skill is written',
|
|
||||||
heading: 'Writing a skill',
|
|
||||||
match: /\b(author|authored|authoring|write|written|writing|markdown|definition|upload|edit|edited)\b/,
|
|
||||||
text: 'A skill is a Markdown file: frontmatter declares what it is and which pages it applies to, and the body documents what it can do.',
|
|
||||||
bullets: [
|
|
||||||
'It only applies on the pages it declares.',
|
|
||||||
'It is attached to an agent from this shared library, never copied into one.',
|
|
||||||
'A definition naming something the product does not have is refused with a message rather than half-registered.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'lists',
|
|
||||||
label: 'The two skill lists',
|
|
||||||
heading: 'Owliver skills and Board skills',
|
|
||||||
match: /\b(list|lists|two|owliver|board|kind|kinds|difference|library|registry|skill|skills)\b/,
|
|
||||||
bullets: [
|
|
||||||
'An Owliver skill answers questions in this panel.',
|
|
||||||
'A Board skill extends a Krow page with a section the page renders.',
|
|
||||||
'A definition can do both; it is filed by what it declares, never by a setting.',
|
|
||||||
],
|
|
||||||
note: 'One library behind both lists, so a skill behaves identically wherever it is used.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const WORKSPACE_SKILLS_TOPICS = WORKSPACE_SKILLS.topics;
|
|
||||||
export const WORKSPACE_SKILLS_CAPABILITIES = WORKSPACE_SKILLS.capabilities;
|
|
||||||
export const respondWorkspaceSkills = WORKSPACE_SKILLS.respond;
|
|
||||||
|
|
||||||
/* ── Skill editor ───────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const SKILL_CONFIGURE = workspacePage({
|
|
||||||
label: 'Skill Configure',
|
|
||||||
topics: [
|
|
||||||
'skill', 'skills', 'definition', 'markdown', 'frontmatter', 'field', 'fields',
|
|
||||||
'page', 'pages', 'surface', 'placement', 'section', 'source', 'sources', 'trigger',
|
|
||||||
'triggers', 'action', 'actions', 'capability', 'capabilities', 'category', 'status',
|
|
||||||
'active', 'draft', 'archive', 'archived', 'validate', 'validation', 'save', 'publish',
|
|
||||||
'editor', 'configure', 'configuration', 'owliver', 'board', 'workspace',
|
|
||||||
],
|
|
||||||
elsewhere: RECORDS_ELSEWHERE,
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: 'validation',
|
|
||||||
label: 'Why a definition is refused',
|
|
||||||
heading: 'Validation',
|
|
||||||
match: /\b(valid|validate|validated|validation|refuse|refused|error|errors|fail|fails|reject|rejected|wrong)\b/,
|
|
||||||
text: 'The vocabulary is closed, so everything a definition names is checked before it is stored.',
|
|
||||||
bullets: [
|
|
||||||
'A page, placement or source the product does not have is refused with a message.',
|
|
||||||
'A section that needs a record the placement does not supply is refused rather than drawing an empty card.',
|
|
||||||
'A definition that parses but loses a field reports what it lost, instead of half-registering.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'reach',
|
|
||||||
label: 'Where this skill will apply',
|
|
||||||
heading: 'Where it applies',
|
|
||||||
match: /\b(apply|applies|applied|where|reach|reaches|attach|attached|agent|agents|scope|surface|placement)\b/,
|
|
||||||
bullets: [
|
|
||||||
'On the pages this definition declares, and nowhere else.',
|
|
||||||
'For an agent, only once it is attached to that agent.',
|
|
||||||
'An agent can never use a skill to reach a page the skill does not declare.',
|
|
||||||
],
|
|
||||||
note: 'Attach it in Workspace → Agents.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'screen',
|
|
||||||
label: 'What this editor configures',
|
|
||||||
heading: 'Editing a skill',
|
|
||||||
match: /\b(editor|edit|editing|configure|configures|configuration|definition|frontmatter|field|fields|status|trigger|triggers|screen|skill|skills)\b/,
|
|
||||||
text: 'A skill definition, as Markdown. The frontmatter declares what it is and where it applies; the body documents what it can do.',
|
|
||||||
bullets: [
|
|
||||||
'Pages — the surfaces this skill applies on. It never applies anywhere else.',
|
|
||||||
'Triggers — the wordings that reach it.',
|
|
||||||
'Capabilities — what it can answer or draw.',
|
|
||||||
'Status — active, draft or archived.',
|
|
||||||
],
|
|
||||||
note: 'Nothing is executed from Markdown. A definition names things the product already has, and a name it does not have is refused.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SKILL_CONFIGURE_TOPICS = SKILL_CONFIGURE.topics;
|
|
||||||
export const SKILL_CONFIGURE_CAPABILITIES = SKILL_CONFIGURE.capabilities;
|
|
||||||
export const respondSkillConfigure = SKILL_CONFIGURE.respond;
|
|
||||||
|
|
||||||
/* ── Skill Development ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const SKILL_DEVELOPMENT = workspacePage({
|
|
||||||
label: 'Skill Development',
|
|
||||||
topics: [
|
|
||||||
'training', 'path', 'paths', 'progression', 'level', 'levels', 'rung', 'ladder',
|
|
||||||
'development', 'capability', 'capabilities', 'define', 'definition',
|
|
||||||
'workspace', 'configure', 'configuration', 'owliver',
|
|
||||||
],
|
|
||||||
elsewhere: RECORDS_ELSEWHERE,
|
|
||||||
sections: [
|
|
||||||
{
|
|
||||||
id: 'levels',
|
|
||||||
label: 'How the levels work',
|
|
||||||
heading: 'Levels',
|
|
||||||
match: /\b(level|levels|rung|rungs|ladder|progress|progression|stage|stages)\b/,
|
|
||||||
bullets: [
|
|
||||||
'Levels are ordered, and each states what someone at that level can do.',
|
|
||||||
'A path with no levels defines a name and nothing measurable.',
|
|
||||||
'Editing a level changes the definition, never anyone’s recorded progress.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'screen',
|
|
||||||
label: 'What a training path is',
|
|
||||||
heading: 'Skill Development',
|
|
||||||
match: /\b(training|path|paths|define|defines|definition|development|capability|capabilities|configure|screen)\b/,
|
|
||||||
text: 'A training path defines a capability the workforce holds and the levels it progresses through. It is a definition, not a reading: nothing here says who currently holds what.',
|
|
||||||
bullets: [
|
|
||||||
'Each path names its levels, from first exposure to independent practice.',
|
|
||||||
'A path is neither an Owliver skill nor a Board skill — it describes people, not the product.',
|
|
||||||
'It is authored here and read wherever progression is shown.',
|
|
||||||
],
|
|
||||||
note: 'For who holds which capability today, ask me on Talent Pool or KROW Forge — those pages hold the records.',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SKILL_DEVELOPMENT_TOPICS = SKILL_DEVELOPMENT.topics;
|
|
||||||
export const SKILL_DEVELOPMENT_CAPABILITIES = SKILL_DEVELOPMENT.capabilities;
|
|
||||||
export const respondSkillDevelopment = SKILL_DEVELOPMENT.respond;
|
|
||||||
@@ -1,29 +1,68 @@
|
|||||||
import {
|
|
||||||
ANALYTICS_CAPABILITIES, CANDIDATE_CAPABILITIES, OVERVIEW_CAPABILITIES,
|
/**
|
||||||
respondAnalytics, respondCandidates, respondOverview,
|
* The words each page's answers are actually about.
|
||||||
} from './capabilities/employer';
|
*
|
||||||
import {
|
* Inlined here when the capability modules were deleted. They used to sit
|
||||||
ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES,
|
* beside the functions that computed answers from browser data; those functions
|
||||||
CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, CREATE_POSITION_CAPABILITIES,
|
* are gone — the agent answers now — but this vocabulary survives them, because
|
||||||
FORGE_CAPABILITIES,
|
* it decides something different: whether a question belongs to THIS page or to
|
||||||
HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, PROFILE_CAPABILITIES,
|
* another one. A question matching none of a page's topics is a question the
|
||||||
TALENT_POOL_CAPABILITIES,
|
* reader should be taken elsewhere to ask, and that is still true with an agent
|
||||||
respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList,
|
* behind the panel.
|
||||||
respondControlCenter, respondCreatePosition, respondForge, respondHiredHistory,
|
*/
|
||||||
respondPositions,
|
|
||||||
respondProfile, respondTalentPool,
|
const AGENT_CONFIGURE_TOPICS = [
|
||||||
} from './capabilities/admin';
|
'agent', 'agents', 'subagent', 'subagents',
|
||||||
import {
|
'skill', 'skills', 'knowledge',
|
||||||
AGENT_CONFIGURE_CAPABILITIES, AGENT_CONFIGURE_TOPICS,
|
'reasoning', 'starter', 'starters', 'web search',
|
||||||
SETTINGS_CAPABILITIES, SETTINGS_TOPICS,
|
'instruction', 'instructions', 'configure', 'configuration', 'setting', 'settings',
|
||||||
SKILL_CONFIGURE_CAPABILITIES, SKILL_CONFIGURE_TOPICS,
|
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
|
||||||
SKILL_DEVELOPMENT_CAPABILITIES, SKILL_DEVELOPMENT_TOPICS,
|
'workspace',
|
||||||
WORKSPACE_AGENTS_CAPABILITIES, WORKSPACE_AGENTS_TOPICS,
|
];
|
||||||
WORKSPACE_CAPABILITIES, WORKSPACE_SKILLS_CAPABILITIES, WORKSPACE_SKILLS_TOPICS,
|
|
||||||
WORKSPACE_TOPICS,
|
|
||||||
respondAgentConfigure, respondSettings, respondSkillConfigure, respondSkillDevelopment,
|
|
||||||
respondWorkspace, respondWorkspaceAgents, respondWorkspaceSkills,
|
const SETTINGS_TOPICS = [
|
||||||
} from './capabilities/workspace';
|
'setting', 'settings', 'account', 'password', 'credential', 'security', 'two-factor',
|
||||||
|
'two factor', '2fa', 'permission', 'access', 'organization', 'organisation',
|
||||||
|
'client', 'user', 'users', 'team', 'audit', 'notification', 'digest', 'email',
|
||||||
|
'density', 'compact', 'preference', 'automation', 'toggle', 'configure', 'configuration',
|
||||||
|
'workspace', 'owliver', 'assistant',
|
||||||
|
];
|
||||||
|
|
||||||
|
const WORKSPACE_TOPICS = [
|
||||||
|
'workspace', 'agent', 'agents', 'skill', 'skills', 'capability', 'capabilities',
|
||||||
|
'training', 'path', 'paths', 'library', 'registry', 'owliver', 'extend', 'configure',
|
||||||
|
'configuration', 'govern', 'development',
|
||||||
|
];
|
||||||
|
|
||||||
|
const WORKSPACE_AGENTS_TOPICS = [
|
||||||
|
'agent', 'agents', 'subagent', 'subagents', 'skill', 'skills', 'page', 'pages',
|
||||||
|
'publish', 'published', 'draft', 'archive', 'archived', 'revert', 'shipped',
|
||||||
|
'configure', 'configuration', 'reasoning', 'workspace', 'owliver',
|
||||||
|
'constrained', 'cover', 'covers', 'fallback', 'default',
|
||||||
|
];
|
||||||
|
|
||||||
|
const WORKSPACE_SKILLS_TOPICS = [
|
||||||
|
'skill', 'skills', 'library', 'registry', 'owliver', 'board', 'section', 'surface',
|
||||||
|
'placement', 'markdown', 'definition', 'upload', 'author', 'attach',
|
||||||
|
'active', 'draft', 'archive', 'archived', 'category', 'capability', 'capabilities',
|
||||||
|
'workspace', 'agent', 'agents', 'page', 'pages',
|
||||||
|
];
|
||||||
|
|
||||||
|
const SKILL_CONFIGURE_TOPICS = [
|
||||||
|
'skill', 'skills', 'definition', 'markdown', 'frontmatter', 'field', 'fields',
|
||||||
|
'page', 'pages', 'surface', 'placement', 'section', 'source', 'sources', 'trigger',
|
||||||
|
'triggers', 'action', 'actions', 'capability', 'capabilities', 'category', 'status',
|
||||||
|
'active', 'draft', 'archive', 'archived', 'validate', 'validation', 'save', 'publish',
|
||||||
|
'editor', 'configure', 'configuration', 'owliver', 'board', 'workspace',
|
||||||
|
];
|
||||||
|
|
||||||
|
const SKILL_DEVELOPMENT_TOPICS = [
|
||||||
|
'training', 'path', 'paths', 'progression', 'level', 'levels', 'rung', 'ladder',
|
||||||
|
'development', 'capability', 'capabilities', 'define', 'definition',
|
||||||
|
'workspace', 'configure', 'configuration', 'owliver',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page contexts for the Owliver dashboard panel.
|
* Page contexts for the Owliver dashboard panel.
|
||||||
@@ -45,34 +84,24 @@ export const ASSISTANT_CONTEXTS = {
|
|||||||
'employer.overview': {
|
'employer.overview': {
|
||||||
id: 'employer.overview',
|
id: 'employer.overview',
|
||||||
page: 'Overview',
|
page: 'Overview',
|
||||||
capabilities: OVERVIEW_CAPABILITIES,
|
|
||||||
respond: respondOverview,
|
|
||||||
},
|
},
|
||||||
'employer.candidates': {
|
'employer.candidates': {
|
||||||
id: 'employer.candidates',
|
id: 'employer.candidates',
|
||||||
page: 'Candidates',
|
page: 'Candidates',
|
||||||
capabilities: CANDIDATE_CAPABILITIES,
|
|
||||||
respond: respondCandidates,
|
|
||||||
},
|
},
|
||||||
'employer.analytics': {
|
'employer.analytics': {
|
||||||
id: 'employer.analytics',
|
id: 'employer.analytics',
|
||||||
page: 'Analytics',
|
page: 'Analytics',
|
||||||
capabilities: ANALYTICS_CAPABILITIES,
|
|
||||||
respond: respondAnalytics,
|
|
||||||
},
|
},
|
||||||
'admin.controlCenter': {
|
'admin.controlCenter': {
|
||||||
id: 'admin.controlCenter',
|
id: 'admin.controlCenter',
|
||||||
page: 'Control Center',
|
page: 'Control Center',
|
||||||
topics: ['health', 'platform', 'attention', 'urgent', 'bottleneck', 'funnel', 'pipeline', 'conversion', 'recommend', 'should', 'summary', 'summarize', 'workforce', 'operation', 'velocity', 'speed', 'hiring', 'how many', 'unusual', 'anomal', 'risk', 'overview', 'status'],
|
topics: ['health', 'platform', 'attention', 'urgent', 'bottleneck', 'funnel', 'pipeline', 'conversion', 'recommend', 'should', 'summary', 'summarize', 'workforce', 'operation', 'velocity', 'speed', 'hiring', 'how many', 'unusual', 'anomal', 'risk', 'overview', 'status'],
|
||||||
capabilities: CONTROL_CENTER_CAPABILITIES,
|
|
||||||
respond: respondControlCenter,
|
|
||||||
},
|
},
|
||||||
'admin.positions': {
|
'admin.positions': {
|
||||||
id: 'admin.positions',
|
id: 'admin.positions',
|
||||||
page: 'Positions',
|
page: 'Positions',
|
||||||
topics: ['position', 'role', 'posting', 'vacancy', 'fill', 'attention', 'priorit', 'bottleneck', 'funnel', 'waiting', 'review', 'screen', 'applicant', 'pipeline', 'strength', 'activity', 'hiring', 'how many', 'which'],
|
topics: ['position', 'role', 'posting', 'vacancy', 'fill', 'attention', 'priorit', 'bottleneck', 'funnel', 'waiting', 'review', 'screen', 'applicant', 'pipeline', 'strength', 'activity', 'hiring', 'how many', 'which'],
|
||||||
capabilities: POSITIONS_CAPABILITIES,
|
|
||||||
respond: respondPositions,
|
|
||||||
},
|
},
|
||||||
/* Specifying a role is a different question from managing the ones that
|
/* Specifying a role is a different question from managing the ones that
|
||||||
exist, so Create Position is its own context rather than Positions with a
|
exist, so Create Position is its own context rather than Positions with a
|
||||||
@@ -84,8 +113,6 @@ export const ASSISTANT_CONTEXTS = {
|
|||||||
'requirement', 'skill', 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience',
|
'requirement', 'skill', 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience',
|
||||||
'typical', 'position', 'role', 'job description', 'form', 'field', 'step', 'how do i',
|
'typical', 'position', 'role', 'job description', 'form', 'field', 'step', 'how do i',
|
||||||
'what do i', 'explain', 'summar', 'flow'],
|
'what do i', 'explain', 'summar', 'flow'],
|
||||||
capabilities: CREATE_POSITION_CAPABILITIES,
|
|
||||||
respond: respondCreatePosition,
|
|
||||||
},
|
},
|
||||||
/* The Candidates list and Candidates Analysis are separate contexts because they
|
/* The Candidates list and Candidates Analysis are separate contexts because they
|
||||||
ask different questions of the same records: the list is triage — who needs a
|
ask different questions of the same records: the list is triage — who needs a
|
||||||
@@ -94,15 +121,11 @@ export const ASSISTANT_CONTEXTS = {
|
|||||||
id: 'admin.candidatesList',
|
id: 'admin.candidatesList',
|
||||||
page: 'Candidates',
|
page: 'Candidates',
|
||||||
topics: ['candidate', 'applicant', 'attention', 'waiting', 'interview', 'ready', 'score', 'unscored', 'risk', 'flag', 'strongest', 'best', 'top', 'compare', 'shortlist', 'pipeline', 'summar', 'how many', 'who'],
|
topics: ['candidate', 'applicant', 'attention', 'waiting', 'interview', 'ready', 'score', 'unscored', 'risk', 'flag', 'strongest', 'best', 'top', 'compare', 'shortlist', 'pipeline', 'summar', 'how many', 'who'],
|
||||||
capabilities: CANDIDATE_LIST_CAPABILITIES,
|
|
||||||
respond: respondCandidateList,
|
|
||||||
},
|
},
|
||||||
'admin.candidates': {
|
'admin.candidates': {
|
||||||
id: 'admin.candidates',
|
id: 'admin.candidates',
|
||||||
page: 'Candidates Analysis',
|
page: 'Candidates Analysis',
|
||||||
topics: ['candidate', 'applicant', 'gap', 'missing', 'coverage', 'unscored', 'risk', 'flag', 'compare', 'top', 'best', 'strongest', 'shortlist', 'recommend', 'hire', 'who', 'pool', 'quality'],
|
topics: ['candidate', 'applicant', 'gap', 'missing', 'coverage', 'unscored', 'risk', 'flag', 'compare', 'top', 'best', 'strongest', 'shortlist', 'recommend', 'hire', 'who', 'pool', 'quality'],
|
||||||
capabilities: ADMIN_CANDIDATE_CAPABILITIES,
|
|
||||||
respond: respondAdminCandidates,
|
|
||||||
},
|
},
|
||||||
/* Analytics reads the same records as the Control Center, but as performance
|
/* Analytics reads the same records as the Control Center, but as performance
|
||||||
over time rather than as a state to act on — hence its own capabilities. */
|
over time rather than as a state to act on — hence its own capabilities. */
|
||||||
@@ -110,8 +133,6 @@ export const ASSISTANT_CONTEXTS = {
|
|||||||
id: 'admin.analytics',
|
id: 'admin.analytics',
|
||||||
page: 'Analytics',
|
page: 'Analytics',
|
||||||
topics: ['trend', 'over time', 'month', 'week', 'department', 'category', 'team', 'perform', 'bottleneck', 'funnel', 'conversion', 'position', 'role', 'score', 'attention', 'rate', 'average', 'breakdown', 'report', 'how many', 'compare'],
|
topics: ['trend', 'over time', 'month', 'week', 'department', 'category', 'team', 'perform', 'bottleneck', 'funnel', 'conversion', 'position', 'role', 'score', 'attention', 'rate', 'average', 'breakdown', 'report', 'how many', 'compare'],
|
||||||
capabilities: ADMIN_ANALYTICS_CAPABILITIES,
|
|
||||||
respond: respondAdminAnalytics,
|
|
||||||
},
|
},
|
||||||
/* Forge, Talent Pool and Hired History read the same platform records as the
|
/* Forge, Talent Pool and Hired History read the same platform records as the
|
||||||
contexts above, but ask different questions of them: proof and progression,
|
contexts above, but ask different questions of them: proof and progression,
|
||||||
@@ -126,22 +147,16 @@ export const ASSISTANT_CONTEXTS = {
|
|||||||
'certification', 'publish', 'published', 'draft', 'archive', 'status', 'category',
|
'certification', 'publish', 'published', 'draft', 'archive', 'status', 'category',
|
||||||
'owliver', 'evaluat', 'criteri', 'rubric', 'proof', 'evidence', 'verify', 'verification',
|
'owliver', 'evaluat', 'criteri', 'rubric', 'proof', 'evidence', 'verify', 'verification',
|
||||||
'verified', 'workforce', 'gap', 'missing', 'what do we have'],
|
'verified', 'workforce', 'gap', 'missing', 'what do we have'],
|
||||||
capabilities: FORGE_CAPABILITIES,
|
|
||||||
respond: respondForge,
|
|
||||||
},
|
},
|
||||||
'admin.talentPool': {
|
'admin.talentPool': {
|
||||||
id: 'admin.talentPool',
|
id: 'admin.talentPool',
|
||||||
page: 'Talent Pool',
|
page: 'Talent Pool',
|
||||||
topics: ['talent', 'pool', 'worker', 'profile', 'priorit', 'availab', 'verif', 'score', 'unscored', 'segment', 'supply', 'summar', 'how many', 'who', 'shortlist'],
|
topics: ['talent', 'pool', 'worker', 'profile', 'priorit', 'availab', 'verif', 'score', 'unscored', 'segment', 'supply', 'summar', 'how many', 'who', 'shortlist'],
|
||||||
capabilities: TALENT_POOL_CAPABILITIES,
|
|
||||||
respond: respondTalentPool,
|
|
||||||
},
|
},
|
||||||
'admin.hiredHistory': {
|
'admin.hiredHistory': {
|
||||||
id: 'admin.hiredHistory',
|
id: 'admin.hiredHistory',
|
||||||
page: 'Hired History',
|
page: 'Hired History',
|
||||||
topics: ['hire', 'hired', 'outcome', 'department', 'quality', 'recent', 'strongest', 'pattern', 'time to hire', 'retention', 'how many', 'who'],
|
topics: ['hire', 'hired', 'outcome', 'department', 'quality', 'recent', 'strongest', 'pattern', 'time to hire', 'retention', 'how many', 'who'],
|
||||||
capabilities: HIRED_HISTORY_CAPABILITIES,
|
|
||||||
respond: respondHiredHistory,
|
|
||||||
},
|
},
|
||||||
/* The account page. A form rather than a workflow, but the questions people
|
/* The account page. A form rather than a workflow, but the questions people
|
||||||
ask about it — what may I do, how is this secured, what did I change — are
|
ask about it — what may I do, how is this secured, what did I change — are
|
||||||
@@ -153,8 +168,6 @@ export const ASSISTANT_CONTEXTS = {
|
|||||||
'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out',
|
'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out',
|
||||||
'preference', 'setting', 'digest', 'density', 'workspace', 'owliver', 'profile',
|
'preference', 'setting', 'digest', 'density', 'workspace', 'owliver', 'profile',
|
||||||
'name', 'email', 'activity', 'recent', 'do here', 'how do i', 'edit', 'change my'],
|
'name', 'email', 'activity', 'recent', 'do here', 'how do i', 'edit', 'change my'],
|
||||||
capabilities: PROFILE_CAPABILITIES,
|
|
||||||
respond: respondProfile,
|
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Agent configuration — a workspace page, not an operational one.
|
* Agent configuration — a workspace page, not an operational one.
|
||||||
@@ -173,8 +186,6 @@ export const ASSISTANT_CONTEXTS = {
|
|||||||
id: 'admin.agentConfigure',
|
id: 'admin.agentConfigure',
|
||||||
page: 'Agent Configure',
|
page: 'Agent Configure',
|
||||||
topics: AGENT_CONFIGURE_TOPICS,
|
topics: AGENT_CONFIGURE_TOPICS,
|
||||||
capabilities: AGENT_CONFIGURE_CAPABILITIES,
|
|
||||||
respond: respondAgentConfigure,
|
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
* Settings, and the workspace pages behind it.
|
* Settings, and the workspace pages behind it.
|
||||||
@@ -199,50 +210,36 @@ export const ASSISTANT_CONTEXTS = {
|
|||||||
id: 'admin.settings',
|
id: 'admin.settings',
|
||||||
page: 'Settings',
|
page: 'Settings',
|
||||||
topics: SETTINGS_TOPICS,
|
topics: SETTINGS_TOPICS,
|
||||||
capabilities: SETTINGS_CAPABILITIES,
|
|
||||||
respond: respondSettings,
|
|
||||||
},
|
},
|
||||||
'admin.workspace': {
|
'admin.workspace': {
|
||||||
id: 'admin.workspace',
|
id: 'admin.workspace',
|
||||||
page: 'Workspace',
|
page: 'Workspace',
|
||||||
topics: WORKSPACE_TOPICS,
|
topics: WORKSPACE_TOPICS,
|
||||||
capabilities: WORKSPACE_CAPABILITIES,
|
|
||||||
respond: respondWorkspace,
|
|
||||||
},
|
},
|
||||||
'admin.workspaceAgents': {
|
'admin.workspaceAgents': {
|
||||||
id: 'admin.workspaceAgents',
|
id: 'admin.workspaceAgents',
|
||||||
page: 'Agents',
|
page: 'Agents',
|
||||||
topics: WORKSPACE_AGENTS_TOPICS,
|
topics: WORKSPACE_AGENTS_TOPICS,
|
||||||
capabilities: WORKSPACE_AGENTS_CAPABILITIES,
|
|
||||||
respond: respondWorkspaceAgents,
|
|
||||||
},
|
},
|
||||||
'admin.workspaceSkills': {
|
'admin.workspaceSkills': {
|
||||||
id: 'admin.workspaceSkills',
|
id: 'admin.workspaceSkills',
|
||||||
page: 'Skills',
|
page: 'Skills',
|
||||||
topics: WORKSPACE_SKILLS_TOPICS,
|
topics: WORKSPACE_SKILLS_TOPICS,
|
||||||
capabilities: WORKSPACE_SKILLS_CAPABILITIES,
|
|
||||||
respond: respondWorkspaceSkills,
|
|
||||||
},
|
},
|
||||||
'admin.skillConfigure': {
|
'admin.skillConfigure': {
|
||||||
id: 'admin.skillConfigure',
|
id: 'admin.skillConfigure',
|
||||||
page: 'Skill Configure',
|
page: 'Skill Configure',
|
||||||
topics: SKILL_CONFIGURE_TOPICS,
|
topics: SKILL_CONFIGURE_TOPICS,
|
||||||
capabilities: SKILL_CONFIGURE_CAPABILITIES,
|
|
||||||
respond: respondSkillConfigure,
|
|
||||||
},
|
},
|
||||||
'admin.skillDevelopment': {
|
'admin.skillDevelopment': {
|
||||||
id: 'admin.skillDevelopment',
|
id: 'admin.skillDevelopment',
|
||||||
page: 'Skill Development',
|
page: 'Skill Development',
|
||||||
topics: SKILL_DEVELOPMENT_TOPICS,
|
topics: SKILL_DEVELOPMENT_TOPICS,
|
||||||
capabilities: SKILL_DEVELOPMENT_CAPABILITIES,
|
|
||||||
respond: respondSkillDevelopment,
|
|
||||||
},
|
},
|
||||||
'admin.activity': {
|
'admin.activity': {
|
||||||
id: 'admin.activity',
|
id: 'admin.activity',
|
||||||
page: 'Activity',
|
page: 'Activity',
|
||||||
topics: ['activity', 'audit', 'event', 'log', 'security', 'breach', 'compliance', 'trace', 'unusual', 'anomal', 'suspicious', 'user', 'who', 'account', 'busiest'],
|
topics: ['activity', 'audit', 'event', 'log', 'security', 'breach', 'compliance', 'trace', 'unusual', 'anomal', 'suspicious', 'user', 'who', 'account', 'busiest'],
|
||||||
capabilities: ACTIVITY_CAPABILITIES,
|
|
||||||
respond: respondActivity,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -423,6 +423,19 @@ const PLACEHOLDERS = {
|
|||||||
/**
|
/**
|
||||||
* Builds suggestions from what is actually on the page.
|
* Builds suggestions from what is actually on the page.
|
||||||
*
|
*
|
||||||
|
* NOT what the panel renders, and no longer on the production path. The chips
|
||||||
|
* Owliver offers now come from `GET /api/v1/owliver/suggestions`, which ranks
|
||||||
|
* against the rows in PostgreSQL and the caller's permissions — neither of
|
||||||
|
* which a fact sheet assembled in the browser can speak for. `buildIntro` above
|
||||||
|
* is unaffected: a greeting states what is on the page, and the page is where
|
||||||
|
* that is known.
|
||||||
|
*
|
||||||
|
* What is left here is the derivation itself, which is still read by
|
||||||
|
* `scripts/skill-check.mjs` and pinned by the committed Owliver baseline: given
|
||||||
|
* a fact sheet, which questions does this page's data raise. It is kept because
|
||||||
|
* the baseline is a record of behaviour rather than of code, and rewriting it
|
||||||
|
* to match a deletion would erase the comparison it exists to make.
|
||||||
|
*
|
||||||
* A suggestion that names two real candidates is a different product from one
|
* A suggestion that names two real candidates is a different product from one
|
||||||
* that says "Compare candidates" — it proves the assistant already looked.
|
* that says "Compare candidates" — it proves the assistant already looked.
|
||||||
* Each carries the capability that answers it best, so a click is precise.
|
* Each carries the capability that answers it best, so a click is precise.
|
||||||
|
|||||||
@@ -29,4 +29,4 @@ export { AssistantPanel } from './AssistantPanel';
|
|||||||
export { default as KrowAssistant } from './KrowAssistant';
|
export { default as KrowAssistant } from './KrowAssistant';
|
||||||
export { ASSISTANT_CONTEXTS, getContext } from './contexts';
|
export { ASSISTANT_CONTEXTS, getContext } from './contexts';
|
||||||
export { EXCLUDED_ROUTES, enabledRoutes, resolveAssistantContext } from './placement';
|
export { EXCLUDED_ROUTES, enabledRoutes, resolveAssistantContext } from './placement';
|
||||||
export { createHttpProvider, createLocalProvider } from './provider';
|
export { createAgentProvider, createUnconfiguredProvider } from './provider';
|
||||||
|
|||||||
@@ -75,7 +75,14 @@ export function buildFacts({
|
|||||||
.filter((e) => e.viable.length === 1);
|
.filter((e) => e.viable.length === 1);
|
||||||
|
|
||||||
/* Strong candidates nobody has moved on — the most expensive kind of delay. */
|
/* Strong candidates nobody has moved on — the most expensive kind of delay. */
|
||||||
const stalled = ranked.filter((a) => a.ai_score >= 80 && a.status === 'ai_screened');
|
/* Screened and waiting on a decision — which includes the shortlisted, who
|
||||||
|
are waiting on exactly that. Restricting this to `ai_screened` read as
|
||||||
|
correct only while nothing was ever shortlisted: the first shortlisted
|
||||||
|
candidate would have dropped silently out of the count. positionInsights
|
||||||
|
draws the same set with ['ai_screened', 'shortlisted']. */
|
||||||
|
const stalled = ranked.filter(
|
||||||
|
(a) => a.ai_score >= 80 && ['ai_screened', 'shortlisted'].includes(a.status)
|
||||||
|
);
|
||||||
|
|
||||||
const funnel = [
|
const funnel = [
|
||||||
{ key: 'applied', label: 'Applied', count: applications.length },
|
{ key: 'applied', label: 'Applied', count: applications.length },
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
/**
|
|
||||||
* Ranking the panel's own suggestions against what is being typed.
|
|
||||||
*
|
|
||||||
* This is not a catalogue and deliberately does not own one. Owliver already
|
|
||||||
* decides what can be asked on a page — the agent's starters, every attached
|
|
||||||
* skill's declared suggestions, the page's own derived prompts — and each of
|
|
||||||
* those chips carries the metadata that makes it *executable*: a `capability`
|
|
||||||
* that names one of the page's answers, a `skillId`/`skillCapability` pair that
|
|
||||||
* names a skill's reading, a `positionId` that says which record, a `route` for
|
|
||||||
* the ones that navigate. All this module does is choose which of those
|
|
||||||
* already-built chips are worth showing for a given query, and hand them back
|
|
||||||
* unchanged so that clicking one runs exactly what it always ran.
|
|
||||||
*
|
|
||||||
* Returning the original object rather than a copy is the whole contract. A
|
|
||||||
* ranked chip is the same chip; `runPrompt` cannot tell it apart from one that
|
|
||||||
* arrived unfiltered, so nothing about how a suggestion executes depends on
|
|
||||||
* whether it was typed towards or offered outright.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The shortest query worth ranking. Below it the panel shows nothing at all —
|
|
||||||
* one character matches most of the catalogue, which is the wall of chips this
|
|
||||||
* replaced.
|
|
||||||
*/
|
|
||||||
export const MIN_QUERY_CHARS = 2;
|
|
||||||
|
|
||||||
/** Never more than a row. The composer is what the reader came for. */
|
|
||||||
export const MAX_MATCHES = 3;
|
|
||||||
|
|
||||||
/** One shared empty array, so a keystroke that matches nothing is referentially
|
|
||||||
stable and does not rerender the chip row. */
|
|
||||||
const NONE = [];
|
|
||||||
|
|
||||||
const lower = (value) => String(value ?? '').toLowerCase();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Letters and digits only, Unicode aware — the same thing the reader would
|
|
||||||
* count. Punctuation alone never counts as having typed anything.
|
|
||||||
*/
|
|
||||||
const meaningful = (value) => (String(value ?? '').match(/[\p{L}\p{N}]/gu) || []).length;
|
|
||||||
|
|
||||||
/** A string as the words worth matching on. */
|
|
||||||
const words = (value) => lower(value).split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Words too common to carry a subject.
|
|
||||||
*
|
|
||||||
* Only used to stop a query made *entirely* of them from matching the whole
|
|
||||||
* catalogue: "show me the" should offer nothing rather than everything. A
|
|
||||||
* stop word alongside a real term is still scored, because "show pipeline"
|
|
||||||
* should rank a chip saying both above one saying only the second.
|
|
||||||
*/
|
|
||||||
const STOP_WORDS = new Set([
|
|
||||||
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'do', 'does', 'did',
|
|
||||||
'show', 'me', 'my', 'our', 'as', 'of', 'for', 'in', 'on', 'to', 'and', 'or',
|
|
||||||
'with', 'what', 'how', 'why', 'can', 'you', 'i', 'it', 'please', 'give',
|
|
||||||
'tell', 'about', 'this', 'that', 'any', 'all',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How well one term matches one field.
|
|
||||||
*
|
|
||||||
* Prefix matching is what makes this feel like typing rather than searching:
|
|
||||||
* "platf" has to find "Platform health" four characters before the word is
|
|
||||||
* finished. Infix matching is allowed only from four characters, where a
|
|
||||||
* fragment is specific enough that finding it mid-word is a hit rather than an
|
|
||||||
* accident — "line" must not match "pipeline" while "peli" reasonably does.
|
|
||||||
*/
|
|
||||||
function fieldScore(term, fieldWords, exact, partial) {
|
|
||||||
let best = 0;
|
|
||||||
for (const word of fieldWords) {
|
|
||||||
if (word === term) return exact;
|
|
||||||
if (word.startsWith(term)) best = Math.max(best, partial);
|
|
||||||
else if (term.length >= 4 && word.includes(term)) best = Math.max(best, partial - 1);
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What a chip resolves to, as words.
|
|
||||||
*
|
|
||||||
* A capability id is not decoration — `pipeline-health` is the name of the
|
|
||||||
* reading the chip runs, and it is frequently the only place the subject
|
|
||||||
* appears. The Control Center's bottleneck chip reads "Bottleneck at
|
|
||||||
* interviewed" and sends a sentence about candidates dropping between stages;
|
|
||||||
* nothing in either says "pipeline", and typing that word is exactly how a
|
|
||||||
* reader would look for it. So the chip's own routing metadata is matched too,
|
|
||||||
* at the lowest weight of the three fields — it is what the chip *is*, not what
|
|
||||||
* it says, and a chip that says the word should always rank above one that
|
|
||||||
* merely resolves to it.
|
|
||||||
*/
|
|
||||||
const intentWords = (chip) => words(
|
|
||||||
[chip?.capability, chip?.skillId, chip?.skillCapability].filter(Boolean).join(' ')
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A chip's score for a query, or 0 for "do not offer this".
|
|
||||||
*
|
|
||||||
* The label is weighted above the prompt because the label is what the reader
|
|
||||||
* sees: a chip that reads "Platform health" is a better answer to "platform"
|
|
||||||
* than one that happens to mention the word in the sentence it sends, even
|
|
||||||
* though both would answer.
|
|
||||||
*/
|
|
||||||
function score(chip, terms, phrase) {
|
|
||||||
const label = lower(chip?.label);
|
|
||||||
const prompt = lower(chip?.prompt);
|
|
||||||
if (!label && !prompt) return 0;
|
|
||||||
|
|
||||||
let total = 0;
|
|
||||||
let matched = 0;
|
|
||||||
|
|
||||||
/* The whole query as one phrase, which is the strongest signal there is —
|
|
||||||
"pipeline health" typed in full should beat two chips that each carry one
|
|
||||||
of those words. */
|
|
||||||
if (label.includes(phrase)) total += 12;
|
|
||||||
else if (prompt.includes(phrase)) total += 7;
|
|
||||||
|
|
||||||
const labelWords = words(label);
|
|
||||||
const promptWords = words(prompt);
|
|
||||||
const routeWords = intentWords(chip);
|
|
||||||
|
|
||||||
for (const term of terms) {
|
|
||||||
const hit = Math.max(
|
|
||||||
fieldScore(term, labelWords, 6, 4),
|
|
||||||
fieldScore(term, promptWords, 4, 2),
|
|
||||||
fieldScore(term, routeWords, 3, 2)
|
|
||||||
);
|
|
||||||
if (hit) {
|
|
||||||
matched += 1;
|
|
||||||
total += hit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A chip has to actually be about something that was typed. */
|
|
||||||
if (!matched) return 0;
|
|
||||||
|
|
||||||
/* Every term landing somewhere is worth more than most of them landing. */
|
|
||||||
if (matched === terms.length) total += 3;
|
|
||||||
|
|
||||||
/* A suggestion that would have to ask which record before it could answer
|
|
||||||
ranks below one that answers — the same order the resolver already puts
|
|
||||||
them in when they are offered unfiltered. */
|
|
||||||
if (chip?.deferred) total -= 3;
|
|
||||||
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The best few of `prompts` for `query`, in the chips' own objects.
|
|
||||||
*
|
|
||||||
* Stable: chips scoring equally keep the order they arrived in, which is the
|
|
||||||
* order the panel already considers most useful — agent starters, then declared
|
|
||||||
* skill suggestions, then the page's derived prompts.
|
|
||||||
*/
|
|
||||||
export function rankPrompts(prompts = [], query = '', max = MAX_MATCHES) {
|
|
||||||
const text = String(query ?? '').trim();
|
|
||||||
if (meaningful(text) < MIN_QUERY_CHARS) return NONE;
|
|
||||||
|
|
||||||
const terms = words(text);
|
|
||||||
if (!terms.length || terms.every((term) => STOP_WORDS.has(term))) return NONE;
|
|
||||||
|
|
||||||
const phrase = lower(text);
|
|
||||||
const scored = [];
|
|
||||||
prompts.forEach((chip, index) => {
|
|
||||||
const value = score(chip, terms, phrase);
|
|
||||||
if (value > 0) scored.push({ chip, value, index });
|
|
||||||
});
|
|
||||||
|
|
||||||
scored.sort((a, b) => b.value - a.value || a.index - b.index);
|
|
||||||
return scored.length ? scored.slice(0, max).map((entry) => entry.chip) : NONE;
|
|
||||||
}
|
|
||||||
@@ -26,182 +26,424 @@
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getContext } from './contexts';
|
import { confirmation, note } from './blocks';
|
||||||
import { note, toSnapshots } from './blocks';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An answer, at the depth the agent asked for.
|
* The agent provider: the real runtime, over the real API.
|
||||||
*
|
*
|
||||||
* Three modes, and only two of them do anything: `balanced` is today's
|
* The only provider that answers. Everything that makes that safe lives on the
|
||||||
* behaviour exactly, so every existing answer and every agent that declares no
|
* server:
|
||||||
* reasoning is unchanged.
|
|
||||||
*
|
*
|
||||||
* `webSearch` is handled here too, and handled honestly. No search provider is
|
* - **The principal is the session's.** The body carries a question and
|
||||||
* configured in this deployment, so an agent with the flag set gets a note
|
* nothing else about who is asking. The browser cannot name a caller, so it
|
||||||
* saying so rather than an answer that quietly came from nowhere. Silently
|
* cannot ask about records it is not entitled to see.
|
||||||
* ignoring the flag would be worse: the configuration would read as working.
|
* - **The tools and corpora are the SPEC's.** Not the request's. An agent
|
||||||
|
* reads what its published definition says it may read, and no field here
|
||||||
|
* can widen that.
|
||||||
|
* - **A write cannot happen from a question.** The backend answers a proposed
|
||||||
|
* write with a confirmation payload and performs nothing. Approving it is a
|
||||||
|
* second, explicit call carrying the token — see `confirmation` below.
|
||||||
|
*
|
||||||
|
* One snapshot, not a stream. The endpoint is not streaming yet, so this yields
|
||||||
|
* the finished answer once. The signature is the streaming one because that is
|
||||||
|
* what the seam is, and switching to real streaming later changes this function
|
||||||
|
* and nothing else.
|
||||||
*/
|
*/
|
||||||
function shapeForDepth(document, agent, facts) {
|
export function createAgentProvider({ baseUrl = '/api/v1' } = {}) {
|
||||||
if (!agent || !document?.blocks?.length) return document;
|
|
||||||
|
|
||||||
const blocks = [...document.blocks];
|
|
||||||
|
|
||||||
if (agent.reasoning === 'fast') {
|
|
||||||
/* The headline and the first supporting block. Enough to answer, without
|
|
||||||
the breakdown someone asking a quick question did not want. */
|
|
||||||
const trimmed = blocks.slice(0, 2);
|
|
||||||
return { ...document, blocks: trimmed.length ? trimmed : blocks };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agent.reasoning === 'deep') {
|
|
||||||
const counted = [
|
|
||||||
facts?.applications?.length != null && `${facts.applications.length} applications`,
|
|
||||||
facts?.postings?.length != null && `${facts.postings.length} positions`,
|
|
||||||
facts?.staff?.length != null && `${facts.staff.length} hires`,
|
|
||||||
].filter(Boolean);
|
|
||||||
if (counted.length) {
|
|
||||||
blocks.push(note(`Read from ${counted.join(', ')} on this page.`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agent.webSearch) {
|
|
||||||
blocks.push(note(
|
|
||||||
'Web search is enabled on this agent, but no search provider is configured '
|
|
||||||
+ 'in this deployment, so nothing outside this workspace was consulted.'
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...document, blocks };
|
|
||||||
}
|
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Local provider — answers computed from the dashboard's own data.
|
|
||||||
*
|
|
||||||
* Deterministic: the same question against the same data returns the same
|
|
||||||
* answer, which is what makes the assistant demonstrable and testable.
|
|
||||||
*/
|
|
||||||
export function createLocalProvider({ latency = 380, frameDelay = 26 } = {}) {
|
|
||||||
return {
|
return {
|
||||||
id: 'local',
|
id: 'agent',
|
||||||
|
|
||||||
async *stream({ contextId, capability, question, facts, agent = null, signal }) {
|
async *stream({ question, agent = null, confirmation = null, agentVersion = 0, signal }) {
|
||||||
const context = getContext(contextId);
|
/* No agent, no run. The panel resolves which agent covers the page before
|
||||||
if (!context) throw new Error(`Unknown assistant context: ${contextId}`);
|
calling; reaching here without one means the routing layer changed and
|
||||||
|
this should say so rather than guess at an agent id. */
|
||||||
const document = capability
|
if (!agent?.id) {
|
||||||
? context.capabilities.find((c) => c.id === capability)?.run(facts)
|
yield [note('No agent is available for this page.')];
|
||||||
?? context.respond(question, facts)
|
return;
|
||||||
: context.respond(question, facts);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The agent's reasoning mode, as answer depth.
|
|
||||||
*
|
|
||||||
* A real configuration effect rather than a label: `fast` returns the
|
|
||||||
* headline and stops, `deep` says which records the reading counted.
|
|
||||||
* `balanced` — the default, and what an agent that declares nothing gets
|
|
||||||
* — is untouched, so this cannot change any existing answer.
|
|
||||||
*
|
|
||||||
* Depth never changes *what* was read. The page decided that before the
|
|
||||||
* provider was called; this only decides how much of it to say.
|
|
||||||
*/
|
|
||||||
const shaped = shapeForDepth(document, agent, facts);
|
|
||||||
|
|
||||||
// A brief pause before the first frame, so the answer reads as considered
|
|
||||||
// rather than precomputed.
|
|
||||||
await sleep(latency);
|
|
||||||
if (signal?.aborted) return;
|
|
||||||
|
|
||||||
for (const snapshot of toSnapshots(shaped)) {
|
|
||||||
if (signal?.aborted) return;
|
|
||||||
yield snapshot;
|
|
||||||
await sleep(frameDelay);
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
const response = await fetch(`${baseUrl}/agents/${encodeURIComponent(agent.id)}/runs`, {
|
||||||
* HTTP provider — the path to a real backend.
|
|
||||||
*
|
|
||||||
* Reads Server-Sent-Event style `data:` lines and yields a growing block array.
|
|
||||||
* A line may carry a whole block (`{ block: … }`) or a text delta
|
|
||||||
* (`{ delta: "…" }`), which is appended to a trailing text block. Unused today;
|
|
||||||
* it exists so the shape of the integration is settled rather than guessed at.
|
|
||||||
*
|
|
||||||
* The request body sends `contextId`, `capability` and `question` — not the fact
|
|
||||||
* sheet. Dashboard data should be read server-side from the caller's own session
|
|
||||||
* rather than posted from the browser, so the client cannot ask about records it
|
|
||||||
* is not entitled to see.
|
|
||||||
*/
|
|
||||||
export function createHttpProvider({ endpoint, headers = {} }) {
|
|
||||||
if (!endpoint) throw new Error('createHttpProvider requires an endpoint');
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: 'http',
|
|
||||||
|
|
||||||
async *stream({ contextId, capability, question, agent = null, owliverContext = null, signal }) {
|
|
||||||
const response = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...headers },
|
headers: {
|
||||||
/* `agent` and `owliverContext` travel; the fact sheet still does not.
|
'Content-Type': 'application/json',
|
||||||
Dashboard records are read server-side from the caller's own session,
|
/* Ask for a stream. The server answers the same run either way — the
|
||||||
so the client cannot ask about records it is not entitled to see —
|
final event carries exactly the body the non-streaming path
|
||||||
and an agent cannot widen that by being named in the body. */
|
returns — so a deployment that cannot stream degrades to one late
|
||||||
body: JSON.stringify({ contextId, capability, question, agent, owliverContext }),
|
snapshot rather than to a broken panel. */
|
||||||
|
Accept: 'text/event-stream, application/json',
|
||||||
|
},
|
||||||
|
/* The session cookie. Without it the API answers 401, which is the
|
||||||
|
correct answer to a browser that is not signed in. */
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
input: question,
|
||||||
|
confirmation: confirmation || undefined,
|
||||||
|
/* Pins the conversation to the version it started with. Every answer
|
||||||
|
comes back carrying its version; sending it on the next turn is what
|
||||||
|
stops an edit published mid-thread from silently changing which
|
||||||
|
agent is answering. */
|
||||||
|
agentVersion: agentVersion || undefined,
|
||||||
|
}),
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok || !response.body) {
|
if (!response.ok) {
|
||||||
throw new Error(`Assistant request failed: ${response.status}`);
|
yield [note(await describeFailure(response))];
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
/* Not a stream after all — a proxy that buffers, or a server answering
|
||||||
const decoder = new TextDecoder();
|
JSON. Read it whole. */
|
||||||
const blocks = [];
|
if (!isEventStream(response) || !response.body) {
|
||||||
let buffer = '';
|
yield toBlocks(await response.json());
|
||||||
|
return;
|
||||||
const appendDelta = (delta) => {
|
|
||||||
const last = blocks[blocks.length - 1];
|
|
||||||
if (last?.type === 'text') last.text += delta;
|
|
||||||
else blocks.push({ type: 'text', text: delta });
|
|
||||||
};
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
const lines = buffer.split('\n');
|
|
||||||
// The final element may be a partial line; hold it for the next read.
|
|
||||||
buffer = lines.pop() ?? '';
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (!line.startsWith('data:')) continue;
|
|
||||||
const payload = line.slice(5).trim();
|
|
||||||
if (!payload || payload === '[DONE]') continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(payload);
|
|
||||||
if (parsed.block) blocks.push(parsed.block);
|
|
||||||
else if (parsed.delta) appendDelta(parsed.delta);
|
|
||||||
} catch {
|
|
||||||
appendDelta(payload);
|
|
||||||
}
|
|
||||||
yield [...blocks];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
yield* readRunStream(response, signal);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The provider the app uses. Local by default; point
|
* Whether the server actually opened a stream, rather than answering JSON.
|
||||||
* `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to switch, with no other
|
*
|
||||||
* code change.
|
* Defensive about `headers` because the answer to "is this a stream" must be
|
||||||
|
* NO when anything is unexpected. A response shape this does not recognise gets
|
||||||
|
* read whole, which works; assuming a stream and finding none would hang.
|
||||||
|
*/
|
||||||
|
function isEventStream(response) {
|
||||||
|
const type = response?.headers?.get?.('Content-Type') || '';
|
||||||
|
return type.includes('text/event-stream');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the run's event stream, yielding the answer as it grows.
|
||||||
|
*
|
||||||
|
* Two kinds of event and they are handled very differently:
|
||||||
|
*
|
||||||
|
* - `delta` is a fragment of assistant text. The accumulated text is
|
||||||
|
* re-parsed into blocks on every one, so a half-written answer renders as
|
||||||
|
* far as it makes sense to — a table mid-construction stays as text until
|
||||||
|
* its rows arrive, which reads better than a broken table.
|
||||||
|
* - `run` is the finished result: the same body the non-streaming path
|
||||||
|
* returns, carrying confirmations, the termination and the token cost.
|
||||||
|
* Whatever text streamed is replaced by it, because the final snapshot is
|
||||||
|
* authoritative and the deltas were a preview of it.
|
||||||
|
*
|
||||||
|
* A client that ignored every delta and read only the last event would be in
|
||||||
|
* exactly the state it would have reached without streaming. That is what keeps
|
||||||
|
* the two paths honest rather than merely similar.
|
||||||
|
*/
|
||||||
|
async function* readRunStream(response, signal) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
let buffer = '';
|
||||||
|
let text = '';
|
||||||
|
let final = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
/* The last element may be half a line; hold it for the next read. */
|
||||||
|
buffer = lines.pop() ?? '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.startsWith('data:')) continue;
|
||||||
|
const payload = line.slice(5).trim();
|
||||||
|
if (!payload || payload === '[DONE]') continue;
|
||||||
|
|
||||||
|
let event;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
/* A malformed frame is dropped rather than rendered. It cannot be
|
||||||
|
assistant text — the server encodes every event as JSON — so
|
||||||
|
showing it would put transport noise in front of a reader. */
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof event.delta === 'string') {
|
||||||
|
text += event.delta;
|
||||||
|
yield markdownToBlocks(text);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (event.run) final = event.run;
|
||||||
|
if (event.error) {
|
||||||
|
yield [note(event.error.message || 'The agent could not be reached.')];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (signal?.aborted) break;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
/* Releasing matters on an abort: a reader still holding the body keeps the
|
||||||
|
connection open, and a user who pressed Stop expects it to stop. */
|
||||||
|
try { reader.cancel(); } catch { /* already closed */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (final) {
|
||||||
|
yield toBlocks(final);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* The stream ended without a final event — the run was aborted, or the
|
||||||
|
connection dropped mid-answer. Whatever arrived is kept: a half-read answer
|
||||||
|
is worth more to the reader than an empty panel. */
|
||||||
|
if (text) yield markdownToBlocks(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a run result into blocks.
|
||||||
|
*
|
||||||
|
* The termination decides the shape, and every one of the six produces
|
||||||
|
* something a person can act on. A run that ended without completing is not an
|
||||||
|
* error to swallow: it has an answer-so-far worth keeping and a reason worth
|
||||||
|
* reading.
|
||||||
|
*/
|
||||||
|
function toBlocks(run) {
|
||||||
|
const blocks = [];
|
||||||
|
|
||||||
|
if (run.output) blocks.push(...markdownToBlocks(run.output));
|
||||||
|
|
||||||
|
/* Pending writes before the trailing note: somebody scrolling to the bottom
|
||||||
|
should meet the decision, not a footnote about token cost. */
|
||||||
|
for (const c of run.confirmations || []) {
|
||||||
|
blocks.push(confirmation(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The surface layer's wording for a run that did not complete. Rendered as a
|
||||||
|
note rather than as prose, so it reads as the system speaking rather than
|
||||||
|
as the agent's own words. */
|
||||||
|
if (run.message) blocks.push(note(run.message));
|
||||||
|
|
||||||
|
if (!blocks.length) {
|
||||||
|
blocks.push(note('The agent finished without saying anything.'));
|
||||||
|
}
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns a model's markdown into the block vocabulary the panel already renders.
|
||||||
|
*
|
||||||
|
* A real model writes markdown — headings, numbered steps, tables. The local
|
||||||
|
* simulator never did: it emitted short single paragraphs, so the text renderer
|
||||||
|
* only ever handled inline bold and italic. Point the panel at a real model and
|
||||||
|
* `## What I'd do, in order` arrives on screen with the hashes still attached,
|
||||||
|
* and a comparison table arrives as pipes.
|
||||||
|
*
|
||||||
|
* The fix is NOT to render markdown inside a text block. The panel already has
|
||||||
|
* a heading block, a list block and a table block, all styled with the same
|
||||||
|
* tokens as the dashboard cards beside them — so the honest move is to parse
|
||||||
|
* into those, and let a generated answer look like it belongs to Krow rather
|
||||||
|
* than like a chat window that happens to be embedded in it.
|
||||||
|
*
|
||||||
|
* Deliberately a small parser and not a markdown library. Four constructs is
|
||||||
|
* what a model actually produces in an answer; anything else falls through as a
|
||||||
|
* paragraph, which reads correctly even when it is not styled richly. A full
|
||||||
|
* parser would be a large dependency in exchange for handling footnotes nobody
|
||||||
|
* writes.
|
||||||
|
*/
|
||||||
|
export function markdownToBlocks(markdown) {
|
||||||
|
const lines = String(markdown).replace(/\r\n/g, '\n').split('\n');
|
||||||
|
const blocks = [];
|
||||||
|
let paragraph = [];
|
||||||
|
let listItems = null;
|
||||||
|
let ordered = false;
|
||||||
|
|
||||||
|
const flushParagraph = () => {
|
||||||
|
const text = paragraph.join(' ').trim();
|
||||||
|
paragraph = [];
|
||||||
|
if (text) blocks.push({ type: 'text', text });
|
||||||
|
};
|
||||||
|
const flushList = () => {
|
||||||
|
if (listItems?.length) blocks.push({ type: 'list', items: listItems, ordered });
|
||||||
|
listItems = null;
|
||||||
|
};
|
||||||
|
const flushAll = () => { flushParagraph(); flushList(); };
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
const trimmed = line.trim();
|
||||||
|
|
||||||
|
if (!trimmed) { flushAll(); continue; }
|
||||||
|
|
||||||
|
/* A heading. The level is dropped: this panel has one heading style, and
|
||||||
|
inventing three would give a 380px column a hierarchy it cannot show. */
|
||||||
|
const heading = /^(#{1,6})\s+(.+)$/.exec(trimmed);
|
||||||
|
if (heading) {
|
||||||
|
flushAll();
|
||||||
|
blocks.push({ type: 'heading', text: stripInline(heading[2]) });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A table: a pipe row followed by a separator row. Checked together,
|
||||||
|
because a single pipe row is far more likely to be prose. */
|
||||||
|
if (trimmed.startsWith('|') && isSeparatorRow(lines[i + 1])) {
|
||||||
|
flushAll();
|
||||||
|
const { block, next } = parseTable(lines, i);
|
||||||
|
if (block) { blocks.push(block); i = next; continue; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const bullet = /^[-*]\s+(.+)$/.exec(trimmed);
|
||||||
|
const numbered = /^\d+[.)]\s+(.+)$/.exec(trimmed);
|
||||||
|
if (bullet || numbered) {
|
||||||
|
const wantOrdered = Boolean(numbered);
|
||||||
|
/* A list that changes kind mid-way is two lists. */
|
||||||
|
if (listItems && ordered !== wantOrdered) flushList();
|
||||||
|
flushParagraph();
|
||||||
|
ordered = wantOrdered;
|
||||||
|
listItems = listItems || [];
|
||||||
|
listItems.push((bullet || numbered)[1].trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
flushList();
|
||||||
|
paragraph.push(trimmed);
|
||||||
|
}
|
||||||
|
flushAll();
|
||||||
|
|
||||||
|
return blocks.length ? blocks : [{ type: 'text', text: String(markdown).trim() }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `|---|---:|` — the row that makes the one above it a header. */
|
||||||
|
function isSeparatorRow(line) {
|
||||||
|
return Boolean(line && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes('-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitRow(line) {
|
||||||
|
return line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a markdown table starting at `start`.
|
||||||
|
*
|
||||||
|
* Rows with the wrong number of cells are padded or trimmed rather than
|
||||||
|
* dropped. A model occasionally miscounts a pipe, and losing a whole row of an
|
||||||
|
* answer over a formatting slip is worse than showing an empty cell — which the
|
||||||
|
* renderer already draws as an em dash.
|
||||||
|
*/
|
||||||
|
function parseTable(lines, start) {
|
||||||
|
const header = splitRow(lines[start]);
|
||||||
|
const columns = header.map((label, i) => ({ key: `c${i}`, label: stripInline(label) }));
|
||||||
|
|
||||||
|
const rows = [];
|
||||||
|
let i = start + 2;
|
||||||
|
for (; i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (!line.trim().startsWith('|')) break;
|
||||||
|
const cells = splitRow(line);
|
||||||
|
const row = {};
|
||||||
|
columns.forEach((col, n) => { row[col.key] = stripInline(cells[n] ?? ''); });
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rows.length) return { block: null, next: start };
|
||||||
|
return { block: { type: 'table', columns, rows }, next: i - 1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes markdown a cell or heading cannot show.
|
||||||
|
*
|
||||||
|
* Table cells and headings are rendered as plain strings by their components,
|
||||||
|
* so `**Maria**` would appear with the asterisks. Paragraphs and list items are
|
||||||
|
* left alone — those go through `Inline`, which renders bold properly.
|
||||||
|
*/
|
||||||
|
function stripInline(value) {
|
||||||
|
return String(value).replace(/\*\*(.+?)\*\*/g, '$1').replace(/`(.+?)`/g, '$1').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A failed request, in one sentence a person can act on.
|
||||||
|
*
|
||||||
|
* The status is what distinguishes the cases that matter, and they are
|
||||||
|
* genuinely different actions: sign in again, ask someone for access, or wait.
|
||||||
|
* Flattening them into "something went wrong" makes the user's next move a
|
||||||
|
* guess.
|
||||||
|
*/
|
||||||
|
async function describeFailure(response) {
|
||||||
|
let detail = '';
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
detail = body?.error?.message || '';
|
||||||
|
} catch {
|
||||||
|
/* A non-JSON error body is a proxy or a gateway, not this API. The status
|
||||||
|
still says enough. */
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (response.status) {
|
||||||
|
case 401:
|
||||||
|
return 'Your session has expired. Sign in again to keep asking.';
|
||||||
|
case 404:
|
||||||
|
/* Deliberately the same answer for "no such agent" and "not yours" — the
|
||||||
|
API refuses to distinguish them, and repeating the distinction here
|
||||||
|
would undo that. */
|
||||||
|
return 'That agent is not available on this workspace.';
|
||||||
|
case 422:
|
||||||
|
return detail || 'That agent cannot run right now.';
|
||||||
|
case 429:
|
||||||
|
return 'Too many requests just now. Try again in a moment.';
|
||||||
|
default:
|
||||||
|
return detail || 'The agent could not be reached. Try again in a moment.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider the app uses.
|
||||||
|
*
|
||||||
|
* There used to be three: a local simulator that computed answers from data
|
||||||
|
* already in the browser, a streaming HTTP provider for a deployment that had
|
||||||
|
* one, and the agent. The simulator is gone, and its removal is the point of
|
||||||
|
* this file's current shape.
|
||||||
|
*
|
||||||
|
* WHY IT WENT
|
||||||
|
*
|
||||||
|
* Two answering paths behind one avatar meant the same question got different
|
||||||
|
* answers depending on phrasing — "assign the strongest free worker" matched a
|
||||||
|
* template and reported nobody was available, while "put the best free worker
|
||||||
|
* on" reached the agent, which found somebody and proposed them. A user cannot
|
||||||
|
* be expected to know which sentence talks to which system, and a product where
|
||||||
|
* the wording decides the answer is a demo with good manners.
|
||||||
|
*
|
||||||
|
* WHAT IT COST, SAID PLAINLY
|
||||||
|
*
|
||||||
|
* The simulator was instant, free, and could not be wrong about a figure — it
|
||||||
|
* read the same cache the page rendered from. The agent takes ten to twenty
|
||||||
|
* seconds and costs tokens. That is a real regression on speed, accepted in
|
||||||
|
* exchange for answers that can be followed up, cannot be beaten by a synonym,
|
||||||
|
* and can act on what they find.
|
||||||
|
*
|
||||||
|
* AN UNCONFIGURED DEPLOYMENT NOW SAYS SO
|
||||||
|
*
|
||||||
|
* With no VITE_AGENT_API there is nothing to fall back to. That state is
|
||||||
|
* explicit rather than silent: every question is answered with the reason,
|
||||||
|
* because a panel that quietly does nothing is the worst of the three
|
||||||
|
* possibilities and the hardest to diagnose.
|
||||||
*/
|
*/
|
||||||
export function createAssistantProvider() {
|
export function createAssistantProvider() {
|
||||||
const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT;
|
const base = import.meta.env?.VITE_AGENT_API;
|
||||||
return endpoint ? createHttpProvider({ endpoint }) : createLocalProvider();
|
return base ? createAgentProvider({ baseUrl: base }) : createUnconfiguredProvider();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider for a deployment with no agent configured.
|
||||||
|
*
|
||||||
|
* Answers every question with the same sentence, which is the honest thing to
|
||||||
|
* do: nothing here can answer, and pretending otherwise is what the simulator
|
||||||
|
* was doing.
|
||||||
|
*/
|
||||||
|
export function createUnconfiguredProvider() {
|
||||||
|
return {
|
||||||
|
id: 'unconfigured',
|
||||||
|
// eslint-disable-next-line require-yield
|
||||||
|
async *stream() {
|
||||||
|
yield [note(
|
||||||
|
'Owliver is not configured on this deployment. Set VITE_AGENT_API and give the '
|
||||||
|
+ 'backend a model credential, and this panel will answer from your workspace.'
|
||||||
|
)];
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,14 +166,21 @@ export function navigationAnswer(destination) {
|
|||||||
/**
|
/**
|
||||||
* The reply when the question fits neither this page nor another one.
|
* The reply when the question fits neither this page nor another one.
|
||||||
*
|
*
|
||||||
* Lists what this page can answer, taken from the capability labels the panel
|
* Reached only on a deployment with no agent configured. With one, a question
|
||||||
* already shows as chips — so the offer is always exactly what is on screen.
|
* this page cannot place is handed to the agent rather than declined — see
|
||||||
|
* `preferAgent`, which turns `outOfScope` into `answer`.
|
||||||
|
*
|
||||||
|
* It used to list the page's capability labels, taken from the chips on screen.
|
||||||
|
* Those labels were the local simulator's readings and went with it. The page's
|
||||||
|
* TOPICS are what survive, and they are a better offer anyway: a capability
|
||||||
|
* label named a canned report, where a topic names a subject somebody can
|
||||||
|
* actually ask about in their own words.
|
||||||
*/
|
*/
|
||||||
export function outOfScopeAnswer(context) {
|
export function outOfScopeAnswer(context) {
|
||||||
const labels = (context?.capabilities || []).map((c) => c.label);
|
const subjects = (context?.topics || []).slice(0, 6);
|
||||||
return doc(
|
return doc(
|
||||||
text(`I do not have that on **${context?.page || 'this page'}**.`),
|
text(`I do not have that on **${context?.page || 'this page'}**.`),
|
||||||
list(labels.slice(0, 6), { ordered: false }),
|
subjects.length ? text(`This page covers ${subjects.join(', ')}.`) : null,
|
||||||
note('Ask about one of those, or open the page the question belongs to.')
|
note('Ask about one of those, or open the page the question belongs to.')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -928,3 +935,79 @@ export function resolveIntent({
|
|||||||
|
|
||||||
return { kind: 'outOfScope', doc: outOfScopeAnswer(context) };
|
return { kind: 'outOfScope', doc: outOfScopeAnswer(context) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── Preferring the agent ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decides whether a resolved intent should defer to the agent.
|
||||||
|
*
|
||||||
|
* BACKGROUND, because this function only makes sense with it.
|
||||||
|
*
|
||||||
|
* This panel grew up answering from the browser. Everything above computes an
|
||||||
|
* answer out of data React Query already fetched — instant, free, and unable to
|
||||||
|
* be wrong about a figure, because it reads the same cache the page renders
|
||||||
|
* from. What it cannot do is reason, and it can only answer the question shapes
|
||||||
|
* somebody wrote a matcher for.
|
||||||
|
*
|
||||||
|
* There is now a real agent behind the panel: a model, seventeen authorized
|
||||||
|
* tools, permissioned document retrieval, and a write path that asks before it
|
||||||
|
* acts. It can answer anything, and it can be asked a follow-up.
|
||||||
|
*
|
||||||
|
* Both paths wearing the same avatar was the problem. "Assign the strongest
|
||||||
|
* free worker to Picker" matched a template and answered "nobody is both
|
||||||
|
* qualified and free"; "put the best free worker on Picker" reached the agent,
|
||||||
|
* which found somebody and proposed them. Same intent, opposite answers,
|
||||||
|
* decided by which verb the user happened to type. That is not a product.
|
||||||
|
*
|
||||||
|
* WHAT THIS CHANGES, AND WHAT IT DELIBERATELY DOES NOT
|
||||||
|
*
|
||||||
|
* An intent that only produces TEXT defers to the agent. An intent that DOES
|
||||||
|
* something does not, and the distinction is the whole of the rule:
|
||||||
|
*
|
||||||
|
* - A template answer is one of several possible descriptions of rows the
|
||||||
|
* agent can also read. The agent's version can be followed up and cannot be
|
||||||
|
* beaten by a synonym, so it wins.
|
||||||
|
* - A flow, a draft action, an assignment, a headcount change, an interview
|
||||||
|
* being marked ready — these perform work the agent has no tool for. They
|
||||||
|
* are kept exactly as they are. Removing them would lose capability, not
|
||||||
|
* gimmickry.
|
||||||
|
* - A guided form that asks "which position?" one question at a time is an
|
||||||
|
* honest form. It stays, and it is not the agent pretending to converse.
|
||||||
|
* - `navigate` stays: sending somebody to the page that owns a subject is a
|
||||||
|
* product decision, not a failure to answer.
|
||||||
|
*
|
||||||
|
* `outOfScope` becomes an answer, and that is the clearest win here. It is the
|
||||||
|
* panel declining a question the agent could simply have answered.
|
||||||
|
*
|
||||||
|
* NOTHING IS DELETED. Every matcher above still runs and still returns what it
|
||||||
|
* always did; this only chooses not to use the text ones while an agent is
|
||||||
|
* live. Turn the agent off and the panel behaves exactly as it shipped — which
|
||||||
|
* is what makes this safe to try before anything is removed for good.
|
||||||
|
*/
|
||||||
|
export function preferAgent(intent, { modelBacked = false } = {}) {
|
||||||
|
if (!modelBacked || !intent) return intent;
|
||||||
|
|
||||||
|
/* Anything that performs work keeps its path. The presence of one of these
|
||||||
|
keys IS the definition of "does something" — see the handlers in
|
||||||
|
useAssistant, which are the only readers of them. */
|
||||||
|
if (intent.create || intent.perform || intent.assign || intent.headcount ||
|
||||||
|
intent.interview || intent.action || intent.flow) {
|
||||||
|
return intent;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (intent.kind) {
|
||||||
|
/* Pure text. The agent reads the same rows and can be asked a follow-up. */
|
||||||
|
case 'answer-doc':
|
||||||
|
case 'skill':
|
||||||
|
case 'workforce':
|
||||||
|
/* A refusal the agent would not have made. */
|
||||||
|
case 'outOfScope':
|
||||||
|
return { kind: 'answer' };
|
||||||
|
|
||||||
|
/* 'answer' already goes to the agent. 'navigate' and 'constrained' are
|
||||||
|
deliberate product behaviour and are left alone. */
|
||||||
|
default:
|
||||||
|
return intent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
useUserActivity, useWorkerProfile, useWorkerProfiles,
|
||||||
} from '@/lib/krowHooks';
|
} from '@/lib/krowHooks';
|
||||||
import { skillsForContext } from '@/lib/skills/registry';
|
import { skillsForContext } from '@/lib/skills/registry';
|
||||||
import { suggestionsForPosition } from '@/lib/skills/owliverResolver';
|
|
||||||
import {
|
import {
|
||||||
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
||||||
} from '@/lib/skills/positionFlow';
|
} from '@/lib/skills/positionFlow';
|
||||||
@@ -25,12 +24,22 @@ import { storableContext } from '@/lib/agents/context';
|
|||||||
import { buildFacts } from './insights';
|
import { buildFacts } from './insights';
|
||||||
import { agentRequest } from '@/lib/agents/runtime';
|
import { agentRequest } from '@/lib/agents/runtime';
|
||||||
import { createAssistantProvider } from './provider';
|
import { createAssistantProvider } from './provider';
|
||||||
import { resolveIntent } from './routing';
|
import { preferAgent, resolveIntent } from './routing';
|
||||||
import { doc, text as textBlock, toSnapshots } from './blocks';
|
import { doc, text as textBlock, toSnapshots } from './blocks';
|
||||||
|
|
||||||
/** One provider instance for the app's lifetime. */
|
/** One provider instance for the app's lifetime. */
|
||||||
const provider = createAssistantProvider();
|
const provider = createAssistantProvider();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a real agent is answering, as opposed to the local simulator.
|
||||||
|
*
|
||||||
|
* Read once, from the provider the app actually built. Not a separate flag: a
|
||||||
|
* second switch could disagree with the first, and "the panel thought it had an
|
||||||
|
* agent and did not" is a failure mode with no visible symptom beyond worse
|
||||||
|
* answers.
|
||||||
|
*/
|
||||||
|
const agentBacked = provider.id === 'agent';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the same React Query caches the pages render from, so the assistant
|
* Reads the same React Query caches the pages render from, so the assistant
|
||||||
* costs no extra requests and cannot be looking at a different snapshot than the
|
* costs no extra requests and cannot be looking at a different snapshot than the
|
||||||
@@ -228,7 +237,8 @@ function withoutAuthoringActions(messages = []) {
|
|||||||
* routing applies to both without either knowing it exists.
|
* routing applies to both without either knowing it exists.
|
||||||
*/
|
*/
|
||||||
export function useConversation({
|
export function useConversation({
|
||||||
contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview,
|
contextId, facts, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
|
||||||
|
onAssignWorkers, onScheduleInterview,
|
||||||
/* Finishing a draft: the same two mutations the Create Position form calls.
|
/* Finishing a draft: the same two mutations the Create Position form calls.
|
||||||
Passed in rather than reached for, so this layer still writes nothing
|
Passed in rather than reached for, so this layer still writes nothing
|
||||||
itself and there is one update path for a position. */
|
itself and there is one update path for a position. */
|
||||||
@@ -414,6 +424,18 @@ export function useConversation({
|
|||||||
* thread, is persisted and archived like any other, and nothing about it is
|
* thread, is persisted and archived like any other, and nothing about it is
|
||||||
* simulated — it is the live pipeline pointed at another surface.
|
* simulated — it is the live pipeline pointed at another surface.
|
||||||
*/
|
*/
|
||||||
|
/* The most recent question, for the confirmation path. A ref rather than
|
||||||
|
state: nothing renders from it, and making it state would re-render the
|
||||||
|
whole panel on every keystroke-completed turn for no visible reason. */
|
||||||
|
const lastQuestionRef = React.useRef('');
|
||||||
|
|
||||||
|
/* The agent version this conversation started on.
|
||||||
|
§3: running conversations pin the version they started with. Held in a ref
|
||||||
|
rather than state because nothing renders from it — and reset with the
|
||||||
|
thread, so a NEW conversation picks up whatever is current rather than
|
||||||
|
inheriting a version somebody has since moved on from. */
|
||||||
|
const pinnedVersionRef = React.useRef(0);
|
||||||
|
|
||||||
const send = React.useCallback(async ({
|
const send = React.useCallback(async ({
|
||||||
question, capability = null, positionId = null, scope = null,
|
question, capability = null, positionId = null, scope = null,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -463,16 +485,22 @@ export function useConversation({
|
|||||||
if (!intent) {
|
if (!intent) {
|
||||||
intent = capability
|
intent = capability
|
||||||
? { kind: 'answer' }
|
? { kind: 'answer' }
|
||||||
: resolveIntent({
|
: preferAgent(
|
||||||
question: text,
|
resolveIntent({
|
||||||
contextId: turnContext,
|
question: text,
|
||||||
disabledSkills: turnDisabled,
|
contextId: turnContext,
|
||||||
customSkills, roles, skillCategories,
|
disabledSkills: turnDisabled,
|
||||||
courses, workforce, skillContext, positionId,
|
customSkills, roles, skillCategories,
|
||||||
agent: turnAgent,
|
courses, workforce, skillContext, positionId,
|
||||||
agentCoversPage: turnCovers,
|
agent: turnAgent,
|
||||||
agentSuggestion, owliverContext,
|
agentCoversPage: turnCovers,
|
||||||
});
|
agentSuggestion, owliverContext,
|
||||||
|
}),
|
||||||
|
/* Only while a real agent is behind the panel. With the local
|
||||||
|
simulator there is nothing better to defer TO, and deferring
|
||||||
|
would turn every template answer into a worse one. */
|
||||||
|
{ modelBacked: agentBacked },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -482,41 +510,56 @@ export function useConversation({
|
|||||||
*/
|
*/
|
||||||
if (intent.kind === 'flow' && intent.create) {
|
if (intent.kind === 'flow' && intent.create) {
|
||||||
let created = null;
|
let created = null;
|
||||||
|
/* Kept, not swallowed. The reply states the outcome, and "it did not
|
||||||
|
work" is a worse outcome to state than the reason it did not: a
|
||||||
|
required field, a refused role, or an API that is not running. */
|
||||||
|
let failure = null;
|
||||||
try {
|
try {
|
||||||
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
|
created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status);
|
||||||
} catch {
|
} catch (error) {
|
||||||
created = null;
|
created = null;
|
||||||
|
failure = error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (created?.id) {
|
if (created?.id) {
|
||||||
/**
|
/**
|
||||||
* What can now be asked about the position that was just made.
|
* What can now be asked, with the position in the database.
|
||||||
*
|
*
|
||||||
* A live position is something the page's skills can read; a draft is
|
* The record exists, so the organization is materially different from
|
||||||
* not finished being specified, so offering to analyse it would be
|
* what it was one turn ago — there is one more role to fill, or one
|
||||||
* answering about a record the admin has not committed to. The registry
|
* more unfinished draft — and what is worth asking has changed with it.
|
||||||
* decides *which* skills can say something — see
|
* `onRefreshSuggestions` asks the server that question again rather
|
||||||
* `suggestionsForPosition` — and this decides only whether it is yet
|
* than deriving an answer here: the ranking is the API's, against the
|
||||||
* the moment to ask them.
|
* rows it has just written, filtered by the caller's role.
|
||||||
|
*
|
||||||
|
* A draft is offered nothing. It is not finished being specified, and
|
||||||
|
* suggesting readings of a record the admin has not committed to would
|
||||||
|
* be answering about something that is not yet true.
|
||||||
*/
|
*/
|
||||||
const ready = created.status === 'active';
|
const ready = created.status === 'active';
|
||||||
|
let refreshed = [];
|
||||||
|
if (ready) {
|
||||||
|
try {
|
||||||
|
refreshed = (await onRefreshSuggestions?.()) || [];
|
||||||
|
} catch {
|
||||||
|
/* The position was created; failing to fetch what to ask next is
|
||||||
|
not a reason to report that it was not. */
|
||||||
|
refreshed = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
intent = {
|
intent = {
|
||||||
...intent,
|
...intent,
|
||||||
flow: null,
|
flow: null,
|
||||||
doc: positionCreatedReply(created),
|
doc: positionCreatedReply(created),
|
||||||
followUp: [
|
followUp: [...createdFollowUp(created), ...refreshed],
|
||||||
...createdFollowUp(created),
|
|
||||||
...(ready
|
|
||||||
? suggestionsForPosition(turnContext, turnDisabled, customSkills, created)
|
|
||||||
: []),
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
/* Keep the answers: the summary is still there to try again from. */
|
/* Keep the answers: the summary is still there to try again from. */
|
||||||
intent = {
|
intent = {
|
||||||
...intent,
|
...intent,
|
||||||
flow: { ...intent.flow, stage: 'review' },
|
flow: { ...intent.flow, stage: 'review' },
|
||||||
doc: positionFailedReply(),
|
doc: positionFailedReply(failure?.message),
|
||||||
followUp: [
|
followUp: [
|
||||||
{ label: 'Create position', prompt: 'Create position' },
|
{ label: 'Create position', prompt: 'Create position' },
|
||||||
{ label: 'Change details', prompt: 'Change details' },
|
{ label: 'Change details', prompt: 'Change details' },
|
||||||
@@ -694,12 +737,16 @@ export function useConversation({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let latest = [];
|
let latest = [];
|
||||||
|
/* Held for the confirmation path: approving a proposed write resumes the
|
||||||
|
run, and a resumed run needs the question that produced the proposal. */
|
||||||
|
lastQuestionRef.current = text;
|
||||||
for await (const snapshot of provider.stream({
|
for await (const snapshot of provider.stream({
|
||||||
contextId: turnContext, capability, question: text, facts, signal: controller.signal,
|
contextId: turnContext, capability, question: text, facts, signal: controller.signal,
|
||||||
/* What the agent *is*, never what it may read. The page settled that
|
/* What the agent *is*, never what it may read. The page settled that
|
||||||
before this call, and `agentRequest` carries no records. */
|
before this call, and `agentRequest` carries no records. */
|
||||||
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
|
agent: turnAgent ? agentRequest(turnAgent, turnContext) : null,
|
||||||
owliverContext,
|
owliverContext,
|
||||||
|
agentVersion: pinnedVersionRef.current,
|
||||||
})) {
|
})) {
|
||||||
if (controller.signal.aborted) break;
|
if (controller.signal.aborted) break;
|
||||||
latest = snapshot;
|
latest = snapshot;
|
||||||
@@ -730,7 +777,8 @@ export function useConversation({
|
|||||||
setPending(null);
|
setPending(null);
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onUpdatePosition,
|
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onRefreshSuggestions,
|
||||||
|
onUpdatePosition,
|
||||||
onGenerateDescription, onAssignWorkers,
|
onGenerateDescription, onAssignWorkers,
|
||||||
onScheduleInterview,
|
onScheduleInterview,
|
||||||
workforce, setFlow, disabledSkills,
|
workforce, setFlow, disabledSkills,
|
||||||
@@ -739,6 +787,68 @@ export function useConversation({
|
|||||||
|
|
||||||
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
const stop = React.useCallback(() => abortRef.current?.abort(), []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approves a proposed write, and lets the run finish.
|
||||||
|
*
|
||||||
|
* The second half of the confirmation flow. The first half ended with the
|
||||||
|
* agent describing something and doing nothing; this carries the person's
|
||||||
|
* decision back and the server performs exactly the call that description was
|
||||||
|
* issued against — same tool, same arguments, same caller. A token authorises
|
||||||
|
* one write and expires; it is not a mode.
|
||||||
|
*
|
||||||
|
* The original question is re-sent alongside it, because the run that resumes
|
||||||
|
* is a NEW run: it has to be able to reach the same tool call again for the
|
||||||
|
* token to match. That is why the token is not bound to a run id — see
|
||||||
|
* tools/confirm.go.
|
||||||
|
*
|
||||||
|
* Nothing happens locally. This layer does not write, does not optimistically
|
||||||
|
* mark anything done, and does not tell the user it worked: the answer that
|
||||||
|
* comes back says what actually happened, including a refusal if the world
|
||||||
|
* moved between the asking and the answering.
|
||||||
|
*/
|
||||||
|
const confirm = React.useCallback(async (block) => {
|
||||||
|
if (!block?.token) return;
|
||||||
|
|
||||||
|
/* The question this confirmation was raised for. Read from the thread
|
||||||
|
rather than held in state, so approving an older proposal still resends
|
||||||
|
the right question rather than whatever was typed most recently. */
|
||||||
|
const question = lastQuestionRef.current;
|
||||||
|
if (!question) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
setError(null);
|
||||||
|
setPending({ blocks: [], thinking: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
let latest = [];
|
||||||
|
for await (const snapshot of provider.stream({
|
||||||
|
contextId, capability: null, question, facts,
|
||||||
|
agent: agent ? agentRequest(agent, contextId) : null,
|
||||||
|
owliverContext,
|
||||||
|
confirmation: block.token,
|
||||||
|
agentVersion: pinnedVersionRef.current,
|
||||||
|
signal: controller.signal,
|
||||||
|
})) {
|
||||||
|
if (controller.signal.aborted) break;
|
||||||
|
latest = snapshot;
|
||||||
|
setPending({ blocks: snapshot, thinking: false });
|
||||||
|
}
|
||||||
|
if (latest.length) {
|
||||||
|
const next = [...messagesRef.current, { role: 'assistant', blocks: latest }];
|
||||||
|
messagesRef.current = next;
|
||||||
|
persist(next);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.name !== 'AbortError') {
|
||||||
|
setError('That approval could not be completed. Nothing was changed.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPending(null);
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
}, [contextId, facts, agent, owliverContext, persist]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* States something in the thread without a question having been asked.
|
* States something in the thread without a question having been asked.
|
||||||
*
|
*
|
||||||
@@ -859,6 +969,8 @@ export function useConversation({
|
|||||||
send,
|
send,
|
||||||
announce,
|
announce,
|
||||||
stop,
|
stop,
|
||||||
|
/** Approves a proposed write and resumes the run. See `confirm`. */
|
||||||
|
confirm,
|
||||||
reset,
|
reset,
|
||||||
submitFeedback,
|
submitFeedback,
|
||||||
feedback,
|
feedback,
|
||||||
|
|||||||
@@ -29,8 +29,12 @@ export default function HiringAnalytics({ applications, staff }) {
|
|||||||
const totalApps = applications.length;
|
const totalApps = applications.length;
|
||||||
const hireRate = totalApps ? Math.round((hiredApps.length / totalApps) * 100) : 0;
|
const hireRate = totalApps ? Math.round((hiredApps.length / totalApps) * 100) : 0;
|
||||||
|
|
||||||
const avgHireScore = hiredApps.length
|
/* Averaged over the hires that carry a score. A hire can reach 'hired'
|
||||||
? Math.round(hiredApps.reduce((s, a) => s + (a.ai_score || 0), 0) / hiredApps.length)
|
without ever being scored, and counting that as a 0 drags the figure down
|
||||||
|
by the number of people nobody scored rather than by anything about them. */
|
||||||
|
const scoredHires = hiredApps.filter(a => a.ai_score > 0);
|
||||||
|
const avgHireScore = scoredHires.length
|
||||||
|
? Math.round(scoredHires.reduce((s, a) => s + a.ai_score, 0) / scoredHires.length)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const sevenDaysAgo = new Date();
|
const sevenDaysAgo = new Date();
|
||||||
@@ -48,7 +52,7 @@ export default function HiringAnalytics({ applications, staff }) {
|
|||||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">HIRE RATE</div>
|
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">HIRE RATE</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
|
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
|
||||||
<div className="text-2xl font-heading font-bold text-[#333F48]">{avgHireScore}</div>
|
<div className="text-2xl font-heading font-bold text-[#333F48]">{avgHireScore || '—'}</div>
|
||||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">AVG SCORE</div>
|
<div className="text-[10px] text-[#9CA3AF] tracking-wide mt-0.5">AVG SCORE</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
|
<div className="bg-[#F9FAFB] rounded-xl p-3 text-center border border-[#F3F4F6]">
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { Clock, DollarSign, Award, Zap, Scale, Heart } from 'lucide-react';
|
|||||||
|
|
||||||
export default function ImpactMetrics({ applications, interviews, staff: _staff }) {
|
export default function ImpactMetrics({ applications, interviews, staff: _staff }) {
|
||||||
const hired = applications.filter(a => a.status === 'hired');
|
const hired = applications.filter(a => a.status === 'hired');
|
||||||
const screened = applications.filter(a => a.ai_score > 0);
|
/* Named for what it is: the applications the AI actually scored. Elsewhere
|
||||||
|
in the product "screened" means the stage (status <> 'applied'), which is a
|
||||||
|
different and larger set — every saving below is per AI screen performed. */
|
||||||
|
const aiScreened = applications.filter(a => a.ai_score > 0);
|
||||||
|
|
||||||
// 1. Reduce time-to-hire: avg days from application created to hired
|
// 1. Reduce time-to-hire: avg days from application created to hired
|
||||||
const timeToHire = hired.length
|
const timeToHire = hired.length
|
||||||
@@ -20,19 +23,20 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff
|
|||||||
const tthDisplay = timeToHire ? `${timeToHire}d avg` : 'Instant screen';
|
const tthDisplay = timeToHire ? `${timeToHire}d avg` : 'Instant screen';
|
||||||
|
|
||||||
// 2. Lower recruiting costs: $45 per manual screen + $80 per manual interview saved
|
// 2. Lower recruiting costs: $45 per manual screen + $80 per manual interview saved
|
||||||
const costSaved = screened.length * 45 + interviews.length * 80;
|
const costSaved = aiScreened.length * 45 + interviews.length * 80;
|
||||||
|
|
||||||
// 3. Improve quality of hire: avg AI score of hired candidates
|
// 3. Improve quality of hire: avg AI score of hired candidates
|
||||||
const qualityOfHire = hired.length
|
const scoredHires = hired.filter(a => a.ai_score > 0);
|
||||||
? Math.round(hired.reduce((s, a) => s + (a.ai_score || 0), 0) / hired.length)
|
const qualityOfHire = scoredHires.length
|
||||||
|
? Math.round(scoredHires.reduce((s, a) => s + a.ai_score, 0) / scoredHires.length)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// 4. Increase recruiter productivity: hours saved (15 min per AI screen + 30 min per interview)
|
// 4. Increase recruiter productivity: hours saved (15 min per AI screen + 30 min per interview)
|
||||||
const hoursSaved = Math.round((screened.length * 15 + interviews.length * 30) / 60);
|
const hoursSaved = Math.round((aiScreened.length * 15 + interviews.length * 30) / 60);
|
||||||
|
|
||||||
// 5. Standardize hiring decisions: % of applicants with standardized AI scoring applied
|
// 5. Standardize hiring decisions: % of applicants with standardized AI scoring applied
|
||||||
const standardizedPct = applications.length
|
const standardizedPct = applications.length
|
||||||
? Math.round((screened.length / applications.length) * 100)
|
? Math.round((aiScreened.length / applications.length) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// 6. Improve candidate experience: AI responds instantly (interview completion rate as proxy)
|
// 6. Improve candidate experience: AI responds instantly (interview completion rate as proxy)
|
||||||
@@ -53,7 +57,7 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff
|
|||||||
icon: DollarSign,
|
icon: DollarSign,
|
||||||
label: 'Cost Saved',
|
label: 'Cost Saved',
|
||||||
value: `$${costSaved.toLocaleString()}`,
|
value: `$${costSaved.toLocaleString()}`,
|
||||||
sub: `${screened.length} screens · ${interviews.length} interviews automated`,
|
sub: `${aiScreened.length} screens · ${interviews.length} interviews automated`,
|
||||||
color: '#333F48',
|
color: '#333F48',
|
||||||
bg: '#F3F4F6',
|
bg: '#F3F4F6',
|
||||||
},
|
},
|
||||||
@@ -61,7 +65,11 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff
|
|||||||
icon: Award,
|
icon: Award,
|
||||||
label: 'Quality of Hire',
|
label: 'Quality of Hire',
|
||||||
value: qualityOfHire ? `${qualityOfHire}/100` : '—',
|
value: qualityOfHire ? `${qualityOfHire}/100` : '—',
|
||||||
sub: hired.length ? `${hired.length} hired candidates` : 'Awaiting first hire',
|
/* The basis is the scored hires, not every hire, so the caption counts
|
||||||
|
the ones the figure is actually made of. */
|
||||||
|
sub: scoredHires.length
|
||||||
|
? `${scoredHires.length} scored ${scoredHires.length === 1 ? 'hire' : 'hires'}`
|
||||||
|
: hired.length ? 'No hire has been scored' : 'Awaiting first hire',
|
||||||
color: '#0838E0',
|
color: '#0838E0',
|
||||||
bg: '#FEFCE8',
|
bg: '#FEFCE8',
|
||||||
},
|
},
|
||||||
@@ -77,7 +85,7 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff
|
|||||||
icon: Scale,
|
icon: Scale,
|
||||||
label: 'Decisions Standardized',
|
label: 'Decisions Standardized',
|
||||||
value: `${standardizedPct}%`,
|
value: `${standardizedPct}%`,
|
||||||
sub: `${screened.length} of ${applications.length} applicants scored`,
|
sub: `${aiScreened.length} of ${applications.length} applicants scored`,
|
||||||
color: '#0838E0',
|
color: '#0838E0',
|
||||||
bg: '#EEF3FE',
|
bg: '#EEF3FE',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ export default function ScoreDistribution({ applications }) {
|
|||||||
return scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;
|
return scored.length ? Math.round(scored.reduce((s, a) => s + a.ai_score, 0) / scored.length) : 0;
|
||||||
}, [applications]);
|
}, [applications]);
|
||||||
|
|
||||||
const screened = applications.filter(a => a.ai_score > 0).length;
|
/* Deliberately "scored", not "screened": everywhere else screened means the
|
||||||
|
stage (status <> 'applied'), and on this corpus that is 14 against 9 here.
|
||||||
|
Calling both "screened" made this card and the unscreened count on the
|
||||||
|
dashboard fail to add up to the pool. */
|
||||||
|
const scored = applications.filter(a => a.ai_score > 0).length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
<div className="glass-card border border-white/60 rounded-2xl p-6 shadow-sm">
|
||||||
@@ -34,7 +38,9 @@ export default function ScoreDistribution({ applications }) {
|
|||||||
<div className="text-[10px] text-[#9CA3AF] tracking-wide">AVG SCORE</div>
|
<div className="text-[10px] text-[#9CA3AF] tracking-wide">AVG SCORE</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[12px] text-[#6B7280] mb-5">{screened} candidates screened</p>
|
<p className="text-[12px] text-[#6B7280] mb-5">
|
||||||
|
{scored} {scored === 1 ? 'candidate' : 'candidates'} scored
|
||||||
|
</p>
|
||||||
|
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
<BarChart data={data}>
|
<BarChart data={data}>
|
||||||
|
|||||||
@@ -6,13 +6,7 @@
|
|||||||
* up disagreeing with the drawer it opens.
|
* up disagreeing with the drawer it opens.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
|
import { atOrBeyond, HIRED_STATUSES } from '@/lib/hiringRecords';
|
||||||
|
|
||||||
/** Everyone who reached this stage or went past it. */
|
|
||||||
const atOrBeyond = (applications, stage) => {
|
|
||||||
const from = STAGE_ORDER.indexOf(stage);
|
|
||||||
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hiring health.
|
* Hiring health.
|
||||||
@@ -80,7 +74,7 @@ export function buildPosition(posting, applications) {
|
|||||||
const screened = atOrBeyond(apps, 'ai_screened').length;
|
const screened = atOrBeyond(apps, 'ai_screened').length;
|
||||||
const shortlisted = atOrBeyond(apps, 'shortlisted').length;
|
const shortlisted = atOrBeyond(apps, 'shortlisted').length;
|
||||||
const interviews = atOrBeyond(apps, 'interview').length;
|
const interviews = atOrBeyond(apps, 'interview').length;
|
||||||
const hired = apps.filter((a) => a.status === 'hired').length;
|
const hired = apps.filter((a) => HIRED_STATUSES.includes(a.status)).length;
|
||||||
|
|
||||||
const scored = apps.filter((a) => a.ai_score > 0);
|
const scored = apps.filter((a) => a.ai_score > 0);
|
||||||
const qualified = scored.filter((a) => a.ai_score >= 70).length;
|
const qualified = scored.filter((a) => a.ai_score >= 70).length;
|
||||||
|
|||||||
@@ -300,6 +300,16 @@ export function normalizeAgent(raw, { agentId = '', body = '' } = {}) {
|
|||||||
webSearch: data.webSearch === true || data.web_search === true,
|
webSearch: data.webSearch === true || data.web_search === true,
|
||||||
pages: normalizePages(data.pages, { errors }),
|
pages: normalizePages(data.pages, { errors }),
|
||||||
skills: uniqueStrings(data.skills, { where: 'skills', errors, label: 'a skill id' }),
|
skills: uniqueStrings(data.skills, { where: 'skills', errors, label: 'a skill id' }),
|
||||||
|
/* The tools this agent may call, by registry name.
|
||||||
|
|
||||||
|
Skills are guidance the model reads; tools are what it can actually do.
|
||||||
|
An agent with skills and no tools can discuss the work and look nothing
|
||||||
|
up — which is what every agent authored in this editor was, because
|
||||||
|
this field did not exist and the parser dropped `tools:` on the way in.
|
||||||
|
|
||||||
|
The choices come from GET /api/v1/tools rather than a list kept here,
|
||||||
|
so a tool renamed in the backend cannot leave a stale option in a form. */
|
||||||
|
tools: uniqueStrings(data.tools, { where: 'tools', errors, label: 'a tool name' }),
|
||||||
subagents,
|
subagents,
|
||||||
knowledge,
|
knowledge,
|
||||||
starters,
|
starters,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const EMPTY_AGENT_FIELDS = Object.freeze({
|
|||||||
webSearch: false,
|
webSearch: false,
|
||||||
pages: [],
|
pages: [],
|
||||||
skills: [],
|
skills: [],
|
||||||
|
tools: [],
|
||||||
subagents: [],
|
subagents: [],
|
||||||
knowledge: [],
|
knowledge: [],
|
||||||
starters: [],
|
starters: [],
|
||||||
@@ -66,6 +67,7 @@ export function agentFieldsFromSource(source) {
|
|||||||
webSearch: agent.webSearch,
|
webSearch: agent.webSearch,
|
||||||
pages: [...agent.pages],
|
pages: [...agent.pages],
|
||||||
skills: [...agent.skills],
|
skills: [...agent.skills],
|
||||||
|
tools: [...(agent.tools || [])],
|
||||||
subagents: [...agent.subagents],
|
subagents: [...agent.subagents],
|
||||||
knowledge: agent.knowledge.map((k) => ({ ...k })),
|
knowledge: agent.knowledge.map((k) => ({ ...k })),
|
||||||
starters: agent.starters.map((s) => ({ ...s })),
|
starters: agent.starters.map((s) => ({ ...s })),
|
||||||
@@ -116,6 +118,9 @@ export function agentPatch(fields = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (fields.skills !== undefined) patch.skills = listOrRemove(fields.skills);
|
if (fields.skills !== undefined) patch.skills = listOrRemove(fields.skills);
|
||||||
|
/* Capability, as opposed to guidance. Written the same way as skills so the
|
||||||
|
round trip is the same one: form → frontmatter → parser → form. */
|
||||||
|
if (fields.tools !== undefined) patch.tools = listOrRemove(fields.tools);
|
||||||
if (fields.subagents !== undefined) patch.subagents = listOrRemove(fields.subagents);
|
if (fields.subagents !== undefined) patch.subagents = listOrRemove(fields.subagents);
|
||||||
|
|
||||||
if (fields.starters !== undefined) {
|
if (fields.starters !== undefined) {
|
||||||
|
|||||||
181
src/lib/agents/agentStore.js
Normal file
181
src/lib/agents/agentStore.js
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { base44 } from '@/api/base44Client';
|
||||||
|
import { request } from '@/api/httpClient';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { parseAgent } from './registry';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The authored-agent registry, over the API.
|
||||||
|
*
|
||||||
|
* Authored agents used to live in the account's preferences as Markdown. That
|
||||||
|
* persisted — preferences are a real column in a real table — but it persisted
|
||||||
|
* to the wrong place: the runtime resolves an agent from `agent_definitions`
|
||||||
|
* and never reads preferences, so an agent created in the editor showed as
|
||||||
|
* "published" in the list and answered every run with 404.
|
||||||
|
*
|
||||||
|
* This writes to `/api/v1/agent-definitions`, which is the table the runtime
|
||||||
|
* loads from. An agent saved here is one Owliver can actually be asked to run.
|
||||||
|
*
|
||||||
|
* Markdown stays the artefact on both sides, and the same parser reads it in
|
||||||
|
* both processes — `internal/definition` is checked against this one by a
|
||||||
|
* conformance test replaying a capture of the real frontend module graph, so
|
||||||
|
* "the backend understood it differently" is a failing test rather than a
|
||||||
|
* support ticket.
|
||||||
|
*/
|
||||||
|
const KEY = ['agentDefinitions'];
|
||||||
|
|
||||||
|
/** Every authored agent this account can see, newest first. */
|
||||||
|
export function useAgentDefinitions() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: KEY,
|
||||||
|
queryFn: () => base44.entities.AgentDefinition.list('-created_date', 200),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The stored rows as the registry reader wants them.
|
||||||
|
*
|
||||||
|
* `{ path, raw }` is the shape `readAgentRegistry` already takes, so the reader
|
||||||
|
* did not have to learn where definitions come from — only the store changed.
|
||||||
|
*/
|
||||||
|
export function sourcesFrom(rows) {
|
||||||
|
return (rows || [])
|
||||||
|
.filter((r) => r && typeof r.markdown === 'string' && r.markdown.trim())
|
||||||
|
.map((r) => ({ path: `authored/${r.definition_id || r.id}.md`, raw: r.markdown }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update by definition id.
|
||||||
|
*
|
||||||
|
* The id an author writes (`activity-agent`) is not the row's id (a uuid), and
|
||||||
|
* the two endpoints take different ones: the registry is a CRUD resource keyed
|
||||||
|
* by uuid, while a run is addressed by the definition id. Resolving that here
|
||||||
|
* keeps every caller in the author's vocabulary.
|
||||||
|
*/
|
||||||
|
export function useSaveAgentDefinition() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async ({ definitionId, markdown, visibility = 'personal' }) => {
|
||||||
|
const rows = qc.getQueryData(KEY) || (await base44.entities.AgentDefinition.list('-created_date', 200));
|
||||||
|
const existing = (rows || []).find((r) => r.definition_id === definitionId);
|
||||||
|
/* visibility is immutable after creation, so it is sent only on create —
|
||||||
|
patching it back is rejected by the service and would turn a plain save
|
||||||
|
into an error the author cannot act on. */
|
||||||
|
return existing
|
||||||
|
? base44.entities.AgentDefinition.update(existing.id, { markdown })
|
||||||
|
: base44.entities.AgentDefinition.create({ markdown, visibility });
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes the authored definition. A shipped agent returns to its shipped form. */
|
||||||
|
export function useDeleteAgentDefinition() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (definitionId) => {
|
||||||
|
const rows = qc.getQueryData(KEY) || (await base44.entities.AgentDefinition.list('-created_date', 200));
|
||||||
|
const existing = (rows || []).find((r) => r.definition_id === definitionId);
|
||||||
|
if (!existing) return { ok: true };
|
||||||
|
return base44.entities.AgentDefinition.delete(existing.id);
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tools an author may choose from, as the backend reports them.
|
||||||
|
*
|
||||||
|
* Served rather than listed here: a tool renamed or removed in the registry
|
||||||
|
* would otherwise leave a stale option in this form, and the agent built from
|
||||||
|
* it would fail at resolve time with nothing on screen to explain why.
|
||||||
|
*/
|
||||||
|
export function useToolCatalogue() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['toolCatalogue'],
|
||||||
|
queryFn: () => request('GET', '/tools'),
|
||||||
|
/* The tool set changes when the backend is deployed, not while somebody is
|
||||||
|
filling in a form. */
|
||||||
|
staleTime: 10 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Row lookup by the id an author writes, for callers that need the uuid. */
|
||||||
|
export function useRowFor(rows) {
|
||||||
|
return useCallback(
|
||||||
|
(definitionId) => (rows || []).find((r) => r.definition_id === definitionId) || null,
|
||||||
|
[rows]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Memoised sources, so the registry is not rebuilt on every render. */
|
||||||
|
export function useAuthoredSources(rows) {
|
||||||
|
return useMemo(() => sourcesFrom(rows), [rows]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves agents left in the account's preferences into the registry.
|
||||||
|
*
|
||||||
|
* Authored agents used to be stored as `preferences.customAgents`. Reading
|
||||||
|
* moved to the registry, and without this that history would simply stop being
|
||||||
|
* shown: the rows stay in the preferences column, the list no longer reads
|
||||||
|
* them, and an agent somebody wrote disappears with no message. Losing an
|
||||||
|
* author's work quietly is worse than any of the problems this change fixed.
|
||||||
|
*
|
||||||
|
* Runs once per session, and only forward:
|
||||||
|
*
|
||||||
|
* - an id already in the registry is left alone. Re-posting would overwrite
|
||||||
|
* a definition the author may have edited since.
|
||||||
|
* - preferences are cleared only after every write has succeeded, so a failed
|
||||||
|
* migration can be retried rather than having eaten the originals.
|
||||||
|
* - a definition the backend refuses (an unknown tool, say) leaves everything
|
||||||
|
* in place and reports, rather than dropping that one on the floor.
|
||||||
|
*/
|
||||||
|
export function useMigrateStoredAgents({ rows, stored, clearStored, enabled }) {
|
||||||
|
const save = useSaveAgentDefinition();
|
||||||
|
const done = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || done.current || !stored?.length) return;
|
||||||
|
done.current = true;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const known = new Set((rows || []).map((r) => r.definition_id));
|
||||||
|
const failures = [];
|
||||||
|
let moved = 0;
|
||||||
|
|
||||||
|
for (const entry of stored) {
|
||||||
|
const markdown = typeof entry === 'string' ? entry : entry?.raw;
|
||||||
|
if (!markdown) continue;
|
||||||
|
let parsed = null;
|
||||||
|
try {
|
||||||
|
parsed = parseAgent(markdown, { custom: true });
|
||||||
|
} catch {
|
||||||
|
failures.push('a definition that could not be read');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (known.has(parsed.id)) continue;
|
||||||
|
try {
|
||||||
|
await save.mutateAsync({ definitionId: parsed.id, markdown });
|
||||||
|
moved += 1;
|
||||||
|
} catch (error) {
|
||||||
|
failures.push(`${parsed.id}: ${error?.message || 'refused'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
toast.error(`Some stored agents could not be moved. ${failures.join('; ')}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (moved > 0) {
|
||||||
|
await clearStored();
|
||||||
|
toast.success(`${moved} stored agent${moved === 1 ? '' : 's'} moved into the registry.`);
|
||||||
|
} else {
|
||||||
|
/* Nothing to move — every stored id already exists in the registry, so
|
||||||
|
the preferences copy is redundant and can go. */
|
||||||
|
await clearStored();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [enabled, rows, stored, clearStored, save]);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { canonicalPage } from '@/lib/skills/surfaces';
|
import { DOMAIN_SURFACES, canonicalPage } from '@/lib/skills/surfaces';
|
||||||
import { pageKeyForContext } from '@/lib/skills/registry';
|
import { pageKeyForContext } from '@/lib/skills/registry';
|
||||||
import { getAgent } from './registry';
|
import { getAgent } from './registry';
|
||||||
import { reasoningFor } from './vocabulary';
|
import { reasoningFor } from './vocabulary';
|
||||||
@@ -122,19 +122,33 @@ export function agentsForContext(agents = [], contextId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The general agent every page falls back to.
|
* Whether an agent is a *general* one: it covers every domain surface.
|
||||||
*
|
*
|
||||||
* Named once, here, because two different things need it and neither should
|
* Derived from the definition rather than matched against an id. This used to
|
||||||
* carry its own copy: resolving a default, and deciding whether a page has an
|
* be `FALLBACK_AGENT_ID = 'krow-workforce-agent'` — a literal agent key that
|
||||||
* agent *of its own*.
|
* two functions below branched on, which is precisely the thing agent specs
|
||||||
|
* being data is supposed to make impossible. With a key in the runtime,
|
||||||
|
* renaming the general agent silently demotes it, deleting it leaves two dead
|
||||||
|
* branches, and a workspace can never write a second general agent because
|
||||||
|
* only one id is privileged.
|
||||||
|
*
|
||||||
|
* Reading `pages` instead makes it a fact about the spec: an agent listing
|
||||||
|
* every surface that holds workforce records is an agent with no speciality,
|
||||||
|
* which is exactly what makes it the sensible fallback. A general agent may
|
||||||
|
* list *more* than the domain surfaces — the workforce agent also covers the
|
||||||
|
* agent workspace — so this is a subset test, never an equality one.
|
||||||
*/
|
*/
|
||||||
export const FALLBACK_AGENT_ID = 'krow-workforce-agent';
|
export function isGeneralAgent(agent) {
|
||||||
|
if (!agent?.pages?.length) return false;
|
||||||
|
const covered = new Set(agent.pages);
|
||||||
|
return DOMAIN_SURFACES.every((id) => covered.has(id));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The agent written *for* this page, if there is one.
|
* The agent written *for* this page, if there is one.
|
||||||
*
|
*
|
||||||
* The general agent is deliberately excluded. It covers every surface — which
|
* General agents are deliberately excluded. One covers every surface — which is
|
||||||
* is what makes it a fallback — so counting it as a page's own agent would make
|
* what makes it a fallback — so counting it as a page's own agent would make
|
||||||
* "does this page have a native agent?" true everywhere and the distinction
|
* "does this page have a native agent?" true everywhere and the distinction
|
||||||
* meaningless.
|
* meaningless.
|
||||||
*
|
*
|
||||||
@@ -142,20 +156,26 @@ export const FALLBACK_AGENT_ID = 'krow-workforce-agent';
|
|||||||
* a broken one: see `resolveDefaultAgent`.
|
* a broken one: see `resolveDefaultAgent`.
|
||||||
*/
|
*/
|
||||||
export function nativeAgentForContext(agents = [], contextId) {
|
export function nativeAgentForContext(agents = [], contextId) {
|
||||||
return agentsForContext(agents, contextId).find((a) => a.id !== FALLBACK_AGENT_ID) || null;
|
return agentsForContext(agents, contextId).find((a) => !isGeneralAgent(a)) || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The general agent, when it can answer here.
|
* The general agent, when one can answer here.
|
||||||
*
|
*
|
||||||
* Falls through to whichever published agent covers the page if the general one
|
* `agentsForContext` has already narrowed to published agents covering this
|
||||||
* has been archived or does not list this surface — a page must never be left
|
* page and sorted them most-specific-first, so general agents sit at the end
|
||||||
* without an agent because of how the registry happens to be configured.
|
* and the *last* of them is the broadest. Taking that one is identical to the
|
||||||
|
* old behaviour while exactly one general agent exists, and is a stated choice
|
||||||
|
* rather than an arbitrary one once a workspace has written a second.
|
||||||
|
*
|
||||||
|
* Falls through to whichever published agent covers the page when no general
|
||||||
|
* one does — a page must never be left without an agent because of how the
|
||||||
|
* registry happens to be configured.
|
||||||
*/
|
*/
|
||||||
export function fallbackAgentForContext(agents = [], contextId) {
|
export function fallbackAgentForContext(agents = [], contextId) {
|
||||||
const general = getAgent(agents, FALLBACK_AGENT_ID);
|
const covering = agentsForContext(agents, contextId);
|
||||||
if (general && general.status === 'published' && agentCovers(general, contextId)) return general;
|
const general = covering.filter(isGeneralAgent);
|
||||||
return agentsForContext(agents, contextId)[0] || null;
|
return general[general.length - 1] || covering[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks';
|
||||||
|
import {
|
||||||
|
useAgentDefinitions, useAuthoredSources, useDeleteAgentDefinition,
|
||||||
|
useMigrateStoredAgents, useSaveAgentDefinition,
|
||||||
|
} from './agentStore';
|
||||||
import { reportSave } from '@/lib/skills/saveFeedback';
|
import { reportSave } from '@/lib/skills/saveFeedback';
|
||||||
import { AGENTS, parseAgent, readAgentRegistry, validateAgentSource } from './registry';
|
import { AGENTS, parseAgent, readAgentRegistry, validateAgentSource } from './registry';
|
||||||
import { customAgentSource, removeCustomAgent, upsertCustomAgent } from './customAgents';
|
import { customAgentSource } from './customAgents';
|
||||||
import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agentLifecycle';
|
import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agentLifecycle';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,11 +28,36 @@ import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agen
|
|||||||
*/
|
*/
|
||||||
export function useAgents() {
|
export function useAgents() {
|
||||||
const preferences = usePreferences();
|
const preferences = usePreferences();
|
||||||
const updatePreferences = useUpdatePreferences();
|
|
||||||
|
|
||||||
const stored = useMemo(() => preferences.customAgents || [], [preferences.customAgents]);
|
/* Authored agents come from the registry the runtime resolves from, not from
|
||||||
|
this account's preferences. They used to come from preferences, and the
|
||||||
|
consequence was an agent that the list called "published" and every run
|
||||||
|
answered 404: the backend stored it faithfully, in a table the runtime does
|
||||||
|
not read. Skills are still preferences-backed — that is a separate move. */
|
||||||
|
const definitions = useAgentDefinitions();
|
||||||
|
const saveDefinition = useSaveAgentDefinition();
|
||||||
|
const deleteDefinition = useDeleteAgentDefinition();
|
||||||
|
|
||||||
|
const rows = useMemo(() => definitions.data || [], [definitions.data]);
|
||||||
|
const stored = useAuthoredSources(rows);
|
||||||
const customSkills = useMemo(() => preferences.customSkills || [], [preferences.customSkills]);
|
const customSkills = useMemo(() => preferences.customSkills || [], [preferences.customSkills]);
|
||||||
|
|
||||||
|
/* Anything an author wrote before the store moved. Without this it would stop
|
||||||
|
being displayed rather than being carried across — the rows would sit in
|
||||||
|
the preferences column, unread, and the agent would appear to have been
|
||||||
|
deleted. Runs once, and clears the old copy only after every write lands. */
|
||||||
|
const updatePreferences = useUpdatePreferences();
|
||||||
|
const legacy = useMemo(() => preferences.customAgents || [], [preferences.customAgents]);
|
||||||
|
useMigrateStoredAgents({
|
||||||
|
rows,
|
||||||
|
stored: legacy,
|
||||||
|
enabled: !definitions.isPending,
|
||||||
|
clearStored: useCallback(
|
||||||
|
() => updatePreferences.mutateAsync({ customAgents: [] }),
|
||||||
|
[updatePreferences]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
const { agents, diagnostics } = useMemo(
|
const { agents, diagnostics } = useMemo(
|
||||||
() => readAgentRegistry(stored, { customSkills }),
|
() => readAgentRegistry(stored, { customSkills }),
|
||||||
[stored, customSkills]
|
[stored, customSkills]
|
||||||
@@ -63,23 +92,29 @@ export function useAgents() {
|
|||||||
const problem = validateAgentSource(source);
|
const problem = validateAgentSource(source);
|
||||||
if (problem) return { ok: false, error: problem };
|
if (problem) return { ok: false, error: problem };
|
||||||
|
|
||||||
const { agent, next } = upsertCustomAgent(stored, source);
|
const agent = parseAgent(source, { custom: true });
|
||||||
await updatePreferences.mutateAsync(
|
try {
|
||||||
{ customAgents: next },
|
await saveDefinition.mutateAsync({ definitionId: agent.id, markdown: source });
|
||||||
reportSave(message || `${agent.name} saved`)
|
} catch (error) {
|
||||||
);
|
/* The service refuses a definition naming a tool that does not exist, and
|
||||||
|
says which. Surfaced rather than swallowed: the author picked it, and
|
||||||
|
the alternative is an agent quietly missing the capability. */
|
||||||
|
return { ok: false, error: error?.message || 'That agent could not be saved.' };
|
||||||
|
}
|
||||||
|
reportSave(message || `${agent.name} saved`);
|
||||||
return { ok: true, agent };
|
return { ok: true, agent };
|
||||||
}, [stored, updatePreferences]);
|
}, [saveDefinition]);
|
||||||
|
|
||||||
/** Removes the account's definition. A shipped agent returns to its shipped form. */
|
/** Removes the account's definition. A shipped agent returns to its shipped form. */
|
||||||
const remove = useCallback(async (id) => {
|
const remove = useCallback(async (id) => {
|
||||||
const next = removeCustomAgent(stored, id);
|
try {
|
||||||
await updatePreferences.mutateAsync(
|
await deleteDefinition.mutateAsync(id);
|
||||||
{ customAgents: next },
|
} catch (error) {
|
||||||
reportSave(isShipped(id) ? 'Reverted to the shipped definition' : 'Agent removed')
|
return { ok: false, error: error?.message || 'That agent could not be removed.' };
|
||||||
);
|
}
|
||||||
|
reportSave(isShipped(id) ? 'Reverted to the shipped definition' : 'Agent removed');
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}, [stored, updatePreferences, isShipped]);
|
}, [deleteDefinition, isShipped]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publishes, refusing to overwrite a newer published version.
|
* Publishes, refusing to overwrite a newer published version.
|
||||||
@@ -123,7 +158,8 @@ export function useAgents() {
|
|||||||
return {
|
return {
|
||||||
agents,
|
agents,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
saving: updatePreferences.isPending,
|
loading: definitions.isPending,
|
||||||
|
saving: saveDefinition.isPending || deleteDefinition.isPending,
|
||||||
isShipped,
|
isShipped,
|
||||||
isOverridden,
|
isOverridden,
|
||||||
sourceFor,
|
sourceFor,
|
||||||
|
|||||||
@@ -95,8 +95,47 @@ export function summarise(hires) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The application stages this product counts, in the order they happen. */
|
/**
|
||||||
export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
|
* The application stages this product counts, in the order they happen.
|
||||||
|
*
|
||||||
|
* `assigned` sits past `hired`: a candidate assigned to a shift was hired to get
|
||||||
|
* there, which is why the backend counts the two together everywhere it asks how
|
||||||
|
* many people a role actually has (`status IN ('hired','assigned')`).
|
||||||
|
*/
|
||||||
|
export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired', 'assigned'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every value of the `application_status` enum. Declared here so the ladder can
|
||||||
|
* be checked against it: the funnel lost `rejected` and `assigned` for as long
|
||||||
|
* as it did because nothing in the app held the full list to check against.
|
||||||
|
*/
|
||||||
|
export const APPLICATION_STATUSES = [
|
||||||
|
'applied', 'ai_screened', 'shortlisted', 'interview', 'hired', 'rejected', 'assigned',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Counted as hired. `assigned` is hired and then rostered, not a separate fate. */
|
||||||
|
export const HIRED_STATUSES = ['hired', 'assigned'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far a candidate got.
|
||||||
|
*
|
||||||
|
* `rejected` is terminal from any stage and overwrites the stage it was reached
|
||||||
|
* from, so its status alone cannot place it on the ladder. The furthest honest
|
||||||
|
* claim is that somebody assessed them, so it ranks with `ai_screened`: counted
|
||||||
|
* as screened, never counted as shortlisted.
|
||||||
|
*
|
||||||
|
* A status this does not recognise ranks -1 and drops out of every bucket
|
||||||
|
* including `applied` — which is how `rejected` and `assigned` used to vanish
|
||||||
|
* from a position's funnel entirely. The suite checks this covers the schema.
|
||||||
|
*/
|
||||||
|
export const rankOf = (status) =>
|
||||||
|
status === 'rejected' ? STAGE_ORDER.indexOf('ai_screened') : STAGE_ORDER.indexOf(status);
|
||||||
|
|
||||||
|
/** Everyone who reached this stage or went past it. */
|
||||||
|
export const atOrBeyond = (applications, stage) => {
|
||||||
|
const from = STAGE_ORDER.indexOf(stage);
|
||||||
|
return applications.filter((a) => rankOf(a.status) >= from);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applied → screened → shortlisted → interview → hired, with the pass-through
|
* Applied → screened → shortlisted → interview → hired, with the pass-through
|
||||||
@@ -107,17 +146,14 @@ export const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview'
|
|||||||
* later stages as larger than earlier ones, which is not a funnel.
|
* later stages as larger than earlier ones, which is not a funnel.
|
||||||
*/
|
*/
|
||||||
export function buildFunnel(applications) {
|
export function buildFunnel(applications) {
|
||||||
const atOrBeyond = (stage) => {
|
const reached = (stage) => atOrBeyond(applications, stage).length;
|
||||||
const from = STAGE_ORDER.indexOf(stage);
|
|
||||||
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from).length;
|
|
||||||
};
|
|
||||||
|
|
||||||
const stages = [
|
const stages = [
|
||||||
{ key: 'applied', label: 'Applied', count: applications.length },
|
{ key: 'applied', label: 'Applied', count: applications.length },
|
||||||
{ key: 'ai_screened', label: 'Screened', count: atOrBeyond('ai_screened') },
|
{ key: 'ai_screened', label: 'Screened', count: reached('ai_screened') },
|
||||||
{ key: 'shortlisted', label: 'Shortlisted', count: atOrBeyond('shortlisted') },
|
{ key: 'shortlisted', label: 'Shortlisted', count: reached('shortlisted') },
|
||||||
{ key: 'interview', label: 'Interview', count: atOrBeyond('interview') },
|
{ key: 'interview', label: 'Interview', count: reached('interview') },
|
||||||
{ key: 'hired', label: 'Hired', count: applications.filter((a) => a.status === 'hired').length },
|
{ key: 'hired', label: 'Hired', count: applications.filter((a) => HIRED_STATUSES.includes(a.status)).length },
|
||||||
];
|
];
|
||||||
|
|
||||||
const transitions = stages.slice(1).map((stage, i) => {
|
const transitions = stages.slice(1).map((stage, i) => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { base44 } from '@/api/base44Client';
|
import { API_BASE_URL, base44 } from '@/api/base44Client';
|
||||||
import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi';
|
import { generateJobDescription, screenCandidate, matchTalentForJob } from './krowAi';
|
||||||
import { recalcProfilePatch } from './krowScore';
|
import { recalcProfilePatch } from './krowScore';
|
||||||
import { logActivity } from './userTracking';
|
import { logActivity } from './userTracking';
|
||||||
@@ -177,12 +177,141 @@ export function useUserActivity() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What Owliver could usefully be asked on this page.
|
||||||
|
*
|
||||||
|
* The server answers, and the server is the only thing that decides: it filters
|
||||||
|
* the readings against the caller's role and — with nothing typed — ranks them
|
||||||
|
* against the organization's actual state in PostgreSQL. This hook requests and
|
||||||
|
* caches; it does not score, filter or reorder.
|
||||||
|
*
|
||||||
|
* Keyed on the page and the query together, so every distinct composer state
|
||||||
|
* has its own cache entry and typing back to something already asked is
|
||||||
|
* answered without a request. `placeholderData` keeps the previous answer on
|
||||||
|
* screen while the next one is in flight, which is what stops the chip row
|
||||||
|
* emptying and refilling between keystrokes.
|
||||||
|
*
|
||||||
|
* A failure resolves to no suggestions rather than to an error state. The panel
|
||||||
|
* still works with none — the composer is what the reader came for — and an
|
||||||
|
* error banner over a suggestion is a bigger interruption than the suggestion
|
||||||
|
* was worth.
|
||||||
|
*
|
||||||
|
* The untyped request is the one that reads the database, so it is invalidated
|
||||||
|
* whenever something the context counts has changed. See `owliverContextKey`.
|
||||||
|
*/
|
||||||
|
export const owliverContextKey = ['owliverSuggestions'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this backend serves the endpoint at all.
|
||||||
|
*
|
||||||
|
* A deployment lag, not a bug, and it has happened: the route exists in the Go
|
||||||
|
* source and is not yet on the host a given dev server proxies to, which
|
||||||
|
* answers `404` for it and `200` for everything else. Every keystroke would
|
||||||
|
* then be a request that cannot succeed, and no later keystroke can change the
|
||||||
|
* answer — so the first `404` closes the circuit for the rest of the page load.
|
||||||
|
*
|
||||||
|
* Only `404` closes it. A `401` means the session is not signed in, a `500`
|
||||||
|
* means the server had a bad moment, and an unreachable API means it is being
|
||||||
|
* restarted: all three are worth trying again, and treating them as "this
|
||||||
|
* backend does not have the route" would silence suggestions permanently over a
|
||||||
|
* temporary fault. Module-level rather than component state because the fact is
|
||||||
|
* about the backend, not about one panel.
|
||||||
|
*/
|
||||||
|
let routeUnavailable = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Say it once, out loud.
|
||||||
|
*
|
||||||
|
* The circuit breaker below stops the requests; it must not stop the reader
|
||||||
|
* finding out why the chip row is empty. A suggestion that quietly never
|
||||||
|
* arrives is indistinguishable from a page that has nothing to suggest, and
|
||||||
|
* that ambiguity is what makes this fault expensive — it looks like a frontend
|
||||||
|
* bug and it is a deployment lag.
|
||||||
|
*
|
||||||
|
* So the panel degrades to no suggestions, and the console says exactly which
|
||||||
|
* request failed and what would fix it. Nothing is substituted for the missing
|
||||||
|
* answer: there is no fallback list, and no locally-ranked catalogue standing
|
||||||
|
* in for the server's. An empty row is the honest rendering of "the backend
|
||||||
|
* could not tell us".
|
||||||
|
*/
|
||||||
|
function reportMissingRoute() {
|
||||||
|
if (routeUnavailable) return;
|
||||||
|
routeUnavailable = true;
|
||||||
|
console.warn(
|
||||||
|
`[krow] ${API_BASE_URL}/owliver/suggestions answered 404. The backend this ` +
|
||||||
|
'frontend is talking to does not register that route, so Owliver will show ' +
|
||||||
|
'no suggestion chips until it is deployed. This is a backend deployment ' +
|
||||||
|
'version mismatch, not a frontend fault — the route exists in the Go source ' +
|
||||||
|
'(go-api/internal/httpserver/owliver.go). Nothing is being substituted for ' +
|
||||||
|
'the missing answer.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the API what could usefully be asked here.
|
||||||
|
*
|
||||||
|
* The one place the request is made, so the circuit breaker above cannot be
|
||||||
|
* bypassed by a caller that reaches for `base44.owliver` directly. Resolves to
|
||||||
|
* an empty list on any failure: the panel works with no suggestions — the
|
||||||
|
* composer is what the reader came for — and an error banner over a suggestion
|
||||||
|
* is a bigger interruption than the suggestion was worth.
|
||||||
|
*/
|
||||||
|
/** @param {any} request */
|
||||||
|
export async function fetchOwliverSuggestions({ page, query = '' } = {}) {
|
||||||
|
if (!page || routeUnavailable) return [];
|
||||||
|
try {
|
||||||
|
return await base44.owliver.suggestions({ page, query });
|
||||||
|
} catch (error) {
|
||||||
|
if (error?.status === 404) reportMissingRoute();
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What Owliver could usefully be asked on this page.
|
||||||
|
*
|
||||||
|
* The server answers, and the server is the only thing that decides: it filters
|
||||||
|
* the readings against the caller's role and — with nothing typed — ranks them
|
||||||
|
* against the organization's actual state in PostgreSQL. This hook requests and
|
||||||
|
* caches; it does not score, filter or reorder.
|
||||||
|
*
|
||||||
|
* Keyed on the page and the query together, so every distinct composer state
|
||||||
|
* has its own cache entry and typing back to something already asked is
|
||||||
|
* answered without a request. `placeholderData` keeps the previous answer on
|
||||||
|
* screen while the next one is in flight, which is what stops the chip row
|
||||||
|
* emptying and refilling between keystrokes.
|
||||||
|
*
|
||||||
|
* The untyped request is the one that reads the database, so it is invalidated
|
||||||
|
* whenever something the context counts has changed. See `owliverContextKey`.
|
||||||
|
*/
|
||||||
|
/** @param {any} request */
|
||||||
|
export function useOwliverSuggestions({ page, query = '', enabled = true } = {}) {
|
||||||
|
const typed = String(query || '').trim();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['owliverSuggestions', page || null, typed],
|
||||||
|
queryFn: () => fetchOwliverSuggestions({ page, query: typed }),
|
||||||
|
enabled: Boolean(page) && enabled,
|
||||||
|
/* Long enough that a keystroke returned to is instant, short enough that a
|
||||||
|
position created in another tab is reflected on the next open. */
|
||||||
|
staleTime: 30_000,
|
||||||
|
placeholderData: (previous) => previous,
|
||||||
|
/* `fetchOwliverSuggestions` never rejects, so a retry would only repeat a
|
||||||
|
request that already resolved. Said explicitly so the default of one
|
||||||
|
retry does not read as a safety net that is doing something. */
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useCreateJobPosting() {
|
export function useCreateJobPosting() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data),
|
mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
||||||
|
/* The position count the server ranks suggestions against has changed, so
|
||||||
|
what Owliver offers next has to be asked again rather than read from a
|
||||||
|
cache written before the record existed. */
|
||||||
|
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||||
logActivity('create_position');
|
logActivity('create_position');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -195,6 +324,7 @@ export function useUpdateJobPosting() {
|
|||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['jobPosting', variables.id] });
|
queryClient.invalidateQueries({ queryKey: ['jobPosting', variables.id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -205,6 +335,7 @@ export function useCreateApplication() {
|
|||||||
mutationFn: /** @param {any} data */ (data) => base44.entities.JobApplication.create(data),
|
mutationFn: /** @param {any} data */ (data) => base44.entities.JobApplication.create(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||||
logActivity('apply_job');
|
logActivity('apply_job');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -216,6 +347,7 @@ export function useUpdateApplication() {
|
|||||||
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobApplication.update(id, data),
|
mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobApplication.update(id, data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -238,6 +370,7 @@ export function useScreenCandidate() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||||
logActivity('screen_candidate');
|
logActivity('screen_candidate');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -268,6 +401,7 @@ export function useScreenAllCandidates() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||||
logActivity('screen_candidate');
|
logActivity('screen_candidate');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -326,6 +460,7 @@ export function useHireCandidate() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||||
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||||
/* The `hire_candidate` entry is written inside the same transaction as the
|
/* The `hire_candidate` entry is written inside the same transaction as the
|
||||||
hire, so there is nothing to log here — only something to refetch. A
|
hire, so there is nothing to log here — only something to refetch. A
|
||||||
@@ -358,6 +493,7 @@ export function useCreateInterview() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['interviews'] });
|
queryClient.invalidateQueries({ queryKey: ['interviews'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||||
logActivity('start_interview');
|
logActivity('start_interview');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -507,6 +643,7 @@ export function useAssignWorkers() {
|
|||||||
looking at three different moments. */
|
looking at three different moments. */
|
||||||
queryClient.invalidateQueries({ queryKey: ['assignments'] });
|
queryClient.invalidateQueries({ queryKey: ['assignments'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||||
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
queryClient.invalidateQueries({ queryKey: ['jobPostings'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
|
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
|
||||||
},
|
},
|
||||||
@@ -549,6 +686,7 @@ export function useMarkInterviewReady() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: owliverContextKey });
|
||||||
queryClient.invalidateQueries({ queryKey: ['interviews'] });
|
queryClient.invalidateQueries({ queryKey: ['interviews'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
|
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,18 @@
|
|||||||
const clamp = (n) => Math.max(0, Math.min(100, n));
|
const clamp = (n) => Math.max(0, Math.min(100, n));
|
||||||
|
|
||||||
export function computeKrowScore(profile) {
|
export function computeKrowScore(profile) {
|
||||||
const attendance = clamp(profile.attendance_score ?? 100);
|
/**
|
||||||
|
* Attendance defaults to 100 for *display*: an unrated worker shows a full bar
|
||||||
|
* rather than an accusatory 0. That default must not become a scoring input.
|
||||||
|
*
|
||||||
|
* With no shifts behind it, attendance is an assumption, and it carries 30% of
|
||||||
|
* reliability and 12% of the KROW score — enough on its own to lift a profile
|
||||||
|
* with no evidence whatsoever from 0 to 12, out of "Not yet scored" and into
|
||||||
|
* the low-scoring band, where every ranking then reads it as a poor worker
|
||||||
|
* rather than an unassessed one. `shifts_completed` is the record it needs.
|
||||||
|
*/
|
||||||
|
const hasAttendanceRecord = (profile.shifts_completed || 0) > 0;
|
||||||
|
const attendance = hasAttendanceRecord ? clamp(profile.attendance_score ?? 100) : 0;
|
||||||
const performance = clamp(profile.performance_score ?? 0);
|
const performance = clamp(profile.performance_score ?? 0);
|
||||||
const education = clamp(
|
const education = clamp(
|
||||||
(profile.completed_courses?.length || 0) * 12 +
|
(profile.completed_courses?.length || 0) * 12 +
|
||||||
@@ -34,7 +45,20 @@ export function computeKrowScore(profile) {
|
|||||||
return {
|
return {
|
||||||
krow_score,
|
krow_score,
|
||||||
reliability,
|
reliability,
|
||||||
breakdown: { attendance, performance, education, clientReviews, supervisorReviews, growth, experience },
|
/* Rounded for output only — the weighted sums above use the exact values.
|
||||||
|
These are percentages a person reads, and (4.4 / 5) * 100 is
|
||||||
|
88.00000000000001 in binary floating point. */
|
||||||
|
breakdown: {
|
||||||
|
/* null, not 0, where there is no record — the product's own rule for an
|
||||||
|
absent dimension, so a reader renders "—" instead of a flat zero. */
|
||||||
|
attendance: hasAttendanceRecord ? Math.round(attendance) : null,
|
||||||
|
performance: Math.round(performance),
|
||||||
|
education: Math.round(education),
|
||||||
|
clientReviews: Math.round(clientReviews),
|
||||||
|
supervisorReviews: Math.round(supervisorReviews),
|
||||||
|
growth: Math.round(growth),
|
||||||
|
experience: Math.round(experience),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +74,11 @@ export function computeProfileCompletion(profile) {
|
|||||||
if (profile.completed_courses?.length) filled++;
|
if (profile.completed_courses?.length) filled++;
|
||||||
if (profile.earned_badges?.length) filled++;
|
if (profile.earned_badges?.length) filled++;
|
||||||
if (profile.ai_interview_score > 0) filled++;
|
if (profile.ai_interview_score > 0) filled++;
|
||||||
const total = CORE_FIELDS.length + 5;
|
/* Seven optional fields can increment above, not five: languages,
|
||||||
|
availability, skills, experience, completed_courses, earned_badges and the
|
||||||
|
interview score. With a denominator of five the percentage ran past 100 —
|
||||||
|
the most complete seeded profile computed 107% complete. */
|
||||||
|
const total = CORE_FIELDS.length + 7;
|
||||||
return Math.round((filled / total) * 100);
|
return Math.round((filled / total) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { SUPPORTED_PERIODS, periodLabel } from './surfaces';
|
import { SUPPORTED_PERIODS, periodLabel } from './surfaces';
|
||||||
|
import { atOrBeyond, HIRED_STATUSES } from '@/lib/hiringRecords';
|
||||||
import { CRITERIA_LABELS } from '@/lib/positionModel';
|
import { CRITERIA_LABELS } from '@/lib/positionModel';
|
||||||
import { poolFor } from '@/lib/workforce';
|
import { poolFor } from '@/lib/workforce';
|
||||||
import { candidateRoute } from './workforceFlow';
|
import { candidateRoute } from './workforceFlow';
|
||||||
@@ -108,13 +109,8 @@ function matchDetail(row) {
|
|||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The application stages this product counts, in the order they happen. */
|
/* The stage ladder lives in hiringRecords: one derivation, so a skill and the
|
||||||
const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired'];
|
page it is read beside cannot disagree about how many were screened. */
|
||||||
|
|
||||||
const atOrBeyond = (applications, stage) => {
|
|
||||||
const from = STAGE_ORDER.indexOf(stage);
|
|
||||||
return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Applications counted by period — the reading behind an activity section. */
|
/** Applications counted by period — the reading behind an activity section. */
|
||||||
function activityOverTime(applications, periods, now) {
|
function activityOverTime(applications, periods, now) {
|
||||||
@@ -152,7 +148,7 @@ function pipelineOf(applications) {
|
|||||||
{ id: 'screened', label: 'Screened', value: atOrBeyond(applications, 'ai_screened').length },
|
{ id: 'screened', label: 'Screened', value: atOrBeyond(applications, 'ai_screened').length },
|
||||||
{ id: 'shortlisted', label: 'Shortlisted', value: atOrBeyond(applications, 'shortlisted').length },
|
{ id: 'shortlisted', label: 'Shortlisted', value: atOrBeyond(applications, 'shortlisted').length },
|
||||||
{ id: 'interview', label: 'Interview', value: atOrBeyond(applications, 'interview').length },
|
{ id: 'interview', label: 'Interview', value: atOrBeyond(applications, 'interview').length },
|
||||||
{ id: 'hired', label: 'Hired', value: applications.filter((a) => a.status === 'hired').length },
|
{ id: 'hired', label: 'Hired', value: applications.filter((a) => HIRED_STATUSES.includes(a.status)).length },
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -304,7 +300,7 @@ const RESOLVERS = {
|
|||||||
...interviews
|
...interviews
|
||||||
.filter((i) => i.application_id === candidate?.id)
|
.filter((i) => i.application_id === candidate?.id)
|
||||||
.map((i) => ({ id: i.id, title: 'Interview', detail: i.status, at: i.created_date })),
|
.map((i) => ({ id: i.id, title: 'Interview', detail: i.status, at: i.created_date })),
|
||||||
candidate?.status === 'hired' && {
|
HIRED_STATUSES.includes(candidate?.status) && {
|
||||||
id: 'hired', title: 'Hired', detail: candidate.job_title, at: candidate.updated_date,
|
id: 'hired', title: 'Hired', detail: candidate.job_title, at: candidate.updated_date,
|
||||||
},
|
},
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
@@ -437,7 +433,7 @@ const RESOLVERS = {
|
|||||||
* applications and the staff records they became, never stored.
|
* applications and the staff records they became, never stored.
|
||||||
*/
|
*/
|
||||||
'hires.performance': ({ applications = [], staff = [] }) => {
|
'hires.performance': ({ applications = [], staff = [] }) => {
|
||||||
const hired = applications.filter((a) => a.status === 'hired');
|
const hired = applications.filter((a) => HIRED_STATUSES.includes(a.status));
|
||||||
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
|
const scores = applications.map((a) => a.ai_score).filter((n) => n > 0);
|
||||||
const days = hired
|
const days = hired
|
||||||
.map((a) => Math.round((new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / 86400000))
|
.map((a) => Math.round((new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / 86400000))
|
||||||
|
|||||||
@@ -44,7 +44,19 @@ const PER_SKILL = 3;
|
|||||||
const TOTAL = 4;
|
const TOTAL = 4;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The chips a page's skills offer.
|
* The chips a page's skills *declare*.
|
||||||
|
*
|
||||||
|
* NOT what the panel renders, and no longer on the production path. Which
|
||||||
|
* suggestions Owliver offers is decided by `GET /api/v1/owliver/suggestions` —
|
||||||
|
* the backend filters against the caller's role and ranks against the rows in
|
||||||
|
* PostgreSQL, and `lib/skills/serverSuggestions.js` turns the reply into chips.
|
||||||
|
* Nothing in `src/` ranks a suggestion any more.
|
||||||
|
*
|
||||||
|
* What is left here is the reading of a *definition*: given a skill file, what
|
||||||
|
* did its author write under `owliver.suggestions`, and which of those name a
|
||||||
|
* capability the skill actually offers. That is a question about the Markdown
|
||||||
|
* rather than about the workspace, and it is what the editor's preview and the
|
||||||
|
* definition checks in `scripts/skill-check.mjs` ask.
|
||||||
*
|
*
|
||||||
* Capped deliberately. A workspace with six skills attached would otherwise
|
* Capped deliberately. A workspace with six skills attached would otherwise
|
||||||
* bury the page's own suggestions under twenty of them, and a suggestion nobody
|
* bury the page's own suggestions under twenty of them, and a suggestion nobody
|
||||||
@@ -365,52 +377,3 @@ export function presentable(data) {
|
|||||||
export const CAPABILITY_SUMMARIES = OWLIVER_CAPABILITIES.map(
|
export const CAPABILITY_SUMMARIES = OWLIVER_CAPABILITIES.map(
|
||||||
({ id, label, summary }) => ({ id, label, summary })
|
({ id, label, summary }) => ({ id, label, summary })
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ── Suggestions for a record that has just appeared ────────────────────── */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What can now be asked about a record the conversation just produced.
|
|
||||||
*
|
|
||||||
* Creating a position is the moment "who could do this?" becomes worth asking,
|
|
||||||
* and the panel is the only thing that knows a position now exists. Rather than
|
|
||||||
* naming a skill to offer — which would put a candidate-matching feature inside
|
|
||||||
* the position-creation flow — this asks the registry the general question: of
|
|
||||||
* the skills attached to this page, which declare a capability whose reading is
|
|
||||||
* *about one position*? Those are exactly the ones that can say something about
|
|
||||||
* the record just made.
|
|
||||||
*
|
|
||||||
* The record's own title is appended to each prompt, so the answer resolves
|
|
||||||
* against it directly and the reader is never asked to pick from a list that
|
|
||||||
* includes the position they are looking at. Nothing is named here: a skill
|
|
||||||
* added tomorrow that reads a position is offered on the same terms.
|
|
||||||
*/
|
|
||||||
export function suggestionsForPosition(contextId, disabled = [], customSources = [], position) {
|
|
||||||
if (!position?.title) return [];
|
|
||||||
|
|
||||||
const chips = [];
|
|
||||||
|
|
||||||
for (const skill of owliverSkillsForContext(contextId, disabled, customSources)) {
|
|
||||||
/* Only capabilities that read one position — a workspace-wide reading has
|
|
||||||
nothing to do with the record that was just created. */
|
|
||||||
const scoped = skill.owliver.capabilities.filter(
|
|
||||||
(capability) => skill.owliver.responses[capability]?.context === 'positionId'
|
|
||||||
);
|
|
||||||
if (!scoped.length) continue;
|
|
||||||
|
|
||||||
const offered = skill.owliver.suggestions
|
|
||||||
.filter((s) => !s.capability || scoped.includes(s.capability))
|
|
||||||
.slice(0, 1)
|
|
||||||
.map((s) => ({
|
|
||||||
label: s.label,
|
|
||||||
/* Named, so the reading resolves against this position rather than
|
|
||||||
asking which one. */
|
|
||||||
prompt: `${s.prompt} for ${position.title}`,
|
|
||||||
skillId: skill.id,
|
|
||||||
skillCapability: s.capability || null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
chips.push(...offered);
|
|
||||||
}
|
|
||||||
|
|
||||||
return chips.slice(0, 2);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -477,10 +477,25 @@ export function positionCreatedReply(position) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The write failed. The draft is kept, so the answers are not lost. */
|
/**
|
||||||
export function positionFailedReply() {
|
* The write failed. The draft is kept, so the answers are not lost.
|
||||||
|
*
|
||||||
|
* The server's own message is said out loud when there is one. This used to be
|
||||||
|
* a fixed sentence, and a fixed sentence is the wrong answer to three different
|
||||||
|
* failures: a validation error naming a field, a permission refusal, and an API
|
||||||
|
* that is not running all read as "I could not create that position", leaving
|
||||||
|
* the reader to guess which of the three they are looking at and what to change.
|
||||||
|
*
|
||||||
|
* The message comes from `KrowApiError.message`, which `httpClient` sets to the
|
||||||
|
* server's wording verbatim — so the reason is the API's, not one invented here
|
||||||
|
* from a status code.
|
||||||
|
*/
|
||||||
|
export function positionFailedReply(reason = null) {
|
||||||
|
const said = String(reason || '').trim();
|
||||||
|
|
||||||
return doc(
|
return doc(
|
||||||
text('I could not create that position.'),
|
text('I could not create that position.'),
|
||||||
|
said ? note(said) : null,
|
||||||
note('Nothing was saved. Choose Create position to try again.')
|
note('Nothing was saved. Choose Create position to try again.')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
93
src/lib/skills/serverSuggestions.js
Normal file
93
src/lib/skills/serverSuggestions.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import { getContext } from '@/components/ai-assistant/contexts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The server's suggestions, as chips the panel can already run.
|
||||||
|
*
|
||||||
|
* This module is the whole of the frontend's part in suggestions, and it is
|
||||||
|
* deliberately small. `GET /api/v1/owliver/suggestions` decides *which*
|
||||||
|
* questions are worth offering and *in what order* — against the caller's role
|
||||||
|
* and, when nothing has been typed, against the organization's actual state in
|
||||||
|
* PostgreSQL. Nothing here re-decides any of that. There is no score, no
|
||||||
|
* threshold, no reordering and no filter: the list arrives ranked and leaves in
|
||||||
|
* the same order it arrived.
|
||||||
|
*
|
||||||
|
* What is left to do is a translation, and only one. A suggestion names an
|
||||||
|
* `intent`, which is the id of one of the page's own capabilities — the same id
|
||||||
|
* the manifests in `components/ai-assistant/capabilities/` declare, held to the
|
||||||
|
* server's catalogue by `TestIntentIDsAreFrontendCapabilities`. A chip carrying
|
||||||
|
* that id in its `capability` field runs that capability directly, through the
|
||||||
|
* path a page chip has always taken. So this maps `intent` onto `capability`
|
||||||
|
* and hands the chip back.
|
||||||
|
*
|
||||||
|
* An `intent` the page does not declare is not dropped and not guessed at: the
|
||||||
|
* chip is handed over as an ordinary question, and the existing intent routing
|
||||||
|
* answers it exactly as the same words typed by hand would be. One path, and a
|
||||||
|
* server that names something this build has not shipped yet degrades to asking
|
||||||
|
* rather than to a dead chip.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The capability ids a page context declares.
|
||||||
|
*
|
||||||
|
* Read from the same manifest the panel dispatches on, so "can this chip run
|
||||||
|
* directly" is answered by the thing that would have to run it rather than by a
|
||||||
|
* list kept alongside.
|
||||||
|
*/
|
||||||
|
function capabilityIds(contextId) {
|
||||||
|
const context = getContext(contextId);
|
||||||
|
return new Set((context?.capabilities || []).map((capability) => capability.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One server suggestion as a chip.
|
||||||
|
*
|
||||||
|
* `label` and `prompt` are both the server's text. They are the same string on
|
||||||
|
* purpose: the text *is* the question, so showing one wording and sending
|
||||||
|
* another would mean the reader clicked something other than what ran.
|
||||||
|
*
|
||||||
|
* `shape` carries the server's `capability` — the section type the query asked
|
||||||
|
* to be drawn as ("as a flow", "as a table"). It rides along for the renderer
|
||||||
|
* and is deliberately not merged into `capability`, which names the reading
|
||||||
|
* rather than its drawing; conflating the two would dispatch a question about
|
||||||
|
* hiring activity to a capability called `flow` that does not exist.
|
||||||
|
*/
|
||||||
|
export function suggestionChip(suggestion, declared) {
|
||||||
|
const text = String(suggestion?.text || '').trim();
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
const intent = String(suggestion?.intent || '').trim();
|
||||||
|
const shape = String(suggestion?.capability || '').trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: text,
|
||||||
|
prompt: text,
|
||||||
|
/* Only when the page can actually run it. Anything else is asked. */
|
||||||
|
...(intent && declared.has(intent) ? { capability: intent } : {}),
|
||||||
|
...(shape ? { shape } : {}),
|
||||||
|
/* Provenance, so a chip that came from the API is distinguishable from a
|
||||||
|
follow-up an answer raised. Nothing dispatches on it; it exists because
|
||||||
|
"where did this suggestion come from" is the first question anyone asks
|
||||||
|
of a suggestion that looks wrong. */
|
||||||
|
source: 'api',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A whole response, in the order the server ranked it.
|
||||||
|
*
|
||||||
|
* Returns a new array only when there is something in it, so an empty answer is
|
||||||
|
* referentially stable and a composer that matches nothing does not rerender
|
||||||
|
* the chip row on every keystroke.
|
||||||
|
*/
|
||||||
|
const NONE = [];
|
||||||
|
|
||||||
|
export function suggestionChips(suggestions = [], contextId = null) {
|
||||||
|
if (!Array.isArray(suggestions) || !suggestions.length) return NONE;
|
||||||
|
|
||||||
|
const declared = capabilityIds(contextId);
|
||||||
|
const chips = suggestions
|
||||||
|
.map((suggestion) => suggestionChip(suggestion, declared))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return chips.length ? chips : NONE;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { agentTemplate } from '@/lib/agents/customAgents';
|
|||||||
import { agentFieldsFromSource, applyAgentFields } from '@/lib/agents/agentFields';
|
import { agentFieldsFromSource, applyAgentFields } from '@/lib/agents/agentFields';
|
||||||
import { validateAgentSource } from '@/lib/agents/registry';
|
import { validateAgentSource } from '@/lib/agents/registry';
|
||||||
import { AgentConfigure } from '@/components/agents/AgentConfigure';
|
import { AgentConfigure } from '@/components/agents/AgentConfigure';
|
||||||
|
import { useToolCatalogue } from '@/lib/agents/agentStore';
|
||||||
import { AgentSkillWorkspace } from '@/components/agents/skills/AgentSkillWorkspace';
|
import { AgentSkillWorkspace } from '@/components/agents/skills/AgentSkillWorkspace';
|
||||||
import { AgentTestPanel } from '@/components/agents/AgentTestPanel';
|
import { AgentTestPanel } from '@/components/agents/AgentTestPanel';
|
||||||
import { AgentInsightsPanel } from '@/components/agents/AgentInsightsPanel';
|
import { AgentInsightsPanel } from '@/components/agents/AgentInsightsPanel';
|
||||||
@@ -81,6 +82,10 @@ export default function AdminAgentDetail() {
|
|||||||
agents, saving, isShipped, isOverridden, sourceFor, save, publish,
|
agents, saving, isShipped, isOverridden, sourceFor, save, publish,
|
||||||
} = useAgents();
|
} = useAgents();
|
||||||
|
|
||||||
|
/* The tools this deployment registers. Fetched once and cached: the set
|
||||||
|
changes when the backend is deployed, not while a form is open. */
|
||||||
|
const { data: toolCatalogue = [] } = useToolCatalogue();
|
||||||
|
|
||||||
const creating = !id;
|
const creating = !id;
|
||||||
|
|
||||||
/* The definition this screen started from: a stored or shipped one, or a
|
/* The definition this screen started from: a stored or shipped one, or a
|
||||||
@@ -421,6 +426,14 @@ export default function AdminAgentDetail() {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onOpenSkills={() => setView('skills')}
|
onOpenSkills={() => setView('skills')}
|
||||||
scopeLocked={shipped}
|
scopeLocked={shipped}
|
||||||
|
/* The same write path the catalog uses. Configure now lists skills
|
||||||
|
beside every other capability, and both screens agree about when
|
||||||
|
one takes effect. */
|
||||||
|
onToggleSkill={onToggleSkill}
|
||||||
|
pendingSkill={pendingSkill}
|
||||||
|
/* What this agent may actually call. Served by the backend, so the
|
||||||
|
options are the tools that exist rather than a copy kept here. */
|
||||||
|
toolCatalogue={toolCatalogue}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{view === 'skills' && (
|
{view === 'skills' && (
|
||||||
|
|||||||
@@ -140,9 +140,14 @@ export default function AdminTalentPool() {
|
|||||||
...s,
|
...s,
|
||||||
count: s.members.length,
|
count: s.members.length,
|
||||||
available: s.members.filter((p) => (p.availability || []).length > 0).length,
|
available: s.members.filter((p) => (p.availability || []).length > 0).length,
|
||||||
avgExperience: s.members.length
|
avgExperience: (() => {
|
||||||
? Math.round(s.members.reduce((sum, p) => sum + (p.experience_years || 0), 0) / s.members.length)
|
/* 0 years is "not stated", not "first year" — the cards that show it
|
||||||
: 0,
|
gate on experience_years > 0, so the average is over the same set. */
|
||||||
|
const stated = s.members.filter((p) => (p.experience_years || 0) > 0);
|
||||||
|
return stated.length
|
||||||
|
? Math.round(stated.reduce((sum, p) => sum + p.experience_years, 0) / stated.length)
|
||||||
|
: 0;
|
||||||
|
})(),
|
||||||
}));
|
}));
|
||||||
}, [profiles]);
|
}, [profiles]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user