diff --git a/.env b/.env index e767daa..838819a 100644 --- a/.env +++ b/.env @@ -1,41 +1,19 @@ -# ============================================================================ -# Krow frontend — example environment +# Local development against the deployed backend. # -# Copy to .env and adjust. .env is gitignored. -# -# cp .env.example .env -# -# 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. +# 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 +# session cookie (Secure, SameSite=Lax, host-scoped) working in the browser. 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 + +# 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 diff --git a/.env.example b/.env.example index 243c562..82957fe 100644 --- a/.env.example +++ b/.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 # client code. 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= diff --git a/.gitignore b/.gitignore index f2a82a4..a0dc7e2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 # location is documented, and a checkout with no .env needs it. diff --git a/Dockerfile b/Dockerfile index 76e626b..702b4b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,21 @@ COPY . . ARG VITE_API_BASE_URL=https://mcp.krowforce.com/api/v1 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 # ---- Runtime stage ---- diff --git a/docs/api-audit.md b/docs/api-audit.md index e47113b..cc6bfa4 100644 --- a/docs/api-audit.md +++ b/docs/api-audit.md @@ -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 the backend contract's §2A. -**Called, registered in backend source, and NOT deployed.** This is the one row -in this document where those three come apart, so it is worth stating plainly: +**Called, registered in backend source, and NOT deployed on every host.** This +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` | -| Backend source registers it | `go-api/internal/httpserver/owliver.go:22`, commit `b6f8655`, pushed to `origin/main` | -| `https://mcp.krowforce.com` serves it | **No — 404** | +| 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` | +| `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 `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 route is not on that backend. -The panel treats the two differently as of `useSuggestions.js`: a `404` closes a -circuit breaker and it stops asking for the rest of the page load, because no -further keystroke can change the answer. A `401`, a `500` and an unreachable API -are all still retried. +The panel treats the two differently, in `fetchOwliverSuggestions` +(`src/lib/krowHooks.js`): a `404` closes a circuit breaker and it stops asking +for the rest of the page load, because no further keystroke can change the +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 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 | | --- | --- | | `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/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/store.js`~~ | **Fixed.** The file is deleted. It was 173 lines and imported by nothing. | +| ~~`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/api/httpClient.js:96` | `User: 'users'` names a resource the backend does not have. No call site, so harmless today. | diff --git a/package.json b/package.json index 64f5f36..b990f1f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "lint": "eslint . --quiet", "lint:fix": "eslint . --fix", "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", "preview": "vite preview" }, diff --git a/scripts/__baseline__/README.md b/scripts/__baseline__/README.md new file mode 100644 index 0000000..0f261e0 --- /dev/null +++ b/scripts/__baseline__/README.md @@ -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. diff --git a/scripts/__baseline__/owliver-baseline.json b/scripts/__baseline__/owliver-baseline.json index add7667..cb39bee 100644 --- a/scripts/__baseline__/owliver-baseline.json +++ b/scripts/__baseline__/owliver-baseline.json @@ -2,16 +2,29 @@ "schema": 1, "today": "2026-08-20T09:00:00.000Z", "skillIds": [ + "activity-analysis", "analytics-insights", + "anomaly-detection", + "attendance-analysis", "bartending-training", + "candidate-analysis", "candidate-search", "create-position", "customer-service-training", + "executive-summary", "food-safety-training", "forge-skill-management", "hiring-activity-assistant", + "hiring-history-analysis", + "hiring-pulse-analysis", "leadership-training", - "server-training" + "learning-analysis", + "operational-risk", + "overtime-analysis", + "server-training", + "staffing-risk", + "talent-pool-analysis", + "workforce-analytics" ], "diagnostics": [], "routes": { @@ -24,15 +37,35 @@ "/admin/positions": "admin.positions", "/admin/positions/new": "admin.createPosition", "/admin/profile": "admin.profile", + "/admin/settings": "admin.settings", "/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": { "admin.controlCenter": { "pageKey": "control-center", "route": "/admin", - "skills": [], - "suggestions": [], + "skills": [ + "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": [ "What needs attention?", "Platform health", @@ -118,7 +151,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] } @@ -129,16 +162,19 @@ "route": "/admin/positions", "skills": [ "create-position", - "hiring-activity-assistant" + "hiring-activity-assistant", + "staffing-risk" ], "suggestions": [ "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": [ "What needs attention?", "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" ], "intents": [ @@ -162,7 +198,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -226,7 +262,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] } @@ -254,7 +290,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -278,7 +314,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -328,7 +364,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] } @@ -338,14 +374,19 @@ "pageKey": "candidates", "route": "/admin/candidates", "skills": [ + "candidate-analysis", "candidate-search" ], - "suggestions": [], + "suggestions": [ + "How strong is the candidate pool?", + "Score bands", + "Show candidates by score" + ], "prompts": [ "1 waiting on a decision", "Who are the strongest candidates?", "Which candidates are ready for interview?", - "10 have no score", + "9 have no score", "Summarize the candidate pipeline", "Identify candidate risks" ], @@ -380,7 +421,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -427,7 +468,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] } @@ -437,13 +478,18 @@ "pageKey": "candidates-analysis", "route": "/admin/candidates-analysis", "skills": [ + "candidate-analysis", "candidate-search" ], - "suggestions": [], + "suggestions": [ + "How strong is the candidate pool?", + "Score bands", + "Show candidates by score" + ], "prompts": [ "Compare Chef & Marcus", "1 missing credentials", - "10 unscored applicants", + "9 unscored applicants", "Recommend next actions", "Recruitment insights" ], @@ -458,7 +504,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -472,7 +518,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -486,7 +532,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -536,7 +582,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] } @@ -545,8 +591,13 @@ "admin.hiredHistory": { "pageKey": "hired-history", "route": "/admin/hired", - "skills": [], - "suggestions": [], + "skills": [ + "hiring-history-analysis" + ], + "suggestions": [ + "How is hiring performance?", + "Who did we hire recently?" + ], "prompts": [ "3 hires unrated", "Bartender leads on hires", @@ -565,7 +616,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -579,7 +630,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -593,7 +644,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -646,7 +697,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] } @@ -655,8 +706,14 @@ "admin.talentPool": { "pageKey": "talent-pool", "route": "/admin/talent-pool", - "skills": [], - "suggestions": [], + "skills": [ + "talent-pool-analysis" + ], + "suggestions": [ + "How healthy is the talent pool?", + "Strongest people in the pool", + "Who is in the pool?" + ], "prompts": [ "Why does Maria rank first?", "4 profiles unscored", @@ -675,7 +732,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -699,7 +756,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -752,7 +809,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] } @@ -762,9 +819,13 @@ "pageKey": "krow-forge", "route": "/admin/university", "skills": [ - "forge-skill-management" + "forge-skill-management", + "learning-analysis" + ], + "suggestions": [ + "How is training progressing?", + "What does the library hold?" ], - "suggestions": [], "prompts": [ "Create a skill training", "40 published", @@ -783,7 +844,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -797,7 +858,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -811,7 +872,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -864,7 +925,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] } @@ -874,16 +935,25 @@ "pageKey": "analytics", "route": "/admin/analytics", "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": [ "What is the hiring trend?", "Which department is performing best?", "Biggest drop at Hired", "Which positions are converting best?", "Summarize hiring performance", - "10 unscored applications" + "9 unscored applications" ], "intents": [ { @@ -906,7 +976,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -920,7 +990,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -970,7 +1040,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] } @@ -979,8 +1049,17 @@ "admin.activity": { "pageKey": "activity", "route": "/admin/activity", - "skills": [], - "suggestions": [], + "skills": [ + "activity-analysis", + "anomaly-detection", + "operational-risk" + ], + "suggestions": [ + "Activity over recent periods", + "Is anything unusual?", + "Summarize workspace activity", + "What kinds of event are there?" + ], "prompts": [ "3 unusual patterns", "Summarize today's activity", @@ -999,7 +1078,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -1013,7 +1092,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -1076,7 +1155,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] } @@ -1105,7 +1184,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -1119,7 +1198,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] }, @@ -1179,7 +1258,7 @@ "destination": null, "doc": [ "text", - "list", + "text", "note" ] } diff --git a/scripts/seed-fixture.mjs b/scripts/seed-fixture.mjs new file mode 100644 index 0000000..141278d --- /dev/null +++ b/scripts/seed-fixture.mjs @@ -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.'); + } +} diff --git a/scripts/skill-check.mjs b/scripts/skill-check.mjs index e0d076a..3ad7041 100644 --- a/scripts/skill-check.mjs +++ b/scripts/skill-check.mjs @@ -15,7 +15,10 @@ import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { createServer } from 'vite'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; import { BASELINE_PATH, captureBaseline } from './owliver-capture.mjs'; +import { buildFixture, FIXTURE_PATH } from './seed-fixture.mjs'; const ROOT = process.cwd(); const results = []; @@ -1169,10 +1172,21 @@ record('the form reads it back unchanged', /* ── 10. Storage round trip ─────────────────────────────────────────────── The editors do not hand the registry a record; they write Markdown into - `preferences.customSkills`, which `base44Client` persists to localStorage and - reads back on the next load. "The source disappeared after reload" is a claim - about *that* path, so it is exercised here rather than assumed: the same - module the app runs, against a localStorage the same shape the browser's is. */ + `preferences.customSkills`, which `base44Client` sends to the API and reads + back on the next load. "The source disappeared after reload" is a claim about + *that* path, so it is exercised here rather than assumed: the same module the + app runs, against the same requests it makes. + + The API is stubbed, and only here. `base44Client` is production code and must + talk to the real backend — that is the whole point of it — so what is + replaced is the transport underneath it: `fetch`, answering the three + endpoints this section drives with an in-memory record. Everything above the + transport is the real thing: the same client, the same request builder, the + same envelope unwrapping, the same synchronous preferences accessor. + + Node has no `localStorage` and no origin, so both are supplied. The origin is + why this used to fail outright: the client builds a same-origin path + (`/api/v1/...`), which `fetch` cannot resolve without one. */ console.log('\n── Storage round trip ──'); const store = new Map(); @@ -1182,7 +1196,110 @@ globalThis.localStorage = { removeItem: (k) => store.delete(k), }; +/* The user the stubbed API holds, and the requests it saw. Both are asserted + below: what the client sent matters as much as what it did with the reply. */ +const apiUser = { + id: 'user_demo', full_name: 'Alex Rivera', email: 'demo@krow.app', + role: 'admin', account_type: 'employer', + preferences: { owliverDefault: true, compactDensity: false, emailDigest: true }, +}; +const apiCalls = []; + +/* What GET /api/v1/owliver/suggestions answers with. Set per assertion, so a + test can say what the server returned and check what the panel made of it. */ +let apiSuggestions = []; + +/* Turns the suggestions route into one this backend does not have — the + deployment lag where the route is in the Go source and not yet on the host + the dev server proxies to. Everything else keeps answering, which is exactly + what makes that state confusing to diagnose by hand. */ +let apiSuggestionsDeployed = true; + +/* The collections the stubbed API serves, by resource path. + * + * Filled in by section 12 from `api/seed.js` — the shipped fixture, which is + * what it is for now that the running app reads PostgreSQL instead. Declared + * here because the stub is installed before that module is loaded, and empty + * until then so an accidental early read is an empty list rather than a lie. + * + * The paths are `httpClient.js`'s own RESOURCE_PATHS, which is a declared table + * rather than a derivation, so the two are written out the same way. */ +const apiEntities = new Map(); + +/* The subset of the list contract this fixture implements: repeated parameters + * as `IN`, `-field` as descending, `limit` as a cap. Enough for the reads below + * and deliberately no more — a fuller reimplementation would be a second + * backend to keep honest. */ +function serveList(records, params) { + let out = [...records]; + + for (const [key, values] of params) { + if (key === 'sort' || key === 'limit' || key === 'offset') continue; + out = out.filter((record) => values.includes(String(record[key]))); + } + + const sort = params.has('sort') ? String(params.get('sort')[0] ?? '') : ''; + if (sort) { + const desc = sort.startsWith('-'); + const field = desc ? sort.slice(1) : sort; + out.sort((a, b) => { + const [x, y] = [a[field], b[field]]; + const cmp = x === y ? 0 + : (typeof x === 'number' && typeof y === 'number') ? x - y + : String(x ?? '').localeCompare(String(y ?? '')); + return desc ? -cmp : cmp; + }); + } + + const limit = Number(params.get('limit')?.[0]); + return limit > 0 ? out.slice(0, limit) : out; +} + +const jsonResponse = (data) => new Response(JSON.stringify({ data }), { + status: 200, headers: { 'Content-Type': 'application/json' }, +}); + +globalThis.fetch = async (url, init = {}) => { + const method = init.method || 'GET'; + const path = String(url); + apiCalls.push({ method, path, body: init.body ? JSON.parse(init.body) : null }); + + if (method === 'GET' && path === '/api/v1/me') return jsonResponse(apiUser); + + if (method === 'PATCH' && path === '/api/v1/me/preferences') { + apiUser.preferences = { ...apiUser.preferences, ...JSON.parse(init.body) }; + return jsonResponse(apiUser.preferences); + } + + if (method === 'GET' && path.startsWith('/api/v1/owliver/suggestions')) { + if (!apiSuggestionsDeployed) { + return new Response( + JSON.stringify({ error: { code: 'not_found', message: 'not found' } }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); + } + return jsonResponse({ suggestions: apiSuggestions }); + } + + const listed = /^\/api\/v1\/([a-z-]+)(?:\?(.*))?$/.exec(path); + if (method === 'GET' && listed && apiEntities.has(listed[1])) { + const params = new URLSearchParams(listed[2] || ''); + const grouped = new Map(); + for (const key of new Set(params.keys())) grouped.set(key, params.getAll(key)); + return jsonResponse(serveList(apiEntities.get(listed[1]), grouped)); + } + + return new Response( + JSON.stringify({ error: { code: 'not_found', message: `no stub for ${method} ${path}` } }), + { status: 404, headers: { 'Content-Type': 'application/json' } } + ); +}; + const { base44 } = await server.ssrLoadModule('/src/api/base44Client.js'); +/* The client hydrates itself with GET /me at module load; wait for that to land + so the synchronous accessor below reads the server's answer rather than the + default shape. */ +await base44.auth.me(); const custom = await server.ssrLoadModule('/src/lib/skills/customSkills.js'); /* Saving is what the editor's Save button does: compose, validate, upsert, @@ -1230,6 +1347,145 @@ record('the Owliver skill offers its chip, and draws no card', .some((c) => c.skillId === 'owliver-conversation-test') && Object.keys(reg.parseSkill(REGRESSION, { custom: true }).ui).length === 0); +/* ── 10b. Owliver suggestions come from the API ─────────────────────────── + * + * The suggestion the panel offers is a claim that the reader could usefully ask + * something, and only the backend can make that claim: it knows the caller's + * role, and — with nothing typed — it ranks against what is actually in + * PostgreSQL. So the frontend's whole part in this is a request and a + * translation, and these checks are about exactly that boundary. + * + * What is asserted, in order: the request the client makes, and the chip the + * panel makes of the reply. Nothing here re-implements ranking, because nothing + * in `src/` does either — the point of the change these checks protect is that + * the local ranker is gone. `MISSING matchPrompts` below is the guard against + * it coming back. + */ +console.log('\n── Owliver suggestions from the API ──'); + +const serverSuggestions = await server.ssrLoadModule('/src/lib/skills/serverSuggestions.js'); + +/* ── The request ─────────────────────────────────────────────────────────── */ + +const requestFor = async (args) => { + const before = apiCalls.length; + await base44.owliver.suggestions(args); + return apiCalls.slice(before).map((c) => c.path); +}; + +const typedRequest = await requestFor({ page: 'positions', query: 'pipeline' }); +record('a typed query is asked of the API', + typedRequest.length === 1 + && typedRequest[0].startsWith('/api/v1/owliver/suggestions') + && typedRequest[0].includes('page=positions') + && typedRequest[0].includes('query=pipeline'), + typedRequest.join(', ') || 'no request made'); + +/* Absent, not empty. `?query=` and no `query` at all are different requests: + the first says "the composer holds nothing", the second asks the data what to + suggest, and only the second reads the database. */ +const untypedRequest = await requestFor({ page: 'positions' }); +record('an empty composer asks the data, not for an empty string', + untypedRequest.length === 1 && !untypedRequest[0].includes('query='), + untypedRequest.join(', ')); + +record('a page-less request is not made at all', + (await requestFor({ query: 'pipeline' })).length === 0, + 'nothing to ask about'); + +/* ── The reply, as chips ─────────────────────────────────────────────────── */ + +/* Two the Positions page declares and one it does not, so both branches of the + translation are exercised by one response. */ +apiSuggestions = [ + { text: 'Which positions need attention?', intent: 'positions-attention' }, + { text: 'Show hiring activity as a flow', intent: 'hiring-operations', capability: 'flow' }, + { text: 'A reading this build has not shipped', intent: 'not-a-capability' }, +]; +const fromServer = await base44.owliver.suggestions({ page: 'positions', query: 'attention' }); +const chips = serverSuggestions.suggestionChips(fromServer, positionsContext); + +record('every suggestion the server sent becomes a chip', + chips.length === apiSuggestions.length, `${chips.length} of ${apiSuggestions.length}`); + +record('...in the order the server ranked them', + chips.map((c) => c.label).join(' | ') === apiSuggestions.map((s) => s.text).join(' | '), + chips.map((c) => c.label).join(' | ')); + +record('...reading exactly what the server wrote', + chips.every((c, i) => c.label === apiSuggestions[i].text && c.prompt === apiSuggestions[i].text), + 'label and prompt are the server text'); + +/* These two checks used to assert that a suggestion resolved to a LOCAL + capability — a canned reading the browser would run itself. Those readings + were the simulator, and they were deleted along with it. The property that + replaced them matters more: every suggestion now becomes a question, asked in + the server's own words, and nothing in between rewrites it. */ +record('every suggestion becomes a question, in the server\'s own words', + chips.every((c, i) => c.prompt === apiSuggestions[i].text), + chips.map((c) => c.prompt).join(' | ')); + +/* `capability` on a server suggestion names a SECTION TYPE to draw, not + anything to run. It used to be at risk of being confused with the chip's own + capability field; there is no longer a second meaning to confuse it with, and + this asserts the remaining one still rides through untouched. */ +record('a requested shape rides through as a shape, not as an instruction', + chips[1].shape === 'flow', + `shape=${chips[1].shape}`); + +record('every chip says it came from the API', + chips.every((c) => c.source === 'api')); + +apiSuggestions = []; +record('no suggestions is an empty row, not a fabricated one', + serverSuggestions.suggestionChips( + await base44.owliver.suggestions({ page: 'positions', query: 'sourdough' }), + positionsContext + ).length === 0); + +/* ── A backend without the route ─────────────────────────────────────────── */ + +/* The deployment lag this guards against is real and has happened: the route is + in the Go source and not yet on the host a 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. */ +const hooks = await server.ssrLoadModule('/src/lib/krowHooks.js'); + +apiSuggestionsDeployed = false; +record('a suggestion request that 404s resolves to none, not an error', + (await hooks.fetchOwliverSuggestions({ page: 'positions', query: 'attention' })).length === 0); + +const afterBreaker = apiCalls.length; +await hooks.fetchOwliverSuggestions({ page: 'positions', query: 'pipeline' }); +record('...and stops asking a backend that does not have the route', + apiCalls.length === afterBreaker, + `${apiCalls.length - afterBreaker} request(s) after the 404`); + +/* ── The local ranker is gone ────────────────────────────────────────────── */ + +/* Structural, because this is the property that decays quietly. The panel used + to rank a locally-built catalogue against the query — the same job the API + now does — and the failure mode of it returning is not an error but a second + opinion silently outranking the server's. */ +record('the panel imports no local suggestion ranker', + !existsSync(join(ROOT, 'src/components/ai-assistant/matchPrompts.js')), + 'matchPrompts.js removed'); + +const panelSource = readFileSync(join(ROOT, 'src/components/ai-assistant/KrowAssistant.jsx'), 'utf8'); +record('...and does not rank, score or sort suggestions itself', + !/rankPrompts|\.sort\(|score\(/.test(panelSource), + 'no ranking in the panel'); +record('...it asks the API instead', + /useOwliverSuggestions/.test(panelSource) && /suggestionChips/.test(panelSource)); + +/* The production data client must not carry the fixture. */ +record('the app does not import the seed fixture', + !/from '\.\/seed'/.test(readFileSync(join(ROOT, 'src/api/base44Client.js'), 'utf8')), + 'base44Client reads demoUser.js, not seed.js'); +record('the local entity store is gone', + !existsSync(join(ROOT, 'src/api/store.js')), + 'store.js removed'); + /* ── 11. A fresh Add Owliver Skill screen ───────────────────────────────── What the form starts as, asserted at the state that drives the checkbox rather than at the checkbox. `checked` is @@ -1403,6 +1659,19 @@ const shiftSeed = await server.ssrLoadModule('/src/api/attendanceSeed.js'); const seedModule = await server.ssrLoadModule('/src/api/seed.js'); const resolverModule = await server.ssrLoadModule('/src/lib/skills/dataResolver.js'); +/* The stubbed API serves the shipped fixture from here on, so the reads below + go through the real client, the real request builder and the real envelope — + only the transport is replaced. See the note above the `fetch` stub. */ +for (const [entity, path] of Object.entries({ + JobPosting: 'job-postings', JobApplication: 'job-applications', AIInterview: 'ai-interviews', + Staff: 'staff', WorkerProfile: 'worker-profiles', Course: 'courses', Badge: 'badges', + LearningPath: 'learning-paths', Certification: 'certifications', + RoleCategory: 'role-categories', UserActivity: 'user-activity', Evidence: 'evidence', + User: 'users', Assignment: 'assignments', ShiftRecord: 'shift-records', +})) { + apiEntities.set(path, seedModule.seedData[entity] || []); +} + const SHIFTS = shiftSeed.SHIFT_RECORDS; const SHIFT_STATUSES = ['present', 'late', 'absent', 'no_show', 'excused']; @@ -3007,10 +3276,24 @@ record('agent starters carry no capability, so they cannot address an unoffered * that is renamed or removed fails here rather than in someone's browser. */ const appSource = readFileSync(join(ROOT, 'src/App.jsx'), 'utf8'); -const switcherSource = readFileSync(join(ROOT, 'src/components/ai-assistant/AgentSwitcher.jsx'), 'utf8'); + +/** + * The switcher, if there still is one. + * + * `AgentSwitcher.jsx` was removed when the panel took over agent selection, and + * these two checks read its source to assert properties *of that file*. A + * deleted file has no dead routes and no configure-specific branch, so the + * checks below are vacuously satisfied — but reading it unconditionally threw + * ENOENT and stopped the whole run here, taking every section after it with it. + * Absent is a state to report, not to crash on. + */ +const SWITCHER_PATH = join(ROOT, 'src/components/ai-assistant/AgentSwitcher.jsx'); +const switcherSource = existsSync(SWITCHER_PATH) ? readFileSync(SWITCHER_PATH, 'utf8') : null; /* Only live navigations count: a disabled control goes nowhere by design. */ -const navigated = [...switcherSource.matchAll(/navigate\('([^']+)'\)/g)].map((m) => m[1]); +const navigated = switcherSource + ? [...switcherSource.matchAll(/navigate\('([^']+)'\)/g)].map((m) => m[1]) + : []; const routed = new Set( [...appSource.matchAll(/ !routed.has(to)); record('every route the agent switcher navigates to exists', dead.length === 0, - dead.length ? `DEAD: ${dead.join(', ')}` : `${navigated.length} destination(s): ${navigated.join(', ')}`); + !switcherSource ? 'no switcher component — nothing to navigate' + : dead.length ? `DEAD: ${dead.join(', ')}` + : `${navigated.length} destination(s): ${navigated.join(', ')}`); /** - * Authoring is reachable from the switcher, and its screen exists. + * Authoring is reachable, and its screen exists. * - * An earlier version of this asserted the opposite — that the control was - * disabled — which was correct while the management screens did not exist and - * a visual review had found it navigating to a 404. Now that it is built, the - * durable property is the one above: whatever the switcher offers must resolve. - * This states the specific case so the pair cannot silently invert again. + * This asked about the SWITCHER, which no longer exists. The panel header used + * to open a popover listing every agent, Browse all and Create agent; it was + * deliberately reduced to a label, because it was a second place to decide + * which agent answers — competing with the editor, and letting a reader put + * Owliver into a state nothing on the page explained. See AgentBadge.jsx. + * + * Creating an agent moved with it, to the Agents list, which is where + * AgentBadge's own note says this belongs. The property is unchanged and worth + * keeping — offered somewhere, and the route resolves — so it is asserted + * against where the control actually is. */ -record('creating an agent is reachable from the switcher', - /navigate\('\/admin\/workspace\/agents\/new'\)/.test(switcherSource) +const agentsListSource = readFileSync(join(ROOT, 'src/pages/admin/WorkspaceAgents.jsx'), 'utf8'); +record('creating an agent is reachable, and its screen exists', + /navigate\('\/admin\/workspace\/agents\/new'\)/.test(agentsListSource) && routed.has('/admin/workspace/agents/new'), - 'offered, and the screen exists'); + 'offered on the Agents list, and the route resolves'); record('every published agent offers at least one starter where it applies', ALL_AGENTS.filter((a) => a.status === 'published') @@ -3547,9 +3838,15 @@ for (const route of ['/admin/workspace/agents/new', '/admin/workspace/agents/ana resolved?.id === CONFIGURE_CONTEXT, resolved?.id || 'no panel'); } +/* This used to assert the context carried a `respond` function and a list of + capabilities. Both were the local simulator's, and both went with it. The + property it was really guarding — that Agent Configure reuses a context from + the shared table rather than declaring a panel of its own — survives, and is + what is asserted now. */ record('the configure context is the one Owliver already uses, not a new panel', - Boolean(contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT]?.respond) - && Boolean(contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT]?.capabilities?.length), + Boolean(contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT]) + && Boolean(contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT]?.page) + && Array.isArray(contexts.ASSISTANT_CONTEXTS[CONFIGURE_CONTEXT]?.topics), 'a context in the existing table, resolved by the existing placement'); /** @@ -3701,18 +3998,37 @@ record('...and never answered from the configure page itself', const detailSource = readFileSync(join(ROOT, 'src/pages/admin/AgentDetail.jsx'), 'utf8'); const configureSource = readFileSync(join(ROOT, 'src/components/agents/AgentConfigure.jsx'), 'utf8'); +/* The property is right and the old pattern was too blunt. It matched any + occurrence of the substring, so `useAssistantPanel` — the hook for talking to + the ONE panel the shell mounts, and the opposite of defining a second one — + read as a violation, and so did the words `useConversation` inside a comment. + + What actually constitutes a second chat is IMPORTING one or RENDERING one, so + that is what is matched now. */ +const secondChat = [ + /import\s+[^;]*\bKrowAssistant\b[^;]*from/, + /import\s+[^;]*\bcreateAssistantProvider\b[^;]*from/, + /import\s+[^;]*\buseConversation\b[^;]*from/, + / re.test(detailSource + configureSource)); + record('the configure screen defines no chat of its own', - !/KrowAssistant|AssistantPanel|useConversation|createAssistantProvider/.test(detailSource + configureSource), - 'no second panel, provider or conversation'); + secondChat.length === 0, + secondChat.length ? `defines one: ${secondChat[0]}` : 'uses the shell\'s panel, defines none'); record('the panel is mounted once, by the shell', /* ` !fields\.skills\.includes\(id\) && onToggleSkill/, + 'remove skill': /onRemove=\{onToggleSkill \? \(\) => onToggleSkill\(id\) : undefined\}/, 'add knowledge': /knowledge: \[\s*\n?\s*\.\.\.fields\.knowledge/, 'remove knowledge': /knowledge: fields\.knowledge\.filter/, 'add starter': /starters: \[\.\.\.fields\.starters/, @@ -3808,6 +4143,20 @@ const CONTROLS = { }; const missingControls = Object.entries(CONTROLS) .filter(([, re]) => !re.test(configureSource)).map(([k]) => k); +/* Skills are bounded by the pages above — "a page decides which skills exist + there; an agent chooses among them, and can narrow that list, never widen + it." The old controls offered a flat list, which is how an agent ends up + carrying a skill its pages do not provide. */ +record('the skills offered are bounded by the pages chosen', + /getSkillsForPage\(page,/.test(configureSource) + && /for \(const page of fields\.pages\)/.test(configureSource), + 'derived from fields.pages, not a flat catalog'); + +/* An agent whose pages changed may still carry a skill they no longer offer. + Dropping it silently would edit the definition behind the author's back. */ +record('a skill the pages no longer offer is flagged, not dropped', + /Not available on the pages above/.test(configureSource)); + record('every field the editor had is still editable', missingControls.length === 0, missingControls.length ? `MISSING ${missingControls.join(', ')}` : `${Object.keys(CONTROLS).length} controls`); @@ -4198,11 +4547,19 @@ record('an operational question on an agent-less page is declined or routed, nev /* The decline names what the page *can* do, so it is an offer rather than a dead end. */ +/* The offer used to be a bulleted list of capability labels — the simulator's + canned readings, deleted with it. It is now a sentence naming the page's + subjects, which is a better offer: a label named a report, a subject names + something somebody can ask about in their own words. The check asserts the + PROPERTY (a decline says what the page does cover) rather than the block + type, so the next change of shape does not break it for no reason. */ record('the decline offers what the page can answer instead', AGENTLESS_CONTEXTS.every((contextId) => { const intent = routingModule.resolveIntent({ question: 'What is our attendance rate?', contextId }); if (intent.kind !== 'outOfScope') return true; - return (intent.doc?.blocks || []).some((b) => b.type === 'list' && (b.items || []).length > 0); + const said = (intent.doc?.blocks || []) + .map((b) => b.text || (b.items || []).join(' ')).join(' '); + return said.includes('This page covers') || /\b(covers|ask about)\b/.test(said); })); /* ── The switcher still works there ─────────────────────────────────────── */ @@ -4356,6 +4713,604 @@ if (!existsSync(BASELINE_PATH)) { } } +/* ── The agent provider ─────────────────────────────────────────────────────── + * + * The provider that calls the real backend. Checked here rather than left to a + * browser because the failure modes are all shape failures — a run that came + * back Completed and rendered nothing, a proposed write rendered as prose with + * no button — and none of them is visible without asserting on the blocks. + * + * `fetch` is stubbed. The point is the mapping from a run result to blocks, not + * the network; the network is tested on the Go side, against a real model. + */ +console.log('\n── Agent provider ──'); +{ + const providerMod = await server.ssrLoadModule('/src/components/ai-assistant/provider.js'); + + const drain = async (stream) => { + let last = []; + for await (const snapshot of stream) last = snapshot; + return last; + }; + + const withFetch = async (impl, fn) => { + const original = globalThis.fetch; + globalThis.fetch = impl; + try { return await fn(); } finally { globalThis.fetch = original; } + }; + + const jsonResponse = (body, status = 200) => ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + }); + + const provider = providerMod.createAgentProvider({ baseUrl: '/api/v1' }); + + /* A completed run renders its answer. */ + { + const blocks = await withFetch( + async () => jsonResponse({ + runId: 'run_1', termination: 'Completed', + output: 'Twelve events, mostly logins.', usage: {}, + }), + () => drain(provider.stream({ question: 'what happened?', agent: { id: 'activity-agent' } })) + ); + record('agent provider: a completed run renders its answer', + blocks.some((b) => b.type === 'text' && b.text.includes('Twelve events')), + `${blocks.length} block(s)`); + record('agent provider: a completed run adds no system note', + !blocks.some((b) => b.type === 'note')); + } + + /* A proposed write renders as a decision, not as prose. */ + { + const blocks = await withFetch( + async () => jsonResponse({ + runId: 'run_2', termination: 'ConfirmationPending', + message: 'The agent has proposed a change and is waiting for you to approve it.', + confirmations: [{ + token: 'cnf_abc', tool: 'assign_worker', + title: 'Assign Maya Chen to Bar Supervisor', + summary: 'Maya Chen will be scheduled to work Friday evening.', + details: [{ label: 'Worker', value: 'Maya Chen' }], + warnings: ['Maya Chen already has 1 assignment over this period.'], + }], + usage: {}, + }), + () => drain(provider.stream({ question: 'cover Friday', agent: { id: 'coverage-agent' } })) + ); + const confirmation = blocks.find((b) => b.type === 'confirmation'); + record('agent provider: a proposed write renders as a confirmation block', + Boolean(confirmation), confirmation ? confirmation.title : `${blocks.length} block(s)`); + record('agent provider: the confirmation carries its token', + confirmation?.token === 'cnf_abc'); + record('agent provider: the confirmation carries its warnings', + confirmation?.warnings?.length === 1); + } + + /* A token, once approved, travels on the next request — and nothing else about + the caller does. The browser cannot name a principal. */ + { + let sent = null; + await withFetch( + async (_url, init) => { sent = JSON.parse(init.body); return jsonResponse({ termination: 'Completed', output: 'Assigned.', usage: {} }); }, + () => drain(provider.stream({ + question: 'cover Friday', agent: { id: 'coverage-agent' }, confirmation: 'cnf_abc', + })) + ); + record('agent provider: an approval sends its token', sent?.confirmation === 'cnf_abc'); + record('agent provider: the body names no principal', + sent && !('identity' in sent) && !('userId' in sent) && !('orgId' in sent), + Object.keys(sent || {}).join(', ')); + } + + /* Failures say what to do about them, and a 404 does not distinguish + "no such agent" from "not yours". */ + { + for (const [status, expect] of [[401, 'Sign in again'], [404, 'not available'], [429, 'Try again']]) { + const blocks = await withFetch( + async () => jsonResponse({ error: { message: 'x' } }, status), + () => drain(provider.stream({ question: 'hi', agent: { id: 'a' } })) + ); + const note = blocks.find((b) => b.type === 'note'); + record(`agent provider: ${status} explains what to do`, + Boolean(note?.text?.includes(expect)), note?.text || 'no note'); + } + } + + /* No agent, no run. The panel resolves an agent before calling; reaching the + provider without one is a routing bug and should say so. */ + { + let called = false; + const blocks = await withFetch( + async () => { called = true; return jsonResponse({}); }, + () => drain(provider.stream({ question: 'hi', agent: null })) + ); + record('agent provider: no agent means no request', !called); + record('agent provider: no agent says so', blocks.some((b) => b.type === 'note')); + } +} + +/* ── Preferring the agent ───────────────────────────────────────────────────── + * + * The rule that decides whether a question is answered by the browser or by the + * agent. Checked here because getting it wrong in either direction is invisible + * from the outside: too eager and a form flow breaks; too shy and the panel + * keeps answering from templates while a real agent sits behind it. + */ +console.log('\n── Preferring the agent ──'); +{ + const routing = await server.ssrLoadModule('/src/components/ai-assistant/routing.js'); + const { preferAgent } = routing; + + const defers = (intent) => preferAgent(intent, { modelBacked: true }).kind === 'answer'; + const keeps = (intent) => preferAgent(intent, { modelBacked: true }) === intent; + + /* Text answers go to the agent — it reads the same rows and can be followed up. */ + for (const kind of ['answer-doc', 'skill', 'workforce', 'outOfScope']) { + record(`prefer-agent: ${kind} defers to the agent`, defers({ kind })); + } + + /* Anything that DOES something keeps its path. The agent has no tool for + these, so deferring would lose capability rather than gimmickry. */ + const acts = [ + ['flow + create', { kind: 'flow', create: {} }], + ['draft + perform', { kind: 'draft', perform: {} }], + ['workforce + assign', { kind: 'workforce', assign: {} }], + ['workforce + headcount', { kind: 'workforce', headcount: {} }], + ['workforce + interview', { kind: 'workforce', interview: {} }], + ['skill + action', { kind: 'skill', action: {} }], + ]; + for (const [label, intent] of acts) { + record(`prefer-agent: ${label} keeps its own path`, keeps(intent)); + } + + /* Deliberate product behaviour, left alone. */ + record('prefer-agent: navigate is left alone', keeps({ kind: 'navigate', destination: {} })); + record('prefer-agent: constrained is left alone', keeps({ kind: 'constrained', agent: {} })); + + /* With no agent behind the panel there is nothing better to defer TO, and + deferring would turn every template answer into a worse one. */ + const inert = ['answer-doc', 'skill', 'workforce', 'outOfScope'] + .every((kind) => preferAgent({ kind }, { modelBacked: false }).kind === kind); + record('prefer-agent: inert without a live agent', inert, + 'the panel behaves exactly as it shipped'); +} + +/* ── Version pinning ────────────────────────────────────────────────────────── + * + * §3: running conversations pin the version they started with. The frontend's + * half is small — send back the version the first answer carried — and the + * consequence of getting it wrong is invisible: an edit published mid-thread + * would silently change which agent is answering, and the reader would see the + * change as the agent being inconsistent. + */ +console.log('\n── Version pinning ──'); +{ + const providerMod = await server.ssrLoadModule('/src/components/ai-assistant/provider.js'); + const provider = providerMod.createAgentProvider({ baseUrl: '/api/v1' }); + + const drain = async (stream) => { let last = []; for await (const s of stream) last = s; return last; }; + const withFetch = async (impl, fn) => { + const orig = globalThis.fetch; + globalThis.fetch = impl; + try { return await fn(); } finally { globalThis.fetch = orig; } + }; + const json = (body) => ({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => body, + }); + + let sent = null; + const capture = async (_u, init) => { sent = JSON.parse(init.body); return json({ termination: 'Completed', output: 'ok', usage: {} }); }; + + await withFetch(capture, () => drain(provider.stream({ + question: 'q', agent: { id: 'a' }, agentVersion: 3, + }))); + record('pinning: a pinned version travels with the question', sent.agentVersion === 3, + `agentVersion=${sent.agentVersion}`); + + await withFetch(capture, () => drain(provider.stream({ question: 'q', agent: { id: 'a' } }))); + record('pinning: an unpinned turn sends none, and gets whatever is current', + sent.agentVersion === undefined, `agentVersion=${sent.agentVersion}`); + + /* The one that matters: an approval carries the version it was approved + under, so the write happens against the agent the person was shown. */ + await withFetch(capture, () => drain(provider.stream({ + question: 'q', agent: { id: 'a' }, confirmation: 'cnf_x', agentVersion: 2, + }))); + record('pinning: an approval carries both its token and its version', + sent.confirmation === 'cnf_x' && sent.agentVersion === 2, + JSON.stringify(sent)); +} + +/* ── A deployment with no agent ─────────────────────────────────────────────── + * + * The local simulator used to be the fallback. It is gone, so an unconfigured + * deployment now has NOTHING to answer with — and the whole risk of deleting it + * is that this state becomes silent. A panel that accepts a question and + * produces nothing is the worst of the possible failures and the hardest to + * diagnose from the outside. + */ +console.log('\n── No agent configured ──'); +{ + const { createUnconfiguredProvider } = await server.ssrLoadModule('/src/components/ai-assistant/provider.js'); + const p = createUnconfiguredProvider(); + + let blocks = []; + for await (const snap of p.stream({ question: 'what needs my attention?' })) blocks = snap; + + record('unconfigured: a question still gets an answer', blocks.length > 0, + `${blocks.length} block(s)`); + record('unconfigured: the answer says WHY it cannot answer', + blocks.some((b) => /not configured/i.test(b.text || '')), + blocks[0]?.text?.slice(0, 80) || 'nothing said'); + record('unconfigured: it names what to set', + blocks.some((b) => /VITE_AGENT_API/.test(b.text || '')), + 'an operator can act on it'); + record('unconfigured: it is not silent', p.id === 'unconfigured'); +} + +/* ── Streaming ──────────────────────────────────────────────────────────────── + * + * The property worth guarding is not "deltas arrive" — it is that a client + * reading only the LAST snapshot ends up where it would have without streaming. + * If that ever stops being true, streaming has quietly become a second, worse + * answering path. + */ +console.log('\n── Streaming ──'); +{ + const providerMod = await server.ssrLoadModule('/src/components/ai-assistant/provider.js'); + const provider = providerMod.createAgentProvider({ baseUrl: '/api/v1' }); + + const sse = (frames) => ({ + ok: true, + status: 200, + headers: { get: (k) => (k === 'Content-Type' ? 'text/event-stream' : null) }, + body: { + getReader() { + const chunks = frames.map((f) => new TextEncoder().encode(`data: ${JSON.stringify(f)}\n\n`)); + let i = 0; + return { + read: async () => (i < chunks.length ? { done: false, value: chunks[i++] } : { done: true }), + cancel: () => {}, + }; + }, + }, + }); + + const withFetch = async (impl, fn) => { + const orig = globalThis.fetch; + globalThis.fetch = impl; + try { return await fn(); } finally { globalThis.fetch = orig; } + }; + + const collect = async (stream) => { + const snaps = []; + for await (const s of stream) snaps.push(s); + return snaps; + }; + + /* Text arrives progressively, and the final run replaces it. */ + { + const snaps = await withFetch( + async () => sse([ + { delta: '## Roles at risk\n\n' }, + { delta: 'Three roles have applicants but ' }, + { delta: 'none strong.' }, + { run: { termination: 'Completed', output: '## Roles at risk\n\nThree roles have applicants but none strong.', usage: {} } }, + ]), + () => collect(provider.stream({ question: 'risk?', agent: { id: 'positions-agent' } })) + ); + + record('streaming: the answer arrives in more than one snapshot', snaps.length > 1, + `${snaps.length} snapshots`); + record('streaming: text renders before the run finishes', + snaps[0].some((b) => b.type === 'heading' || b.type === 'text'), + JSON.stringify(snaps[0]).slice(0, 70)); + + /* The one that matters. */ + const last = snaps[snaps.length - 1]; + record('streaming: the last snapshot is what a non-streaming client would get', + last.some((b) => b.type === 'heading' && /Roles at risk/.test(b.text)) + && last.some((b) => b.type === 'text' && /none strong/.test(b.text)), + JSON.stringify(last).slice(0, 90)); + } + + /* A proposed write survives the stream — it rides on the final run event, not + on any delta, so a client that only read deltas would miss the decision. */ + { + const snaps = await withFetch( + async () => sse([ + { delta: "I'll put someone forward." }, + { run: { + termination: 'ConfirmationPending', + output: "I'll put someone forward.", + confirmations: [{ token: 'cnf_x', title: 'Assign Maya Chen', summary: 'She will be scheduled.' }], + usage: {}, + } }, + ]), + () => collect(provider.stream({ question: 'cover friday', agent: { id: 'positions-agent' } })) + ); + const last = snaps[snaps.length - 1]; + record('streaming: a proposed write survives to the final snapshot', + last.some((b) => b.type === 'confirmation' && b.token === 'cnf_x')); + } + + /* A stream that dies mid-answer keeps what arrived. An empty panel would + throw away text the reader was already reading. */ + { + const snaps = await withFetch( + async () => sse([{ delta: 'Half an answ' }]), + () => collect(provider.stream({ question: 'x', agent: { id: 'a' } })) + ); + const last = snaps[snaps.length - 1]; + record('streaming: a dropped connection keeps what arrived', + last.some((b) => /Half an answ/.test(b.text || '')), + JSON.stringify(last).slice(0, 60)); + } + + /* A server that answers JSON instead of a stream still works. */ + { + const snaps = await withFetch( + async () => ({ + ok: true, status: 200, + headers: { get: () => 'application/json' }, + json: async () => ({ termination: 'Completed', output: 'Plain answer.', usage: {} }), + }), + () => collect(provider.stream({ question: 'x', agent: { id: 'a' } })) + ); + record('streaming: a non-streaming server still answers', + snaps.at(-1).some((b) => /Plain answer/.test(b.text || ''))); + } +} + +/* ── An assumed attendance does not manufacture a score ──────────────────── */ + +/** + * `attendance_score` defaults to 100 so an unrated worker shows a full bar + * instead of an accusatory 0. It carries 30% of reliability and 12% of the KROW + * score, so used as a scoring input it lifted a profile with no evidence at all + * from 0 to 12 — out of "Not yet scored" and into the low band, where every + * ranking read it as a poor worker rather than an unassessed one. And this is + * live: recalcProfilePatch runs on course completion, skill assessments and + * identity creation, so a seeded 0 did not stay 0. + */ +const scoreModule = await server.ssrLoadModule('/src/lib/krowScore.js'); +const noEvidence = seedModule.seedData.WorkerProfile.filter((p) => !(p.shifts_completed > 0)); +record('the seed still contains profiles with no attendance record', noEvidence.length > 0, + `${noEvidence.length} of ${seedModule.seedData.WorkerProfile.length}`); +const manufactured = noEvidence.filter((p) => scoreModule.computeKrowScore(p).krow_score > 0); +record('a profile with no evidence scores 0, not a number made of an assumption', + manufactured.length === 0, + manufactured.map((p) => `${p.full_name}=${scoreModule.computeKrowScore(p).krow_score}`).join(', ') || 'all zero'); +record('...and its breakdown reports attendance as absent, not as zero percent', + noEvidence.every((p) => scoreModule.computeKrowScore(p).breakdown.attendance === null)); + +/* Recalculating a seeded profile must change nothing. + krowScore.js calls itself "formula-based, auto-recalculated", but every one of + the nine seeded profiles disagreed with the formula that claims to compute it + — hand-authored narrative values on one side, an engine on the other, never + reconciled. That is not cosmetic: recalcProfilePatch runs on course + completion, skill assessments and identity creation, so a worker's score + jumped the moment they touched anything (Priya 68 → 78, a whole band). The + seed now stores the engine's own output, and this keeps the two in step. */ +const scoreDrift = seedModule.seedData.WorkerProfile + .map((p) => ({ p, r: scoreModule.recalcProfilePatch(p) })) + .filter(({ p, r }) => + r.krow_score !== p.krow_score || + r.reliability_score !== p.reliability_score || + r.profile_completion !== p.profile_completion); +record('recalculating a seeded profile changes nothing', scoreDrift.length === 0, + scoreDrift.map(({ p, r }) => + `${p.full_name}: ${p.krow_score}/${p.reliability_score}/${p.profile_completion}` + + ` -> ${r.krow_score}/${r.reliability_score}/${r.profile_completion}`).join('; ') || 'all 9 in step'); + +/* A completion percentage cannot exceed 100. It could: seven optional fields + incremented the numerator and the denominator counted five, so the most + complete profile computed 107% complete. */ +const overComplete = seedModule.seedData.WorkerProfile + .filter((p) => scoreModule.computeProfileCompletion(p) > 100); +record('profile completion never exceeds 100%', overComplete.length === 0, + overComplete.map((p) => `${p.full_name}=${scoreModule.computeProfileCompletion(p)}`).join(', ') || 'all within range'); + +/* And the breakdown is percentages a person reads, not raw binary floats. */ +const unrounded = seedModule.seedData.WorkerProfile.flatMap((p) => + Object.entries(scoreModule.computeKrowScore(p).breakdown) + .filter(([, v]) => v !== null && !Number.isInteger(v)) + .map(([k, v]) => `${p.full_name}.${k}=${v}`)); +record('every breakdown figure is a whole percentage', unrounded.length === 0, + unrounded.join(', ') || 'all whole'); + +/* The guard must not silence a worker who has actually turned up. */ +const withRecord = seedModule.seedData.WorkerProfile.filter((p) => p.shifts_completed > 0); +record('a worker with a shift record still scores on attendance', + withRecord.length > 0 && + withRecord.every((p) => scoreModule.computeKrowScore(p).breakdown.attendance > 0), + `${withRecord.length} profiles with shifts`); + +/* Real evidence still earns a score — the guard withholds, it does not block. */ +const firstCourse = seedModule.seedData.Course[0]; +const earned = scoreModule.recalcProfilePatch({ + ...noEvidence[0], completed_courses: [firstCourse.id], earned_badges: [], + xp: (noEvidence[0].xp || 0) + (firstCourse.xp || 0), +}); +record('completing a real course does earn a score', earned.krow_score > 0, + `${noEvidence[0].full_name} → ${earned.krow_score}`); + +/* ── Every application status can be placed on the funnel ────────────────── */ + +/** + * `atOrBeyond` ranks a status by its index in STAGE_ORDER, so a status missing + * from that list ranks -1 and falls out of *every* bucket including `applied`. + * That is what happened to `rejected` and `assigned`: a position's funnel simply + * lost them, and a role whose candidates had all been assigned to shifts read as + * unfilled. Nothing in the app held the full status list to check against. + */ +const records = await server.ssrLoadModule('/src/lib/hiringRecords.js'); +const unplaceable = records.APPLICATION_STATUSES.filter((st) => records.rankOf(st) < 0); +record('every application status can be placed on the funnel', + unplaceable.length === 0, unplaceable.join(', ') || 'all placed'); + +record('a rejected candidate counts as screened', + records.rankOf('rejected') >= records.STAGE_ORDER.indexOf('ai_screened')); +record('...but is never counted as shortlisted, a stage rejection overwrites', + records.rankOf('rejected') < records.STAGE_ORDER.indexOf('shortlisted')); +record('an assigned candidate counts as hired', + records.HIRED_STATUSES.includes('assigned') && + records.rankOf('assigned') >= records.STAGE_ORDER.indexOf('hired')); + +/* "Screened and waiting on a decision" includes the shortlisted. This counted + `ai_screened` alone, which read as correct only while nothing was ever + shortlisted — the first shortlisted candidate dropped out of the count in + silence. The baseline records the prompt; this records the rule. */ +const insightsFacts = insightsModule.buildFacts({ + postings: SEED.JobPosting, + interviews: [], + staff: [], + profiles: [], + activity: [], + courses: [], + profile: null, + user: seedModule.DEMO_USER, + today: new Date('2026-08-20T09:00:00.000Z'), + applications: [ + { id: 's1', status: 'shortlisted', ai_score: 91, job_posting_id: 'p', applicant_name: 'Shortlisted Strong' }, + { id: 's2', status: 'ai_screened', ai_score: 85, job_posting_id: 'p', applicant_name: 'Screened Strong' }, + { id: 's3', status: 'shortlisted', ai_score: 40, job_posting_id: 'p', applicant_name: 'Shortlisted Weak' }, + ], +}); +record('a shortlisted strong candidate counts as waiting on a decision', + insightsFacts.stalled.length === 2, + insightsFacts.stalled.map((a) => a.applicant_name).join(', ') || 'none'); + +/* The backend fixture is generated from this seed, not maintained beside it. + `seed/fixtures/seed.json` is what the Go seeder loads and `src/api/seed.js` is + what a person edits. They were two hand-maintained copies of the same data, + which fails quietly: the demo and the API would answer the same question with + different numbers, and the first symptom would be a page disagreeing with an + agent. This asserts the committed fixture is exactly what this seed implies — + `npm run seed:fixture` regenerates it. */ +const builtFixture = await buildFixture(server); +if (!existsSync(FIXTURE_PATH)) { + record('the backend fixture is in step with this seed', false, + 'seed.json missing — run `npm run seed:fixture`'); +} else { + const onDisk = readFileSync(FIXTURE_PATH, 'utf8'); + record('the backend fixture is in step with this seed', onDisk === builtFixture, + onDisk === builtFixture + ? `${JSON.parse(builtFixture).entities.JobApplication.length} applications, byte-identical` + : 'stale — run `npm run seed:fixture`'); +} + +/* ShiftRecord stays out of the fixture on purpose: its dates anchor to the day + the seeder runs, so the Go side generates it rather than reading a snapshot + that would go stale. */ +record('...and leaves the date-anchored shift records to the Go seeder', + !('ShiftRecord' in JSON.parse(builtFixture).entities) && seedModule.seedData.ShiftRecord.length > 0, + `${seedModule.seedData.ShiftRecord.length} shift records held in seed.js only`); + +/* The seed must exercise every status, or none of the above can catch anything. */ +const seededStatuses = new Set(seedModule.seedData.JobApplication.map((a) => a.status)); +const neverSeeded = records.APPLICATION_STATUSES.filter((st) => !seededStatuses.has(st)); +record('the seed exercises every application status', + neverSeeded.length === 0, + neverSeeded.length ? `never produced: ${neverSeeded.join(', ')}` : `all ${seededStatuses.size}`); +record('...and at least one assignment exists, so `assigned` means something', + seedModule.seedData.Assignment.length > 0, + `${seedModule.seedData.Assignment.length} assignment(s)`); +record('...while most positions still have none, so the empty case stays covered', + seedModule.seedData.Assignment.length < seedModule.seedData.JobPosting.length, + `${seedModule.seedData.Assignment.length} of ${seedModule.seedData.JobPosting.length} positions`); + +/* The accounting identity: a funnel that loses people is not a funnel. */ +const positionsModule = await server.ssrLoadModule('/src/lib/admin/positionInsights.js'); +const everyStage = [ + { id: '1', job_posting_id: 'p', status: 'applied', ai_score: 0 }, + { id: '2', job_posting_id: 'p', status: 'ai_screened', ai_score: 75 }, + { id: '3', job_posting_id: 'p', status: 'rejected', ai_score: 40 }, + { id: '4', job_posting_id: 'p', status: 'assigned', ai_score: 88 }, +]; +const posStats = positionsModule.buildPosition( + { id: 'p', title: 'Bartender', status: 'active' }, everyStage).stats; +record('a position accounts for every applicant it has', + posStats.unscreened + posStats.screened === posStats.applied, + `${posStats.unscreened} unscreened + ${posStats.screened} screened of ${posStats.applied}`); +record('a rejected applicant is screened but not shortlisted', + posStats.screened === 3 && posStats.shortlisted === 1, + `screened ${posStats.screened}, shortlisted ${posStats.shortlisted}`); +record('an assigned applicant still counts as hired on the position', + posStats.hired === 1, `hired ${posStats.hired}`); + +const funnel = records.buildFunnel(everyStage); +record('the funnel never grows as it narrows', + funnel.stages.every((st, i) => i === 0 || st.count <= funnel.stages[i - 1].count), + funnel.stages.map((st) => `${st.label}=${st.count}`).join(' ')); + +/* The list above is the app's claim about the schema. This is the schema. */ +const SCHEMA_SQL = join(ROOT, '..', 'krow-backend', 'migrations', '000001_initial_schema.up.sql'); +if (existsSync(SCHEMA_SQL)) { + const declared = readFileSync(SCHEMA_SQL, 'utf8') + .match(/CREATE TYPE application_status AS ENUM \(([^)]*)\)/)?.[1] + ?.match(/'([a-z_]+)'/g)?.map((q) => q.slice(1, -1)) ?? []; + record('the app knows every status the schema defines', + declared.length > 0 && + declared.every((st) => records.APPLICATION_STATUSES.includes(st)) && + records.APPLICATION_STATUSES.every((st) => declared.includes(st)), + `schema: ${declared.join(', ')}`); +} else { + record('the app knows every status the schema defines', true, + 'krow-backend not present beside this repo — cross-check skipped, not verified'); +} + +/* ── The dashboard cards obey the same rule as the resolvers ─────────────── */ + +/** + * `talent.pool` above proves the resolver does not average away the unscored. + * The dashboard cards compute their own figures and were never covered, and + * three of them broke the rule: two averaged an unscored hire in as a zero, and + * one labelled its AI-scored count "screened" — a word the rest of the product + * uses for the stage (status <> 'applied'), so that card and the unscreened + * count on the same dashboard did not add up to the pool. + * + * Rendered rather than reimplemented: the figure that matters is the one a + * person reads off the card. + */ +const renderCard = async (path, props) => + renderToStaticMarkup(React.createElement((await server.ssrLoadModule(path)).default, props)); + +const twoHiresOneScored = [ + { id: 'h1', status: 'hired', ai_score: 80, created_date: '2026-01-01', updated_date: '2026-01-02' }, + { id: 'h2', status: 'hired', ai_score: 0, created_date: '2026-01-01', updated_date: '2026-01-02' }, +]; + +const hiringCard = await renderCard('/src/components/krow/HiringAnalytics.jsx', + { applications: twoHiresOneScored, interviews: [], staff: [] }); +const shownAvg = hiringCard.match(/>([\d—]+)<\/div>
]*>AVG SCORE/)?.[1]; +record('hiring analytics averages only the hires that carry a score', + shownAvg === '80', `card shows ${shownAvg ?? '(not found)'}, want 80 (not 40)`); + +const noneScored = twoHiresOneScored.map((a) => ({ ...a, ai_score: 0 })); +const hiringNone = await renderCard('/src/components/krow/HiringAnalytics.jsx', + { applications: noneScored, interviews: [], staff: [] }); +record('...and shows an em dash rather than a confident 0 when none is scored', + /—<\/div>
]*>AVG SCORE/.test(hiringNone)); + +const impact = await renderCard('/src/components/krow/ImpactMetrics.jsx', + { applications: twoHiresOneScored, interviews: [], staff: [] }); +record('quality of hire averages only the scored hires', + impact.includes('80/100'), impact.includes('40/100') ? 'shows 40/100' : 'no 40/100'); +record('...and its caption counts the hires the figure is made of', + /1 scored hire/.test(impact), /2 hired candidates/.test(impact) ? 'still says 2 hired candidates' : ''); + +const dist = await renderCard('/src/components/krow/ScoreDistribution.jsx', + { applications: twoHiresOneScored }); +record('the score card says "scored", not "screened"', + /1 candidate scored/.test(dist.replace(/\s+/g, ' ')) && !/candidates? screened/.test(dist), + dist.replace(/\s+/g, ' ').match(/\d+ candidates? \w+/)?.[0] ?? '(caption not found)'); + await server.close(); /* ── 8. Production bundle ─────────────────────────────────────────────────── */ diff --git a/src/agents/activity-agent.md b/src/agents/activity-agent.md index a8e3dad..397733b 100644 --- a/src/agents/activity-agent.md +++ b/src/agents/activity-agent.md @@ -21,6 +21,9 @@ starters: permissions: owner: demo@krow.app access: all +tools: + - activity_breakdown + - activity_signals --- # Activity Agent diff --git a/src/agents/analytics-agent.md b/src/agents/analytics-agent.md index 8797eea..1ff6307 100644 --- a/src/agents/analytics-agent.md +++ b/src/agents/analytics-agent.md @@ -23,6 +23,14 @@ starters: permissions: owner: demo@krow.app access: all +tools: + - workspace_summary + - workforce_attendance + - workforce_overtime + - workforce_coverage + - candidates_quality + - hires_performance + - activity_breakdown --- # Analytics Agent diff --git a/src/agents/candidates-agent.md b/src/agents/candidates-agent.md index 51f0b60..6a753b0 100644 --- a/src/agents/candidates-agent.md +++ b/src/agents/candidates-agent.md @@ -21,6 +21,12 @@ starters: permissions: owner: demo@krow.app access: all +tools: + - candidates_quality + - talent_pool + - hires_recent + - candidates_awaiting + - move_application --- # Candidates Agent diff --git a/src/agents/control-center-agent.md b/src/agents/control-center-agent.md index c67ab19..d18383f 100644 --- a/src/agents/control-center-agent.md +++ b/src/agents/control-center-agent.md @@ -25,6 +25,16 @@ starters: permissions: owner: demo@krow.app access: all +tools: + - knowledge_search + - workspace_summary + - operations_risk + - activity_signals + - positions_risk + - workforce_coverage + - candidates_awaiting +sources: + - policy_docs --- # Control Center Agent diff --git a/src/agents/hired-history-agent.md b/src/agents/hired-history-agent.md index d43f204..90da6fa 100644 --- a/src/agents/hired-history-agent.md +++ b/src/agents/hired-history-agent.md @@ -19,6 +19,9 @@ starters: permissions: owner: demo@krow.app access: all +tools: + - hires_recent + - hires_performance --- # Hired History Agent diff --git a/src/agents/krow-forge-agent.md b/src/agents/krow-forge-agent.md index f3dcfa7..4d7bf63 100644 --- a/src/agents/krow-forge-agent.md +++ b/src/agents/krow-forge-agent.md @@ -20,6 +20,9 @@ starters: permissions: owner: demo@krow.app access: all +tools: + - workforce_training + - talent_pool --- # KROW Forge Agent diff --git a/src/agents/krow-workforce-agent.md b/src/agents/krow-workforce-agent.md index 13e909e..95690d9 100644 --- a/src/agents/krow-workforce-agent.md +++ b/src/agents/krow-workforce-agent.md @@ -78,6 +78,16 @@ permissions: people: - user: demo@krow.app role: manager +tools: + - workspace_summary + - operations_risk + - positions_risk + - workforce_attendance + - workforce_coverage + - candidates_quality + - talent_pool +sources: + - policy_docs --- # Krow Workforce Agent diff --git a/src/agents/positions-agent.md b/src/agents/positions-agent.md index 9fbc695..da55eeb 100644 --- a/src/agents/positions-agent.md +++ b/src/agents/positions-agent.md @@ -22,6 +22,15 @@ starters: permissions: owner: demo@krow.app access: all +tools: + - positions_risk + - open_positions + - available_workers + - workforce_coverage + - candidates_quality + - assign_worker + - candidates_awaiting + - move_application --- # Positions Agent diff --git a/src/agents/talent-pool-agent.md b/src/agents/talent-pool-agent.md index 16ecd86..31e320a 100644 --- a/src/agents/talent-pool-agent.md +++ b/src/agents/talent-pool-agent.md @@ -19,6 +19,10 @@ starters: permissions: owner: demo@krow.app access: all +tools: + - talent_pool + - workforce_training + - available_workers --- # Talent Pool Agent diff --git a/src/api/base44Client.js b/src/api/base44Client.js index 969aa77..cc619ac 100644 --- a/src/api/base44Client.js +++ b/src/api/base44Client.js @@ -11,16 +11,19 @@ * * React → base44Client.js → HTTP → Go API → PostgreSQL * - * The entity surface is unchanged. `store.js` and `seed.js` are no longer the - * source of data — the seeded dataset now lives in PostgreSQL, loaded by the - * backend's `make seed`. `seed.js` is still imported for one thing: the shape - * of the demo user's default preferences, which the synchronous accessor below - * needs before the first response arrives. + * The entity surface is unchanged. The seeded dataset lives in PostgreSQL, + * loaded by the backend's `make seed`, and nothing in the running app carries a + * copy of it: `store.js` — the localStorage database this replaced — is gone, + * and `seed.js` is now a test fixture that no production module imports. + * + * 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 { invokeLLM, uploadFile } from './aiEngine'; -import { DEMO_USER } from './seed'; +import { DEMO_USER } from './demoUser'; const ENTITY_NAMES = [ 'JobPosting', 'JobApplication', 'AIInterview', 'Staff', 'WorkerProfile', @@ -33,6 +36,14 @@ const ENTITY_NAMES = [ /* Shifts worked, missed and overrun. The operational record behind attendance and overtime analysis — see `api/attendanceSeed.js`. */ '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( @@ -210,7 +221,7 @@ const auth = { * Preferences, read synchronously. * * 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. */ 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 ───────────────────────────────────────────── */ 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. */ export { API_BASE_URL }; diff --git a/src/api/demoUser.js b/src/api/demoUser.js new file mode 100644 index 0000000..633dd1b --- /dev/null +++ b/src/api/demoUser.js @@ -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, + }, +}; diff --git a/src/api/httpClient.js b/src/api/httpClient.js index 39ad90c..a9b1f8e 100644 --- a/src/api/httpClient.js +++ b/src/api/httpClient.js @@ -109,6 +109,11 @@ const RESOURCE_PATHS = { User: 'users', Assignment: 'assignments', 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 ────────────────────────────────────────────────────────────── */ diff --git a/src/api/seed.js b/src/api/seed.js index bb155ce..570d9e9 100644 --- a/src/api/seed.js +++ b/src/api/seed.js @@ -9,6 +9,7 @@ */ import { SHIFT_RECORDS } from './attendanceSeed'; +import { DEMO_USER } from './demoUser'; 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', }), 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', phone: '+1-415-555-0113', years_experience: 7, english_level: 'native', job_posting_id: 'job_security', job_title: 'Event Security Officer', + status: 'rejected', updated_date: iso('2026-08-07'), }), unscreened({ 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', job_posting_id: 'job_bartender_corp', 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_match_label: 'Excellent Match', ai_summary: @@ -583,6 +593,9 @@ const JOB_APPLICATIONS = [ /* 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', applicant_name: 'Sofia Mendez', email: 'sofia.mendez@email.com', @@ -599,7 +612,7 @@ const JOB_APPLICATIONS = [ selfie_url: 'https://i.pravatar.cc/240?img=47', job_posting_id: 'job_server_fine', job_title: 'Event Server – Fine Dining', - status: 'ai_screened', + status: 'shortlisted', ai_score: 89, ai_match_label: 'Excellent Match', ai_summary: @@ -1426,9 +1439,18 @@ const WORKER_PROFILES = [ salary_expectations: '$30–$36/hr', leadership_potential: 92, ai_interview_score: 93, - krow_score: 94, - reliability_score: 95, - profile_completion: 100, + krow_score: 95, + reliability_score: 96, + 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, /* Seven years on the floor, as a completed ladder: Server and Leadership to Advanced, Customer Service to Advanced, Food Safety to Beginner. */ @@ -1479,9 +1501,18 @@ const WORKER_PROFILES = [ industries: ['Hospitality'], leadership_potential: 24, ai_interview_score: 0, - krow_score: 12, - reliability_score: 38, - profile_completion: 62, + krow_score: 30, + reliability_score: 37, + 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, /* One year in: the Beginner rung of Server, and one Intermediate module started — so her card reads "Beginner, advancing" rather than a flat @@ -1537,9 +1568,18 @@ const WORKER_PROFILES = [ salary_expectations: '$26–$32/hr', leadership_potential: 68, ai_interview_score: 86, - krow_score: 82, + krow_score: 87, 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, /* Server through Intermediate, plus two of the three Advanced modules — Fine Dining Service Standards is the one thing between him and Advanced, @@ -1585,9 +1625,18 @@ const WORKER_PROFILES = [ industries: ['Hospitality'], leadership_potential: 45, ai_interview_score: 74, - krow_score: 68, - reliability_score: 84, - profile_completion: 80, + krow_score: 78, + reliability_score: 86, + 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, completed_courses: completions( [...SERVER_BEGINNER, ...SERVER_INTERMEDIATE, ...CS_BEGINNER, ...CS_INTERMEDIATE, ...FOOD_BEGINNER], @@ -1624,9 +1673,18 @@ const WORKER_PROFILES = [ industries: ['Hospitality'], leadership_potential: 20, ai_interview_score: 0, - krow_score: 41, - reliability_score: 72, - profile_completion: 58, + krow_score: 35, + reliability_score: 42, + 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, completed_courses: completions( [...SERVER_BEGINNER, ...CS_BEGINNER, ...FOOD_BEGINNER, ...LEAD_BEGINNER], @@ -1665,7 +1723,16 @@ const WORKER_PROFILES = [ ai_interview_score: 0, krow_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, completed_courses: [], earned_badges: [], @@ -1700,7 +1767,16 @@ const WORKER_PROFILES = [ ai_interview_score: 0, krow_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, completed_courses: [], earned_badges: [], @@ -1735,7 +1811,16 @@ const WORKER_PROFILES = [ ai_interview_score: 0, krow_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, completed_courses: [], earned_badges: [], @@ -1770,7 +1855,16 @@ const WORKER_PROFILES = [ ai_interview_score: 0, krow_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, completed_courses: [], earned_badges: [], @@ -1878,33 +1972,56 @@ const USER_ACTIVITY = ACTIVITY_EVENTS.map(([event_type, user_email, user_name, a /* ── Signed-in demo user ───────────────────────────────────────────────── */ -export const DEMO_USER = { - id: 'user_demo', - full_name: 'Alex Rivera', - email: 'demo@krow.app', - role: 'admin', - account_type: 'employer', - created_date: iso('2026-06-01'), - /* 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, - }, -}; +/** + * Re-exported, not declared. + * + * The default user shape moved to `api/demoUser.js` so that `base44Client.js` + * can have it without importing this file — and with it every position, + * candidate, interview and shift record below — into the production bundle. + * The fixtures here are for the test scripts, and they still read + * `seedData.User` and `DEMO_USER` from this module, so both keep working. + */ +export { DEMO_USER } from './demoUser'; /* ── Assignments ─────────────────────────────────────────────────────────── 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 anything else. - Empty by design. This deployment has no assignment records yet: they are - written by the assignment flow, not shipped as seed. The engine reads an - empty set correctly — every position simply reports nobody assigned — and - the UI omits the demand figures rather than inventing coverage that does - not exist. */ -const ASSIGNMENTS = []; + Was empty by design, on the reasoning that assignments are written by the + assignment flow rather than shipped. That held until it turned out to be the + reason nothing exercised `assigned`: the funnel dropped that status out of + every stage bucket, and the agent's own candidate lookup offered somebody + already working a shift as a person to chase. Neither was visible against a + 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, and its dates are anchored to now rather than to this file's fixed calendar. diff --git a/src/api/store.js b/src/api/store.js deleted file mode 100644 index 3010c0f..0000000 --- a/src/api/store.js +++ /dev/null @@ -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; - }, - }; -} diff --git a/src/components/agents/AgentConfigure.jsx b/src/components/agents/AgentConfigure.jsx index eb3ce61..09440aa 100644 --- a/src/components/agents/AgentConfigure.jsx +++ b/src/components/agents/AgentConfigure.jsx @@ -1,13 +1,14 @@ import * as React from 'react'; import { BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus, - SlidersHorizontal, Trash2, X, + SlidersHorizontal, Trash2, Wrench, X, } from 'lucide-react'; import { Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Switch, Textarea, } from '@/components/ds'; 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 { AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip, @@ -96,6 +97,16 @@ function useActiveSection(ids) { /** @param {any} props */ export function AgentConfigure({ 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 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); + /** + * 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 instructionWords = React.useMemo( @@ -176,9 +213,10 @@ export function AgentConfigure({ id: 'capabilities', icon: Boxes, label: 'Capabilities', - meta: `${fields.pages.length + fields.knowledge.length}`, + meta: `${fields.pages.length + fields.skills.length + fields.knowledge.length}`, items: [ { 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 }, ], }, @@ -384,6 +422,176 @@ export function AgentConfigure({

