update the chatbox

This commit is contained in:
2026-08-25 18:56:07 +05:30
parent 6c105f5b76
commit ab9adde6cd
13 changed files with 1158 additions and 201 deletions

41
.env.bak Normal file
View 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
View 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.

View File

@@ -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 ───────────────────────────────────────────── */
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. */
export { API_BASE_URL };

View File

@@ -1,14 +1,12 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import {
BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus,
SlidersHorizontal, Trash2, X, Zap,
SlidersHorizontal, Trash2, X,
} from 'lucide-react';
import {
Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger,
SelectValue, Switch, Textarea,
} from '@/components/ds';
import { allSkills } from '@/lib/skills/registry';
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
import {
@@ -116,9 +114,6 @@ export function AgentConfigure({
[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(
() => agents.filter((a) => a.id !== fields.id && a.status === 'published'),
[agents, fields.id]
@@ -181,10 +176,9 @@ export function AgentConfigure({
id: 'capabilities',
icon: Boxes,
label: 'Capabilities',
meta: `${fields.pages.length + fields.skills.length + fields.knowledge.length}`,
meta: `${fields.pages.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 },
],
},
@@ -313,7 +307,7 @@ export function AgentConfigure({
icon={Boxes}
title="Capabilities"
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}
onToggle={() => toggleSection('capabilities')}
>
@@ -390,80 +384,6 @@ export function AgentConfigure({
</p>
</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&apos;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. */}
<div className="pt-4">
<GroupHead

View File

@@ -30,6 +30,16 @@ 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.
*
* 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.
@@ -522,12 +532,25 @@ export default function KrowAssistant({
() => buildIntro(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
position" — which replace the standing set until the next turn. */
const followUp = messages[messages.length - 1]?.followUp;
const prompts = React.useMemo(() => {
if (followUp?.length) return followUp;
/**
* 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
@@ -586,7 +609,40 @@ export default function KrowAssistant({
});
/* `pageContext` decides which suggestions can answer without asking, so the
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. */
const lastAnswerIndex = React.useMemo(

View File

@@ -697,7 +697,7 @@ const userActivityInsights = (f) => {
* panel and the log can never disagree about what matters.
*/
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.

View 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;
}

View File

@@ -3,7 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Button } from '@/components/ui/button';
import { Mic, MicOff, Volume2, Loader2, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { generateInterviewQuestion, evaluateInterview } from '@/lib/krowAi';
import { useCreateInterview, useUpdateApplication } from '@/lib/krowHooks';
import { useCreateInterview } from '@/lib/krowHooks';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
import { cn } from '@/lib/utils';
@@ -28,7 +28,6 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
const scrollRef = useRef(null);
const createInterview = useCreateInterview();
const updateApplication = useUpdateApplication();
const jobTitle = job?.title || 'the role';
const candidateName = application?.applicant_name || 'Candidate';
@@ -153,11 +152,23 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
setTimeout(() => askNextQuestion(newMessages), 300);
}, [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) => {
setPhase('evaluating');
try {
const result = await evaluateInterview(finalMessages, job, candidateName, language);
const interviewRecord = await createInterview.mutateAsync({
await createInterview.mutateAsync({
application_id: application.id,
job_posting_id: application.job_posting_id,
job_title: jobTitle,
@@ -175,18 +186,13 @@ export default function AIInterviewModal({ open, onClose, application, job, cond
summary: result.summary,
reasoning: result.reasoning,
});
await updateApplication.mutateAsync({ id: application.id, data: {
status: 'interview',
interview_id: interviewRecord.id,
ai_score: result.overall_interview_score,
} });
setEvaluation(result);
setPhase('done');
} catch {
setError('Evaluation failed. Please try again.');
setPhase('active');
}
}, [application, job, candidateName, createInterview, updateApplication, language]);
}, [application, job, candidateName, createInterview, language]);
const startInterview = useCallback(() => {
setPhase('active');

View File

@@ -8,6 +8,7 @@ const EVENT_LABELS = {
create_position: 'Create Position',
screen_candidate: 'Screen Candidate',
hire_candidate: 'Hire Candidate',
assign_employee: 'Assign Employee',
apply_job: 'Apply to Job',
start_interview: 'Start Interview',
};
@@ -19,6 +20,7 @@ const EVENT_COLORS = {
create_position: 'bg-[#F5F3FF] text-[#5B21B6]',
screen_candidate: 'bg-[#FFFBEB] text-[#92400E]',
hire_candidate: 'bg-[#ECFDF5] text-[#065F46]',
assign_employee: 'bg-[#F0F9FF] text-[#075985]',
apply_job: 'bg-[#EEF3FE] text-[#1E40AF]',
start_interview: 'bg-[#FEF2F2] text-[#991B1B]',
};

View File

@@ -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() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ application, job }) => {
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' });
await base44.entities.Staff.create({
name: application.applicant_name,
email: application.email,
phone: application.phone,
const result = await base44.workflows.hire(application.id, {
role: job?.title || job?.role_category || 'Staff',
profile_tier: tier,
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',
});
return updated;
return result?.application;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['applications'] });
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() {
const queryClient = useQueryClient();
return useMutation({
@@ -400,15 +423,32 @@ export function useAssignments() {
* 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.
*
* Writes three things per person, because an assignment that updated only one
* of them would leave the system disagreeing with itself: the assignment
* record, the application's status where the person applied, and an activity
* event carrying every id involved.
* Still three writes per person — the assignment, the application's status, and
* the activity event carrying every id involved — but they are now one request
* and one transaction rather than 3n sequential round-trips that could fail in
* 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() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: /** @param {any} vars */ async ({ position, workers = [], applications = [] }) => {
if (workers.length === 0) return [];
const startsAt = position.start_date
? new Date(position.start_date).toISOString()
: new Date().toISOString();
@@ -416,37 +456,25 @@ export function useAssignWorkers() {
? null
: new Date(new Date(startsAt).getTime() + position.duration_months * 30 * 86400000).toISOString();
const created = [];
for (const worker of workers) {
const record = await base44.entities.Assignment.create({
job_posting_id: position.id,
const batch = workers.map((worker) => {
const entry = {
worker_email: worker.email,
worker_name: worker.name,
starts_at: startsAt,
ends_at: endsAt,
status: 'active',
source: 'owliver',
match_score: worker.score ?? null,
});
created.push(record);
};
/**
* 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(
const application = applications.find(
(a) => a.job_posting_id === position.id
&& String(a.email || '').toLowerCase() === String(worker.email || '').toLowerCase()
);
if (!application) {
application = await base44.entities.JobApplication.create({
if (application) {
entry.application_id = application.id;
} else {
entry.application = {
applicant_name: worker.name,
email: worker.email || '',
phone: worker.profile?.phone || '',
@@ -461,20 +489,17 @@ export function useAssignWorkers() {
job_title: position.title,
status: 'assigned',
ai_score: worker.score ?? 0,
});
} else {
await base44.entities.JobApplication.update(application.id, { status: 'assigned' });
};
}
record.application_id = application.id;
await logActivity('assign_employee', {
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 entry;
});
}
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: () => {
/* Everything that reads workforce state refreshes together, so the

View File

@@ -11,6 +11,7 @@ const EVENT_LABELS = {
create_position: 'Created a position',
screen_candidate: 'Screened a candidate',
hire_candidate: 'Hired a candidate',
assign_employee: 'Assigned an employee',
apply_job: 'Applied for a job',
start_interview: 'Started an interview',
};

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react';
import {
Activity, FilePlus2, LogIn, LogOut, Mic, ScanSearch, Send, ShieldAlert, UserCheck, UserPlus,
Users,
} from 'lucide-react';
import {
Avatar, Badge, DataTable, MetricStrip, SearchInput, Surface, Timeline,
@@ -23,6 +24,7 @@ import { SkillSurface } from '@/components/skills/SkillSurface';
const SEVERITY = {
hire_candidate: 'high',
create_position: 'high',
assign_employee: 'medium',
screen_candidate: 'medium',
start_interview: 'medium',
signup: 'low',
@@ -50,6 +52,7 @@ const TIMELINE_LIMIT = 12;
*/
const EVENT_ICON = {
hire_candidate: UserCheck,
assign_employee: Users,
create_position: FilePlus2,
screen_candidate: ScanSearch,
start_interview: Mic,

View File

@@ -1,12 +1,25 @@
import path from 'node:path';
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
// backend-free, so the alias is declared directly.
// The API host the dev server forwards to. Only read here; nothing in client
// 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.
@@ -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
* TLS termination requires to route the request at all.
*/
function proxyRoutes(prefixes) {
function proxyRoutes(prefixes, PROXY_TARGET) {
return Object.fromEntries(prefixes.map((prefix) => [prefix, {
target: PROXY_TARGET,
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()],
resolve: {
alias: {
@@ -91,6 +109,7 @@ export default defineConfig({
* nginx.conf), so this is a development-only shim for a property the
* deployed app has for free.
*/
proxy: proxyRoutes(['/api', '/health']),
proxy: proxyRoutes(['/api', '/health'], PROXY_TARGET),
},
};
});