candidates and board ui agent issue
Some checks failed
CI / check (push) Failing after 4m58s

This commit is contained in:
2026-09-05 10:46:06 +05:30
parent e02a0c23d4
commit 6249e00a3a
78 changed files with 15071 additions and 1998 deletions

View File

@@ -1,7 +1,7 @@
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
import { CERT_OPTIONS, ENGLISH_LEVELS } from '@/lib/positionModel';
import { ENGLISH_LEVELS, certificationsForRole } from '@/lib/positionModel';
import {
AVAILABILITY_OPTIONS, defaultEmployeeRole, desiredPayLabel,
AVAILABILITY_OPTIONS, desiredPayLabel,
} from '@/lib/employeeRoleModel';
import {
extractCertifications, extractEnglish, extractExperience, extractPay, extractRole,
@@ -31,37 +31,54 @@ const fields = {
* and what survives a worker profile being removed. A name with no email
* behind it is not an answer, so `parse` refuses one it cannot resolve.
*/
worker: {
label: 'Worker',
settled: (draft) => Boolean(String(draft.worker_email || '').trim()),
retry: 'Name the worker, or type their email address.',
parse: (answer, { workers = [] }) => {
const said = String(answer).trim();
if (!said) return null;
/* An email typed outright is the worker, whether or not a profile
exists — a role can be recorded before the profile is created. */
const email = /\b[^\s@]+@[^\s@]+\.[^\s@]+\b/.exec(said)?.[0];
const lower = said.toLowerCase();
const match = workers.find((w) => (email
? String(w.email || '').toLowerCase() === email.toLowerCase()
: String(w.name || '').toLowerCase() === lower))
/* A chip carries the full name; a typed answer may be part of one. */
|| (!email && workers.find((w) => String(w.name || '').toLowerCase().includes(lower)));
if (match) {
return {
worker_profile_id: match.id || null,
worker_email: match.email,
worker_name: match.name || '',
};
}
return email ? { worker_profile_id: null, worker_email: email, worker_name: '' } : null;
/**
* The new employee's name.
*
* A name and nothing more. It is NOT looked up, because a name cannot select
* anybody: an organization may employ any number of people who share one, and
* the previous version of this field searched the existing workers for a name
* match and attached the role to the first hit. With several people of the
* same name that silently filed the role against the wrong person; with a
* name nobody had, it understood nothing and asked the same question again,
* which is the loop this flow was stuck in.
*/
worker_name: {
label: 'Name',
settled: (draft) => Boolean(String(draft.worker_name || '').trim()),
retry: 'Type the new employee\u2019s full name.',
parse: (answer) => {
const name = String(answer).trim().replace(/\s+/g, ' ');
return name.length >= 2 ? { worker_name: name } : null;
},
summary: (draft) => (draft.worker_name
? `Worker: ${draft.worker_name} (${draft.worker_email})`
: `Worker: ${draft.worker_email}`),
summary: (draft) => `Name: ${draft.worker_name}`,
},
/**
* The new employee's email, which is their identity.
*
* Asked outright and never derived. There is no rule anywhere that turns a
* name into an address, no fallback to the operator's own account, and no
* reuse of anything an earlier conversation collected — an invented address
* is a real person's record filed under something they do not own.
*
* `worker_profiles` carries UNIQUE (org_id, email) over a `citext` column, so
* this value is what decides whether the person already exists. The check is
* the database's, not this field's: two operators recording the same person
* at the same moment cannot both win, whatever either browser believed.
*/
worker_email: {
label: 'Email',
settled: (draft) => Boolean(String(draft.worker_email || '').trim()),
retry: 'Type the employee\u2019s email address — it is how the record is identified.',
parse: (answer) => {
const said = String(answer).trim();
/* The whole answer must be the address. Pulling one out of a sentence
would accept "I don't know, maybe bob@x.com" as a considered answer. */
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(said)
? { worker_email: said }
: null;
},
summary: (draft) => `Email: ${draft.worker_email}`,
},
role_category: {
@@ -174,12 +191,25 @@ const fields = {
* `@workers` is the worker profiles the panel has already loaded for this
* caller — org-scoped by the API, and nothing here widens that view.
*/
const resolve = (token, { roles = [], workers = [] }) => {
const resolve = (token, { roles = [], postings = null }, draft = {}) => {
switch (token) {
case '@workers': return workers.map((w) => w.name).filter(Boolean);
case '@roles': return roles;
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
case '@certifications': return CERT_OPTIONS;
/**
* Only the certifications that matter for the role just chosen.
*
* Never the global list. A worker declaring themselves a Chef is asked
* about the certifications this organization puts on its Chef postings, and
* about nothing else — offering `Guard Card` there is the assistant
* inventing a requirement, and a chip is a suggestion the reader is
* entitled to read as informed.
*
* `null` back from the helper means the postings have not arrived; `[]`
* means this role genuinely has none. Both produce no chips here, and
* neither falls back to a global list — the step is optional, so `Skip` is
* offered by the engine either way and the reader can still type one.
*/
case '@certifications': return certificationsForRole(draft.role_category, postings) || [];
case '@availability': return AVAILABILITY_OPTIONS;
default: return [];
}
@@ -296,14 +326,28 @@ export const employeeRoleRegistry = {
extract,
/**
* No prefill from the opening request.
* No prefill from the opening request, and an EMPTY draft rather than a
* defaulted one.
*
* "Create an employee role" names nobody, and the posting flow's habit of
* reading a role out of the request would settle `role_category` from the
* word "role" in the phrase that started the conversation. The first question
* is who this is about, and it is asked.
*
* Returning `defaultEmployeeRole()` here — the record's write-time defaults —
* was worse than it looks. Every `settled` test asks whether a field HAS a
* value, and the defaults give all of them one: `certifications: []` is an
* array, `notes: ''` is a string, `experience_years: 0` is a number. So five
* of the eight questions were answered before they were asked, and the
* conversation went worker → role → pay and stopped. The certification step
* could not be reached at all.
*
* The two are different things wearing the same shape: write-time defaults
* are what a MISSING answer becomes, and a conversation must be able to tell
* missing from answered. `toEmployeeRolePayload` still applies them at the
* write, so nothing is lost by starting empty.
*/
prefill: () => defaultEmployeeRole(),
prefill: () => ({}),
/**
* One verb, because there is one outcome. A declared role has no draft state:

View File

@@ -1,5 +1,5 @@
import { doc, list, note, text } from '@/components/ai-assistant/blocks';
import { CERT_OPTIONS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
import { ENGLISH_LEVELS, certificationsForRole, payLabel } from '@/lib/positionModel';
import {
buildPositionPrefill, extractCertifications, extractEnglish, extractExperience,
extractLocation, extractPay, extractRole,
@@ -143,12 +143,16 @@ const fields = {
* provision a TENANT the operator cannot then see, because every read is
* predicated on the session's own org_id.
*/
const resolve = (token, { roles = [], companies = [] }) => {
const resolve = (token, { roles = [], companies = [], postings = null }, draft = {}) => {
switch (token) {
case '@roles': return roles;
case '@companies': return companies;
case '@english': return ENGLISH_LEVELS.map((l) => l.label);
case '@certifications': return CERT_OPTIONS;
/* The same rule as the role flow, from the same helper: what this
organization already asks for on postings of this category. A position
being written for a role it has never posted before offers none, which is
honest — there is nothing to go on yet. */
case '@certifications': return certificationsForRole(draft.role_category || draft.title, postings) || [];
default: return [];
}
};