update the chatbox
This commit is contained in:
41
.env.bak
Normal file
41
.env.bak
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# ============================================================================
|
||||||
|
# Krow frontend — example environment
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
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
|
||||||
661
docs/api-audit.md
Normal file
661
docs/api-audit.md
Normal file
@@ -0,0 +1,661 @@
|
|||||||
|
# Krow frontend — API audit
|
||||||
|
|
||||||
|
Every API this repository needs, and where each one stands against the running
|
||||||
|
Go backend at `../krow-backend`.
|
||||||
|
|
||||||
|
**The backend's `docs/api-contract.md` is normative.** This document does not
|
||||||
|
restate its semantics — it inventories what *this* repo calls, and marks each
|
||||||
|
against what the server actually registers. Nothing here proposes a route,
|
||||||
|
request field, response field or table that is not traced to a frontend call
|
||||||
|
site or an existing backend requirement.
|
||||||
|
|
||||||
|
## Status labels
|
||||||
|
|
||||||
|
| Label | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| **Served** | The frontend calls it, and the backend registers it. Working. |
|
||||||
|
| **Adopt** | The backend registers it; the frontend does not use it yet, and should. No new work on the server. |
|
||||||
|
| **Local stub** | Implemented in-browser. No network, no server, no key. |
|
||||||
|
| **Browser-only** | State lives in `localStorage` / `sessionStorage` and nowhere else. |
|
||||||
|
| **External** | Third-party host, hotlinked, no auth, no contract. |
|
||||||
|
| **Dead code** | Frontend code exists with no consumer and no route. Not a backend gap. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Route counts, derived from source
|
||||||
|
|
||||||
|
The figure "38 endpoints" is the backend contract's **§2 table** (34 Live +
|
||||||
|
4 "unreachable today"). It is not the count of what the server registers.
|
||||||
|
|
||||||
|
Counted from source rather than from any README:
|
||||||
|
|
||||||
|
| Group | Routes | Where counted |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Entity CRUD | **34** | `Ops` bits across 14 resources in `internal/domain/resources_gen.go` |
|
||||||
|
| `/me` | 4 | `internal/httpserver/me.go:83-86` |
|
||||||
|
| `/auth` | 2 | `internal/httpserver/auth.go:129-130` |
|
||||||
|
| Agent + skill definitions | 10 | `internal/httpserver/definitions.go:11-21` |
|
||||||
|
| Workflows | 2 | `internal/httpserver/workflows.go:87-88` |
|
||||||
|
| Owliver suggestions | 1 | `internal/httpserver/owliver.go` |
|
||||||
|
| Health | 1 | `internal/httpserver/server.go:146` |
|
||||||
|
| **Total registered** | **54** | |
|
||||||
|
|
||||||
|
`Server.Endpoints()` returns 53 — the sum of the six `route*` helpers,
|
||||||
|
excluding `GET /health`, which is registered before the sum.
|
||||||
|
|
||||||
|
The backend README used to say "51 registered routes", omitting the two
|
||||||
|
workflow routes; it now enumerates all 54 and is back in step with its router.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Configuration and transport
|
||||||
|
|
||||||
|
| Variable | Default | Read by |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `VITE_API_BASE_URL` | `/api/v1` | `src/api/httpClient.js:50` — client code |
|
||||||
|
| `VITE_API_PROXY_TARGET` | `https://mcp.krowforce.com` | `vite.config.js:9` — dev server only |
|
||||||
|
| `VITE_ASSISTANT_ENDPOINT` | *(unset)* | `src/components/ai-assistant/provider.js:205` |
|
||||||
|
|
||||||
|
- **Development.** `vite.config.js` proxies `/api` and `/health` to
|
||||||
|
`VITE_API_PROXY_TARGET`, so the browser only ever talks to one origin.
|
||||||
|
- **Production.** `Dockerfile:33` bakes
|
||||||
|
`ARG VITE_API_BASE_URL=https://mcp.krowforce.com/api/v1` at build time —
|
||||||
|
`.dockerignore` excludes `.env*`, so no env file reaches the image, and
|
||||||
|
`nginx.conf` has **no** `location /api/`. A relative base URL in production
|
||||||
|
would answer a login POST with 405.
|
||||||
|
- **Session cookie.** HttpOnly, SameSite=Lax. `httpClient.js` sends
|
||||||
|
`credentials: 'include'` and warns in DEV if the base URL is cross-origin
|
||||||
|
(`httpClient.js:72-84`).
|
||||||
|
- **Envelope.** `{ data, meta }` in, bare records out — `request()` returns
|
||||||
|
`payload.data` and drops `meta` entirely (`httpClient.js:226`).
|
||||||
|
- **Errors.** `{ error: { code, message, details } }` becomes a `KrowApiError`
|
||||||
|
carrying `.status`, `.code`, `.details`. A transport failure becomes
|
||||||
|
`status: 0, code: 'unreachable'` with a message naming the URL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Entity CRUD — Served
|
||||||
|
|
||||||
|
`RESOURCE_PATHS` (`src/api/httpClient.js:96`) against `Ops`
|
||||||
|
(`internal/domain/resources_gen.go`). **Every method the frontend actually
|
||||||
|
calls has a route. Zero new endpoints are needed here.**
|
||||||
|
|
||||||
|
| Entity | Path | Frontend calls | Backend `Ops` | |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| JobPosting | `job-postings` | list, get, create, update | List·Get·Create·Update | Served |
|
||||||
|
| JobApplication | `job-applications` | list, filter, create, update, delete | List·Create·Update·Delete | Served |
|
||||||
|
| AIInterview | `ai-interviews` | list, create | List·Create | Served |
|
||||||
|
| Staff | `staff` | list, create, update | List·Create·Update | Served |
|
||||||
|
| WorkerProfile | `worker-profiles` | list, filter, create, update | List·Create·Update | Served |
|
||||||
|
| Course | `courses` | list, get, create, update | List·Get·Create·Update | Served |
|
||||||
|
| LearningPath | `learning-paths` | list | List | Served |
|
||||||
|
| RoleCategory | `role-categories` | list, create | List·Create | Served |
|
||||||
|
| Certification | `certifications` | list, create, delete | List·Create·Delete | Served |
|
||||||
|
| UserActivity | `user-activity` | list, filter, create | List·Create | Served |
|
||||||
|
| Evidence | `evidence` | list, filter, create, update | List·Create·Update | Served |
|
||||||
|
| Assignment | `assignments` | list, create | List·Create | Served |
|
||||||
|
| ShiftRecord | `shift-records` | list | List | Served |
|
||||||
|
| Badge | `badges` | `Badge.list` via `useBadges` | **`Ops: 0` — no routes** | Dead code |
|
||||||
|
| User | `users` | *(none)* | **no resource** | Dead code |
|
||||||
|
|
||||||
|
`filter()` is not a separate endpoint — it is `GET /{resource}` with field
|
||||||
|
params. Arrays become repeated params (`?status=applied&status=hired`);
|
||||||
|
`undefined` is omitted; `sort` is always sent, because `?sort=` means "no
|
||||||
|
ordering" while omitting it applies the endpoint default.
|
||||||
|
|
||||||
|
### The two dead-code rows
|
||||||
|
|
||||||
|
Neither is a backend gap, and neither warrants a new endpoint:
|
||||||
|
|
||||||
|
- **Badge.** `useBadges` (`src/lib/krowHooks.js:537`) has **zero consumers**.
|
||||||
|
The backend contract §2 records why: every badge the UI renders comes from
|
||||||
|
`worker_profiles.earned_badges`. `GET /api/v1/badges` would 404 if this hook
|
||||||
|
were ever wired. Fix in this repo by deleting the hook, or accept it as
|
||||||
|
unreachable.
|
||||||
|
- **User.** `'User'` is in `ENTITY_NAMES` and `RESOURCE_PATHS`, so
|
||||||
|
`createEntity('User')` builds a client for `/api/v1/users` — a path with no
|
||||||
|
backend resource. **No call site exists.** Authentication goes through `/me`.
|
||||||
|
|
||||||
|
### Defaults line up
|
||||||
|
|
||||||
|
The backend's `DefaultSort` / `DefaultLimit` were derived from these call
|
||||||
|
sites, and match them:
|
||||||
|
|
||||||
|
| Resource | Frontend call | Backend default |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| job-applications | `list('-ai_score', 200)` | `-ai_score` / 200 |
|
||||||
|
| worker-profiles | `list('-krow_score', 500)` | `-krow_score` / 500 |
|
||||||
|
| user-activity, assignments, shift-records | `list('-created_date', 500)` | `-created_date` / 500 |
|
||||||
|
| courses, certifications, evidence, badges | `list('-created_date', 200)` | `-created_date` / 200 |
|
||||||
|
| job-postings, ai-interviews, staff, learning-paths, role-categories | `list('-created_date', 100)` | `-created_date` / 100 |
|
||||||
|
|
||||||
|
Filters actually sent: `job_posting_id` (applications),
|
||||||
|
`email` (worker profiles, `limit 1`), `worker_email` (evidence),
|
||||||
|
`user_email` (activity, `limit 20` — `src/pages/Profile.jsx:28`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Auth and preferences — Served
|
||||||
|
|
||||||
|
| Method | Path | Frontend |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET` | `/api/v1/me` | `auth.me()`, and a module-load hydration promise |
|
||||||
|
| `PATCH` | `/api/v1/me` | `auth.updateMe(patch)` |
|
||||||
|
| `PATCH` | `/api/v1/me/preferences` | `auth.updatePreferences(patch)` |
|
||||||
|
| `POST` | `/api/v1/auth/login` | `{ email, password, remember_me }` |
|
||||||
|
| `POST` | `/api/v1/auth/logout` | Redirects locally even if the call fails |
|
||||||
|
|
||||||
|
- `GET /me` is fired once at module load and shared, so the eleven `me()` call
|
||||||
|
sites in the first render make one request between them
|
||||||
|
(`src/api/base44Client.js:130-143`).
|
||||||
|
- A 401 is the ordinary signed-out state, not a failure. Every credential
|
||||||
|
failure returns the same 401 with the same message, by design.
|
||||||
|
- `auth.preferences()` is **synchronous** and must stay so —
|
||||||
|
`AssistantPanelContext` decides whether Owliver starts open in a `useState`
|
||||||
|
initialiser. That is why the last user is mirrored to `localStorage` under
|
||||||
|
`krow_demo_user`.
|
||||||
|
|
||||||
|
### Preference keys in use
|
||||||
|
|
||||||
|
| Key | Written by | Note |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `owliverDefault` | Settings | Seeded (`src/api/seed.js:1891`) |
|
||||||
|
| `compactDensity` | Settings | Seeded; read by `ds/DataTable.jsx:67` |
|
||||||
|
| `emailDigest` | Settings | Seeded |
|
||||||
|
| `customSkills` | Skill editors, `WorkspaceSkills.jsx` | Array of `{ path: 'custom/<id>.md', raw: <markdown> }` |
|
||||||
|
| `customAgents` | `lib/agents/useAgents.js:68,78` | Same shape, for agents |
|
||||||
|
| `disabledSkills` | `WorkspaceSkills.jsx`, `AgentDetail.jsx` | Array of skill ids |
|
||||||
|
| `removedSkills` | `WorkspaceSkills.jsx:331,353` | Array of skill ids |
|
||||||
|
|
||||||
|
> **Drift:** the backend contract §1 still says *"Auth: None in v1. Every
|
||||||
|
> endpoint is unauthenticated."* That is stale — sessions, argon2id and cookie
|
||||||
|
> middleware are implemented (`migrations/000004_auth_sessions.up.sql`,
|
||||||
|
> `internal/auth/`, `internal/httpserver/auth.go`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Adopt — 12 endpoints the backend already has and the frontend ignores
|
||||||
|
|
||||||
|
None of these needs server work. All exist and are registered today.
|
||||||
|
|
||||||
|
### 5.1 Workflow endpoints (2) — **Served**
|
||||||
|
|
||||||
|
The backend built these specifically to replace frontend call sequences, and
|
||||||
|
says so in the source. Both are now adopted.
|
||||||
|
|
||||||
|
| Endpoint | Frontend call site | Replaced |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST /api/v1/job-applications/{id}/hire` | `base44.workflows.hire` ← `useHireCandidate` | `PATCH /job-applications/{id}` + `POST /staff` + `POST /user-activity` |
|
||||||
|
| `POST /api/v1/job-postings/{id}/assignments` | `base44.workflows.assign` ← `useAssignWorkers` | per worker: `POST /assignments` + `POST`/`PATCH` `/job-applications` + `POST /user-activity` |
|
||||||
|
|
||||||
|
`internal/httpserver/workflows.go:85` reads verbatim:
|
||||||
|
|
||||||
|
> *"handleHire moves an application to `hired` and creates the staff record in
|
||||||
|
> one transaction. Replaces the two-call sequence at krowHooks.js:302-303."*
|
||||||
|
|
||||||
|
That sequence is gone. `useHireCandidate` now issues one request and sends only
|
||||||
|
what a hiring decision chooses — `role`, `profile_tier`, `hire_date`, `status`;
|
||||||
|
name, email, phone, score, posting and application id are carried across from
|
||||||
|
the application by the server. `useAssignWorkers` sends the whole batch in one
|
||||||
|
body, naming each worker's application by `application_id` where the page
|
||||||
|
already holds it and by an `application` payload where it does not, so a worker
|
||||||
|
with no application is found-or-filed inside the same transaction rather than in
|
||||||
|
a second, untransacted request.
|
||||||
|
|
||||||
|
Both handlers also write their own `user-activity` entry inside the transaction
|
||||||
|
— `hire_candidate` and `assign_employee` — so the frontend no longer logs
|
||||||
|
either. It invalidates `['userActivity']` instead.
|
||||||
|
|
||||||
|
The server-side handlers authorize on the operations they perform — hire needs
|
||||||
|
UPDATE on job-applications, CREATE on staff, CREATE on user-activity — checked
|
||||||
|
all-or-nothing before any transaction opens.
|
||||||
|
|
||||||
|
### 5.1a Interview completion — **Served**
|
||||||
|
|
||||||
|
`POST /api/v1/ai-interviews` is routed through the same workflow service
|
||||||
|
(`httpserver/api.go:156`): creating an interview also moves its application to
|
||||||
|
`status: 'interview'`, sets `interview_id`, and copies
|
||||||
|
`overall_interview_score` onto `ai_score` when the request supplied one — all in
|
||||||
|
one transaction. `AIInterviewModal.finishInterview` no longer patches the
|
||||||
|
application afterwards. That second call was not merely redundant: for a talent
|
||||||
|
user sitting their own interview it was a 403, because `ai-interviews:Create` is
|
||||||
|
open to everyone and `job-applications:Update` is operators only — the interview
|
||||||
|
existed, the application still read `applied`, and nothing that counts
|
||||||
|
`status === 'interview' || interview_id` could see it.
|
||||||
|
|
||||||
|
### 5.2 Definition endpoints (10)
|
||||||
|
|
||||||
|
`agent-definitions` and `skill-definitions`, five methods each: `GET`, `POST`
|
||||||
|
on the collection; `GET`, `PATCH`, `DELETE` on `/{id}`.
|
||||||
|
|
||||||
|
**This is where account-authored Owliver skills are supposed to live.**
|
||||||
|
`migrations/000005_agent_skill_definitions.up.sql` states the intent verbatim:
|
||||||
|
|
||||||
|
> *"Before this migration the last two lived in `user_preferences.extra`, a
|
||||||
|
> jsonb blob with no owner, no tenancy, no size bound, no server-side
|
||||||
|
> validation and no query surface — and returned in full by GET /api/v1/me on
|
||||||
|
> every page load. This migration is that move."*
|
||||||
|
|
||||||
|
**The frontend has not moved.** It still writes `customSkills` (121 references
|
||||||
|
across the repo) and `customAgents` through `PATCH /me/preferences`.
|
||||||
|
|
||||||
|
What the tables give that the preferences blob does not:
|
||||||
|
|
||||||
|
| Column | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `markdown` | The definition verbatim — the authoritative artefact |
|
||||||
|
| `definition_id` | Author-facing id, `^[a-z0-9][a-z0-9-]*$`, unique per owner or per org |
|
||||||
|
| `visibility` | `personal` \| `organization` — shadow-by-id is deliberate |
|
||||||
|
| `owner_user_id` / `org_id` | Tenancy and ownership; personal definitions cascade with their owner |
|
||||||
|
| `status` | agents: `draft`/`published`/`archived`; skills: `active`/`inactive` |
|
||||||
|
| `version` | Agents only, monotonic. **Skills have no version** — none is invented |
|
||||||
|
| `name`, `description`, `pages` | Server-parsed projections, never accepted from a request body |
|
||||||
|
|
||||||
|
Plus a `length(markdown) BETWEEN 1 AND 65536` bound the jsonb blob has no
|
||||||
|
equivalent of.
|
||||||
|
|
||||||
|
**`disabledSkills` and `removedSkills` stay in preferences** — the same
|
||||||
|
migration says so. They are per-account arrays of skill *ids*, mostly shipped
|
||||||
|
ids, so they are suppression preferences over a namespace, not definitions.
|
||||||
|
|
||||||
|
### 5.3 Owliver suggestions (1)
|
||||||
|
|
||||||
|
`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:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
| --- | --- |
|
||||||
|
| 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** |
|
||||||
|
|
||||||
|
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
|
||||||
|
frontend bug, and no change in this repository fixes it.** The two ways out are
|
||||||
|
to deploy `b6f8655` to that host, or to point `VITE_API_PROXY_TARGET` at a
|
||||||
|
backend built from current source.
|
||||||
|
|
||||||
|
Two things make this expensive to diagnose, and both are worth knowing before
|
||||||
|
reaching for the frontend code again:
|
||||||
|
|
||||||
|
- **An unauthenticated probe cannot tell you anything.** Authentication wraps the
|
||||||
|
whole mux (`server.go:153`), so *every* path under `/api/v1` answers `401`
|
||||||
|
when the session cookie is absent — a registered route and a nonexistent one
|
||||||
|
alike. `curl` against the host proves nothing on its own. The `404` only
|
||||||
|
appears once a request is authenticated and reaches the router, which is why
|
||||||
|
it shows up in the browser and not in a terminal.
|
||||||
|
- **`401` versus `404` is the whole diagnosis.** Through the dev proxy, `401`
|
||||||
|
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 endpoint replaces the *selection*, not
|
||||||
|
the answering: it returns at most three `{ text, intent }` pairs, where `intent`
|
||||||
|
is the frontend capability id verbatim — `position-strength`,
|
||||||
|
`hiring-operations` — so a chosen suggestion dispatches through the capability
|
||||||
|
manifests that already exist. An optional `capability` names the section type
|
||||||
|
when the query asked for one ("as a flow"), from `OWLIVER_CAPABILITIES`.
|
||||||
|
|
||||||
|
`page` is a `SKILL_SURFACES` id, not a route and not a context id, so the call
|
||||||
|
site is `surfaceForRoute()` / the page key the skill registry already derives —
|
||||||
|
not `ASSISTANT_CONTEXTS`. Aliases resolve, so `hired` and `hired-history` both
|
||||||
|
work.
|
||||||
|
|
||||||
|
What the server adds over computing this in the browser is the permission
|
||||||
|
filter: each intent declares what it reads, checked against the policy table, so
|
||||||
|
a caller is never offered a reading their role cannot perform. That check cannot
|
||||||
|
be done client-side, which is the reason this one is worth adopting rather than
|
||||||
|
keeping local.
|
||||||
|
|
||||||
|
No table and no migration — the catalogue is derived from the capability
|
||||||
|
manifests and the closed Owliver vocabulary. §6's `owliver:` suggestions are a
|
||||||
|
different thing and unaffected: those are what an *authored skill* declares, and
|
||||||
|
the backend still treats the block as opaque.
|
||||||
|
|
||||||
|
### 5.4 Registered, deliberately not adopted (1)
|
||||||
|
|
||||||
|
`GET /api/v1/me/preferences` exists. The frontend reads preferences off
|
||||||
|
`GET /me` plus the `krow_demo_user` mirror instead, because
|
||||||
|
`auth.preferences()` must answer synchronously on first paint. Not a gap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Owliver skill response API — Local stub
|
||||||
|
|
||||||
|
What a skill definition may declare about what Owliver can be *asked for*. The
|
||||||
|
`owliver:` block, normalized by `src/lib/skills/owliverConfig.js` against the
|
||||||
|
closed vocabulary in `src/lib/skills/surfaces.js`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
owliver:
|
||||||
|
enabled: true
|
||||||
|
suggestions:
|
||||||
|
- Show hiring activity
|
||||||
|
- label: Summarize it
|
||||||
|
prompt: Summarize hiring activity
|
||||||
|
capability: summary
|
||||||
|
capabilities:
|
||||||
|
- summary
|
||||||
|
- flow
|
||||||
|
responses:
|
||||||
|
flow:
|
||||||
|
title: Hiring Activity Flow
|
||||||
|
source: position.activity
|
||||||
|
steps: [today, yesterday, last-week]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.1 Resolution rules
|
||||||
|
|
||||||
|
- **A response is a section.** It normalizes through the same
|
||||||
|
`normalizeSection()` the page's `ui:` sections go through, so a capability
|
||||||
|
resolves to the same record, the same data source and the same renderer.
|
||||||
|
There is no second shape for "the chat version".
|
||||||
|
- **The block is optional.** No `owliver:` normalizes to `NO_OWLIVER` —
|
||||||
|
disabled, and behaviourally identical to before the feature existed.
|
||||||
|
- `enabled: false` switches it off without deleting what was written.
|
||||||
|
- **`steps:` ≡ `periods:`** — the same list under two names.
|
||||||
|
- **Inheritance.** A response with no `source` takes the first declared `ui:`
|
||||||
|
section's `source`, `periods`, `limit` and `editable`, in declaration order.
|
||||||
|
- **A capability that resolves no source is dropped**, with a named error —
|
||||||
|
otherwise it would register as something Owliver offers and then have nothing
|
||||||
|
to answer with.
|
||||||
|
- **Nothing unknown survives.** Capabilities, sources, periods and shapes are
|
||||||
|
checked against the tables below; an unrecognised value is a named error, not
|
||||||
|
a dropped key.
|
||||||
|
- **`editable` needs both halves.** The source must be `writable` *and* the
|
||||||
|
page must publish a handler (`usePublishPageActions`). Neither side can
|
||||||
|
enable editing alone.
|
||||||
|
|
||||||
|
### 6.2 Capabilities (10)
|
||||||
|
|
||||||
|
`shape` is the section type the answer is drawn with. `terms` are how a
|
||||||
|
question is recognised as asking for that *shape* — never for a subject; a
|
||||||
|
subject belongs in the definition's `triggers`.
|
||||||
|
|
||||||
|
| id | shape | Reads as |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `summary` | *(none — prose)* | The figures read back as sentences |
|
||||||
|
| `flow` | `flow` | Stages or periods as a sequence |
|
||||||
|
| `stats` | `stats` | A row of counted figures |
|
||||||
|
| `list` | `list` | A ranked or plain list of records |
|
||||||
|
| `table` | `table` | Rows and columns |
|
||||||
|
| `timeline` | `timeline` | Dated events, most recent first |
|
||||||
|
| `progress` | `progress` | Bars against a total |
|
||||||
|
| `weights` | `weights` | Weighted criteria, adjustable where the page accepts the write |
|
||||||
|
| `insight` | `insight` | One finding, stated plainly |
|
||||||
|
| `card` | `card` | A titled panel of figures |
|
||||||
|
|
||||||
|
`summary` is the one capability with no component, so it is compatible with
|
||||||
|
every source.
|
||||||
|
|
||||||
|
### 6.3 Data sources (25)
|
||||||
|
|
||||||
|
`context` is what the page must supply for the reading to be possible.
|
||||||
|
`options` are the knobs a definition may set. Only `position.vetting` is
|
||||||
|
writable.
|
||||||
|
|
||||||
|
| id | context | options |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `position.activity` | positionId | periods |
|
||||||
|
| `position.pipeline` | positionId | — |
|
||||||
|
| `position.candidates` | positionId | limit |
|
||||||
|
| `position.matches` | positionId | limit |
|
||||||
|
| `position.requirements` | positionId | — |
|
||||||
|
| `position.vetting` | positionId | — · **writable** |
|
||||||
|
| `candidate.readiness` | candidateId | — |
|
||||||
|
| `candidate.activity` | candidateId | — |
|
||||||
|
| `candidates.pipeline` | — | — |
|
||||||
|
| `candidates.activity` | — | periods |
|
||||||
|
| `candidates.quality` | — | periods, limit |
|
||||||
|
| `positions.demand` | — | limit |
|
||||||
|
| `positions.risk` | — | limit |
|
||||||
|
| `talent.pool` | — | limit |
|
||||||
|
| `hires.recent` | — | limit |
|
||||||
|
| `hires.performance` | — | — |
|
||||||
|
| `workforce.training` | — | limit |
|
||||||
|
| `workforce.coverage` | — | limit |
|
||||||
|
| `workforce.attendance` | — | periods, limit |
|
||||||
|
| `workforce.overtime` | — | periods, limit |
|
||||||
|
| `activity.signals` | — | limit |
|
||||||
|
| `activity.breakdown` | — | periods, limit |
|
||||||
|
| `activity.events` | — | limit |
|
||||||
|
| `operations.risk` | — | — |
|
||||||
|
| `workspace.summary` | — | — |
|
||||||
|
|
||||||
|
Each source declares which `shapes` it can be drawn as; `sourceSupportsShape()`
|
||||||
|
is the single resolver all three consumers use (the normalizer, the Board
|
||||||
|
editor's picker, the Owliver editor's per-capability picker).
|
||||||
|
|
||||||
|
### 6.4 Section types (9) and periods (6)
|
||||||
|
|
||||||
|
`card`, `stats`, `list`, `timeline`, `flow`, `table`, `progress`, `insight`,
|
||||||
|
`weights`.
|
||||||
|
|
||||||
|
`today`, `yesterday`, `last-7-days`, `last-week`, `this-month`,
|
||||||
|
`previous-month` — each a window over `created_date`, resolved at read time.
|
||||||
|
Never a stored figure, never a hard-coded date.
|
||||||
|
|
||||||
|
### 6.5 Surfaces (18)
|
||||||
|
|
||||||
|
`control-center`, `positions`, `create-position` *(alias `new-position`)*,
|
||||||
|
`candidates`, `hired-history` *(alias `hired`)*, `talent-pool`, `krow-forge`
|
||||||
|
*(aliases `university`, `forge`)*, `analytics`, `activity`,
|
||||||
|
`workspace-agent-configure`, `settings`, `workspace`, `workspace-agents`,
|
||||||
|
`workspace-skills`, `workspace-skill-configure`, `skill-development`,
|
||||||
|
`profile`, `candidates-analysis`.
|
||||||
|
|
||||||
|
The nine with placements are domain surfaces; the rest declare
|
||||||
|
`placements: []` — configuration screens holding no workforce records, where a
|
||||||
|
`ui:` skill is refused at validation rather than validating and drawing
|
||||||
|
nothing. `placementProvides()` says which placement supplies a `positionId` or
|
||||||
|
a `candidateId`, so a section reading `position.activity` cannot be attached
|
||||||
|
where no position is in context.
|
||||||
|
|
||||||
|
### 6.6 Where this is enforced
|
||||||
|
|
||||||
|
**Client-side only.** The backend mirrors `SKILL_SURFACES` exactly — all 18
|
||||||
|
ids and the same aliases, in `internal/definition/vocabulary.go:18-40` — and
|
||||||
|
validates a definition's `pages:` against it. It treats `ui:` and `owliver:`
|
||||||
|
as **opaque** (`internal/definition/skill.go:237`).
|
||||||
|
|
||||||
|
So if the definition endpoints in §5.2 are adopted, the server will store and
|
||||||
|
round-trip an `owliver:` block verbatim and check its `pages:`, but will not
|
||||||
|
reject an unknown capability or data source. That validation stays in
|
||||||
|
`owliverConfig.js`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Assistant streaming API — Local stub (wired, unused)
|
||||||
|
|
||||||
|
`src/components/ai-assistant/provider.js`. The UI never generates an answer and
|
||||||
|
never knows where one came from; it calls `provider.stream(request)` and renders
|
||||||
|
snapshots.
|
||||||
|
|
||||||
|
```
|
||||||
|
provider.id: string
|
||||||
|
provider.stream(request): AsyncIterable<Block[]>
|
||||||
|
|
||||||
|
request = { contextId, capability, question, facts, signal }
|
||||||
|
```
|
||||||
|
|
||||||
|
Each yielded value is the response **so far** as a full block array — snapshots,
|
||||||
|
not deltas, because a table or a KPI row has no meaningful half-state.
|
||||||
|
|
||||||
|
`createHttpProvider({ endpoint })` sends:
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST <endpoint>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{ "contextId": "admin.positions", "capability": "flow",
|
||||||
|
"question": "...", "agent": {...}, "owliverContext": {...} }
|
||||||
|
```
|
||||||
|
|
||||||
|
and reads SSE-style lines:
|
||||||
|
|
||||||
|
- `data: {"block": {...}}` — appended as a whole block
|
||||||
|
- `data: {"delta": "..."}` — appended to the trailing text block
|
||||||
|
- `data: [DONE]` — ignored
|
||||||
|
- unparseable payload — appended as text
|
||||||
|
|
||||||
|
Requires a streaming `response.body`; a non-2xx throws
|
||||||
|
`Assistant request failed: <status>`.
|
||||||
|
|
||||||
|
**The fact sheet is deliberately not sent.** Dashboard data is to be 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.
|
||||||
|
|
||||||
|
**Status.** `createAssistantProvider()` returns `createHttpProvider` only when
|
||||||
|
`VITE_ASSISTANT_ENDPOINT` is set. It is unset, so the app runs
|
||||||
|
`createLocalProvider()` — deterministic answers computed from the dashboard's
|
||||||
|
own data. **No backend route exists for this.**
|
||||||
|
|
||||||
|
Related, and honest about itself: an agent with `webSearch` set gets a note
|
||||||
|
saying no search provider is configured in this deployment, rather than an
|
||||||
|
answer that quietly came from nowhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. AI integrations — Local stub
|
||||||
|
|
||||||
|
`src/api/aiEngine.js`. No network, no key, no server route anywhere in the
|
||||||
|
backend.
|
||||||
|
|
||||||
|
### `integrations.Core.InvokeLLM({ prompt, response_json_schema, model })`
|
||||||
|
|
||||||
|
Recognises each workflow by a stable phrase its prompt opens with, reads the
|
||||||
|
structured fields the prompt already carries, and scores deterministically —
|
||||||
|
same input, same output. An unrecognised prompt logs a warning and returns `{}`
|
||||||
|
(or `''` with no schema) rather than a shape the caller cannot use.
|
||||||
|
|
||||||
|
Consumers — `src/lib/krowAi.js`: `generateJobDescription`,
|
||||||
|
`buildResumeFromText`, `screenCandidate`, `generateInterviewQuestion`,
|
||||||
|
`matchTalentForJob`, `evaluateInterview`, `owliverQuestion`,
|
||||||
|
`buildProfileFromConversation`. And `src/lib/provingGround.js`:
|
||||||
|
`challengeFollowUp`, `evaluateChallenge`.
|
||||||
|
|
||||||
|
### `integrations.Core.UploadFile({ file })`
|
||||||
|
|
||||||
|
Returns `{ file_url, file_name, file_size }` where `file_url` is a `blob:` URL
|
||||||
|
from `URL.createObjectURL`. **There is no file storage backend.** The URL is
|
||||||
|
valid for the rest of the session and dies on reload — anything persisted
|
||||||
|
through it (a selfie on a worker profile, challenge media) is a dangling
|
||||||
|
reference afterwards.
|
||||||
|
|
||||||
|
Consumers: `src/pages/KrowIdentity.jsx`,
|
||||||
|
`src/components/krow/proving/MediaChallenge.jsx`,
|
||||||
|
`src/components/krow/proving/VideoRecorder.jsx`.
|
||||||
|
|
||||||
|
### `analytics.track({ eventName, properties })`
|
||||||
|
|
||||||
|
A DEV-only `console.debug` (`src/api/base44Client.js:287-293`). No telemetry
|
||||||
|
endpoint exists. Note that real activity logging is separate and *is* served —
|
||||||
|
`logActivity` writes through `POST /api/v1/user-activity`.
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
Registered on the backend and public. `vite.config.js` proxies it. **No
|
||||||
|
frontend code calls it.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Browser-only state
|
||||||
|
|
||||||
|
| Key | Store | Holds |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `krow_demo_user` | localStorage | Mirror of the last `GET /me`, so `auth.preferences()` can be synchronous |
|
||||||
|
| `krow_assistant:history` | localStorage | Owliver conversation history across sessions |
|
||||||
|
| `krow_assistant_open` | sessionStorage | Whether the panel is open |
|
||||||
|
| `krow_assistant_expanded` | sessionStorage | Expanded state |
|
||||||
|
| `krow_assistant_width` | sessionStorage | Panel width, throttled on drag |
|
||||||
|
| `krow_assistant:agent` | sessionStorage | Selected agent |
|
||||||
|
| `<contextKey>` / `:flow` / `:id` | sessionStorage | Current thread, in-flight flow and conversation id, per page context |
|
||||||
|
|
||||||
|
Other platform APIs: `window.matchMedia` and `window.visualViewport`
|
||||||
|
(§11), `URL.createObjectURL` (§8), `navigator.mediaDevices.getUserMedia` and
|
||||||
|
`MediaRecorder` (`components/krow/proving/VideoRecorder.jsx:21,37`), and
|
||||||
|
`html2canvas` (`components/krow/talent/IdentityCareerCard.jsx`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. External hotlinks — External
|
||||||
|
|
||||||
|
No auth, no versioning, no contract. Each is a live dependency on a third party.
|
||||||
|
|
||||||
|
| Host | Used for | Where |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `fonts.googleapis.com` | Sora webfont | `src/index.css:1` |
|
||||||
|
| `api.qrserver.com` | vCard QR for the KROW ID | `KrowIdPreview.jsx:52`, `talent/IdentityCareerCard.jsx:132` |
|
||||||
|
| `images.unsplash.com` | Portrait fallback when `selfie_url` is empty | `SuggestedTalent.jsx`, `CandidateCard.jsx`, `TalentPoolCard.jsx` |
|
||||||
|
| `i.pravatar.cc` | Seed avatars | `src/api/seed.js` (15 records) |
|
||||||
|
| `static.wixstatic.com` | Image host allowlist | `src/components/ui/image.jsx:6` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Owliver panel responsiveness
|
||||||
|
|
||||||
|
**This section is UI layout, not an API contract.** It is here because it was
|
||||||
|
asked for alongside the API surface, not because anything below crosses a
|
||||||
|
network boundary.
|
||||||
|
|
||||||
|
`src/components/ai-assistant/viewport.js`:
|
||||||
|
|
||||||
|
- **`STACK_BREAKPOINT = 768`**, i.e. Tailwind's `md`, exported so the panel and
|
||||||
|
the provider switch on the same number. Below it Owliver stops being a column
|
||||||
|
beside the dashboard and becomes an overlay — a 380px track on a 375px phone
|
||||||
|
collapses `main` to nothing. Deliberately `md` and not `lg`: tablets lay the
|
||||||
|
inline column out acceptably, so only phones change presentation.
|
||||||
|
- **`useIsPhone()` uses `matchMedia`, not `innerWidth`.** When anything on the
|
||||||
|
page overflows, mobile browsers widen the layout viewport to fit it, and
|
||||||
|
`innerWidth` reports the widened value — so a width comparison reads the
|
||||||
|
viewport as a tablet and hands it a sidebar, defeating the very layout that
|
||||||
|
exists to rescue the page. A media query is evaluated against the media, not
|
||||||
|
the content.
|
||||||
|
- **`useVisualViewport()`** returns the region left over with the on-screen
|
||||||
|
keyboard up. `100dvh` follows the address bar but knows nothing about the
|
||||||
|
keyboard, so a `100dvh` sheet puts its own composer underneath the keys.
|
||||||
|
Returns `null` where the API is missing, and the caller falls back to the
|
||||||
|
`100dvh` class. No fixed pixel heights anywhere.
|
||||||
|
- **Width** is clamped against `useViewportWidth()` and persisted to
|
||||||
|
`krow_assistant_width` on a throttle rather than sixty times a second.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Drift found
|
||||||
|
|
||||||
|
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/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. |
|
||||||
|
|
||||||
|
## 13. Summary
|
||||||
|
|
||||||
|
- **Nothing the frontend calls is missing from the backend.** All 14 live
|
||||||
|
entities and all 5 auth/preference routes are served.
|
||||||
|
- **The two workflow endpoints are adopted.** Hire, assign and interview
|
||||||
|
completion each go over one request and one transaction; the frontend no
|
||||||
|
longer sequences the writes itself, and no longer writes the `hire_candidate`
|
||||||
|
or `assign_employee` audit entries the server now writes inside those
|
||||||
|
transactions.
|
||||||
|
- **12 registered endpoints still go unused**, of which 11 are worth adopting:
|
||||||
|
10 definition endpoints that are where Owliver's authored skills and agents
|
||||||
|
were meant to move, and the Owliver suggestion endpoint, which is the only one
|
||||||
|
that cannot be done correctly in the browser — it is permission filtered.
|
||||||
|
- **Four surfaces have no server at all** — `InvokeLLM`, `UploadFile`, the
|
||||||
|
assistant streaming endpoint, and web search. They are local stubs today, and
|
||||||
|
the app is honest about each.
|
||||||
|
- **The Owliver skill vocabulary is enforced in the browser only.** The backend
|
||||||
|
validates `pages:` and treats the rest of a definition as opaque.
|
||||||
@@ -275,6 +275,58 @@ const auth = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── Workflows ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The multi-record writes, as one request each.
|
||||||
|
*
|
||||||
|
* These are the only endpoints in this file that are not a CRUD projection of a
|
||||||
|
* table, and they exist because the flows below were previously performed as a
|
||||||
|
* sequence of independent requests with no transaction and no rollback — a hire
|
||||||
|
* whose PATCH landed and whose POST did not left a candidate marked `hired` with
|
||||||
|
* no employment record, and nothing in the UI could tell.
|
||||||
|
*
|
||||||
|
* They are shaped as verbs on the record they act on, so the entity surface
|
||||||
|
* above is untouched: `entities.JobApplication` still means the table, and
|
||||||
|
* hiring is a thing you do *to* an application rather than a fifteenth entity.
|
||||||
|
*
|
||||||
|
* `request` unwraps the envelope, so `hire` resolves to
|
||||||
|
* `{ application, staff }` and `assign` to `{ assignments, count }` — both
|
||||||
|
* records the server actually wrote, so no caller needs a follow-up read to
|
||||||
|
* render the outcome.
|
||||||
|
*/
|
||||||
|
const workflows = {
|
||||||
|
/**
|
||||||
|
* Move an application to `hired` and create the staff record, atomically.
|
||||||
|
*
|
||||||
|
* `body` carries only what a hiring form collects — `role`, `profile_tier`,
|
||||||
|
* `hire_date`, `status`, `phone`, `reviewer_name`. Everything else is carried
|
||||||
|
* across from the application by the server, because it is already the truth
|
||||||
|
* about this person and retyping it here is how the two records drift apart.
|
||||||
|
*/
|
||||||
|
async hire(applicationId, body = {}) {
|
||||||
|
return request('POST', `/job-applications/${encodeURIComponent(applicationId)}/hire`, {
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place workers on a posting, atomically.
|
||||||
|
*
|
||||||
|
* All-or-nothing across the batch: assigning six people and having the fourth
|
||||||
|
* fail must not leave three placed, three not, and the caller unsure which.
|
||||||
|
* Each entry names its application by `application_id` if the caller already
|
||||||
|
* has one, or describes one under `application` for the server to find or file
|
||||||
|
* inside the same transaction; a worker taken straight from the talent pool
|
||||||
|
* with neither is placed without one rather than given an invented one.
|
||||||
|
*/
|
||||||
|
async assign(jobPostingId, workers = []) {
|
||||||
|
return request('POST', `/job-postings/${encodeURIComponent(jobPostingId)}/assignments`, {
|
||||||
|
body: { workers },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/* ── Integrations & analytics ───────────────────────────────────────────── */
|
/* ── Integrations & analytics ───────────────────────────────────────────── */
|
||||||
|
|
||||||
const integrations = {
|
const integrations = {
|
||||||
@@ -292,7 +344,7 @@ const analytics = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const base44 = { entities, auth, integrations, analytics };
|
export const base44 = { entities, auth, integrations, analytics, workflows };
|
||||||
|
|
||||||
/** Where the entity data actually comes from, for diagnostics. */
|
/** Where the entity data actually comes from, for diagnostics. */
|
||||||
export { API_BASE_URL };
|
export { API_BASE_URL };
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import {
|
import {
|
||||||
BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus,
|
BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus,
|
||||||
SlidersHorizontal, Trash2, X, Zap,
|
SlidersHorizontal, Trash2, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger,
|
Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger,
|
||||||
SelectValue, Switch, Textarea,
|
SelectValue, Switch, Textarea,
|
||||||
} from '@/components/ds';
|
} from '@/components/ds';
|
||||||
import { allSkills } from '@/lib/skills/registry';
|
|
||||||
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
|
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
|
||||||
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
|
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
|
||||||
import {
|
import {
|
||||||
@@ -116,9 +114,6 @@ export function AgentConfigure({
|
|||||||
[fields.instructions]
|
[fields.instructions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const registry = React.useMemo(() => allSkills(customSkills), [customSkills]);
|
|
||||||
const skillById = React.useMemo(() => new Map(registry.map((s) => [s.id, s])), [registry]);
|
|
||||||
|
|
||||||
const availableSubagents = React.useMemo(
|
const availableSubagents = React.useMemo(
|
||||||
() => agents.filter((a) => a.id !== fields.id && a.status === 'published'),
|
() => agents.filter((a) => a.id !== fields.id && a.status === 'published'),
|
||||||
[agents, fields.id]
|
[agents, fields.id]
|
||||||
@@ -181,10 +176,9 @@ export function AgentConfigure({
|
|||||||
id: 'capabilities',
|
id: 'capabilities',
|
||||||
icon: Boxes,
|
icon: Boxes,
|
||||||
label: 'Capabilities',
|
label: 'Capabilities',
|
||||||
meta: `${fields.pages.length + fields.skills.length + fields.knowledge.length}`,
|
meta: `${fields.pages.length + fields.knowledge.length}`,
|
||||||
items: [
|
items: [
|
||||||
{ id: 'capabilities-pages', label: 'Pages', meta: fields.pages.length },
|
{ id: 'capabilities-pages', label: 'Pages', meta: fields.pages.length },
|
||||||
{ id: 'capabilities-skills', label: 'Skills', meta: fields.skills.length },
|
|
||||||
{ id: 'capabilities-knowledge', label: 'Knowledge', meta: fields.knowledge.length },
|
{ id: 'capabilities-knowledge', label: 'Knowledge', meta: fields.knowledge.length },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -313,7 +307,7 @@ export function AgentConfigure({
|
|||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
title="Capabilities"
|
title="Capabilities"
|
||||||
description="What this agent can reach and use when it answers."
|
description="What this agent can reach and use when it answers."
|
||||||
meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.skills.length} skill${fields.skills.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`}
|
meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`}
|
||||||
open={open.capabilities}
|
open={open.capabilities}
|
||||||
onToggle={() => toggleSection('capabilities')}
|
onToggle={() => toggleSection('capabilities')}
|
||||||
>
|
>
|
||||||
@@ -390,80 +384,6 @@ export function AgentConfigure({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Skills ── what it can do. */}
|
|
||||||
<div className="py-4">
|
|
||||||
<GroupHead
|
|
||||||
id="capabilities-skills"
|
|
||||||
icon={Zap}
|
|
||||||
title="Skills"
|
|
||||||
count={fields.skills.length}
|
|
||||||
action={(
|
|
||||||
<Button variant="outline" size="sm" onClick={onOpenSkills}>
|
|
||||||
<Boxes className="h-3.5 w-3.5" /> Browse catalog
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Attaching happens in the Skills workspace, not here. A
|
|
||||||
checkbox list could say which skills exist; it could not show
|
|
||||||
what an agent is *made of*, which is the question someone
|
|
||||||
composing one is actually asking. */}
|
|
||||||
{fields.skills.length > 0 ? (
|
|
||||||
<ul className="divide-y divide-border">
|
|
||||||
{fields.skills.map((id) => {
|
|
||||||
const skill = skillById.get(id);
|
|
||||||
return (
|
|
||||||
<ItemRow
|
|
||||||
key={id}
|
|
||||||
icon={Zap}
|
|
||||||
title={skill?.name || id}
|
|
||||||
detail={skill?.description}
|
|
||||||
warning={skill ? null : 'No skill with this id is registered.'}
|
|
||||||
removeLabel={`Remove ${skill?.name || id}`}
|
|
||||||
onRemove={() => set({ skills: fields.skills.filter((x) => x !== id) })}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<p className="text-body-sm text-ink-3">
|
|
||||||
No skills attached. This agent can still answer from the page's own reader —
|
|
||||||
or open the{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenSkills}
|
|
||||||
className={`font-medium text-krow-blue underline-offset-2 hover:underline
|
|
||||||
focus-visible:outline-none focus-visible:ring-2
|
|
||||||
focus-visible:ring-krow-blue/40 rounded`}
|
|
||||||
>
|
|
||||||
skill catalog
|
|
||||||
</button>{' '}
|
|
||||||
to give it one.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="mt-2 text-caption text-ink-4">
|
|
||||||
Attached under{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onOpenSkills}
|
|
||||||
className={`font-medium text-krow-blue underline-offset-2 hover:underline
|
|
||||||
focus-visible:outline-none focus-visible:ring-2
|
|
||||||
focus-visible:ring-krow-blue/40 rounded`}
|
|
||||||
>
|
|
||||||
Skills
|
|
||||||
</button>
|
|
||||||
, where Owliver skills and Board skills both live. Definitions are written in the{' '}
|
|
||||||
<Link
|
|
||||||
to="/admin/workspace/skills"
|
|
||||||
className="font-medium text-krow-blue underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
skill library
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Knowledge ── what it has been told. */}
|
{/* Knowledge ── what it has been told. */}
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<GroupHead
|
<GroupHead
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ import { useActiveAgent } from './AgentContext';
|
|||||||
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
import { Message, ThinkingIndicator, TurnDivider } from './AssistantMessage';
|
||||||
import { PromptInput } from './PromptInput';
|
import { PromptInput } from './PromptInput';
|
||||||
import { PromptChips } from './PromptChips';
|
import { PromptChips } from './PromptChips';
|
||||||
|
import { rankPrompts } from './matchPrompts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The chip row's "nothing to offer", as one shared array.
|
||||||
|
*
|
||||||
|
* Identity, not emptiness: `prompts` is recomputed on every keystroke, and a
|
||||||
|
* fresh `[]` each time would rerender the row — and anything memoized against
|
||||||
|
* it — throughout the whole of a query that is still too short to match.
|
||||||
|
*/
|
||||||
|
const EMPTY_PROMPTS = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owliver History — the conversations that came before.
|
* Owliver History — the conversations that came before.
|
||||||
@@ -522,12 +532,25 @@ export default function KrowAssistant({
|
|||||||
() => buildIntro(context.id, facts, userName),
|
() => buildIntro(context.id, facts, userName),
|
||||||
[context.id, facts, userName]
|
[context.id, facts, userName]
|
||||||
);
|
);
|
||||||
/* Suggestions are the page's own, with any skill that offers one on the front.
|
/**
|
||||||
The last answer can also propose follow-ups — the role list after "create a
|
* Everything this page can be asked, in the chips that already know how to
|
||||||
position" — which replace the standing set until the next turn. */
|
* ask it.
|
||||||
const followUp = messages[messages.length - 1]?.followUp;
|
*
|
||||||
const prompts = React.useMemo(() => {
|
* Unchanged in what it collects and in what order: the agent's starters, the
|
||||||
if (followUp?.length) return followUp;
|
* 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
|
/* 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
|
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
|
by the resolver so a workspace with several skills attached cannot bury
|
||||||
@@ -586,7 +609,40 @@ export default function KrowAssistant({
|
|||||||
});
|
});
|
||||||
/* `pageContext` decides which suggestions can answer without asking, so the
|
/* `pageContext` decides which suggestions can answer without asking, so the
|
||||||
chips re-rank when a position is opened or closed. */
|
chips re-rank when a position is opened or closed. */
|
||||||
}, [followUp, skills, context.id, disabledSkills, customSkills, facts, workforce, pageContext, agent]);
|
}, [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.
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
* by saying all of it at once and pushed the composer — the thing the
|
||||||
|
* reader came for — under a wall of suggestions. The greeting still
|
||||||
|
* carries the page's context; `buildIntro` reads the same fact sheet it
|
||||||
|
* always did.
|
||||||
|
*/
|
||||||
|
const followUp = messages[messages.length - 1]?.followUp;
|
||||||
|
const typed = input.trim();
|
||||||
|
const prompts = React.useMemo(() => {
|
||||||
|
if (!typed) return followUp?.length ? followUp : EMPTY_PROMPTS;
|
||||||
|
return rankPrompts(catalogue, typed);
|
||||||
|
}, [typed, followUp, catalogue]);
|
||||||
|
|
||||||
/* The newest assistant turn, which is the one that carries the rating. */
|
/* The newest assistant turn, which is the one that carries the rating. */
|
||||||
const lastAnswerIndex = React.useMemo(
|
const lastAnswerIndex = React.useMemo(
|
||||||
|
|||||||
@@ -697,7 +697,7 @@ const userActivityInsights = (f) => {
|
|||||||
* panel and the log can never disagree about what matters.
|
* panel and the log can never disagree about what matters.
|
||||||
*/
|
*/
|
||||||
const HIGH_SEVERITY = ['hire_candidate', 'create_position'];
|
const HIGH_SEVERITY = ['hire_candidate', 'create_position'];
|
||||||
const MEDIUM_SEVERITY = ['screen_candidate', 'start_interview'];
|
const MEDIUM_SEVERITY = ['assign_employee', 'screen_candidate', 'start_interview'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unusual activity — what is out of pattern, stated as a pattern.
|
* Unusual activity — what is out of pattern, stated as a pattern.
|
||||||
|
|||||||
171
src/components/ai-assistant/matchPrompts.js
Normal file
171
src/components/ai-assistant/matchPrompts.js
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Mic, MicOff, Volume2, Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
import { Mic, MicOff, Volume2, Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||||
import { generateInterviewQuestion, evaluateInterview } from '@/lib/krowAi';
|
import { generateInterviewQuestion, evaluateInterview } from '@/lib/krowAi';
|
||||||
import { useCreateInterview, useUpdateApplication } from '@/lib/krowHooks';
|
import { useCreateInterview } from '@/lib/krowHooks';
|
||||||
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -28,7 +28,6 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
|||||||
const scrollRef = useRef(null);
|
const scrollRef = useRef(null);
|
||||||
|
|
||||||
const createInterview = useCreateInterview();
|
const createInterview = useCreateInterview();
|
||||||
const updateApplication = useUpdateApplication();
|
|
||||||
|
|
||||||
const jobTitle = job?.title || 'the role';
|
const jobTitle = job?.title || 'the role';
|
||||||
const candidateName = application?.applicant_name || 'Candidate';
|
const candidateName = application?.applicant_name || 'Candidate';
|
||||||
@@ -153,11 +152,23 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
|||||||
setTimeout(() => askNextQuestion(newMessages), 300);
|
setTimeout(() => askNextQuestion(newMessages), 300);
|
||||||
}, [transcript, messages, stopListening, askNextQuestion]);
|
}, [transcript, messages, stopListening, askNextQuestion]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write the interview and let the server carry the verdict onto the
|
||||||
|
* application.
|
||||||
|
*
|
||||||
|
* There is deliberately no second call here. `POST /ai-interviews` moves the
|
||||||
|
* application to `interview`, sets `interview_id` and copies the score across
|
||||||
|
* in the same transaction as the interview itself. Doing it from the browser
|
||||||
|
* was two independent requests, and for a talent user sitting their own
|
||||||
|
* interview the second one was a 403 — the interview existed, the application
|
||||||
|
* still read `applied`, and every consumer counting
|
||||||
|
* `status === 'interview' || interview_id` could not see it.
|
||||||
|
*/
|
||||||
const finishInterview = useCallback(async (finalMessages) => {
|
const finishInterview = useCallback(async (finalMessages) => {
|
||||||
setPhase('evaluating');
|
setPhase('evaluating');
|
||||||
try {
|
try {
|
||||||
const result = await evaluateInterview(finalMessages, job, candidateName, language);
|
const result = await evaluateInterview(finalMessages, job, candidateName, language);
|
||||||
const interviewRecord = await createInterview.mutateAsync({
|
await createInterview.mutateAsync({
|
||||||
application_id: application.id,
|
application_id: application.id,
|
||||||
job_posting_id: application.job_posting_id,
|
job_posting_id: application.job_posting_id,
|
||||||
job_title: jobTitle,
|
job_title: jobTitle,
|
||||||
@@ -175,18 +186,13 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
|
|||||||
summary: result.summary,
|
summary: result.summary,
|
||||||
reasoning: result.reasoning,
|
reasoning: result.reasoning,
|
||||||
});
|
});
|
||||||
await updateApplication.mutateAsync({ id: application.id, data: {
|
|
||||||
status: 'interview',
|
|
||||||
interview_id: interviewRecord.id,
|
|
||||||
ai_score: result.overall_interview_score,
|
|
||||||
} });
|
|
||||||
setEvaluation(result);
|
setEvaluation(result);
|
||||||
setPhase('done');
|
setPhase('done');
|
||||||
} catch {
|
} catch {
|
||||||
setError('Evaluation failed. Please try again.');
|
setError('Evaluation failed. Please try again.');
|
||||||
setPhase('active');
|
setPhase('active');
|
||||||
}
|
}
|
||||||
}, [application, job, candidateName, createInterview, updateApplication, language]);
|
}, [application, job, candidateName, createInterview, language]);
|
||||||
|
|
||||||
const startInterview = useCallback(() => {
|
const startInterview = useCallback(() => {
|
||||||
setPhase('active');
|
setPhase('active');
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const EVENT_LABELS = {
|
|||||||
create_position: 'Create Position',
|
create_position: 'Create Position',
|
||||||
screen_candidate: 'Screen Candidate',
|
screen_candidate: 'Screen Candidate',
|
||||||
hire_candidate: 'Hire Candidate',
|
hire_candidate: 'Hire Candidate',
|
||||||
|
assign_employee: 'Assign Employee',
|
||||||
apply_job: 'Apply to Job',
|
apply_job: 'Apply to Job',
|
||||||
start_interview: 'Start Interview',
|
start_interview: 'Start Interview',
|
||||||
};
|
};
|
||||||
@@ -19,6 +20,7 @@ const EVENT_COLORS = {
|
|||||||
create_position: 'bg-[#F5F3FF] text-[#5B21B6]',
|
create_position: 'bg-[#F5F3FF] text-[#5B21B6]',
|
||||||
screen_candidate: 'bg-[#FFFBEB] text-[#92400E]',
|
screen_candidate: 'bg-[#FFFBEB] text-[#92400E]',
|
||||||
hire_candidate: 'bg-[#ECFDF5] text-[#065F46]',
|
hire_candidate: 'bg-[#ECFDF5] text-[#065F46]',
|
||||||
|
assign_employee: 'bg-[#F0F9FF] text-[#075985]',
|
||||||
apply_job: 'bg-[#EEF3FE] text-[#1E40AF]',
|
apply_job: 'bg-[#EEF3FE] text-[#1E40AF]',
|
||||||
start_interview: 'bg-[#FEF2F2] text-[#991B1B]',
|
start_interview: 'bg-[#FEF2F2] text-[#991B1B]',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -294,30 +294,44 @@ export function useGenerateJobDescription() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hire a candidate: the application moves to `hired` and the staff record
|
||||||
|
* appears, in one request and one transaction.
|
||||||
|
*
|
||||||
|
* This used to be a `PATCH` followed by a `POST`, and the failure it kept
|
||||||
|
* inviting was the second one not landing: the candidate read as hired
|
||||||
|
* everywhere while no employment record existed, and nothing in the UI could
|
||||||
|
* tell. `POST /job-applications/{id}/hire` does both writes or neither.
|
||||||
|
*
|
||||||
|
* Only the fields a hiring decision actually chooses are sent. Name, email,
|
||||||
|
* phone, score, posting and application id are carried across from the
|
||||||
|
* application by the server — they are already the truth about this person, and
|
||||||
|
* sending them from here is how the two records drift apart.
|
||||||
|
*
|
||||||
|
* Resolves to the updated **application**, which is what the two-call version
|
||||||
|
* returned, so every call site is unchanged.
|
||||||
|
*/
|
||||||
export function useHireCandidate() {
|
export function useHireCandidate() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
|
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
|
||||||
const tier = application.ai_score >= 80 ? 'Skilled' : application.ai_score >= 60 ? 'Cross-Trained' : 'Beginner';
|
const tier = application.ai_score >= 80 ? 'Skilled' : application.ai_score >= 60 ? 'Cross-Trained' : 'Beginner';
|
||||||
const updated = await base44.entities.JobApplication.update(application.id, { status: 'hired' });
|
const result = await base44.workflows.hire(application.id, {
|
||||||
await base44.entities.Staff.create({
|
|
||||||
name: application.applicant_name,
|
|
||||||
email: application.email,
|
|
||||||
phone: application.phone,
|
|
||||||
role: job?.title || job?.role_category || 'Staff',
|
role: job?.title || job?.role_category || 'Staff',
|
||||||
profile_tier: tier,
|
profile_tier: tier,
|
||||||
hire_date: new Date().toISOString().split('T')[0],
|
hire_date: new Date().toISOString().split('T')[0],
|
||||||
application_id: application.id,
|
|
||||||
job_posting_id: application.job_posting_id,
|
|
||||||
ai_score: application.ai_score,
|
|
||||||
status: 'onboarding',
|
status: 'onboarding',
|
||||||
});
|
});
|
||||||
return updated;
|
return result?.application;
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
queryClient.invalidateQueries({ queryKey: ['applications'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
queryClient.invalidateQueries({ queryKey: ['staff'] });
|
||||||
logActivity('hire_candidate');
|
/* 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
|
||||||
|
second `logActivity` call would file a duplicate audit row for one
|
||||||
|
action. */
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['userActivity'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -328,6 +342,15 @@ export function useMatchTalentForJob() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a completed interview.
|
||||||
|
*
|
||||||
|
* One request writes both records: the server moves the application to
|
||||||
|
* `interview`, sets `interview_id` and copies the score across in the same
|
||||||
|
* transaction as the interview. Callers must not patch the application
|
||||||
|
* themselves afterwards — that was the old two-call flow, and it is the reason
|
||||||
|
* `['applications']` is invalidated here.
|
||||||
|
*/
|
||||||
export function useCreateInterview() {
|
export function useCreateInterview() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -400,15 +423,32 @@ export function useAssignments() {
|
|||||||
* time anything reaches here the admin has seen exactly who and how many. There
|
* time anything reaches here the admin has seen exactly who and how many. There
|
||||||
* is no code path that assigns somebody without that preview having been shown.
|
* is no code path that assigns somebody without that preview having been shown.
|
||||||
*
|
*
|
||||||
* Writes three things per person, because an assignment that updated only one
|
* Still three writes per person — the assignment, the application's status, and
|
||||||
* of them would leave the system disagreeing with itself: the assignment
|
* the activity event carrying every id involved — but they are now one request
|
||||||
* record, the application's status where the person applied, and an activity
|
* and one transaction rather than 3n sequential round-trips that could fail in
|
||||||
* event carrying every id involved.
|
* the middle. Assigning six people and having the fourth fail used to leave
|
||||||
|
* three placed, three not, and the caller unsure which; the batch is now
|
||||||
|
* all-or-nothing.
|
||||||
|
*
|
||||||
|
* Per worker the request says one of two things about the application:
|
||||||
|
*
|
||||||
|
* - `application_id`, when this page already holds the application this
|
||||||
|
* person filed for this posting. The server moves it to `assigned`.
|
||||||
|
* - an `application` payload otherwise. The application is what puts a person
|
||||||
|
* *in the pipeline for this role*, and every downstream step keys on it —
|
||||||
|
* the candidate profile is addressed by it, and the AI interview takes one
|
||||||
|
* as its subject — so a worker pulled from the existing workforce gets one
|
||||||
|
* filed. The server finds it or files it inside the same transaction, which
|
||||||
|
* is what stops a stale local list from producing a duplicate.
|
||||||
|
*
|
||||||
|
* Resolves to the created assignment records, as the loop did.
|
||||||
*/
|
*/
|
||||||
export function useAssignWorkers() {
|
export function useAssignWorkers() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: /** @param {any} vars */ async ({ position, workers = [], applications = [] }) => {
|
mutationFn: /** @param {any} vars */ async ({ position, workers = [], applications = [] }) => {
|
||||||
|
if (workers.length === 0) return [];
|
||||||
|
|
||||||
const startsAt = position.start_date
|
const startsAt = position.start_date
|
||||||
? new Date(position.start_date).toISOString()
|
? new Date(position.start_date).toISOString()
|
||||||
: new Date().toISOString();
|
: new Date().toISOString();
|
||||||
@@ -416,37 +456,25 @@ export function useAssignWorkers() {
|
|||||||
? null
|
? null
|
||||||
: new Date(new Date(startsAt).getTime() + position.duration_months * 30 * 86400000).toISOString();
|
: new Date(new Date(startsAt).getTime() + position.duration_months * 30 * 86400000).toISOString();
|
||||||
|
|
||||||
const created = [];
|
const batch = workers.map((worker) => {
|
||||||
for (const worker of workers) {
|
const entry = {
|
||||||
const record = await base44.entities.Assignment.create({
|
|
||||||
job_posting_id: position.id,
|
|
||||||
worker_email: worker.email,
|
worker_email: worker.email,
|
||||||
worker_name: worker.name,
|
worker_name: worker.name,
|
||||||
starts_at: startsAt,
|
starts_at: startsAt,
|
||||||
ends_at: endsAt,
|
ends_at: endsAt,
|
||||||
status: 'active',
|
|
||||||
source: 'owliver',
|
source: 'owliver',
|
||||||
match_score: worker.score ?? null,
|
match_score: worker.score ?? null,
|
||||||
});
|
};
|
||||||
created.push(record);
|
|
||||||
|
|
||||||
/**
|
const application = applications.find(
|
||||||
* The application is what puts a person *in the pipeline for this role*,
|
|
||||||
* and it is what every downstream step keys on — the candidate profile
|
|
||||||
* is addressed by it, and the AI interview takes one as its subject.
|
|
||||||
*
|
|
||||||
* So assigning somebody from the existing workforce creates one where
|
|
||||||
* none exists, exactly as the Position page's own "admit talent" path
|
|
||||||
* does. Without it an assigned worker would be unreachable: no record to
|
|
||||||
* open, and no way to interview them.
|
|
||||||
*/
|
|
||||||
let application = applications.find(
|
|
||||||
(a) => a.job_posting_id === position.id
|
(a) => a.job_posting_id === position.id
|
||||||
&& String(a.email || '').toLowerCase() === String(worker.email || '').toLowerCase()
|
&& String(a.email || '').toLowerCase() === String(worker.email || '').toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!application) {
|
if (application) {
|
||||||
application = await base44.entities.JobApplication.create({
|
entry.application_id = application.id;
|
||||||
|
} else {
|
||||||
|
entry.application = {
|
||||||
applicant_name: worker.name,
|
applicant_name: worker.name,
|
||||||
email: worker.email || '',
|
email: worker.email || '',
|
||||||
phone: worker.profile?.phone || '',
|
phone: worker.profile?.phone || '',
|
||||||
@@ -461,20 +489,17 @@ export function useAssignWorkers() {
|
|||||||
job_title: position.title,
|
job_title: position.title,
|
||||||
status: 'assigned',
|
status: 'assigned',
|
||||||
ai_score: worker.score ?? 0,
|
ai_score: worker.score ?? 0,
|
||||||
});
|
};
|
||||||
} else {
|
|
||||||
await base44.entities.JobApplication.update(application.id, { status: 'assigned' });
|
|
||||||
}
|
}
|
||||||
record.application_id = application.id;
|
|
||||||
|
|
||||||
await logActivity('assign_employee', {
|
return entry;
|
||||||
details: `${worker.name} assigned to ${position.company ? `${position.company} — ` : ''}${position.title}`,
|
|
||||||
position_id: position.id,
|
|
||||||
application_id: application?.id || null,
|
|
||||||
worker_email: worker.email,
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
return created;
|
/* The `assign_employee` entries are written inside the same transaction,
|
||||||
|
so nothing is logged from here — a `logActivity` call per worker would
|
||||||
|
file a duplicate audit row for every placement. */
|
||||||
|
const result = await base44.workflows.assign(position.id, batch);
|
||||||
|
return result?.assignments || [];
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
/* Everything that reads workforce state refreshes together, so the
|
/* Everything that reads workforce state refreshes together, so the
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const EVENT_LABELS = {
|
|||||||
create_position: 'Created a position',
|
create_position: 'Created a position',
|
||||||
screen_candidate: 'Screened a candidate',
|
screen_candidate: 'Screened a candidate',
|
||||||
hire_candidate: 'Hired a candidate',
|
hire_candidate: 'Hired a candidate',
|
||||||
|
assign_employee: 'Assigned an employee',
|
||||||
apply_job: 'Applied for a job',
|
apply_job: 'Applied for a job',
|
||||||
start_interview: 'Started an interview',
|
start_interview: 'Started an interview',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Activity, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert, UserCheck, UserPlus,
|
Activity, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert, UserCheck, UserPlus,
|
||||||
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
|
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
|
||||||
@@ -23,6 +24,7 @@ import { SkillSurface } from '@/components/skills/SkillSurface';
|
|||||||
const SEVERITY = {
|
const SEVERITY = {
|
||||||
hire_candidate: 'high',
|
hire_candidate: 'high',
|
||||||
create_position: 'high',
|
create_position: 'high',
|
||||||
|
assign_employee: 'medium',
|
||||||
screen_candidate: 'medium',
|
screen_candidate: 'medium',
|
||||||
start_interview: 'medium',
|
start_interview: 'medium',
|
||||||
signup: 'low',
|
signup: 'low',
|
||||||
@@ -50,6 +52,7 @@ const TIMELINE_LIMIT = 12;
|
|||||||
*/
|
*/
|
||||||
const EVENT_ICON = {
|
const EVENT_ICON = {
|
||||||
hire_candidate: UserCheck,
|
hire_candidate: UserCheck,
|
||||||
|
assign_employee: Users,
|
||||||
create_position: FilePlus2,
|
create_position: FilePlus2,
|
||||||
screen_candidate: ScanSearch,
|
screen_candidate: ScanSearch,
|
||||||
start_interview: Mic,
|
start_interview: Mic,
|
||||||
|
|||||||
@@ -1,12 +1,25 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
|
||||||
// The reference app resolves `@/*` through the Base44 Vite plugin. This demo is
|
// The reference app resolves `@/*` through the Base44 Vite plugin. This demo is
|
||||||
// backend-free, so the alias is declared directly.
|
// backend-free, so the alias is declared directly.
|
||||||
// The API host the dev server forwards to. Only read here; nothing in client
|
// The API host the dev server forwards to. Only read here; nothing in client
|
||||||
// code names a backend host.
|
// code names a backend host.
|
||||||
const PROXY_TARGET = process.env.VITE_API_PROXY_TARGET || 'https://mcp.krowforce.com';
|
//
|
||||||
|
// It has to come from `loadEnv`, not `process.env`. Vite reads `.env` files for
|
||||||
|
// *client* code and exposes them on `import.meta.env`; it never copies them into
|
||||||
|
// `process.env`, and this config file runs in Node before any of that happens.
|
||||||
|
// So `process.env.VITE_API_PROXY_TARGET` was always undefined unless the
|
||||||
|
// variable was exported in the shell, and the line in `.env` documenting this
|
||||||
|
// knob did nothing at all — every dev server silently used the hard-coded
|
||||||
|
// fallback no matter what `.env` said. `loadEnv(mode, cwd, '')` reads the same
|
||||||
|
// files Vite would, with no prefix filter, so the `.env` value now actually
|
||||||
|
// takes effect.
|
||||||
|
function proxyTargetFor(mode) {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
|
return env.VITE_API_PROXY_TARGET || 'https://mcp.krowforce.com';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One proxy entry per path prefix, all pointing at the same backend.
|
* One proxy entry per path prefix, all pointing at the same backend.
|
||||||
@@ -18,7 +31,7 @@ const PROXY_TARGET = process.env.VITE_API_PROXY_TARGET || 'https://mcp.krowforce
|
|||||||
* `changeOrigin` rewrites the Host header to the target, which the backend's
|
* `changeOrigin` rewrites the Host header to the target, which the backend's
|
||||||
* TLS termination requires to route the request at all.
|
* TLS termination requires to route the request at all.
|
||||||
*/
|
*/
|
||||||
function proxyRoutes(prefixes) {
|
function proxyRoutes(prefixes, PROXY_TARGET) {
|
||||||
return Object.fromEntries(prefixes.map((prefix) => [prefix, {
|
return Object.fromEntries(prefixes.map((prefix) => [prefix, {
|
||||||
target: PROXY_TARGET,
|
target: PROXY_TARGET,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
@@ -37,7 +50,12 @@ function proxyRoutes(prefixes) {
|
|||||||
}]));
|
}]));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
const PROXY_TARGET = proxyTargetFor(mode);
|
||||||
|
|
||||||
|
console.log(`[vite] proxying /api and /health to ${PROXY_TARGET}`);
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
@@ -91,6 +109,7 @@ export default defineConfig({
|
|||||||
* nginx.conf), so this is a development-only shim for a property the
|
* nginx.conf), so this is a development-only shim for a property the
|
||||||
* deployed app has for free.
|
* deployed app has for free.
|
||||||
*/
|
*/
|
||||||
proxy: proxyRoutes(['/api', '/health']),
|
proxy: proxyRoutes(['/api', '/health'], PROXY_TARGET),
|
||||||
},
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user