+ {/* 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. */} +
+ + Browse catalog + + )} + /> + + {!fields.pages.length ? ( +

+ Choose a page first. Skills belong to pages, so there are none to offer until + this agent has somewhere to answer. +

+ ) : ( +
+ {fields.skills.length > 0 && ( +
    + {fields.skills.map((id) => { + const skill = availableSkills.find((sk) => sk.id === id); + return ( + onToggleSkill(id) : undefined} + /> + ); + })} +
+ )} + + {!fields.skills.length && ( +

+ No skills attached. This agent answers from the page's own reader only. +

+ )} + + + +

+ {attachableSkills.length + ? 'Attaching a skill takes effect immediately — it does not wait for Save.' + : 'Every skill these pages offer is already attached.'} +

+
+ )} +
+ + {/* 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. */} +
+ + +
+ {fields.tools.length > 0 && ( +
    + {fields.tools.map((name) => { + const tool = toolCatalogue.find((t) => t.name === name); + return ( + set({ tools: fields.tools.filter((t) => t !== name) })} + /> + ); + })} +
+ )} + + {!fields.tools.length && ( +

+ No tools attached. This agent can discuss its subject but cannot look + anything up. +

+ )} + + + +

+ A tool marked writes can propose a change. Nothing is written until + somebody approves it, and the agent can only reach what you could reach + yourself. +

