update the chatbox
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
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 { 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');
|
||||
|
||||
@@ -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]',
|
||||
};
|
||||
|
||||
@@ -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 created;
|
||||
return entry;
|
||||
});
|
||||
|
||||
/* 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
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user