+
+
+ {/* Knowledge ── what it has been told. */}
{ + ({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null, + feedback = null, onFeedback = null }) => { if (role === 'user') { return (
@@ -105,7 +106,7 @@ export const Message = React.memo( Owliver
- + {stopped &&

Stopped early.

} diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx index 69e966a..4a17a0f 100644 --- a/src/components/ai-assistant/KrowAssistant.jsx +++ b/src/components/ai-assistant/KrowAssistant.jsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { useQueryClient } from '@tanstack/react-query'; import { ArrowLeft, History, Maximize2, Minimize2, PanelRightClose, RotateCcw, Trash2, X, } from 'lucide-react'; @@ -9,28 +10,28 @@ import { IconButton } from '@/components/ds/IconButton'; import { Alert } from '@/components/ds/Alert'; import { useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription, - useMarkInterviewReady, useShiftRecords, useUpdateJobPosting, + fetchOwliverSuggestions, useMarkInterviewReady, useOwliverSuggestions, useShiftRecords, + useUpdateJobPosting, usePreferences, useRoleCategories, } from '@/lib/krowHooks'; import { ROLE_CATEGORIES } from '@/lib/roleCategories'; import { runAction } from '@/lib/skills/actions'; -import { allSkills, skillsForContext } from '@/lib/skills/registry'; -import { owliverSuggestions } from '@/lib/skills/owliverResolver'; +import { allSkills, pageKeyForContext } from '@/lib/skills/registry'; +import { suggestionChips } from '@/lib/skills/serverSuggestions'; import { useWorkforcePaths } from '@/lib/skills/usePageSkills'; import { profileForEmail } from '@/lib/skillGraph'; import { groupByRecency } from './history'; import { useAssistantPanel } from './AssistantPanelContext'; import { usePageContext } from './PageContext'; import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant'; -import { buildIntro, buildPrompts } from './dynamic'; -import { agentScopedDisabled, agentStarters } from '@/lib/agents/runtime'; +import { buildIntro } from './dynamic'; +import { agentScopedDisabled } from '@/lib/agents/runtime'; import { buildOwliverContext } from '@/lib/agents/context'; import { AgentBadge } from './AgentBadge'; import { useActiveAgent } from './AgentContext'; import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage'; import { PromptInput } from './PromptInput'; import { PromptChips } from './PromptChips'; -import { rankPrompts } from './matchPrompts'; /** * The chip row's "nothing to offer", as one shared array. @@ -41,6 +42,51 @@ import { rankPrompts } from './matchPrompts'; */ 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. * @@ -294,10 +340,6 @@ export default function KrowAssistant({ () => preferences.customSkills || [], [preferences.customSkills] ); - const skills = React.useMemo( - () => skillsForContext(context.id, disabledSkills, customSkills), - [context.id, disabledSkills, customSkills] - ); /** * 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 * 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 { statesFor } = useWorkforcePaths(); const trainingPaths = React.useMemo( @@ -380,6 +433,37 @@ export default function KrowAssistant({ return createJob.mutateAsync(result.data); }, [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. * @@ -463,7 +547,7 @@ export default function KrowAssistant({ const { messages, pending, error, busy, send, announce, stop, reset, history, conversationId, openConversation, forgetConversation, - submitFeedback, feedback, + submitFeedback, feedback, confirm, } = useConversation({ contextId: context.id, facts, @@ -471,6 +555,7 @@ export default function KrowAssistant({ onNavigate: goToPage, onAction: performAction, onCreatePosition: createPosition, + onRefreshSuggestions: refreshSuggestions, onUpdatePosition, onGenerateDescription, onAssignWorkers, @@ -532,103 +617,26 @@ export default function KrowAssistant({ () => buildIntro(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. * * They are separate states, not one merged list, because they answer to - * different owners. Follow-ups belong to the answer that raised them; typed - * matches belong to the composer; the catalogue belongs to the page. Only one - * of them can be true at a time, and the order below is that precedence. + * different owners. Follow-ups belong to the answer that raised them; the + * suggestions belong to the server. Only one of them can be true at a time, + * and the order below is that precedence. * * 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 * never capped and never filtered, and they stand until the next turn or * until the reader starts typing something else. * - * 2. Typed matches. From the second meaningful character, the catalogue - * above is ranked against the query and the best three are offered. - * These are the catalogue's own chip objects, so clicking one runs the - * same capability or skill it would have run when the panel offered the - * whole list outright. + * 2. The server's suggestions. From the moment there is something in the + * composer, `GET /api/v1/owliver/suggestions` is asked what this page + * can usefully answer for this query, and its reply is rendered in the + * order it arrived. The panel does not rank, score, filter or reorder + * 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 * 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 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(() => { if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS; - return rankPrompts(catalogue, typed); - }, [typed, followUp, catalogue]); + return suggestionChips(suggested, context.id); + }, [typed, followUp, suggested, context.id]); /* The newest assistant turn, which is the one that carries the rating. */ const lastAnswerIndex = React.useMemo( @@ -936,6 +966,10 @@ export default function KrowAssistant({ blocks={message.blocks} stopped={message.stopped} 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 the newest answer only — and never while one streams. */ feedback={message.role === 'assistant' && i === lastAnswerIndex ? feedback : null} diff --git a/src/components/ai-assistant/ResponseBlocks.jsx b/src/components/ai-assistant/ResponseBlocks.jsx index 2ae3371..e948828 100644 --- a/src/components/ai-assistant/ResponseBlocks.jsx +++ b/src/components/ai-assistant/ResponseBlocks.jsx @@ -491,8 +491,107 @@ const SkillSectionBlock = React.memo(/** @param {any} props */ ({ block }) => { }); 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 ( +
+
+ + +
+

{block.title}

+ {block.summary && ( +

{block.summary}

+ )} + + {block.details?.length > 0 && ( +
+ {block.details.map((d, i) => ( + +
{d.label}
+
{d.value}
+
+ ))} +
+ )} + + {/* Above the button, deliberately. */} + {block.warnings?.length > 0 && ( +
    + {block.warnings.map((w, i) => ( +
  • +
  • + ))} +
+ )} + +
+ {state === 'pending' ? ( + <> + + + + ) : ( +

+

+ )} +
+
+
+
+ ); +}); +ConfirmationBlock.displayName = 'ConfirmationBlock'; + const RENDERERS = { text: TextBlock, + confirmation: ConfirmationBlock, skillSection: SkillSectionBlock, heading: HeadingBlock, kpis: KpisBlock, @@ -515,7 +614,7 @@ const RENDERERS = { * blocks has consistent rhythm — a heading hugs what follows it, everything * 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 }) => (
{blocks.map((block, i) => { const Renderer = RENDERERS[block.type]; @@ -525,7 +624,7 @@ export const ResponseDocument = React.memo(/** @param {any} props */ ({ blocks = return (
- + {/* The cursor trails the final block only while text is still arriving. */} {streaming && isLast && (block.type === 'text' || block.type === 'heading') && ( /** A caveat. Always the last word on a claim, never the headline. */ 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. */ export const answer = (value) => doc(text(value)); diff --git a/src/components/ai-assistant/capabilities/admin.js b/src/components/ai-assistant/capabilities/admin.js deleted file mode 100644 index faee0dd..0000000 --- a/src/components/ai-assistant/capabilities/admin.js +++ /dev/null @@ -1,2142 +0,0 @@ -import { - Activity, AlertTriangle, ArrowLeftRight, Award, BellRing, Clock, Eye, FileText, Filter, Gauge, - GitBranch, Layers, Lightbulb, Lock, ScrollText, ShieldAlert, ShieldCheck, Star, Target, - TrendingUp, UserCheck, Users, -} from 'lucide-react'; -import { - actions, answer, doc, funnel, heading, insights, kpis, list, meters, note, status, table, - text, timeline, -} from '../blocks'; -import { pct, plural, verb } from '../insights'; -/* The proof-method wording is the Forge page's own, so a skill is never named - one thing in the library and another in the panel beside it. */ -import { TYPE_LABEL } from '@/components/forge/challengeMeta'; -import { toFICO } from '@/lib/talentHome'; -/* The vetting criteria's own labels. Read from the position model rather than - restated here, so the panel calls a weight what the form calls it. */ -import { CRITERIA_LABELS } from '@/lib/positionModel'; -import { PERMISSIONS, PROFILE_ACTIONS } from '@/lib/admin/permissions'; - -/** - * Admin capabilities — Control Center, Candidates Analysis, Activity. - * - * Platform-wide rather than pipeline-specific: data integrity, talent supply, - * audit reconciliation. - */ - -/* ── Control Center ─────────────────────────────────────────────────────── */ - -const platformHealth = (f) => { - const checks = [ - { - label: 'Data integrity', - ok: f.applications.every((a) => a.job_posting_id), - good: 'Every application links to a live posting.', - bad: 'Some applications reference a posting that no longer exists.', - }, - { - label: 'Screening coverage', - value: `${f.standardizedPct}%`, - ok: f.standardizedPct >= 80, - good: 'Applicants are scored consistently.', - bad: `${plural(f.unscreened.length, 'record')} unstandardized.`, - }, - { - label: 'Interview integrity', - ok: f.flagged.length === 0, - good: 'No interviews flagged.', - bad: `${plural(f.flagged.length, 'interview')} below full integrity.`, - }, - { - label: 'Profile completeness', - value: `${f.profiles.length - f.unscoredProfiles.length}/${f.profiles.length}`, - ok: f.unscoredProfiles.length <= f.profiles.length / 2, - good: 'Most talent profiles carry a career score.', - bad: `${plural(f.unscoredProfiles.length, 'profile')} without a score cannot be matched or ranked.`, - }, - { - label: 'Throughput', - value: `${f.hireRate}%`, - ok: f.hired.length > 0, - good: `${plural(f.hired.length, 'hire')} closed.`, - bad: 'No hires closed yet, so there is no conversion baseline.', - }, - ].map((c) => ({ label: c.label, value: c.value, ok: c.ok, note: c.ok ? c.good : c.bad })); - - const passing = checks.filter((c) => c.ok).length; - - return doc( - heading(`Platform health: ${passing}/${checks.length} passing`), - status(checks), - heading('Scale'), - kpis([ - { label: 'Positions', value: f.postings.length, sub: `${f.openPositions.length} open` }, - { label: 'Applications', value: f.total }, - { label: 'Interviews', value: f.interviews.length, sub: `${f.completedInterviews.length} scored` }, - { label: 'Talent pool', value: f.profiles.length, sub: `${f.elite.length} Elite` }, - ]), - text( - passing === checks.length - ? 'Nothing needs intervention at the platform level.' - : 'The failing checks are data-quality issues, not outages — they degrade matching quality rather than break anything.' - ) - ); -}; - -/** - * Workforce summary — the platform's scale in one read. - * - * Answers "how big is this thing and what is it made of", which is a different - * question from "is it healthy": no checks, no verdicts, just the composition. - */ -const workforceSummary = (f) => doc( - heading('Workforce summary', `${plural(f.postings.length, 'position')} · ${plural(f.profiles.length, 'worker')}`), - kpis([ - { label: 'Open roles', value: f.openPositions.length, sub: `of ${f.postings.length}` }, - { label: 'Applicants', value: f.total, sub: `${f.scored.length} scored` }, - { label: 'On staff', value: f.staff.length, sub: `${f.hired.length} hired here` }, - { label: 'Talent pool', value: f.profiles.length, sub: `${f.elite.length} Elite` }, - ]), - heading('Where the people are'), - meters([ - { label: 'In the pipeline', value: f.total, max: f.total + f.profiles.length }, - { label: 'In the talent pool', value: f.profiles.length, max: f.total + f.profiles.length }, - { label: 'Placed on staff', value: f.staff.length, max: f.total + f.profiles.length, tone: 'success' }, - ]), - heading('Demand by role category'), - table( - [ - { key: 'category', label: 'Category' }, - { key: 'roles', label: 'Roles', align: 'right' }, - { key: 'applied', label: 'Applicants', align: 'right' }, - ], - Object.values(f.byRole.reduce((acc, r) => { - acc[r.category] ||= { category: r.category, roles: 0, applied: 0 }; - acc[r.category].roles += 1; - acc[r.category].applied += r.applied; - return acc; - }, {})).sort((a, b) => b.applied - a.applied) - ), - text( - f.profiles.length > f.total - ? `The talent pool is larger than the live pipeline — ${f.profiles.length} registered workers against ${f.total} applications. Most of the supply is not applying to anything.` - : 'Most of the platform\'s people are attached to a live application rather than sitting in the pool.' - ) -); - -/** - * Hiring operations — throughput and speed. - * - * The operational question is whether work is moving, so this leads with rate - * and duration rather than totals. - */ -const hiringOperations = (f) => { - const perRole = f.byRole - .slice() - .sort((a, b) => b.applied - a.applied) - .map((r) => ({ - title: r.title, - applied: r.applied, - qualified: r.qualified, - hired: r.hired || '—', - rate: { value: `${r.applied ? Math.round((r.qualified / r.applied) * 100) : 0}%`, tone: r.applied && r.qualified / r.applied >= 0.5 ? 'success' : 'warning' }, - })); - - return doc( - heading('Hiring operations'), - kpis([ - { label: 'Applicants', value: f.total }, - { label: 'Hires', value: f.hired.length, tone: 'success', sub: `${f.hireRate}% of applicants` }, - { label: 'Time to hire', value: f.timeToHire ? `${f.timeToHire}d` : '—' }, - { label: 'Screening', value: `${f.standardizedPct}%`, tone: f.standardizedPct >= 80 ? 'success' : 'warning', sub: 'coverage' }, - ]), - heading('Throughput 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' }, - { key: 'rate', label: 'Qualified', align: 'right' }, - ], - perRole - ), - insights([ - f.starvedPositions.length && { - tone: 'warning', - title: `${plural(f.starvedPositions.length, 'role')} attracting nobody`, - body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Throughput cannot improve on a role with no input.`, - }, - f.timeToHire > 0 && { - tone: f.timeToHire <= 7 ? 'success' : 'warning', - title: `Hires close in ${plural(f.timeToHire, 'day')}`, - body: f.timeToHire <= 7 - ? 'Fast enough that strong candidates are unlikely to be lost to a competing offer.' - : 'Long enough that the best candidates may accept elsewhere first.', - }, - ]), - note('Time-to-hire is measured from application to decision, so it reflects this platform\'s part of the process only.') - ); -}; - -/** - * Pipeline health — the funnel and its worst step. - * - * A funnel block rather than a table: the shape is the finding, and the single - * weakest transition is what someone can actually act on. - */ -const pipelineHealth = (f) => { - if (!f.total) return answer('Nothing has entered the pipeline yet, so there is no funnel to read.'); - - const worst = f.bottleneck; - - return doc( - heading('Pipeline health', `${plural(f.total, 'candidate')} entered`), - funnel(f.funnel.map((stage, i) => ({ - label: stage.label, - count: stage.count, - rate: i === 0 ? null : f.transitions[i - 1]?.rate, - lost: i === 0 ? 0 : f.transitions[i - 1]?.lost, - }))), - worst && worst.lost > 0 - ? insights([{ - tone: worst.rate < 50 ? 'risk' : 'warning', - title: `${worst.from} → ${worst.to} is the bottleneck`, - body: `Only ${worst.rate}% pass through, losing ${plural(worst.lost, 'candidate')}. Every other step performs better, so this is where recovered conversion is cheapest.`, - }]) - : insights([{ - tone: 'success', - title: 'No single step is losing candidates', - body: 'Conversion is even across the funnel — there is no cheap win to recover here.', - }]), - heading('Stage-to-stage rates'), - table( - [ - { key: 'step', label: 'Transition' }, - { key: 'rate', label: 'Pass', align: 'right' }, - { key: 'lost', label: 'Lost', align: 'right' }, - ], - f.transitions.map((t) => ({ - step: `${t.from} → ${t.to}`, - rate: { value: `${t.rate}%`, tone: t.rate < 60 ? 'warning' : 'success' }, - lost: t.lost ? { value: `−${t.lost}`, tone: 'risk' } : '0', - })) - ) - ); -}; - -/** - * Attention required — the triage queue, ordered by cost of inaction. - * - * The same items the Control Center lists, because the panel must never rank - * things differently from the page beside it. - */ -const attentionRequired = (f) => { - const items = [ - f.starvedPositions.length && { - tone: 'risk', - title: `${plural(f.starvedPositions.length, 'role')} with no candidate supply`, - body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Live and attracting nobody — usually a pay band or distribution problem.`, - }, - f.stalled.length && { - tone: 'risk', - title: `${plural(f.stalled.length, 'strong candidate')} not actioned`, - body: `${f.stalled.map((a) => a.applicant_name).join(', ')} scored 80 or above and ${verb(f.stalled.length, 'is', 'are')} still sitting at AI Screened. The most expensive kind of delay.`, - }, - f.unscreened.length && { - tone: 'warning', - title: `${plural(f.unscreened.length, 'applicant')} waiting on screening`, - body: 'Each unscored application is a decision made without a standard.', - }, - f.unscoredProfiles.length && { - tone: 'warning', - title: `${plural(f.unscoredProfiles.length, 'talent profile')} without a score`, - body: 'Invisible to matching, so employers cannot find them even when they fit.', - }, - f.unratedStaff.length && { - tone: 'info', - title: `${plural(f.unratedStaff.length, 'hire')} awaiting review`, - body: 'Employer ratings are the only verified performance signal feeding scores.', - }, - f.flagged.length && { - tone: 'warning', - title: `${plural(f.flagged.length, 'interview')} flagged for integrity`, - body: 'Worth clearing before those candidates progress.', - }, - ].filter(Boolean); - - if (!items.length) { - return answer('Nothing needs intervention. Every open role has candidates, every applicant is scored, and no interview is flagged.'); - } - - return doc( - heading(`${plural(items.length, 'item')} need attention`, 'Ordered by cost of leaving it'), - insights(items), - text(`Start with ${items[0].title.toLowerCase()} — it is the one where waiting costs the most.`) - ); -}; - -const recommendations = (f) => { - const items = [ - f.unscoredProfiles.length && { - title: `Prompt ${plural(f.unscoredProfiles.length, 'worker')} to finish onboarding`, - body: 'Without a career score they are invisible to matching, so employers cannot find them even when they fit.', - }, - f.unscreened.length && { - title: `${plural(f.unscreened.length, 'application')} unscored`, - body: 'Each one is a decision made without a standard.', - }, - f.starvedPositions.length && { - title: `${plural(f.starvedPositions.length, 'posting')} with zero applicants`, - body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Check distribution and pay bands.`, - }, - f.unratedStaff.length && { - title: `${plural(f.unratedStaff.length, 'hire')} unrated`, - body: 'Employer ratings are the only verified performance signal feeding scores; without them the scores stay theoretical.', - }, - f.flagged.length && { - title: `Review ${plural(f.flagged.length, 'flagged interview')}`, - body: 'Worth clearing before those candidates progress.', - }, - ].filter(Boolean); - - if (!items.length) return answer('No platform actions outstanding. Data is complete and nothing is flagged.'); - - return doc(heading('Recommended actions', 'Ordered by impact on matching quality'), actions(items)); -}; - -/* ── Candidates Analysis ────────────────────────────────────────────────── */ - -const recruitmentInsights = (f) => { - const band = (min, max) => f.profiles.filter((p) => { - const s = p.krow_score || 0; - return s >= min && (max == null || s < max); - }).length; - - const bands = [ - { label: 'Elite', count: band(90), tone: 'info' }, - { label: 'Excellent', count: band(75, 90), tone: 'success' }, - { label: 'Solid', count: band(60, 75) }, - { label: 'Building', count: band(1, 60), tone: 'warning' }, - { label: 'Unscored', count: f.unscoredProfiles.length, tone: 'risk' }, - ]; - - const verified = f.profiles.filter((p) => (p.capabilities || []).length).length; - const topHeavy = band(90) + band(75, 90); - - return doc( - heading('Talent supply', `${plural(f.profiles.length, 'worker')} in the pool`), - table( - [{ key: 'label', label: 'Band' }, { key: 'count', label: 'Workers', align: 'right' }], - bands.map((b) => ({ label: b.label, count: { value: b.count, tone: b.tone } })) - ), - heading('Pipeline'), - kpis([ - { label: 'Applicants', value: f.total, sub: `avg ${f.avgScore}` }, - { label: 'At 80+', value: f.ranked.filter((a) => a.ai_score >= 80).length, tone: 'success' }, - { label: 'Verified skills', value: verified, sub: 'with evidence' }, - { label: 'Applications', value: f.eventCounts.apply_job || 0, sub: 'logged' }, - ]), - insights([ - f.unscoredProfiles.length > topHeavy - ? { - tone: 'warning', - title: 'The pool is bottom-heavy', - body: `${f.unscoredProfiles.length} unscored against ${topHeavy} at Excellent or above. Supply is not the constraint — verification is.`, - } - : { - tone: 'success', - title: 'Verification is keeping pace', - body: 'The scored portion of the pool outweighs the unscored.', - }, - ]), - heading('By role category'), - table( - [ - { key: 'title', label: 'Role' }, - { key: 'applied', label: 'Applied', align: 'right' }, - { key: 'qualified', label: '70+', align: 'right' }, - ], - f.byRole.slice().sort((a, b) => b.applied - a.applied) - .map((r) => ({ title: r.title, applied: r.applied, qualified: r.qualified })) - ) - ); -}; - -const candidateRiskAnalysis = (f) => { - const noHistory = f.scored.filter((a) => !a.years_experience); - - const risks = [ - f.missingCredentials.length && { - tone: 'risk', - title: `${plural(f.missingCredentials.length, 'candidate')} with no credentials`, - body: 'Compliance risk if placed on a role that requires certification.', - }, - f.narrowAvailability.length && { - tone: 'warning', - title: `${plural(f.narrowAvailability.length, 'candidate')} with narrow availability`, - body: 'One slot or none — most likely to fall through on the day.', - }, - f.flagged.length && { - tone: 'risk', - title: `${plural(f.flagged.length, 'interview')} flagged for integrity`, - body: 'Usually suspiciously fast responses. Worth a human re-interview.', - }, - noHistory.length && { - tone: 'warning', - title: `${plural(noHistory.length, 'candidate')} with no recorded experience`, - body: 'Not disqualifying, but unverifiable on paper.', - }, - f.singleCandidateRoles.length && { - tone: 'warning', - title: 'Concentration risk', - body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'depends', 'depend')} on a single viable candidate.`, - }, - ].filter(Boolean); - - if (!risks.length) { - return answer('No material risk flags: credentials, availability and interview integrity all check out across the scored pool.'); - } - - return doc( - heading(`${plural(risks.length, 'risk flag')}`), - insights(risks), - note('Flags to check, not judgements. Each is a question for a human, not a rejection.') - ); -}; - -/** - * Screening gaps — where the record is incomplete. - * - * Distinct from risk: risk is what the data says about a candidate, this is what - * the data fails to say at all. An unscored applicant is not a weak applicant, - * and conflating the two is how good people get passed over. - */ -const screeningGaps = (f) => { - const gaps = [ - { - label: 'Unscored applications', - count: f.unscreened.length, - note: 'Cannot be ranked or compared against anyone.', - }, - { - label: 'Scored without credentials', - count: f.missingCredentials.length, - note: 'No certifications on file to verify against a role requirement.', - }, - { - label: 'No recorded experience', - count: f.scored.filter((a) => !a.years_experience).length, - note: 'Scored on other dimensions, unverifiable on history.', - }, - { - label: 'Interviews started, not scored', - count: f.interviews.length - f.completedInterviews.length, - note: 'Abandoned before a verdict, so the interview signal is missing.', - }, - { - label: 'Availability not set', - count: f.narrowAvailability.length, - note: 'One slot or none recorded, so scheduling is guesswork.', - }, - ].filter((g) => g.count > 0); - - if (!gaps.length) { - return answer('No screening gaps. Every applicant is scored, credentialed and has availability recorded, and every interview reached a verdict.'); - } - - return doc( - heading('Screening gaps', `${plural(gaps.length, 'kind')} of missing data`), - table( - [ - { key: 'label', label: 'Gap' }, - { key: 'count', label: 'Records', align: 'right' }, - ], - gaps.map((g) => ({ label: g.label, count: { value: g.count, tone: 'warning' } })) - ), - heading('Coverage'), - status([ - { - label: 'Screening coverage', - value: `${f.standardizedPct}%`, - ok: f.standardizedPct >= 80, - note: f.standardizedPct >= 80 - ? 'Enough of the pool is scored for comparisons to mean something.' - : `${plural(f.unscreened.length, 'applicant')} outside the standard.`, - }, - { - label: 'Interview completion', - value: `${f.interviewCompletion}%`, - ok: f.interviewCompletion >= 80, - note: f.interviewCompletion >= 80 - ? 'Most interviews reach a verdict.' - : 'A meaningful share of interviews stop before scoring.', - }, - ]), - list(gaps.map((g) => `**${g.label}** — ${g.note}`)), - note('A gap is missing evidence, not a negative finding. These candidates are unassessed, not unsuitable.') - ); -}; - -/** - * Hiring recommendations — who to move on, and in what order. - * - * Named people with a stated reason. A recommendation that does not name anyone - * is an observation, and the Admin already has plenty of those. - */ -const hiringRecommendations = (f) => { - if (!f.scored.length) { - return answer('Nothing is scored yet, so there is no basis for a recommendation. Screen the pipeline first.'); - } - - const advance = f.ranked.filter((a) => a.ai_score >= 80 && a.status === 'ai_screened').slice(0, 4); - const interview = f.ranked.filter((a) => a.ai_score >= 60 && a.ai_score < 80 && a.status === 'ai_screened').slice(0, 4); - const review = f.unscreened.slice(0, 4); - - const rows = [ - ...advance.map((a) => ({ - name: a.applicant_name, - role: a.job_title, - score: { value: a.ai_score, tone: 'success' }, - action: { badge: 'Advance', tone: 'success' }, - })), - ...interview.map((a) => ({ - name: a.applicant_name, - role: a.job_title, - score: { value: a.ai_score, tone: 'info' }, - action: { badge: 'Interview', tone: 'info' }, - })), - ...review.map((a) => ({ - name: a.applicant_name, - role: a.job_title, - score: '—', - action: { badge: 'Screen', tone: 'warning' }, - })), - ]; - - return doc( - heading('Recommended next actions', `${plural(rows.length, 'candidate')}`), - table( - [ - { key: 'name', label: 'Candidate' }, - { key: 'role', label: 'Role' }, - { key: 'score', label: 'Score', align: 'right' }, - { key: 'action', label: 'Action', align: 'right' }, - ], - rows - ), - actions([ - advance.length && { - title: `Advance ${plural(advance.length, 'candidate')} scoring 80 or above`, - body: `${advance.map((a) => a.applicant_name).join(', ')}. Already screened and waiting — the delay, not the assessment, is what loses these people.`, - }, - review.length && { - title: `Screen ${plural(review.length, 'waiting applicant')}`, - body: 'Until they are scored they cannot be compared with anyone already in the funnel.', - }, - f.singleCandidateRoles.length && { - title: `Widen supply on ${plural(f.singleCandidateRoles.length, 'role')}`, - body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')} ${verb(f.singleCandidateRoles.length, 'rests', 'rest')} on one viable candidate. One withdrawal reopens the search.`, - }, - ]), - note('Ranked on screening data only. Nothing here accounts for a conversation you have had that the platform has not seen.') - ); -}; - -/** - * Top candidates side by side. - * - * A comparison is a table — the whole point is reading the same dimension across - * people, which prose makes impossible. - */ -const topCandidates = (f) => { - const top = f.ranked.slice(0, 5); - if (!top.length) return answer('No scored candidates yet, so there is nobody to compare.'); - - const DIMENSIONS = ['experience', 'english', 'reliability', 'certifications', 'availability']; - - return doc( - heading('Strongest candidates', `Top ${top.length} by KROW score`), - table( - [ - { key: 'name', label: 'Candidate' }, - { key: 'role', label: 'Applied for' }, - { key: 'score', label: 'Score', align: 'right' }, - { key: 'stage', label: 'Stage', align: 'right' }, - ], - top.map((a) => ({ - name: a.applicant_name, - role: a.job_title, - score: { value: a.ai_score, tone: a.ai_score >= 80 ? 'success' : 'info' }, - stage: { badge: String(a.status).replace(/_/g, ' '), tone: a.status === 'hired' ? 'success' : 'neutral' }, - })) - ), - heading('Score breakdown', top[0].applicant_name), - meters(DIMENSIONS.map((d) => ({ - label: d[0].toUpperCase() + d.slice(1), - value: top[0].score_breakdown?.[d] ?? 0, - max: 100, - }))), - insights([ - top.length >= 2 && { - tone: 'info', - title: `${top[0].applicant_name} leads by ${top[0].ai_score - top[1].ai_score} points`, - body: top[0].ai_score - top[1].ai_score <= 5 - ? `Effectively level with ${top[1].applicant_name} — close enough that the choice should come down to a conversation, not the score.` - : `A clear enough margin over ${top[1].applicant_name} to act on.`, - }, - f.stalled.length && { - tone: 'warning', - title: `${plural(f.stalled.length, 'candidate')} at 80+ waiting on a decision`, - body: f.stalled.map((a) => a.applicant_name).join(', '), - }, - ]) - ); -}; - -/* ── Activity ───────────────────────────────────────────────────────────── */ - -const auditSummary = (f) => { - const count = (type) => f.activity.filter((e) => e.event_type === type).length; - const hires = count('hire_candidate'); - const screens = count('screen_candidate'); - - return doc( - heading('Audit summary'), - kpis([ - { label: 'Total events', value: f.activity.length }, - { label: 'Last 7 days', value: f.activity7d.length }, - { label: 'Last 24 hours', value: f.activity24h.length, tone: f.activity24h.length ? undefined : 'warning' }, - { label: 'Distinct users', value: new Set(f.activity.map((e) => e.user_email)).size }, - ]), - heading('Privileged actions'), - table( - [{ key: 'action', label: 'Action' }, { key: 'count', label: 'Events', align: 'right' }], - [ - { action: 'Position created', count: count('create_position') }, - { action: 'Candidate screened', count: screens }, - { action: 'Candidate hired', count: hires }, - { action: 'Interview started', count: count('start_interview') }, - ] - ), - heading('Reconciliation'), - status([ - { - label: 'Hires match staff records', - ok: hires === f.staff.length, - value: `${hires}/${f.staff.length}`, - note: hires === f.staff.length - ? 'Every person on staff has a matching hire event.' - : `${Math.abs(f.staff.length - hires)} unexplained.`, - }, - { - label: 'Screening events match scores', - ok: screens >= f.scored.length, - value: `${screens}/${f.scored.length}`, - note: screens < f.scored.length - ? 'Batch screening records one event per run, not per candidate.' - : 'Screening events reconcile with scored candidates.', - }, - ]), - note('An audit trail is only as good as its gaps. The reconciliation above is what I would check first in a real review.') - ); -}; - -const userActivityInsights = (f) => { - const perUser = f.activity.reduce((acc, e) => { - acc[e.user_email] ||= { name: e.user_name, role: e.account_type, count: 0, types: new Set() }; - acc[e.user_email].count += 1; - acc[e.user_email].types.add(e.event_type); - return acc; - }, {}); - - const ranked = Object.entries(perUser).sort((a, b) => b[1].count - a[1].count); - if (!ranked.length) return answer('No activity to profile yet.'); - - const [topEmail, top] = ranked[0]; - const share = Math.round((top.count / f.activity.length) * 100); - - return doc( - heading('Most active accounts'), - table( - [ - { key: 'name', label: 'Account' }, - { key: 'role', label: 'Role' }, - { key: 'events', label: 'Events', align: 'right' }, - ], - ranked.slice(0, 6).map(([email, d]) => ({ - name: d.name || email, - role: { badge: d.role || 'unknown', tone: d.role === 'employer' ? 'info' : 'neutral' }, - events: { value: d.count, tone: d.count === top.count ? 'info' : undefined }, - })) - ), - insights([ - share >= 50 - ? { - tone: 'warning', - title: `One account is ${share}% of all activity`, - body: `${top.name || topEmail} accounts for ${top.count} of ${f.activity.length} events. On a live platform, that concentration is worth confirming is a person and not an integration.`, - } - : { - tone: 'success', - title: 'Activity is spread across accounts', - body: `No single account dominates the log — the busiest is ${share}%.`, - }, - f.activity24h.length === 0 && { - tone: 'warning', - title: 'Nothing in the last 24 hours', - body: 'Expected for a demo dataset; on a live platform it would be worth checking ingestion.', - }, - ]) - ); -}; - -/** - * Privileged actions carry more audit weight than reads. Kept in step with the - * Activity page's own severity table — one definition of "privileged", so the - * panel and the log can never disagree about what matters. - */ -const HIGH_SEVERITY = ['hire_candidate', 'create_position']; -const MEDIUM_SEVERITY = ['assign_employee', 'screen_candidate', 'start_interview']; - -/** - * Unusual activity — what is out of pattern, stated as a pattern. - * - * Deliberately not "suspicious". These are deviations from the platform's own - * baseline, and on a real deployment most of them turn out to be an integration - * or a busy afternoon. Saying otherwise would manufacture alarm. - */ -const unusualActivity = (f) => { - if (!f.activity.length) return answer('No activity logged yet, so there is no baseline to deviate from.'); - - const perUser = f.activity.reduce((acc, e) => { - acc[e.user_email] ||= { name: e.user_name, count: 0, privileged: 0, types: new Set() }; - acc[e.user_email].count += 1; - acc[e.user_email].types.add(e.event_type); - if (HIGH_SEVERITY.includes(e.event_type)) acc[e.user_email].privileged += 1; - return acc; - }, {}); - - const accounts = Object.entries(perUser); - const busiest = accounts.slice().sort((a, b) => b[1].count - a[1].count)[0]; - const share = busiest ? Math.round((busiest[1].count / f.activity.length) * 100) : 0; - - /* Bursts: more than three events from one account inside an hour. */ - const byHour = f.activity.reduce((acc, e) => { - const key = `${e.user_email}|${String(e.created_date).slice(0, 13)}`; - acc[key] = (acc[key] || 0) + 1; - return acc; - }, {}); - const bursts = Object.entries(byHour).filter(([, n]) => n > 3); - - const privileged = f.activity.filter((e) => HIGH_SEVERITY.includes(e.event_type)); - const offHours = f.activity.filter((e) => { - const hour = new Date(e.created_date).getHours(); - return hour < 6 || hour >= 22; - }); - - const patterns = [ - share >= 50 && { - tone: 'warning', - title: `One account is ${share}% of all activity`, - body: `${busiest[1].name || busiest[0]} accounts for ${busiest[1].count} of ${f.activity.length} events. Worth confirming that is a person and not an integration running under someone's credentials.`, - }, - bursts.length && { - tone: 'warning', - title: `${plural(bursts.length, 'burst')} of rapid activity`, - body: 'More than three actions from one account inside a single hour. Normal for batch work, worth a glance if it is not expected.', - }, - offHours.length && { - tone: 'info', - title: `${plural(offHours.length, 'event')} outside working hours`, - body: 'Logged before 06:00 or after 22:00 local time. Not a problem on its own; a pattern of privileged actions at those hours would be.', - }, - f.activity24h.length === 0 && { - tone: 'warning', - title: 'Nothing in the last 24 hours', - body: 'Expected for a seeded dataset. On a live platform, silence is an ingestion question rather than a quiet day.', - }, - privileged.length > f.activity.length * 0.3 && { - tone: 'warning', - title: `Privileged actions are ${Math.round((privileged.length / f.activity.length) * 100)}% of the log`, - body: 'A log dominated by hires and position changes usually means routine reads are not being captured, which weakens the audit trail.', - }, - ].filter(Boolean); - - if (!patterns.length) { - return doc( - heading('Nothing out of pattern'), - kpis([ - { label: 'Events', value: f.activity.length }, - { label: 'Accounts', value: accounts.length }, - { label: 'Busiest share', value: `${share}%`, tone: 'success' }, - ]), - text('Activity is spread across accounts, there are no bursts, and privileged actions are a normal share of the log.') - ); - } - - return doc( - heading(`${plural(patterns.length, 'pattern')} worth attention`), - insights(patterns), - heading('Per account'), - table( - [ - { key: 'name', label: 'Account' }, - { key: 'events', label: 'Events', align: 'right' }, - { key: 'privileged', label: 'Privileged', align: 'right' }, - ], - accounts - .sort((a, b) => b[1].count - a[1].count) - .slice(0, 6) - .map(([email, d]) => ({ - name: d.name || email, - events: { value: d.count, tone: d.count === busiest[1].count ? 'info' : undefined }, - privileged: d.privileged ? { value: d.privileged, tone: 'warning' } : '0', - })) - ), - note('Deviations from this platform\'s own baseline, not accusations. Each one is a question to ask, and most have a dull answer.') - ); -}; - -/** - * Security insights — the audit trail judged as a control, not as a feed. - * - * The question a security reviewer actually asks is whether the log would stand - * up: are privileged actions attributable, is coverage complete, is anything - * unaccounted for. So this reports on the trail itself. - */ -const securityInsights = (f) => { - const privileged = f.activity.filter((e) => HIGH_SEVERITY.includes(e.event_type)); - const attributed = privileged.filter((e) => e.user_email && e.user_name); - const named = f.activity.filter((e) => e.user_email); - const hires = f.activity.filter((e) => e.event_type === 'hire_candidate').length; - - const checks = [ - { - label: 'Privileged actions attributable', - value: `${attributed.length}/${privileged.length}`, - ok: privileged.length === attributed.length, - note: privileged.length === attributed.length - ? 'Every hire and position change traces to a named account.' - : `${privileged.length - attributed.length} cannot be traced to a named person.`, - }, - { - label: 'Events carry an actor', - value: `${named.length}/${f.activity.length}`, - ok: named.length === f.activity.length, - note: named.length === f.activity.length - ? 'No anonymous entries in the log.' - : `${f.activity.length - named.length} entries have no account attached.`, - }, - { - label: 'Hires reconcile with staff', - value: `${hires}/${f.staff.length}`, - ok: hires === f.staff.length, - note: hires === f.staff.length - ? 'Every person on staff has a matching hire event.' - : `${Math.abs(f.staff.length - hires)} on staff without a corresponding hire event, or the reverse.`, - }, - { - label: 'Recent coverage', - value: `${f.activity24h.length} in 24h`, - ok: f.activity24h.length > 0, - note: f.activity24h.length - ? 'The log is being written to.' - : 'No events in the last day — on a live platform, check ingestion before concluding it was quiet.', - }, - { - label: 'Interview integrity', - value: `${f.interviews.length - f.flagged.length}/${f.interviews.length}`, - ok: f.flagged.length === 0, - note: f.flagged.length - ? `${plural(f.flagged.length, 'interview')} below full integrity — usually implausibly fast responses.` - : 'No interview flagged for integrity.', - }, - ]; - - const passing = checks.filter((c) => c.ok).length; - - return doc( - heading(`Security posture: ${passing}/${checks.length} checks passing`), - status(checks), - heading('Privileged action log', `${plural(privileged.length, 'event')}`), - privileged.length - ? timeline( - privileged.slice(0, 6).map((e) => ({ - title: String(e.event_type).replace(/_/g, ' '), - description: `${e.user_name || e.user_email}${e.details ? ` — ${e.details}` : ''}`, - timestamp: new Date(e.created_date).toLocaleString(undefined, { - month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', - }), - tone: MEDIUM_SEVERITY.includes(e.event_type) ? 'warning' : 'risk', - })) - ) - : text('No privileged actions in this log.'), - text( - passing === checks.length - ? 'The trail would hold up to review: every privileged action is attributable and the records reconcile.' - : 'The failing checks are completeness problems. They do not mean something happened — they mean you could not prove it had not.' - ) - ); -}; - -/* ── Positions ──────────────────────────────────────────────────────────── */ - -/** - * Position strength — which roles are actually working. - * - * Ranked by qualified conversion rather than raw applicants: a role with twelve - * applications and nobody above the bar is in worse shape than one with two who - * both clear it, and sorting by volume hides exactly that. - */ -const positionStrength = (f) => { - if (!f.openPositions.length) return answer('There are no open positions to compare.'); - - const rows = f.byRole - .map((r) => ({ ...r, rate: r.applied ? Math.round((r.qualified / r.applied) * 100) : 0 })) - .sort((a, b) => b.rate - a.rate || b.applied - a.applied); - - const best = rows.find((r) => r.applied > 0); - - return doc( - heading('Pipeline strength by position', `${plural(rows.length, 'open role')}`), - table( - [ - { key: 'title', label: 'Role' }, - { key: 'applied', label: 'Applied', align: 'right' }, - { key: 'qualified', label: '70+', align: 'right' }, - { key: 'rate', label: 'Qualified', align: 'right' }, - ], - rows.map((r) => ({ - title: r.title, - applied: r.applied ? r.applied : { value: 0, tone: 'risk' }, - qualified: r.qualified, - rate: { value: `${r.rate}%`, tone: r.rate >= 50 ? 'success' : r.rate > 0 ? 'warning' : 'risk' }, - })) - ), - insights([ - best && { - tone: 'success', - title: `${best.title} has the strongest pipeline`, - body: `${best.qualified} of ${best.applied} applicants clear the bar — ${best.rate}%. If you are filling one role first, this is the one that can actually close.`, - }, - f.starvedPositions.length && { - tone: 'risk', - title: `${plural(f.starvedPositions.length, 'role')} with no supply at all`, - body: `${f.starvedPositions.map((p) => p.title).join(', ')}. No amount of screening improves a role nobody has applied to.`, - }, - ]) - ); -}; - -/** Which roles need a person, and why. */ -const positionsNeedingAttention = (f) => { - const items = [ - f.starvedPositions.length && { - tone: 'risk', - title: `${plural(f.starvedPositions.length, 'role')} with no applicants`, - body: `${f.starvedPositions.map((p) => p.title).join(', ')}. Usually a pay band or distribution problem rather than a hiring one.`, - }, - f.singleCandidateRoles.length && { - tone: 'warning', - title: `${plural(f.singleCandidateRoles.length, 'role')} resting on one viable candidate`, - body: `${f.singleCandidateRoles.map((e) => e.posting.title).join(', ')}. One withdrawal reopens the search.`, - }, - f.unscreened.length && { - tone: 'warning', - title: `${plural(f.unscreened.length, 'applicant')} waiting on screening`, - body: 'Spread across the open roles. Until they are scored the funnels below them are incomplete.', - }, - f.stalled.length && { - tone: 'risk', - title: `${plural(f.stalled.length, 'strong candidate')} not actioned`, - body: `${f.stalled.map((a) => a.applicant_name).join(', ')} scored 80 or above and ${verb(f.stalled.length, 'is', 'are')} still waiting.`, - }, - ].filter(Boolean); - - if (!items.length) { - return answer('Every open role has candidates, everyone is screened, and no role depends on a single person. Nothing needs intervention.'); - } - - return doc( - heading(`${plural(items.length, 'position issue')}`, 'Ordered by cost of leaving it'), - insights(items), - note('Ranked on supply and screening state. A role can look healthy here and still be hard to fill for reasons the platform cannot see.') - ); -}; - -/** - * Positions that were saved but never published. - * - * A draft is a real record with real values on it and no way for anyone to - * apply to it, which makes it the one thing on this page that is genuinely - * unfinished. Each one is named, with what it already holds. - * - * It reports, it does not reopen. This used to attach "Continue to save" to - * every draft, routing to the authoring form — so a question asked in the panel - * could end with the reader back inside Create Position, and a position that - * had been saved minutes earlier was presented as still needing saving. The - * draft is on the Positions list marked Draft and its own card opens the form; - * the panel says where it is rather than taking anyone there. - * - * Read from `postings` by status — no list of drafts is kept anywhere, and a - * position stops appearing here the moment it is published. - */ -const positionDrafts = (f) => { - const drafts = (f.postings || []).filter((p) => p.status === 'draft'); - - if (!drafts.length) { - return answer('Nothing is sitting in draft — every position in the workspace has been published or closed.'); - } - - return doc( - heading(`${plural(drafts.length, 'position')} still in draft`, 'Saved, but not published'), - insights(drafts.map((p) => ({ - tone: 'warning', - title: p.title || 'Untitled position', - body: [ - p.company, - p.location, - 'Not published yet, so nobody can apply and no screening runs.', - ].filter(Boolean).join(' · '), - }))), - note('Each one is on the Positions list marked Draft. Opening its card reopens the form with everything already entered — the vetting weights included — and publishing it there is what makes it an active position.') - ); -}; - -/** Which role to fill first. */ -const hiringPriority = (f) => { - const rows = f.byRole - .map((r) => ({ - ...r, - ready: r.qualified, - rate: r.applied ? Math.round((r.qualified / r.applied) * 100) : 0, - })) - .sort((a, b) => b.ready - a.ready || b.rate - a.rate); - - if (!rows.length) return answer('No open positions to prioritise.'); - - const first = rows[0]; - - return doc( - heading('What to fill first'), - text( - first.ready - ? `**${first.title}** — ${plural(first.ready, 'candidate')} already clear the bar, so this is the role where a decision converts into a hire today rather than starting a search.` - : 'No role currently has a candidate above the bar, so the first move is screening rather than hiring.' - ), - heading('Ranked by candidates ready now'), - table( - [ - { key: 'title', label: 'Role' }, - { key: 'ready', label: 'Ready', align: 'right' }, - { key: 'applied', label: 'Applied', align: 'right' }, - { key: 'hired', label: 'Hired', align: 'right' }, - ], - rows.map((r) => ({ - title: r.title, - ready: r.ready ? { value: r.ready, tone: 'success' } : '0', - applied: r.applied, - hired: r.hired || '—', - })) - ) - ); -}; - -/* ── Candidates list ────────────────────────────────────────────────────── */ - -/** Who is waiting on a decision, most consequential first. */ -const candidatesNeedingAttention = (f) => { - const items = [ - f.stalled.length && { - tone: 'risk', - title: `${plural(f.stalled.length, 'candidate')} at 80+ waiting on a decision`, - body: `${f.stalled.map((a) => a.applicant_name).join(', ')}. Screened, strong, and still sitting at AI Screened — the delay, not the assessment, is what loses these people.`, - }, - f.unscreened.length && { - tone: 'warning', - title: `${plural(f.unscreened.length, 'candidate')} with no score`, - body: 'Cannot be ranked or compared until screening runs, so they are invisible to every sort on this page.', - }, - f.missingCredentials.length && { - tone: 'warning', - title: `${plural(f.missingCredentials.length, 'scored candidate')} with no credentials`, - body: 'Compliance exposure if placed on a role that requires certification.', - }, - f.narrowAvailability.length && { - tone: 'info', - title: `${plural(f.narrowAvailability.length, 'candidate')} with narrow availability`, - body: 'One slot or none recorded — the placements most likely to fall through on the day.', - }, - ].filter(Boolean); - - if (!items.length) { - return answer('Nobody is waiting. Every candidate is scored, credentialed, and either actioned or already moving.'); - } - - return doc( - heading(`${plural(items.length, 'thing')} to look at`), - insights(items), - text(`Start with ${items[0].title.toLowerCase()} — it is the one where waiting costs the most.`) - ); -}; - -/** Who is ready to interview right now. */ -const interviewReady = (f) => { - const ready = f.ranked.filter( - (a) => a.ai_score >= 70 && ['ai_screened', 'shortlisted'].includes(a.status) - ); - - if (!ready.length) { - return answer('Nobody is interview-ready: no scored candidate above 70 is sitting at a stage where an interview is the next step.'); - } - - return doc( - heading('Ready to interview', `${plural(ready.length, 'candidate')} scoring 70 or above`), - table( - [ - { key: 'name', label: 'Candidate' }, - { key: 'role', label: 'Applied for' }, - { key: 'score', label: 'Score', align: 'right' }, - { key: 'stage', label: 'Stage', align: 'right' }, - ], - ready.map((a) => ({ - name: a.applicant_name, - role: a.job_title, - score: { value: a.ai_score, tone: a.ai_score >= 80 ? 'success' : 'info' }, - stage: { badge: String(a.status).replace(/_/g, ' '), tone: 'neutral' }, - })) - ), - note('Screened and above the bar. Availability still needs checking against the event calendar.') - ); -}; - -/** The pipeline as it stands, by stage and score band. */ -const candidatePipelineSummary = (f) => { - if (!f.total) return answer('No candidates in the pipeline yet.'); - - const band = (min, max) => - f.scored.filter((a) => a.ai_score >= min && (max == null || a.ai_score < max)).length; - - return doc( - heading('Candidate pipeline', `${plural(f.total, 'candidate')}`), - kpis([ - { label: 'Scored', value: f.scored.length, sub: `${f.standardizedPct}% coverage` }, - { label: 'At 80+', value: band(80), tone: 'success' }, - { label: 'Interviewing', value: f.interviewing.length }, - { label: 'Hired', value: f.hired.length, tone: 'info' }, - ]), - heading('By score band'), - table( - [{ key: 'band', label: 'Band' }, { key: 'count', label: 'Candidates', align: 'right' }], - [ - { band: 'Strong (80+)', count: { value: band(80), tone: 'success' } }, - { band: 'Good (60–79)', count: band(60, 80) }, - { band: 'Review (40–59)', count: { value: band(40, 60), tone: 'warning' } }, - { band: 'Low (under 40)', count: band(1, 40) }, - { band: 'Not scored', count: { value: f.unscreened.length, tone: 'risk' } }, - ] - ), - heading('By stage'), - funnel(f.funnel.map((stage, i) => ({ - label: stage.label, - count: stage.count, - rate: i === 0 ? null : f.transitions[i - 1]?.rate, - lost: i === 0 ? 0 : f.transitions[i - 1]?.lost, - }))) - ); -}; - -/* ── Analytics ──────────────────────────────────────────────────────────── */ - -const DAY_MS = 1000 * 60 * 60 * 24; - -/** - * Hiring trend — direction of travel, not another chart. - * - * The page already draws the line; what it cannot say is whether the line is - * going up. So this compares the last four weeks against the four before them - * and reports the change, using the same created/updated dates the chart buckets. - */ -const hiringTrend = (f) => { - if (!f.total) return answer('No applications yet, so there is no trend to read.'); - - const inWindow = (value, from, to) => { - const t = new Date(value).getTime(); - return t >= f.today.getTime() - from * DAY_MS && t < f.today.getTime() - to * DAY_MS; - }; - const applied = (from, to) => f.applications.filter((a) => inWindow(a.created_date, from, to)).length; - /* Screening is dated by the update that carried the score, exactly as the - Hiring trend chart buckets it. */ - const screened = (from, to) => f.scored.filter((a) => inWindow(a.updated_date, from, to)).length; - - const recent = applied(28, 0); - const previous = applied(56, 28); - const change = previous ? Math.round(((recent - previous) / previous) * 100) : null; - - const weeks = [3, 2, 1, 0].map((w) => ({ - week: w === 0 ? 'This week' : `${w} week${w === 1 ? '' : 's'} ago`, - applied: applied((w + 1) * 7, w * 7), - screened: screened((w + 1) * 7, w * 7), - })); - - return doc( - heading('Hiring trend', 'Last 4 weeks against the 4 before'), - kpis([ - { label: 'Applications', value: recent, sub: `${previous} in the prior period` }, - { - label: 'Change', - value: change === null ? '—' : `${change > 0 ? '+' : ''}${change}%`, - tone: change === null ? undefined : change >= 0 ? 'success' : 'warning', - }, - { label: 'Screened', value: screened(28, 0), sub: `${f.standardizedPct}% coverage` }, - { label: 'Hires', value: f.hired.length, sub: f.timeToHire ? `${f.timeToHire}d to close` : 'none closed' }, - ]), - heading('Week by week'), - table( - [ - { key: 'week', label: 'Week' }, - { key: 'applied', label: 'Applied', align: 'right' }, - { key: 'screened', label: 'Screened', align: 'right' }, - ], - weeks - ), - insights([ - change !== null && { - tone: change >= 0 ? 'success' : 'warning', - title: change >= 0 - ? `Applications up ${change}% on the previous four weeks` - : `Applications down ${Math.abs(change)}% on the previous four weeks`, - body: change >= 0 - ? 'Supply is growing, so the constraint moves to screening throughput rather than reach.' - : 'Fewer people are arriving. Distribution and pay band are the two things that move this, not screening.', - }, - f.unscreened.length && { - tone: 'warning', - title: `${plural(f.unscreened.length, 'application')} still unscored`, - body: 'The screened line lags the applied line by exactly this much — the gap is a queue, not a rejection rate.', - }, - ]), - note('Windows are counted from today, so this is independent of the 7d/30d/90d control on the chart.') - ); -}; - -/** - * Department performance — which role category is actually converting. - * - * The page's department section ranks by volume. Volume is demand, not - * performance: the category worth copying is the one turning applicants into - * qualified candidates, which is a rate. - */ -const departmentPerformance = (f) => { - if (!f.byRole.length) return answer('No open positions, so there are no departments to compare.'); - - const rows = Object.values(f.byRole.reduce((acc, r) => { - acc[r.category] ||= { category: r.category, roles: 0, applied: 0, qualified: 0, hired: 0 }; - acc[r.category].roles += 1; - acc[r.category].applied += r.applied; - acc[r.category].qualified += r.qualified; - acc[r.category].hired += r.hired; - return acc; - }, {})).map((d) => ({ ...d, rate: d.applied ? Math.round((d.qualified / d.applied) * 100) : 0 })) - .sort((a, b) => b.rate - a.rate || b.applied - a.applied); - - const best = rows.find((d) => d.applied > 0); - const starved = rows.filter((d) => d.applied === 0); - - return doc( - heading('Department performance', `${plural(rows.length, 'category')}`), - table( - [ - { key: 'category', label: 'Category' }, - { key: 'applied', label: 'Applied', align: 'right' }, - { key: 'qualified', label: '70+', align: 'right' }, - { key: 'hired', label: 'Hired', align: 'right' }, - { key: 'rate', label: 'Qualified', align: 'right' }, - ], - rows.map((d) => ({ - category: d.category, - applied: d.applied ? d.applied : { value: 0, tone: 'risk' }, - qualified: d.qualified, - hired: d.hired || '—', - rate: { value: `${d.rate}%`, tone: d.rate >= 50 ? 'success' : d.rate > 0 ? 'warning' : 'risk' }, - })) - ), - insights([ - best && { - tone: 'success', - title: `${best.category} is performing best`, - body: `${best.qualified} of ${best.applied} applicants clear 70 — ${best.rate}%${best.hired ? `, and ${plural(best.hired, 'hire')} closed` : ''}. Whatever sourcing this category uses is the one worth copying.`, - }, - starved.length && { - tone: 'risk', - title: `${plural(starved.length, 'category')} attracting nobody`, - body: `${starved.map((d) => d.category).join(', ')}. A conversion rate cannot be read off zero applicants.`, - }, - ]) - ); -}; - -/* ── Free-text responders ───────────────────────────────────────────────── */ - -/** - * Free-text routing. - * - * Keyword matching, ordered most specific first, because a question mentioning - * both "risk" and "candidates" is a risk question. The ordering *is* the logic - * here, so each responder reads top to bottom as a priority list. - */ -const has = (q, ...words) => words.some((w) => q.includes(w)); - -export const respondControlCenter = (question, f) => { - const q = question.toLowerCase(); - if (has(q, 'attention', 'urgent', 'blocked', 'stuck', 'wrong')) return attentionRequired(f); - if (has(q, 'bottleneck', 'funnel', 'pipeline', 'conversion', 'drop')) return pipelineHealth(f); - if (has(q, 'recommend', 'should', 'fix', 'improve', 'risk', 'next')) return recommendations(f); - if (has(q, 'usage', 'summary', 'summarize', 'workforce', 'scale', 'how many')) return workforceSummary(f); - if (has(q, 'operation', 'velocity', 'speed', 'throughput', 'time to hire', 'hiring')) return hiringOperations(f); - if (has(q, 'unusual', 'anomal', 'suspicious')) return unusualActivity(f); - return platformHealth(f); -}; - -export const respondPositions = (question, f) => { - const q = question.toLowerCase(); - if (has(q, 'draft', 'unfinished', 'not published', 'continue')) return positionDrafts(f); - if (has(q, 'attention', 'urgent', 'problem', 'wrong', 'risk')) return positionsNeedingAttention(f); - if (has(q, 'first', 'priorit', 'which position', 'should i hire')) return hiringPriority(f); - if (has(q, 'bottleneck', 'funnel', 'drop', 'conversion')) return pipelineHealth(f); - if (has(q, 'waiting', 'review', 'screen')) return candidatesNeedingAttention(f); - if (has(q, 'activity', 'summar', 'operation', 'velocity')) return hiringOperations(f); - return positionStrength(f); -}; - -export const respondCandidateList = (question, f) => { - const q = question.toLowerCase(); - if (has(q, 'attention', 'waiting', 'need', 'stuck')) return candidatesNeedingAttention(f); - if (has(q, 'interview', 'ready')) return interviewReady(f); - if (has(q, 'no score', 'unscored', 'not scored', 'missing')) return screeningGaps(f); - if (has(q, 'risk', 'flag', 'concern', 'compliance')) return candidateRiskAnalysis(f); - if (has(q, 'strongest', 'best', 'top', 'compare')) return topCandidates(f); - if (has(q, 'summar', 'pipeline', 'overview')) return candidatePipelineSummary(f); - return topCandidates(f); -}; - -export const respondAdminCandidates = (question, f) => { - const q = question.toLowerCase(); - if (has(q, 'gap', 'missing', 'incomplete', 'coverage', 'unscored')) return screeningGaps(f); - if (has(q, 'risk', 'flag', 'concern', 'compliance')) return candidateRiskAnalysis(f); - if (has(q, 'compare', 'top', 'best', 'strongest', 'shortlist')) return topCandidates(f); - if (has(q, 'recommend', 'should', 'next', 'action', 'hire', 'who')) return hiringRecommendations(f); - return recruitmentInsights(f); -}; - -export const respondAdminAnalytics = (question, f) => { - const q = question.toLowerCase(); - if (has(q, 'trend', 'over time', 'growing', 'slow', 'week', 'month')) return hiringTrend(f); - if (has(q, 'department', 'category', 'team', 'best perform')) return departmentPerformance(f); - if (has(q, 'bottleneck', 'funnel', 'drop', 'conversion', 'stuck', 'lose')) return pipelineHealth(f); - if (has(q, 'position', 'role', 'converting', 'strongest')) return positionStrength(f); - if (has(q, 'attention', 'urgent', 'wrong', 'fix', 'risk')) return attentionRequired(f); - return hiringOperations(f); -}; - -export const respondActivity = (question, f) => { - const q = question.toLowerCase(); - if (has(q, 'security', 'breach', 'attributab', 'compliance', 'integrity', 'trace')) return securityInsights(f); - if (has(q, 'unusual', 'anomal', 'suspicious', 'odd', 'spike', 'burst')) return unusualActivity(f); - if (has(q, 'user', 'who', 'account', 'behaviour', 'behavior', 'busiest')) return userActivityInsights(f); - return auditSummary(f); -}; - -/* ── Capability manifests ───────────────────────────────────────────────── */ - -export const CONTROL_CENTER_CAPABILITIES = [ - { id: 'platform-health', label: 'Platform Health', icon: Gauge, run: platformHealth }, - { id: 'workforce-summary', label: 'Workforce Summary', icon: Users, run: workforceSummary }, - { id: 'hiring-operations', label: 'Hiring Operations', icon: Activity, run: hiringOperations }, - { id: 'pipeline-health', label: 'Pipeline Health', icon: GitBranch, run: pipelineHealth }, - { id: 'attention-required', label: 'Attention Required', icon: BellRing, run: attentionRequired }, - { id: 'recommendations', label: 'Recommendations', icon: Lightbulb, run: recommendations }, -]; - -export const POSITIONS_CAPABILITIES = [ - { id: 'position-drafts', label: 'Unfinished Drafts', icon: FileText, run: positionDrafts }, - { id: 'position-strength', label: 'Pipeline Strength', icon: TrendingUp, run: positionStrength }, - { id: 'positions-attention', label: 'Needs Attention', icon: BellRing, run: positionsNeedingAttention }, - { id: 'hiring-priority', label: 'What To Fill First', icon: Target, run: hiringPriority }, - { id: 'pipeline-health', label: 'Hiring Bottlenecks', icon: GitBranch, run: pipelineHealth }, - { id: 'hiring-operations', label: 'Hiring Activity', icon: Activity, run: hiringOperations }, - { id: 'candidates-waiting', label: 'Candidates Waiting', icon: Users, run: candidatesNeedingAttention }, -]; - -export const CANDIDATE_LIST_CAPABILITIES = [ - { id: 'candidates-attention', label: 'Who Needs Attention', icon: BellRing, run: candidatesNeedingAttention }, - { id: 'top-candidates', label: 'Strongest Candidates', icon: ArrowLeftRight, run: topCandidates }, - { id: 'interview-ready', label: 'Interview Ready', icon: UserCheck, run: interviewReady }, - { id: 'screening-gaps', label: 'Unscored Candidates', icon: Filter, run: screeningGaps }, - { id: 'pipeline-summary', label: 'Pipeline Summary', icon: GitBranch, run: candidatePipelineSummary }, - { id: 'candidate-risk', label: 'Candidate Risks', icon: AlertTriangle, run: candidateRiskAnalysis }, -]; - -export const ADMIN_CANDIDATE_CAPABILITIES = [ - { id: 'candidate-risk', label: 'Candidate Risk', icon: AlertTriangle, run: candidateRiskAnalysis }, - { id: 'recruitment-insights', label: 'Recruitment Insights', icon: TrendingUp, run: recruitmentInsights }, - { id: 'screening-gaps', label: 'Screening Gaps', icon: Filter, run: screeningGaps }, - { id: 'hiring-recommendations', label: 'Hiring Recommendations', icon: Lightbulb, run: hiringRecommendations }, - { id: 'top-candidates', label: 'Compare Top Candidates', icon: ArrowLeftRight, run: topCandidates }, -]; - -export const ADMIN_ANALYTICS_CAPABILITIES = [ - { id: 'hiring-trend', label: 'Hiring Trend', icon: TrendingUp, run: hiringTrend }, - { id: 'department-performance', label: 'Department Performance', icon: Layers, run: departmentPerformance }, - { id: 'pipeline-health', label: 'Hiring Bottlenecks', icon: GitBranch, run: pipelineHealth }, - { id: 'position-conversion', label: 'Position Conversion', icon: Target, run: positionStrength }, - { id: 'hiring-operations', label: 'Hiring Performance', icon: Activity, run: hiringOperations }, - { id: 'attention-required', label: 'Needs Attention', icon: BellRing, run: attentionRequired }, -]; - -export const ACTIVITY_CAPABILITIES = [ - { id: 'audit-summary', label: 'Audit Summary', icon: ScrollText, run: auditSummary }, - { id: 'user-activity', label: 'User Activity', icon: Eye, run: userActivityInsights }, - { id: 'unusual-activity', label: 'Unusual Activity', icon: AlertTriangle, run: unusualActivity }, - { id: 'security-insights', label: 'Security Insights', icon: ShieldAlert, run: securityInsights }, -]; - -/* ── KROW Forge ─────────────────────────────────────────────────────────── */ - -/** - * Forge capabilities — managing workforce skill verification. - * - * The Forge questions an administrator asks are not a worker's. A worker asks - * what to prove next; the person who owns the library asks what is in it, what - * is live, what Owliver is checking, and whether any of it is reaching the - * workforce. Every figure comes from `f.forge`, derived from the same Course - * records and worker profiles the page renders from. - */ - -const forgeLibrary = (f) => { - const g = f.forge; - if (!g.library.length) { - return answer('The library is empty. A skill defines what the workforce learns, how they prove it, and what Owliver checks — start one from Add Skill Training.'); - } - - return doc( - heading('Skill library', `${plural(g.library.length, 'skill')} defined`), - kpis([ - { label: 'Active skills', value: g.published.length, sub: `of ${g.library.length} in the library` }, - { label: 'Training modules', value: g.withTraining.length, sub: 'carrying material' }, - { label: 'Challenges', value: g.withChallenge.length, sub: 'evidence-based' }, - { label: 'Verified skills', value: g.workforceVerified, sub: g.workforceVerified ? 'earned by the workforce' : 'none earned yet' }, - ]), - g.byCategory.length ? heading('By category') : null, - meters(g.byCategory.map((c) => ({ - label: c.category, - value: c.count, - max: g.library.length, - }))), - g.drafts.length - ? note(`${plural(g.drafts.length, 'skill')} ${verb(g.drafts.length, 'is', 'are')} still in draft and not visible to the workforce.`) - : null - ); -}; - -const forgePublished = (f) => { - const g = f.forge; - if (!g.published.length) { - return answer(`Nothing is published. ${g.drafts.length ? `${plural(g.drafts.length, 'draft')} ${verb(g.drafts.length, 'is', 'are')} waiting on a decision.` : 'The library has no skills yet.'}`); - } - - return doc( - heading(`${plural(g.published.length, 'skill')} published`, 'live to the workforce'), - table( - [ - { key: 'skill', label: 'Skill' }, - { key: 'category', label: 'Category' }, - { key: 'proof', label: 'Proof' }, - { key: 'minutes', label: 'Min', align: 'right' }, - ], - g.published.map((c) => ({ - skill: c.title, - category: c.category || '—', - proof: TYPE_LABEL[c.challenge?.type] || 'Not set', - minutes: c.estimated_minutes || '—', - })) - ), - g.drafts.length - ? insights([{ - tone: 'warning', - title: `${plural(g.drafts.length, 'skill')} in draft`, - body: `${g.drafts.map((c) => c.title).join(', ')}. A draft is invisible to the workforce until it is published.`, - }]) - : note('Every skill in the library is published.') - ); -}; - -const forgeEvaluation = (f) => { - const g = f.forge; - const scored = g.library.filter((c) => c.challenge?.rubric?.length); - - if (!scored.length) { - return answer('No skill has evaluation criteria yet. Owliver scores a submission against the criteria set on the skill, so a skill without them cannot verify anything.'); - } - - return doc( - heading('What Owliver evaluates', `across ${plural(scored.length, 'skill')}`), - table( - [ - { key: 'skill', label: 'Skill' }, - { key: 'criteria', label: 'Criteria' }, - { key: 'pass', label: 'Passes at', align: 'right' }, - ], - scored.map((c) => ({ - skill: c.title, - criteria: c.challenge.rubric.map((r) => r.criterion).join(', '), - pass: c.pass_score || '—', - })) - ), - g.library.length > scored.length - ? insights([{ - tone: 'warning', - title: 'Skills with nothing to score against', - body: `${g.library.filter((c) => !c.challenge?.rubric?.length).map((c) => c.title).join(', ')}. Owliver needs criteria to return a verdict.`, - }]) - : note('Every skill in the library tells Owliver what to check.') - ); -}; - -const forgeWorkforce = (f) => { - const g = f.forge; - const touched = g.usage.filter((u) => u.completed > 0 || u.verified > 0); - - if (!touched.length) { - return doc( - text('No skill in the library has been completed by anyone yet.'), - note('Completion and verification are read from worker profiles, so figures appear here as the workforce works through the library.') - ); - } - - return doc( - heading('Workforce usage', `${plural(touched.length, 'skill')} in use`), - table( - [ - { key: 'skill', label: 'Skill' }, - { key: 'completed', label: 'Completed', align: 'right' }, - { key: 'verified', label: 'Verified', align: 'right' }, - ], - touched - .sort((a, b) => b.verified - a.verified || b.completed - a.completed) - .map((u) => ({ - skill: u.course.title, - completed: u.completed, - verified: u.verified, - })) - ), - g.library.length > touched.length - ? note(`${plural(g.library.length - touched.length, 'skill')} ${verb(g.library.length - touched.length, 'has', 'have')} no completions on record.`) - : null - ); -}; - -const forgeGaps = (f) => { - const g = f.forge; - - const gaps = [ - ...g.drafts.map((c) => ({ skill: c.title, gap: 'Unpublished draft' })), - ...g.library.filter((c) => !c.challenge?.type).map((c) => ({ skill: c.title, gap: 'No proof method' })), - ...g.library.filter((c) => !c.challenge?.rubric?.length).map((c) => ({ skill: c.title, gap: 'No Owliver criteria' })), - ...g.library.filter((c) => !c.training_outline?.length && !c.quiz?.length).map((c) => ({ skill: c.title, gap: 'No training material' })), - ]; - - if (!gaps.length) { - return answer(`All ${plural(g.library.length, 'skill')} carry training, a proof method and evaluation criteria, and every one is published.`); - } - - return doc( - heading(`${plural(gaps.length, 'gap')} to close`, 'before these verify anything'), - table( - [ - { key: 'skill', label: 'Skill' }, - { key: 'gap', label: 'Missing' }, - ], - gaps - ), - note('A skill needs training, a way to submit evidence, and criteria for Owliver to score against before it can verify a workforce skill.') - ); -}; - -/* ── Talent pool ────────────────────────────────────────────────────────── */ - -const talentPoolSummary = (f) => { - const t = f.talent; - return doc( - heading('Talent pool', `${plural(f.profiles.length, 'registered worker')}`), - kpis([ - { label: 'Profiles', value: f.profiles.length, sub: `${t.available.length} available` }, - { label: 'Elite', value: f.elite.length, sub: '90+ career score' }, - { label: 'Average score', value: t.avgScore ? toFICO(t.avgScore) : '—', sub: 'scored profiles only' }, - { label: 'Unscored', value: f.unscoredProfiles.length, sub: 'cannot be ranked', tone: f.unscoredProfiles.length ? 'warning' : undefined }, - ]), - heading('Score bands'), - meters(t.bands.map((b) => ({ label: b.label, value: b.count, max: f.profiles.length }))), - text( - f.unscoredProfiles.length - ? `${plural(f.unscoredProfiles.length, 'profile')} ${verb(f.unscoredProfiles.length, 'has', 'have')} no career score, so ${verb(f.unscoredProfiles.length, 'it is', 'they are')} invisible to ranking and matching.` - : 'Every profile carries a career score, so the whole pool is rankable.' - ) - ); -}; - -const talentPriorities = (f) => { - const t = f.talent; - if (!f.profiles.length) return answer('No talent profiles registered yet.'); - - return doc( - heading('Who to prioritize', 'by career score, availability and verification'), - table( - [ - { key: 'name', label: 'Worker' }, - { key: 'score', label: 'Score', align: 'right' }, - { key: 'experience', label: 'Years', align: 'right' }, - { key: 'availability', label: 'Available' }, - ], - t.ranked.slice(0, 6).map((p) => ({ - name: p.full_name || p.email, - /* The page shows career score on the 300–850 scale, so this must too — - the same worker cannot be 94 here and 817 in the row beside it. */ - score: p.krow_score ? toFICO(p.krow_score) : '—', - experience: p.experience_years ?? '—', - availability: (p.availability || []).length ? (p.availability || []).join(', ') : '—', - })) - ), - insights([ - t.top[0] && { - tone: 'success', - title: `${t.top[0].full_name || t.top[0].email} leads the pool`, - body: `Career score ${toFICO(t.top[0].krow_score || 0)}${t.top[0].experience_years ? `, ${plural(t.top[0].experience_years, 'year')} of experience` : ''}.`, - }, - t.available.length < f.profiles.length && { - tone: 'info', - title: `${t.available.length} of ${f.profiles.length} list availability`, - body: 'The rest cannot be matched to a shift until availability is on the record.', - }, - ]) - ); -}; - -const talentVerification = (f) => { - const t = f.talent; - if (!t.unverified.length) { - return answer(`All ${plural(f.profiles.length, 'profile')} carry at least one verified skill.`); - } - - return doc( - heading(`${plural(t.unverified.length, 'profile')} without verified skills`), - table( - [ - { key: 'name', label: 'Worker' }, - { key: 'score', label: 'Score', align: 'right' }, - { key: 'skills', label: 'Claimed skills', align: 'right' }, - ], - t.unverified.slice(0, 8).map((p) => ({ - name: p.full_name || p.email, - score: p.krow_score ? toFICO(p.krow_score) : '—', - skills: (p.skills || []).length, - })) - ), - insights([{ - tone: 'warning', - title: 'Claimed is not proven', - body: 'These profiles carry skills nobody has verified. A Forge challenge turns each claim into evidence an employer can read.', - }]), - note(`${pct(t.unverified.length, f.profiles.length)}% of the pool is unverified.`) - ); -}; - -const talentAvailability = (f) => { - const t = f.talent; - const buckets = Object.values(f.profiles.reduce((acc, p) => { - (p.availability || ['Not stated']).forEach((slot) => { - acc[slot] ||= { slot, count: 0 }; - acc[slot].count += 1; - }); - return acc; - }, {})).sort((a, b) => b.count - a.count); - - return doc( - heading('Availability across the pool', `${t.available.length} of ${f.profiles.length} stated`), - meters(buckets.map((b) => ({ label: b.slot, value: b.count, max: f.profiles.length }))), - text( - buckets[0] - ? `Deepest cover is **${buckets[0].slot}** at ${plural(buckets[0].count, 'worker')}. Thinnest is **${buckets[buckets.length - 1].slot}**.` - : 'No availability recorded yet.' - ) - ); -}; - -/* ── Hired history ──────────────────────────────────────────────────────── */ - -const hiringOutcomes = (f) => { - const h = f.hiring; - if (!h.hires.length) return answer('Nobody has been hired yet, so there is no history to read.'); - - return doc( - heading('Hiring outcomes', plural(h.hires.length, 'hire')), - kpis([ - { label: 'Hires', value: h.hires.length, sub: `${h.onboarding.length} onboarding` }, - { label: 'Average quality', value: h.avgQuality || '—', sub: 'AI score at hire' }, - { label: 'Average speed', value: h.avgSpeed ? `${h.avgSpeed}d` : '—', sub: 'apply to hire' }, - { label: 'Rated', value: `${h.rated.length}/${h.hires.length}`, sub: 'client rating on file', tone: h.unrated.length ? 'warning' : undefined }, - ]), - heading('By department'), - table( - [ - { key: 'name', label: 'Department' }, - { key: 'count', label: 'Hires', align: 'right' }, - { key: 'avgScore', label: 'Avg score', align: 'right' }, - { key: 'avgDays', label: 'Avg days', align: 'right' }, - ], - h.byDepartment.map((d) => ({ - name: d.name, - count: d.count, - avgScore: d.avgScore || '—', - avgDays: d.avgDays || '—', - })) - ), - h.unrated.length - ? note(`${plural(h.unrated.length, 'hire')} ${verb(h.unrated.length, 'has', 'have')} no client rating yet, so outcome quality rests on the ${h.rated.length} that do.`) - : null - ); -}; - -const hiringStrongest = (f) => { - const h = f.hiring; - if (!h.hires.length) return answer('No hires on record yet.'); - - const ranked = [...h.hires].sort((a, b) => (b.score || 0) - (a.score || 0)); - - return doc( - heading('Strongest hires', 'by score at the point of hire'), - table( - [ - { key: 'name', label: 'Hire' }, - { key: 'role', label: 'Role' }, - { key: 'department', label: 'Department' }, - { key: 'score', label: 'Score', align: 'right' }, - { key: 'rating', label: 'Rating', align: 'right' }, - ], - ranked.slice(0, 6).map((x) => ({ - name: x.name, - role: x.role, - department: x.department, - score: x.score || '—', - rating: x.client_rating || '—', - })) - ), - insights([ - h.strongest && { - tone: 'success', - title: `${h.strongest.name} scored highest at ${h.strongest.score}`, - body: `${h.strongest.role} in ${h.strongest.department}${h.strongest.client_rating ? `, rated ${h.strongest.client_rating} since` : ', not yet rated by the client'}.`, - }, - h.byRoleTitle[0] && { - tone: 'info', - title: `${h.byRoleTitle[0].name} is the most-hired role`, - body: `${plural(h.byRoleTitle[0].count, 'hire')}, averaging ${h.byRoleTitle[0].avgScore || 0} at hire.`, - }, - ]) - ); -}; - -const hiringPatterns = (f) => { - const h = f.hiring; - if (!h.hires.length) return answer('No hires on record, so there is no pattern to read.'); - - const fastest = [...h.byRoleTitle].filter((r) => r.avgDays).sort((a, b) => a.avgDays - b.avgDays)[0]; - const slowest = [...h.byRoleTitle].filter((r) => r.avgDays).sort((a, b) => b.avgDays - a.avgDays)[0]; - - return doc( - heading('What stands out', `${plural(h.hires.length, 'hire')} across ${plural(h.byDepartment.length, 'department')}`), - meters(h.byDepartment.map((d) => ({ - label: d.name, - value: d.count, - max: Math.max(...h.byDepartment.map((x) => x.count), 1), - }))), - insights([ - h.byDepartment[0] && { - tone: 'info', - title: `${h.byDepartment[0].name} accounts for the most hires`, - body: `${plural(h.byDepartment[0].count, 'hire')} of ${h.hires.length} — ${pct(h.byDepartment[0].count, h.hires.length)}% of everyone placed.`, - }, - fastest && slowest && fastest.name !== slowest.name && { - tone: 'warning', - title: `${slowest.name} takes ${slowest.avgDays - fastest.avgDays} days longer than ${fastest.name}`, - body: `${slowest.avgDays} days against ${fastest.avgDays}. The gap is where the process, not the candidate, is the constraint.`, - }, - h.unrated.length && { - tone: 'warning', - title: `${plural(h.unrated.length, 'hire')} without a client rating`, - body: 'Outcome quality cannot be judged on hires nobody has rated.', - }, - ]), - actions([ - h.unrated.length ? { title: 'Chase the missing ratings', body: `${plural(h.unrated.length, 'placement')} ${verb(h.unrated.length, 'has', 'have')} no client feedback on file.` } : null, - h.avgSpeed ? { title: 'Compare speed against quality', body: `Average ${h.avgSpeed} days to hire at an average score of ${h.avgQuality}. Faster is only better while the score holds.` } : null, - ]) - ); -}; - -const hiringRecentHires = (f) => { - const h = f.hiring; - if (!h.hires.length) return answer('No hires on record yet.'); - - const recent = [...h.hires] - .filter((x) => x.hire_date) - .sort((a, b) => new Date(b.hire_date).getTime() - new Date(a.hire_date).getTime()) - .slice(0, 6); - - return doc( - heading('Recent hires', 'most recent first'), - timeline(recent.map((x) => ({ - title: `${x.name} — ${x.role}`, - description: `${x.department}${x.score ? ` · scored ${x.score}` : ''}${x.timeToHire ? ` · ${plural(x.timeToHire, 'day')} to hire` : ''}`, - timestamp: x.hire_date, - tone: x.status === 'onboarding' ? 'warning' : 'success', - }))), - h.onboarding.length - ? note(`${plural(h.onboarding.length, 'hire')} ${verb(h.onboarding.length, 'is', 'are')} still onboarding.`) - : null - ); -}; - -/* ── Free-text responders for the three new contexts ────────────────────── */ - -export const respondForge = (question, f) => { - const q = question.toLowerCase(); - if (has(q, 'publish', 'live', 'draft', 'unpublish', 'archived', 'status')) return forgePublished(f); - if (has(q, 'owliver', 'evaluat', 'criteri', 'rubric', 'grade', 'score against', 'pass')) return forgeEvaluation(f); - if (has(q, 'gap', 'missing', 'incomplete', 'unfinished', 'needs', 'ready to publish')) return forgeGaps(f); - if (has(q, 'workforce', 'usage', 'completed', 'verified', 'earned', 'badge', 'who has', 'uptake')) return forgeWorkforce(f); - if (has(q, 'skill', 'library', 'challenge', 'course', 'training', 'category', 'summar', 'overview', 'how many', 'what do we have')) return forgeLibrary(f); - return forgeLibrary(f); -}; - -export const respondTalentPool = (question, f) => { - const q = question.toLowerCase(); - if (has(q, 'verif', 'unverified', 'proof', 'evidence', 'badge')) return talentVerification(f); - if (has(q, 'available', 'availability', 'shift', 'weekend', 'evening')) return talentAvailability(f); - if (has(q, 'priorit', 'strongest', 'best', 'top', 'who should')) return talentPriorities(f); - if (has(q, 'unscored', 'no score', 'missing', 'gap')) return talentPoolSummary(f); - if (has(q, 'summar', 'overview', 'pool', 'how many')) return talentPoolSummary(f); - return talentPriorities(f); -}; - -export const respondHiredHistory = (question, f) => { - const q = question.toLowerCase(); - if (has(q, 'pattern', 'stand out', 'trend', 'insight', 'attention', 'concern')) return hiringPatterns(f); - if (has(q, 'strongest', 'best', 'top', 'quality', 'highest')) return hiringStrongest(f); - if (has(q, 'recent', 'latest', 'summar', 'who did we hire')) return hiringRecentHires(f); - if (has(q, 'department', 'position', 'role', 'team', 'most hires')) return hiringOutcomes(f); - return hiringOutcomes(f); -}; - -/* ── Capability manifests for the three new contexts ────────────────────── */ - -export const FORGE_CAPABILITIES = [ - { id: 'forge-library', label: 'Skill Library', icon: Gauge, run: forgeLibrary }, - { id: 'forge-published', label: 'Published Skills', icon: Target, run: forgePublished }, - { id: 'forge-evaluation', label: 'Owliver Evaluation', icon: ShieldCheck, run: forgeEvaluation }, - { id: 'forge-workforce', label: 'Workforce Usage', icon: UserCheck, run: forgeWorkforce }, - { id: 'forge-gaps', label: 'Gaps To Close', icon: Lock, run: forgeGaps }, -]; - -export const TALENT_POOL_CAPABILITIES = [ - { id: 'talent-priorities', label: 'Who To Prioritize', icon: Star, run: talentPriorities }, - { id: 'talent-summary', label: 'Pool Summary', icon: Users, run: talentPoolSummary }, - { id: 'talent-verification', label: 'Verification Gaps', icon: ShieldCheck, run: talentVerification }, - { id: 'talent-availability', label: 'Availability', icon: Clock, run: talentAvailability }, -]; - -export const HIRED_HISTORY_CAPABILITIES = [ - { id: 'hiring-outcomes', label: 'Hiring Outcomes', icon: Award, run: hiringOutcomes }, - { id: 'hiring-strongest', label: 'Strongest Hires', icon: TrendingUp, run: hiringStrongest }, - { id: 'hiring-patterns', label: 'What Stands Out', icon: Layers, run: hiringPatterns }, - { id: 'hiring-recent', label: 'Recent Hires', icon: ScrollText, run: hiringRecentHires }, -]; - -/* ── Profile ──────────────────────────────────────────────────────────────── - * - * The account page answers questions about the operator rather than the - * workforce: what this account may do, how it is secured, what it has been - * doing, and which switches are on. Everything below reads the same constants - * the page renders, so the panel can never describe a scope or a control that - * is not on screen. - */ - -const profilePermissions = () => doc( - heading('Your permissions', `${PERMISSIONS.length} scopes on this account`), - table( - [{ key: 'scope', label: 'Scope' }, { key: 'level', label: 'Level' }, { key: 'detail', label: 'Covers' }], - PERMISSIONS.map((p) => ({ - scope: p.scope, - level: { - value: p.level, - tone: p.level === 'Full access' ? 'success' : p.level === 'Read only' ? 'info' : 'muted', - }, - detail: p.detail, - })) - ), - note('Scopes are set by the account owner. Billing stays restricted to owners.') -); - -const profileIdentity = (f) => { - const user = f.user || {}; - return doc( - heading('This account'), - table( - [{ key: 'field', label: 'Field' }, { key: 'value', label: 'Value' }], - [ - { field: 'Name', value: user.full_name || '—' }, - { field: 'Email', value: user.email || '—' }, - { field: 'Role', value: 'Platform Administrator' }, - { field: 'Department', value: 'Workforce Operations' }, - { field: 'Account status', value: { value: 'Active · verified', tone: 'success' } }, - { - field: 'Member since', - value: user.created_date - ? new Date(user.created_date).toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) - : '—', - }, - ] - ), - text('Use **Edit profile** at the top of the page to change your display name. Email and role are set by the account owner.') - ); -}; - -const profileSecurity = () => doc( - heading('Security on this account'), - status([ - { label: 'Two-factor authentication', ok: true, value: 'On', note: 'Required for privileged actions.' }, - { label: 'Password age', ok: true, value: '42 days', note: 'Administrator passwords expire every 90 days.' }, - { label: 'Session', ok: true, value: 'Browser tab', note: 'Closing the tab signs this account out.' }, - ]), - text('**Change password** and **Manage sessions** sit in the Security card on the right.') -); - -const profilePreferences = (f) => { - const p = f.user?.preferences || {}; - const state = (on) => ({ value: on ? 'On' : 'Off', tone: on ? 'success' : 'muted' }); - return doc( - heading('Your preferences'), - table( - [{ key: 'setting', label: 'Setting' }, { key: 'state', label: 'State' }, { key: 'detail', label: 'Effect' }], - [ - { setting: 'Owliver workspace', state: state(p.owliverDefault !== false), detail: 'Opens this panel by default on supported pages' }, - { setting: 'Compact density', state: state(Boolean(p.compactDensity)), detail: 'Tighter row heights across Admin tables' }, - { setting: 'Email digest', state: state(p.emailDigest !== false), detail: 'A daily summary of what needs attention' }, - ] - ), - note('Preferences save to your account as soon as you flip a switch.') - ); -}; - -const profileActivity = (f) => { - const email = f.user?.email; - const mine = f.activity.filter((e) => e.user_email === email).slice(0, 6); - if (!mine.length) { - return doc(text('No activity is recorded against this account yet.')); - } - return doc( - heading('Your recent activity', `${plural(mine.length, 'event')} on this account`), - timeline(mine.map((e) => ({ - title: e.event_type.replace(/_/g, ' '), - description: e.details || undefined, - timestamp: new Date(e.created_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }), - }))) - ); -}; - -const profileActions = () => doc( - heading('What you can do here'), - list(PROFILE_ACTIONS.map((a) => `**${a.label}** — ${a.detail}`)), - note('Everything above is on this page. Nothing here changes another operator’s account.') -); - -export const respondProfile = (question, f) => { - const q = question.toLowerCase(); - if (has(q, 'permission', 'scope', 'access', 'allowed', 'can i')) return profilePermissions(f); - if (has(q, 'password', 'two-factor', 'two factor', '2fa', 'security', 'session', 'sign out', 'log out')) return profileSecurity(f); - if (has(q, 'preference', 'setting', 'digest', 'density', 'workspace', 'owliver')) return profilePreferences(f); - if (has(q, 'activity', 'recent', 'history', 'what have i')) return profileActivity(f); - if (has(q, 'do here', 'change my', 'edit', 'update', 'how do i')) return profileActions(f); - return profileIdentity(f); -}; - -export const PROFILE_CAPABILITIES = [ - { id: 'profile-permissions', label: 'My Permissions', icon: ShieldCheck, run: profilePermissions }, - { id: 'profile-identity', label: 'Account Details', icon: Users, run: profileIdentity }, - { id: 'profile-security', label: 'Security', icon: Lock, run: profileSecurity }, - { id: 'profile-preferences', label: 'My Preferences', icon: Gauge, run: profilePreferences }, - { id: 'profile-activity', label: 'My Recent Activity', icon: ScrollText, run: profileActivity }, - { id: 'profile-actions', label: 'What I Can Do Here', icon: Lightbulb, run: profileActions }, -]; - -/* ── Create Position ───────────────────────────────────────────────────── - The authoring surface. Every answer here is about the role being specified, - read against what this workspace has already posted — so the guidance is the - deployment's own history rather than an industry number written into a file. */ - -/** The average of a numeric field across postings that state it. */ -const across = (postings, read) => { - const values = postings.map(read).filter((n) => Number.isFinite(n) && n > 0); - return values.length ? Math.round(values.reduce((a, b) => a + b, 0) / values.length) : 0; -}; - -/** - * What the five vetting criteria mean, and how this workspace usually weights - * them. - * - * The typical column is counted from the positions already posted, so it says - * what this operator does rather than what a template suggests. - */ -const vettingWeights = (f) => { - const posted = f.postings.filter((p) => p.vetting_criteria); - const keys = ['experience', 'english', 'reliability', 'certifications', 'availability']; - const meaning = { - experience: 'Years in the role, weighted against the minimum this position asks for', - english: 'Spoken level, checked in the AI interview', - reliability: 'Attendance and completion history on previous assignments', - certifications: 'Whether the credentials this role requires are held and current', - availability: 'How much of the shift pattern the candidate can actually cover', - }; - - if (!posted.length) { - return doc( - heading('AI vetting weights', 'What each criterion scores'), - list(keys.map((k) => `**${CRITERIA_LABELS[k]}** — ${meaning[k]}`)), - note('The five must total 100%. No position has been posted yet, so there is no house pattern to compare against.') - ); - } - - const typical = keys.map((key) => ({ - criterion: CRITERIA_LABELS[key], - typical: `${across(posted, (p) => Number(p.vetting_criteria?.[key]))}%`, - scores: meaning[key], - })); - - return doc( - heading('AI vetting weights', `Typical across ${plural(posted.length, 'posted position')}`), - table( - [ - { key: 'criterion', label: 'Criterion' }, - { key: 'typical', label: 'Usually', align: 'right' }, - { key: 'scores', label: 'What it scores' }, - ], - typical - ), - note('These are this workspace’s own averages, not a recommendation. The five weights on this form must total 100%.') - ); -}; - -/** - * What comparable roles in this workspace ask for. - * - * Grouped by role category, which is the only comparison the data supports — - * pay and experience are not transferable between a chef and a security post. - */ -const positionBenchmarks = (f) => { - const posted = f.postings.filter((p) => p.role_category); - if (!posted.length) { - return doc( - text('No positions have been posted yet, so there is nothing to benchmark this one against.'), - note('Once a few roles exist I can compare pay, experience and requirements by category.') - ); - } - - const byCategory = new Map(); - posted.forEach((p) => { - const entry = byCategory.get(p.role_category) || { category: p.role_category, list: [] }; - entry.list.push(p); - byCategory.set(p.role_category, entry); - }); - - const rows = [...byCategory.values()] - .sort((a, b) => b.list.length - a.list.length) - .slice(0, 6) - .map(({ category, list: roles }) => ({ - category, - posted: roles.length, - pay: across(roles, (p) => Number(p.pay_range_min)) - ? `$${across(roles, (p) => Number(p.pay_range_min))}–${across(roles, (p) => Number(p.pay_range_max))}/hr` - : '—', - experience: `${across(roles, (p) => Number(p.min_experience_years))} yrs`, - })); - - return doc( - heading('What comparable roles ask for', `${plural(posted.length, 'position')} on file`), - table( - [ - { key: 'category', label: 'Category' }, - { key: 'posted', label: 'Posted', align: 'right' }, - { key: 'pay', label: 'Pay', align: 'right' }, - { key: 'experience', label: 'Min experience', align: 'right' }, - ], - rows - ), - note('Averages across positions already posted in this workspace.') - ); -}; - -/** The credentials this workspace actually asks for, ranked by how often. */ -const positionRequirements = (f) => { - const counts = new Map(); - f.postings.forEach((p) => { - (p.certifications_required || []).forEach((c) => counts.set(c, (counts.get(c) || 0) + 1)); - }); - - if (!counts.size) { - return doc( - text('No position on file requires a certification, so there is no pattern to follow here.'), - note('Add only the credentials this role genuinely needs — every one narrows the pool.') - ); - } - - const ranked = [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8); - return doc( - heading('Credentials this workspace asks for', `${plural(counts.size, 'certification')} in use`), - meters(ranked.map(([label, count]) => ({ - label, - value: count, - max: f.postings.length || count, - }))), - note('Ranked by how many positions require them. Each requirement narrows the pool it is added to.') - ); -}; - -/** How the form's own steps fit together, for someone new to it. */ -const positionSpecSteps = () => doc( - heading('Specifying a position'), - list([ - 'Role category and job title — what the matching engine files this under', - 'Client, requirements and pay — what the posting states', - 'Certifications and required skills — what a person must hold', - 'Generate Job Description with AI — writes the prose from the fields above', - 'AI vetting weights — how the screening score is composed, totalling 100%', - ], { ordered: true }), - note('Nothing is posted until you choose Save as Draft or Publish.') -); - -export const respondCreatePosition = (question, f) => { - const q = question.toLowerCase(); - if (has(q, 'weight', 'vetting', 'criteria', 'scoring', 'score')) return vettingWeights(f); - if (has(q, 'certification', 'credential', 'requirement', 'skill')) return positionRequirements(f); - if (has(q, 'pay', 'rate', 'salary', 'benchmark', 'compare', 'experience', 'typical')) return positionBenchmarks(f); - if (has(q, 'how do i', 'step', 'what do i', 'fill', 'form', 'explain')) return positionSpecSteps(f); - return positionSpecSteps(f); -}; - -export const CREATE_POSITION_CAPABILITIES = [ - { id: 'vetting-weights', label: 'Explain Vetting Weights', icon: Gauge, run: vettingWeights }, - { id: 'position-benchmarks', label: 'Compare With Similar Roles', icon: ArrowLeftRight, run: positionBenchmarks }, - { id: 'position-requirements', label: 'Credentials In Use', icon: ShieldCheck, run: positionRequirements }, - { id: 'position-spec-steps', label: 'How This Form Works', icon: Lightbulb, run: positionSpecSteps }, -]; diff --git a/src/components/ai-assistant/capabilities/employer.js b/src/components/ai-assistant/capabilities/employer.js deleted file mode 100644 index ebfa461..0000000 --- a/src/components/ai-assistant/capabilities/employer.js +++ /dev/null @@ -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 }, -]; diff --git a/src/components/ai-assistant/capabilities/workspace.js b/src/components/ai-assistant/capabilities/workspace.js deleted file mode 100644 index fd379f9..0000000 --- a/src/components/ai-assistant/capabilities/workspace.js +++ /dev/null @@ -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; diff --git a/src/components/ai-assistant/contexts.js b/src/components/ai-assistant/contexts.js index c767dda..934f7c3 100644 --- a/src/components/ai-assistant/contexts.js +++ b/src/components/ai-assistant/contexts.js @@ -1,29 +1,68 @@ -import { - ANALYTICS_CAPABILITIES, CANDIDATE_CAPABILITIES, OVERVIEW_CAPABILITIES, - respondAnalytics, respondCandidates, respondOverview, -} from './capabilities/employer'; -import { - ACTIVITY_CAPABILITIES, ADMIN_ANALYTICS_CAPABILITIES, ADMIN_CANDIDATE_CAPABILITIES, - CANDIDATE_LIST_CAPABILITIES, CONTROL_CENTER_CAPABILITIES, CREATE_POSITION_CAPABILITIES, - FORGE_CAPABILITIES, - HIRED_HISTORY_CAPABILITIES, POSITIONS_CAPABILITIES, PROFILE_CAPABILITIES, - TALENT_POOL_CAPABILITIES, - respondActivity, respondAdminAnalytics, respondAdminCandidates, respondCandidateList, - respondControlCenter, respondCreatePosition, respondForge, respondHiredHistory, - respondPositions, - respondProfile, respondTalentPool, -} from './capabilities/admin'; -import { - AGENT_CONFIGURE_CAPABILITIES, AGENT_CONFIGURE_TOPICS, - SETTINGS_CAPABILITIES, SETTINGS_TOPICS, - SKILL_CONFIGURE_CAPABILITIES, SKILL_CONFIGURE_TOPICS, - SKILL_DEVELOPMENT_CAPABILITIES, SKILL_DEVELOPMENT_TOPICS, - WORKSPACE_AGENTS_CAPABILITIES, WORKSPACE_AGENTS_TOPICS, - WORKSPACE_CAPABILITIES, WORKSPACE_SKILLS_CAPABILITIES, WORKSPACE_SKILLS_TOPICS, - WORKSPACE_TOPICS, - respondAgentConfigure, respondSettings, respondSkillConfigure, respondSkillDevelopment, - respondWorkspace, respondWorkspaceAgents, respondWorkspaceSkills, -} from './capabilities/workspace'; + +/** + * The words each page's answers are actually about. + * + * Inlined here when the capability modules were deleted. They used to sit + * beside the functions that computed answers from browser data; those functions + * are gone — the agent answers now — but this vocabulary survives them, because + * it decides something different: whether a question belongs to THIS page or to + * another one. A question matching none of a page's topics is a question the + * reader should be taken elsewhere to ask, and that is still true with an agent + * behind the panel. + */ + +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', +]; + + + +const 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', + ]; + +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. @@ -45,34 +84,24 @@ export const ASSISTANT_CONTEXTS = { 'employer.overview': { id: 'employer.overview', page: 'Overview', - capabilities: OVERVIEW_CAPABILITIES, - respond: respondOverview, }, 'employer.candidates': { id: 'employer.candidates', page: 'Candidates', - capabilities: CANDIDATE_CAPABILITIES, - respond: respondCandidates, }, 'employer.analytics': { id: 'employer.analytics', page: 'Analytics', - capabilities: ANALYTICS_CAPABILITIES, - respond: respondAnalytics, }, 'admin.controlCenter': { id: 'admin.controlCenter', 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'], - capabilities: CONTROL_CENTER_CAPABILITIES, - respond: respondControlCenter, }, 'admin.positions': { id: 'admin.positions', page: 'Positions', 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 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', 'typical', 'position', 'role', 'job description', 'form', 'field', 'step', 'how do i', 'what do i', 'explain', 'summar', 'flow'], - capabilities: CREATE_POSITION_CAPABILITIES, - respond: respondCreatePosition, }, /* 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 @@ -94,15 +121,11 @@ export const ASSISTANT_CONTEXTS = { id: 'admin.candidatesList', page: 'Candidates', 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': { id: 'admin.candidates', page: 'Candidates Analysis', 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 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', 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'], - capabilities: ADMIN_ANALYTICS_CAPABILITIES, - respond: respondAdminAnalytics, }, /* Forge, Talent Pool and Hired History read the same platform records as the 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', 'owliver', 'evaluat', 'criteri', 'rubric', 'proof', 'evidence', 'verify', 'verification', 'verified', 'workforce', 'gap', 'missing', 'what do we have'], - capabilities: FORGE_CAPABILITIES, - respond: respondForge, }, 'admin.talentPool': { id: 'admin.talentPool', page: 'Talent Pool', 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': { id: 'admin.hiredHistory', page: 'Hired History', 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 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', 'preference', 'setting', 'digest', 'density', 'workspace', 'owliver', 'profile', '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. @@ -173,8 +186,6 @@ export const ASSISTANT_CONTEXTS = { id: 'admin.agentConfigure', page: 'Agent Configure', topics: AGENT_CONFIGURE_TOPICS, - capabilities: AGENT_CONFIGURE_CAPABILITIES, - respond: respondAgentConfigure, }, /** * Settings, and the workspace pages behind it. @@ -199,50 +210,36 @@ export const ASSISTANT_CONTEXTS = { id: 'admin.settings', page: 'Settings', topics: SETTINGS_TOPICS, - capabilities: SETTINGS_CAPABILITIES, - respond: respondSettings, }, 'admin.workspace': { id: 'admin.workspace', page: 'Workspace', topics: WORKSPACE_TOPICS, - capabilities: WORKSPACE_CAPABILITIES, - respond: respondWorkspace, }, 'admin.workspaceAgents': { id: 'admin.workspaceAgents', page: 'Agents', topics: WORKSPACE_AGENTS_TOPICS, - capabilities: WORKSPACE_AGENTS_CAPABILITIES, - respond: respondWorkspaceAgents, }, 'admin.workspaceSkills': { id: 'admin.workspaceSkills', page: 'Skills', topics: WORKSPACE_SKILLS_TOPICS, - capabilities: WORKSPACE_SKILLS_CAPABILITIES, - respond: respondWorkspaceSkills, }, 'admin.skillConfigure': { id: 'admin.skillConfigure', page: 'Skill Configure', topics: SKILL_CONFIGURE_TOPICS, - capabilities: SKILL_CONFIGURE_CAPABILITIES, - respond: respondSkillConfigure, }, 'admin.skillDevelopment': { id: 'admin.skillDevelopment', page: 'Skill Development', topics: SKILL_DEVELOPMENT_TOPICS, - capabilities: SKILL_DEVELOPMENT_CAPABILITIES, - respond: respondSkillDevelopment, }, 'admin.activity': { id: 'admin.activity', page: 'Activity', topics: ['activity', 'audit', 'event', 'log', 'security', 'breach', 'compliance', 'trace', 'unusual', 'anomal', 'suspicious', 'user', 'who', 'account', 'busiest'], - capabilities: ACTIVITY_CAPABILITIES, - respond: respondActivity, }, }; diff --git a/src/components/ai-assistant/dynamic.js b/src/components/ai-assistant/dynamic.js index e71ca0d..1aee4bd 100644 --- a/src/components/ai-assistant/dynamic.js +++ b/src/components/ai-assistant/dynamic.js @@ -423,6 +423,19 @@ const PLACEHOLDERS = { /** * 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 * that says "Compare candidates" — it proves the assistant already looked. * Each carries the capability that answers it best, so a click is precise. diff --git a/src/components/ai-assistant/index.jsx b/src/components/ai-assistant/index.jsx index a33bbf1..1ebf4e3 100644 --- a/src/components/ai-assistant/index.jsx +++ b/src/components/ai-assistant/index.jsx @@ -29,4 +29,4 @@ export { AssistantPanel } from './AssistantPanel'; export { default as KrowAssistant } from './KrowAssistant'; export { ASSISTANT_CONTEXTS, getContext } from './contexts'; export { EXCLUDED_ROUTES, enabledRoutes, resolveAssistantContext } from './placement'; -export { createHttpProvider, createLocalProvider } from './provider'; +export { createAgentProvider, createUnconfiguredProvider } from './provider'; diff --git a/src/components/ai-assistant/insights.js b/src/components/ai-assistant/insights.js index d3dc6b0..faea63d 100644 --- a/src/components/ai-assistant/insights.js +++ b/src/components/ai-assistant/insights.js @@ -75,7 +75,14 @@ export function buildFacts({ .filter((e) => e.viable.length === 1); /* 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 = [ { key: 'applied', label: 'Applied', count: applications.length }, diff --git a/src/components/ai-assistant/matchPrompts.js b/src/components/ai-assistant/matchPrompts.js deleted file mode 100644 index 8e7368c..0000000 --- a/src/components/ai-assistant/matchPrompts.js +++ /dev/null @@ -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; -} diff --git a/src/components/ai-assistant/provider.js b/src/components/ai-assistant/provider.js index a1e71db..9912aff 100644 --- a/src/components/ai-assistant/provider.js +++ b/src/components/ai-assistant/provider.js @@ -26,182 +26,424 @@ * } */ -import { getContext } from './contexts'; -import { note, toSnapshots } from './blocks'; +import { confirmation, note } 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 - * behaviour exactly, so every existing answer and every agent that declares no - * reasoning is unchanged. + * The only provider that answers. Everything that makes that safe lives on the + * server: * - * `webSearch` is handled here too, and handled honestly. No search provider is - * configured in this deployment, so an agent with the flag set gets a note - * saying so rather than an answer that quietly came from nowhere. Silently - * ignoring the flag would be worse: the configuration would read as working. + * - **The principal is the session's.** The body carries a question and + * nothing else about who is asking. The browser cannot name a caller, so it + * cannot ask about records it is not entitled to see. + * - **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) { - 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 } = {}) { +export function createAgentProvider({ baseUrl = '/api/v1' } = {}) { return { - id: 'local', + id: 'agent', - async *stream({ contextId, capability, question, facts, agent = null, signal }) { - const context = getContext(contextId); - if (!context) throw new Error(`Unknown assistant context: ${contextId}`); - - const document = capability - ? context.capabilities.find((c) => c.id === capability)?.run(facts) - ?? context.respond(question, facts) - : 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); + async *stream({ question, agent = null, confirmation = null, agentVersion = 0, signal }) { + /* No agent, no run. The panel resolves which agent covers the page before + calling; reaching here without one means the routing layer changed and + this should say so rather than guess at an agent id. */ + if (!agent?.id) { + yield [note('No agent is available for this page.')]; + return; } - }, - }; -} -/** - * 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, { + const response = await fetch(`${baseUrl}/agents/${encodeURIComponent(agent.id)}/runs`, { method: 'POST', - headers: { 'Content-Type': 'application/json', ...headers }, - /* `agent` and `owliverContext` travel; the fact sheet still does not. - Dashboard records are read server-side from the caller's own session, - so the client cannot ask about records it is not entitled to see — - and an agent cannot widen that by being named in the body. */ - body: JSON.stringify({ contextId, capability, question, agent, owliverContext }), + headers: { + 'Content-Type': 'application/json', + /* Ask for a stream. The server answers the same run either way — the + final event carries exactly the body the non-streaming path + returns — so a deployment that cannot stream degrades to one late + 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, }); - if (!response.ok || !response.body) { - throw new Error(`Assistant request failed: ${response.status}`); + if (!response.ok) { + yield [note(await describeFailure(response))]; + return; } - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - const blocks = []; - let buffer = ''; - - 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]; - } + /* Not a stream after all — a proxy that buffers, or a server answering + JSON. Read it whole. */ + if (!isEventStream(response) || !response.body) { + yield toBlocks(await response.json()); + return; } + + yield* readRunStream(response, signal); }, }; } /** - * The provider the app uses. Local by default; point - * `VITE_ASSISTANT_ENDPOINT` at a streaming endpoint to switch, with no other - * code change. + * Whether the server actually opened a stream, rather than answering JSON. + * + * 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() { - const endpoint = import.meta.env?.VITE_ASSISTANT_ENDPOINT; - return endpoint ? createHttpProvider({ endpoint }) : createLocalProvider(); + const base = import.meta.env?.VITE_AGENT_API; + 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.' + )]; + }, + }; } diff --git a/src/components/ai-assistant/routing.js b/src/components/ai-assistant/routing.js index de36707..f6382e4 100644 --- a/src/components/ai-assistant/routing.js +++ b/src/components/ai-assistant/routing.js @@ -166,14 +166,21 @@ export function navigationAnswer(destination) { /** * 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 - * already shows as chips — so the offer is always exactly what is on screen. + * Reached only on a deployment with no agent configured. With one, a question + * 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) { - const labels = (context?.capabilities || []).map((c) => c.label); + const subjects = (context?.topics || []).slice(0, 6); return doc( 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.') ); } @@ -928,3 +935,79 @@ export function resolveIntent({ 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; + } +} diff --git a/src/components/ai-assistant/useAssistant.js b/src/components/ai-assistant/useAssistant.js index bcea80e..6701278 100644 --- a/src/components/ai-assistant/useAssistant.js +++ b/src/components/ai-assistant/useAssistant.js @@ -6,7 +6,6 @@ import { useUserActivity, useWorkerProfile, useWorkerProfiles, } from '@/lib/krowHooks'; import { skillsForContext } from '@/lib/skills/registry'; -import { suggestionsForPosition } from '@/lib/skills/owliverResolver'; import { advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply, } from '@/lib/skills/positionFlow'; @@ -25,12 +24,22 @@ import { storableContext } from '@/lib/agents/context'; import { buildFacts } from './insights'; import { agentRequest } from '@/lib/agents/runtime'; import { createAssistantProvider } from './provider'; -import { resolveIntent } from './routing'; +import { preferAgent, resolveIntent } from './routing'; import { doc, text as textBlock, toSnapshots } from './blocks'; /** One provider instance for the app's lifetime. */ 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 * 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. */ 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. Passed in rather than reached for, so this layer still writes nothing 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 * 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 ({ question, capability = null, positionId = null, scope = null, }) => { @@ -463,16 +485,22 @@ export function useConversation({ if (!intent) { intent = capability ? { kind: 'answer' } - : resolveIntent({ - question: text, - contextId: turnContext, - disabledSkills: turnDisabled, - customSkills, roles, skillCategories, - courses, workforce, skillContext, positionId, - agent: turnAgent, - agentCoversPage: turnCovers, - agentSuggestion, owliverContext, - }); + : preferAgent( + resolveIntent({ + question: text, + contextId: turnContext, + disabledSkills: turnDisabled, + customSkills, roles, skillCategories, + courses, workforce, skillContext, positionId, + agent: turnAgent, + 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) { 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 { created = await onCreatePosition?.(intent.create.draft, intent.skill, intent.create.status); - } catch { + } catch (error) { created = null; + failure = error; } 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 - * not finished being specified, so offering to analyse it would be - * answering about a record the admin has not committed to. The registry - * decides *which* skills can say something — see - * `suggestionsForPosition` — and this decides only whether it is yet - * the moment to ask them. + * The record exists, so the organization is materially different from + * what it was one turn ago — there is one more role to fill, or one + * more unfinished draft — and what is worth asking has changed with it. + * `onRefreshSuggestions` asks the server that question again rather + * than deriving an answer here: the ranking is the API's, against the + * 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'; + 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, flow: null, doc: positionCreatedReply(created), - followUp: [ - ...createdFollowUp(created), - ...(ready - ? suggestionsForPosition(turnContext, turnDisabled, customSkills, created) - : []), - ], + followUp: [...createdFollowUp(created), ...refreshed], }; } else { /* Keep the answers: the summary is still there to try again from. */ intent = { ...intent, flow: { ...intent.flow, stage: 'review' }, - doc: positionFailedReply(), + doc: positionFailedReply(failure?.message), followUp: [ { label: 'Create position', prompt: 'Create position' }, { label: 'Change details', prompt: 'Change details' }, @@ -694,12 +737,16 @@ export function useConversation({ try { 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({ contextId: turnContext, capability, question: text, facts, signal: controller.signal, /* What the agent *is*, never what it may read. The page settled that before this call, and `agentRequest` carries no records. */ agent: turnAgent ? agentRequest(turnAgent, turnContext) : null, owliverContext, + agentVersion: pinnedVersionRef.current, })) { if (controller.signal.aborted) break; latest = snapshot; @@ -730,7 +777,8 @@ export function useConversation({ setPending(null); abortRef.current = null; } - }, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onUpdatePosition, + }, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onRefreshSuggestions, + onUpdatePosition, onGenerateDescription, onAssignWorkers, onScheduleInterview, workforce, setFlow, disabledSkills, @@ -739,6 +787,68 @@ export function useConversation({ 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. * @@ -859,6 +969,8 @@ export function useConversation({ send, announce, stop, + /** Approves a proposed write and resumes the run. See `confirm`. */ + confirm, reset, submitFeedback, feedback, diff --git a/src/components/krow/HiringAnalytics.jsx b/src/components/krow/HiringAnalytics.jsx index 5b942f1..5542d06 100644 --- a/src/components/krow/HiringAnalytics.jsx +++ b/src/components/krow/HiringAnalytics.jsx @@ -29,8 +29,12 @@ export default function HiringAnalytics({ applications, staff }) { const totalApps = applications.length; const hireRate = totalApps ? Math.round((hiredApps.length / totalApps) * 100) : 0; - const avgHireScore = hiredApps.length - ? Math.round(hiredApps.reduce((s, a) => s + (a.ai_score || 0), 0) / hiredApps.length) + /* Averaged over the hires that carry a score. A hire can reach 'hired' + 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; const sevenDaysAgo = new Date(); @@ -48,7 +52,7 @@ export default function HiringAnalytics({ applications, staff }) {
HIRE RATE
-
{avgHireScore}
+
{avgHireScore || '—'}
AVG SCORE
diff --git a/src/components/krow/ImpactMetrics.jsx b/src/components/krow/ImpactMetrics.jsx index 97d2db9..dd065d5 100644 --- a/src/components/krow/ImpactMetrics.jsx +++ b/src/components/krow/ImpactMetrics.jsx @@ -4,7 +4,10 @@ import { Clock, DollarSign, Award, Zap, Scale, Heart } from 'lucide-react'; export default function ImpactMetrics({ applications, interviews, staff: _staff }) { 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 const timeToHire = hired.length @@ -20,19 +23,20 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff const tthDisplay = timeToHire ? `${timeToHire}d avg` : 'Instant screen'; // 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 - const qualityOfHire = hired.length - ? Math.round(hired.reduce((s, a) => s + (a.ai_score || 0), 0) / hired.length) + const scoredHires = hired.filter(a => a.ai_score > 0); + const qualityOfHire = scoredHires.length + ? Math.round(scoredHires.reduce((s, a) => s + a.ai_score, 0) / scoredHires.length) : 0; // 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 const standardizedPct = applications.length - ? Math.round((screened.length / applications.length) * 100) + ? Math.round((aiScreened.length / applications.length) * 100) : 0; // 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, label: 'Cost Saved', value: `$${costSaved.toLocaleString()}`, - sub: `${screened.length} screens · ${interviews.length} interviews automated`, + sub: `${aiScreened.length} screens · ${interviews.length} interviews automated`, color: '#333F48', bg: '#F3F4F6', }, @@ -61,7 +65,11 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff icon: Award, label: 'Quality of Hire', 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', bg: '#FEFCE8', }, @@ -77,7 +85,7 @@ export default function ImpactMetrics({ applications, interviews, staff: _staff icon: Scale, label: 'Decisions Standardized', value: `${standardizedPct}%`, - sub: `${screened.length} of ${applications.length} applicants scored`, + sub: `${aiScreened.length} of ${applications.length} applicants scored`, color: '#0838E0', bg: '#EEF3FE', }, diff --git a/src/components/krow/ScoreDistribution.jsx b/src/components/krow/ScoreDistribution.jsx index 846564a..e571274 100644 --- a/src/components/krow/ScoreDistribution.jsx +++ b/src/components/krow/ScoreDistribution.jsx @@ -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; }, [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 (
@@ -34,7 +38,9 @@ export default function ScoreDistribution({ applications }) {
AVG SCORE
-

{screened} candidates screened

+

+ {scored} {scored === 1 ? 'candidate' : 'candidates'} scored +

diff --git a/src/lib/admin/positionInsights.js b/src/lib/admin/positionInsights.js index 160ca0b..fcefb5c 100644 --- a/src/lib/admin/positionInsights.js +++ b/src/lib/admin/positionInsights.js @@ -6,13 +6,7 @@ * up disagreeing with the drawer it opens. */ -const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired']; - -/** 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); -}; +import { atOrBeyond, HIRED_STATUSES } from '@/lib/hiringRecords'; /** * Hiring health. @@ -80,7 +74,7 @@ export function buildPosition(posting, applications) { const screened = atOrBeyond(apps, 'ai_screened').length; const shortlisted = atOrBeyond(apps, 'shortlisted').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 qualified = scored.filter((a) => a.ai_score >= 70).length; diff --git a/src/lib/agents/agentConfig.js b/src/lib/agents/agentConfig.js index 9d6399d..5218eb3 100644 --- a/src/lib/agents/agentConfig.js +++ b/src/lib/agents/agentConfig.js @@ -300,6 +300,16 @@ export function normalizeAgent(raw, { agentId = '', body = '' } = {}) { webSearch: data.webSearch === true || data.web_search === true, pages: normalizePages(data.pages, { errors }), 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, knowledge, starters, diff --git a/src/lib/agents/agentFields.js b/src/lib/agents/agentFields.js index f0b78dd..a19ca05 100644 --- a/src/lib/agents/agentFields.js +++ b/src/lib/agents/agentFields.js @@ -31,6 +31,7 @@ export const EMPTY_AGENT_FIELDS = Object.freeze({ webSearch: false, pages: [], skills: [], + tools: [], subagents: [], knowledge: [], starters: [], @@ -66,6 +67,7 @@ export function agentFieldsFromSource(source) { webSearch: agent.webSearch, pages: [...agent.pages], skills: [...agent.skills], + tools: [...(agent.tools || [])], subagents: [...agent.subagents], knowledge: agent.knowledge.map((k) => ({ ...k })), starters: agent.starters.map((s) => ({ ...s })), @@ -116,6 +118,9 @@ export function agentPatch(fields = {}) { } 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.starters !== undefined) { diff --git a/src/lib/agents/agentStore.js b/src/lib/agents/agentStore.js new file mode 100644 index 0000000..cfadbca --- /dev/null +++ b/src/lib/agents/agentStore.js @@ -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]); +} diff --git a/src/lib/agents/runtime.js b/src/lib/agents/runtime.js index 0ecd2fa..83f6612 100644 --- a/src/lib/agents/runtime.js +++ b/src/lib/agents/runtime.js @@ -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 { getAgent } from './registry'; 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 - * carry its own copy: resolving a default, and deciding whether a page has an - * agent *of its own*. + * Derived from the definition rather than matched against an id. This used to + * be `FALLBACK_AGENT_ID = 'krow-workforce-agent'` — a literal agent key that + * 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 general agent is deliberately excluded. It covers every surface — which - * is what makes it a fallback — so counting it as a page's own agent would make + * General agents are deliberately excluded. One covers every surface — which is + * 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 * meaningless. * @@ -142,20 +156,26 @@ export const FALLBACK_AGENT_ID = 'krow-workforce-agent'; * a broken one: see `resolveDefaultAgent`. */ 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 - * has been archived or does not list this surface — a page must never be left - * without an agent because of how the registry happens to be configured. + * `agentsForContext` has already narrowed to published agents covering this + * page and sorted them most-specific-first, so general agents sit at the end + * 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) { - const general = getAgent(agents, FALLBACK_AGENT_ID); - if (general && general.status === 'published' && agentCovers(general, contextId)) return general; - return agentsForContext(agents, contextId)[0] || null; + const covering = agentsForContext(agents, contextId); + const general = covering.filter(isGeneralAgent); + return general[general.length - 1] || covering[0] || null; } /** diff --git a/src/lib/agents/useAgents.js b/src/lib/agents/useAgents.js index 9259c5c..ee4620b 100644 --- a/src/lib/agents/useAgents.js +++ b/src/lib/agents/useAgents.js @@ -1,8 +1,12 @@ import { useCallback, useMemo } from 'react'; import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks'; +import { + useAgentDefinitions, useAuthoredSources, useDeleteAgentDefinition, + useMigrateStoredAgents, useSaveAgentDefinition, +} from './agentStore'; import { reportSave } from '@/lib/skills/saveFeedback'; import { AGENTS, parseAgent, readAgentRegistry, validateAgentSource } from './registry'; -import { customAgentSource, removeCustomAgent, upsertCustomAgent } from './customAgents'; +import { customAgentSource } from './customAgents'; import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agentLifecycle'; /** @@ -24,11 +28,36 @@ import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agen */ export function useAgents() { 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]); + /* 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( () => readAgentRegistry(stored, { customSkills }), [stored, customSkills] @@ -63,23 +92,29 @@ export function useAgents() { const problem = validateAgentSource(source); if (problem) return { ok: false, error: problem }; - const { agent, next } = upsertCustomAgent(stored, source); - await updatePreferences.mutateAsync( - { customAgents: next }, - reportSave(message || `${agent.name} saved`) - ); + const agent = parseAgent(source, { custom: true }); + try { + await saveDefinition.mutateAsync({ definitionId: agent.id, markdown: source }); + } 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 }; - }, [stored, updatePreferences]); + }, [saveDefinition]); /** Removes the account's definition. A shipped agent returns to its shipped form. */ const remove = useCallback(async (id) => { - const next = removeCustomAgent(stored, id); - await updatePreferences.mutateAsync( - { customAgents: next }, - reportSave(isShipped(id) ? 'Reverted to the shipped definition' : 'Agent removed') - ); + try { + await deleteDefinition.mutateAsync(id); + } catch (error) { + 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 }; - }, [stored, updatePreferences, isShipped]); + }, [deleteDefinition, isShipped]); /** * Publishes, refusing to overwrite a newer published version. @@ -123,7 +158,8 @@ export function useAgents() { return { agents, diagnostics, - saving: updatePreferences.isPending, + loading: definitions.isPending, + saving: saveDefinition.isPending || deleteDefinition.isPending, isShipped, isOverridden, sourceFor, diff --git a/src/lib/hiringRecords.js b/src/lib/hiringRecords.js index 11fdaac..a4da7e9 100644 --- a/src/lib/hiringRecords.js +++ b/src/lib/hiringRecords.js @@ -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 @@ -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. */ export function buildFunnel(applications) { - const atOrBeyond = (stage) => { - const from = STAGE_ORDER.indexOf(stage); - return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from).length; - }; + const reached = (stage) => atOrBeyond(applications, stage).length; const stages = [ { key: 'applied', label: 'Applied', count: applications.length }, - { key: 'ai_screened', label: 'Screened', count: atOrBeyond('ai_screened') }, - { key: 'shortlisted', label: 'Shortlisted', count: atOrBeyond('shortlisted') }, - { key: 'interview', label: 'Interview', count: atOrBeyond('interview') }, - { key: 'hired', label: 'Hired', count: applications.filter((a) => a.status === 'hired').length }, + { key: 'ai_screened', label: 'Screened', count: reached('ai_screened') }, + { key: 'shortlisted', label: 'Shortlisted', count: reached('shortlisted') }, + { key: 'interview', label: 'Interview', count: reached('interview') }, + { key: 'hired', label: 'Hired', count: applications.filter((a) => HIRED_STATUSES.includes(a.status)).length }, ]; const transitions = stages.slice(1).map((stage, i) => { diff --git a/src/lib/krowHooks.js b/src/lib/krowHooks.js index 5f3a191..7df7638 100644 --- a/src/lib/krowHooks.js +++ b/src/lib/krowHooks.js @@ -1,5 +1,5 @@ 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 { recalcProfilePatch } from './krowScore'; 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() { const queryClient = useQueryClient(); return useMutation({ mutationFn: /** @param {any} data */ (data) => base44.entities.JobPosting.create(data), onSuccess: () => { 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'); }, }); @@ -195,6 +324,7 @@ export function useUpdateJobPosting() { onSuccess: (_data, variables) => { queryClient.invalidateQueries({ queryKey: ['jobPostings'] }); 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), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['applications'] }); + queryClient.invalidateQueries({ queryKey: owliverContextKey }); logActivity('apply_job'); }, }); @@ -216,6 +347,7 @@ export function useUpdateApplication() { mutationFn: /** @param {any} vars */ ({ id, data }) => base44.entities.JobApplication.update(id, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['applications'] }); + queryClient.invalidateQueries({ queryKey: owliverContextKey }); }, }); } @@ -238,6 +370,7 @@ export function useScreenCandidate() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['applications'] }); + queryClient.invalidateQueries({ queryKey: owliverContextKey }); logActivity('screen_candidate'); }, }); @@ -268,6 +401,7 @@ export function useScreenAllCandidates() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['applications'] }); + queryClient.invalidateQueries({ queryKey: owliverContextKey }); logActivity('screen_candidate'); }, }); @@ -326,6 +460,7 @@ export function useHireCandidate() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['applications'] }); + queryClient.invalidateQueries({ queryKey: owliverContextKey }); queryClient.invalidateQueries({ queryKey: ['staff'] }); /* 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 @@ -358,6 +493,7 @@ export function useCreateInterview() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['interviews'] }); queryClient.invalidateQueries({ queryKey: ['applications'] }); + queryClient.invalidateQueries({ queryKey: owliverContextKey }); logActivity('start_interview'); }, }); @@ -507,6 +643,7 @@ export function useAssignWorkers() { looking at three different moments. */ queryClient.invalidateQueries({ queryKey: ['assignments'] }); queryClient.invalidateQueries({ queryKey: ['applications'] }); + queryClient.invalidateQueries({ queryKey: owliverContextKey }); queryClient.invalidateQueries({ queryKey: ['jobPostings'] }); queryClient.invalidateQueries({ queryKey: ['userActivity'] }); }, @@ -549,6 +686,7 @@ export function useMarkInterviewReady() { }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['applications'] }); + queryClient.invalidateQueries({ queryKey: owliverContextKey }); queryClient.invalidateQueries({ queryKey: ['interviews'] }); queryClient.invalidateQueries({ queryKey: ['userActivity'] }); }, diff --git a/src/lib/krowScore.js b/src/lib/krowScore.js index 1db3b2c..b2da1c7 100644 --- a/src/lib/krowScore.js +++ b/src/lib/krowScore.js @@ -5,7 +5,18 @@ const clamp = (n) => Math.max(0, Math.min(100, n)); 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 education = clamp( (profile.completed_courses?.length || 0) * 12 + @@ -34,7 +45,20 @@ export function computeKrowScore(profile) { return { krow_score, 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.earned_badges?.length) 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); } diff --git a/src/lib/skills/dataResolver.js b/src/lib/skills/dataResolver.js index 91a515c..8339b04 100644 --- a/src/lib/skills/dataResolver.js +++ b/src/lib/skills/dataResolver.js @@ -1,4 +1,5 @@ import { SUPPORTED_PERIODS, periodLabel } from './surfaces'; +import { atOrBeyond, HIRED_STATUSES } from '@/lib/hiringRecords'; import { CRITERIA_LABELS } from '@/lib/positionModel'; import { poolFor } from '@/lib/workforce'; import { candidateRoute } from './workforceFlow'; @@ -108,13 +109,8 @@ function matchDetail(row) { return parts.join(' · '); } -/** The application stages this product counts, in the order they happen. */ -const STAGE_ORDER = ['applied', 'ai_screened', 'shortlisted', 'interview', 'hired']; - -const atOrBeyond = (applications, stage) => { - const from = STAGE_ORDER.indexOf(stage); - return applications.filter((a) => STAGE_ORDER.indexOf(a.status) >= from); -}; +/* The stage ladder lives in hiringRecords: one derivation, so a skill and the + page it is read beside cannot disagree about how many were screened. */ /** Applications counted by period — the reading behind an activity section. */ function activityOverTime(applications, periods, now) { @@ -152,7 +148,7 @@ function pipelineOf(applications) { { id: 'screened', label: 'Screened', value: atOrBeyond(applications, 'ai_screened').length }, { id: 'shortlisted', label: 'Shortlisted', value: atOrBeyond(applications, 'shortlisted').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 { @@ -304,7 +300,7 @@ const RESOLVERS = { ...interviews .filter((i) => i.application_id === candidate?.id) .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, }, ].filter(Boolean); @@ -437,7 +433,7 @@ const RESOLVERS = { * applications and the staff records they became, never stored. */ '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 days = hired .map((a) => Math.round((new Date(a.updated_date).getTime() - new Date(a.created_date).getTime()) / 86400000)) diff --git a/src/lib/skills/owliverResolver.js b/src/lib/skills/owliverResolver.js index 9728e38..3d12365 100644 --- a/src/lib/skills/owliverResolver.js +++ b/src/lib/skills/owliverResolver.js @@ -44,7 +44,19 @@ const PER_SKILL = 3; 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 * 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( ({ 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); -} diff --git a/src/lib/skills/positionFlow.js b/src/lib/skills/positionFlow.js index 9d85385..d65007e 100644 --- a/src/lib/skills/positionFlow.js +++ b/src/lib/skills/positionFlow.js @@ -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( text('I could not create that position.'), + said ? note(said) : null, note('Nothing was saved. Choose Create position to try again.') ); } diff --git a/src/lib/skills/serverSuggestions.js b/src/lib/skills/serverSuggestions.js new file mode 100644 index 0000000..8c7529c --- /dev/null +++ b/src/lib/skills/serverSuggestions.js @@ -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; +} diff --git a/src/pages/admin/AgentDetail.jsx b/src/pages/admin/AgentDetail.jsx index fb3260f..3625525 100644 --- a/src/pages/admin/AgentDetail.jsx +++ b/src/pages/admin/AgentDetail.jsx @@ -14,6 +14,7 @@ import { agentTemplate } from '@/lib/agents/customAgents'; import { agentFieldsFromSource, applyAgentFields } from '@/lib/agents/agentFields'; import { validateAgentSource } from '@/lib/agents/registry'; import { AgentConfigure } from '@/components/agents/AgentConfigure'; +import { useToolCatalogue } from '@/lib/agents/agentStore'; import { AgentSkillWorkspace } from '@/components/agents/skills/AgentSkillWorkspace'; import { AgentTestPanel } from '@/components/agents/AgentTestPanel'; import { AgentInsightsPanel } from '@/components/agents/AgentInsightsPanel'; @@ -81,6 +82,10 @@ export default function AdminAgentDetail() { agents, saving, isShipped, isOverridden, sourceFor, save, publish, } = 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; /* The definition this screen started from: a stored or shipped one, or a @@ -421,6 +426,14 @@ export default function AdminAgentDetail() { onChange={onChange} onOpenSkills={() => setView('skills')} 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' && ( diff --git a/src/pages/admin/TalentPool.jsx b/src/pages/admin/TalentPool.jsx index 9a19e16..36ed580 100644 --- a/src/pages/admin/TalentPool.jsx +++ b/src/pages/admin/TalentPool.jsx @@ -140,9 +140,14 @@ export default function AdminTalentPool() { ...s, count: s.members.length, available: s.members.filter((p) => (p.availability || []).length > 0).length, - avgExperience: s.members.length - ? Math.round(s.members.reduce((sum, p) => sum + (p.experience_years || 0), 0) / s.members.length) - : 0, + avgExperience: (() => { + /* 0 years is "not stated", not "first year" — the cards that show it + 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]);