update draft issue
This commit is contained in:
@@ -77,6 +77,23 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* A question the page handed to the panel, waiting to be asked.
|
||||
*
|
||||
* The one thing a page could not do before: publish what it is looking at
|
||||
* (PageContext) but never *start* a turn about it. So a control whose work
|
||||
* lives in the conversation — Continue, on a draft card — had no way to reach
|
||||
* the conversation, and the only thing left to do with it was navigate to a
|
||||
* form. This is that missing direction, kept as narrow as the read channel:
|
||||
* serializable state, no callbacks into the panel, and the panel decides when
|
||||
* to take it.
|
||||
*
|
||||
* State rather than an event, because the panel is unmounted while collapsed —
|
||||
* `ask` opens it, and the request is still here when it mounts.
|
||||
*/
|
||||
const [request, setRequest] = React.useState(null);
|
||||
const requestSeq = React.useRef(0);
|
||||
|
||||
const context = React.useMemo(
|
||||
() => resolveAssistantContext(role, pathname),
|
||||
[role, pathname]
|
||||
@@ -118,6 +135,27 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [isOpen, isExpanded, setExpandedState, setOpenState]);
|
||||
|
||||
/**
|
||||
* Asks Owliver something on the page's behalf, opening the panel to receive it.
|
||||
*
|
||||
* `positionId` names the record the control was built from, so the answer is
|
||||
* about that position rather than about whichever one the wording happens to
|
||||
* match. Each call carries its own id, so asking the same question twice is
|
||||
* two turns rather than one ignored repeat.
|
||||
*/
|
||||
const ask = React.useCallback(({ question, positionId = null }) => {
|
||||
const text = String(question || '').trim();
|
||||
if (!text) return;
|
||||
requestSeq.current += 1;
|
||||
setRequest({ id: requestSeq.current, question: text, positionId });
|
||||
setOpenState(true);
|
||||
}, [setOpenState]);
|
||||
|
||||
/** Taken by the panel once it has actually asked it. */
|
||||
const consumeRequest = React.useCallback((id) => {
|
||||
setRequest((current) => (current?.id === id ? null : current));
|
||||
}, []);
|
||||
|
||||
/* Width is written on every drag frame, so persistence is debounced rather
|
||||
than hitting sessionStorage sixty times a second. */
|
||||
const setWidthState = React.useCallback((next) => {
|
||||
@@ -148,7 +186,11 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
||||
toggle: () => setOpenState(!isOpen),
|
||||
expand: () => setExpandedState(true),
|
||||
restore: () => setExpandedState(false),
|
||||
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState]);
|
||||
ask,
|
||||
request,
|
||||
consumeRequest,
|
||||
}), [context, supported, isOpen, isExpanded, width, setWidthState, setOpenState, setExpandedState,
|
||||
ask, request, consumeRequest]);
|
||||
|
||||
/* The page's own selection travels beside the window state, mounted here so
|
||||
both the page and the panel are inside it — a skill's data source resolves
|
||||
@@ -182,5 +224,8 @@ export function useAssistantPanel() {
|
||||
toggle: () => {},
|
||||
expand: () => {},
|
||||
restore: () => {},
|
||||
ask: () => {},
|
||||
request: null,
|
||||
consumeRequest: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import { Surface } from '@/components/ds/Surface';
|
||||
import { IconButton } from '@/components/ds/IconButton';
|
||||
import { Alert } from '@/components/ds/Alert';
|
||||
import {
|
||||
useAssignments, useAssignWorkers, useCreateJobPosting, useMarkInterviewReady,
|
||||
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
|
||||
useMarkInterviewReady, useUpdateJobPosting,
|
||||
usePreferences, useRoleCategories,
|
||||
} from '@/lib/krowHooks';
|
||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||
@@ -19,6 +20,7 @@ import { owliverSuggestions } from '@/lib/skills/owliverResolver';
|
||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||
import { profileForEmail } from '@/lib/skillGraph';
|
||||
import { groupByRecency } from './history';
|
||||
import { useAssistantPanel } from './AssistantPanelContext';
|
||||
import { usePageContext } from './PageContext';
|
||||
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
|
||||
import { buildIntro, buildPrompts } from './dynamic';
|
||||
@@ -214,6 +216,9 @@ export default function KrowAssistant({
|
||||
const userName = useCurrentUserName();
|
||||
const navigate = useNavigate();
|
||||
|
||||
/* A question a page has handed over — see the effect below `runPrompt`. */
|
||||
const { request: panelRequest, consumeRequest } = useAssistantPanel();
|
||||
|
||||
/* The app's own router, not a location assignment: a full page load would
|
||||
discard the thread and the panel state along with it. */
|
||||
const goToPage = React.useCallback(
|
||||
@@ -320,6 +325,30 @@ export default function KrowAssistant({
|
||||
return createJob.mutateAsync(result.data);
|
||||
}, [createJob]);
|
||||
|
||||
/**
|
||||
* Finishing a draft, through the mutations the Create Position form calls.
|
||||
*
|
||||
* `useUpdateJobPosting` is the same update the form and the Positions page
|
||||
* use, and `useGenerateJobDescription` is the same generator behind the
|
||||
* form's "Generate Job Description with AI" button. Reusing both is what
|
||||
* keeps one position record, one description and one set of vetting weights
|
||||
* — the panel changes the record, it does not keep a copy of it.
|
||||
*/
|
||||
const updateJob = useUpdateJobPosting();
|
||||
const onUpdatePosition = React.useCallback(
|
||||
(id, data) => updateJob.mutateAsync({ id, data }),
|
||||
[updateJob]
|
||||
);
|
||||
|
||||
const generateDescription = useGenerateJobDescription();
|
||||
const onGenerateDescription = React.useCallback(
|
||||
async (position) => {
|
||||
const result = await generateDescription.mutateAsync({ data: position, draftId: position.id });
|
||||
return result || null;
|
||||
},
|
||||
[generateDescription]
|
||||
);
|
||||
|
||||
/**
|
||||
* The workforce picture the panel reasons over.
|
||||
*
|
||||
@@ -386,6 +415,8 @@ export default function KrowAssistant({
|
||||
onNavigate: goToPage,
|
||||
onAction: performAction,
|
||||
onCreatePosition: createPosition,
|
||||
onUpdatePosition,
|
||||
onGenerateDescription,
|
||||
onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce,
|
||||
@@ -495,14 +526,14 @@ export default function KrowAssistant({
|
||||
* bypasses intent routing. A typed question carries none and is routed. Both
|
||||
* land in the same `send`, so there is a single pipeline to reason about.
|
||||
*/
|
||||
const handleMessage = React.useCallback((question, capability = null) => {
|
||||
const handleMessage = React.useCallback((question, capability = null, positionId = null) => {
|
||||
const text = String(question).trim();
|
||||
if (!text || busy) return;
|
||||
setInput('');
|
||||
/* Asking something while reading History returns to the conversation — the
|
||||
answer is about to arrive there, and leaving the list up would hide it. */
|
||||
setView('chat');
|
||||
send({ question: text, capability });
|
||||
send({ question: text, capability, positionId });
|
||||
}, [busy, send]);
|
||||
|
||||
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
|
||||
@@ -519,9 +550,26 @@ export default function KrowAssistant({
|
||||
navigate(prompt.route);
|
||||
return;
|
||||
}
|
||||
handleMessage(prompt.prompt, prompt.capability);
|
||||
/* A chip built from a position says which one, so a follow-up acts on the
|
||||
record the previous answer was about rather than re-deriving it from the
|
||||
words. */
|
||||
handleMessage(prompt.prompt, prompt.capability, prompt.positionId);
|
||||
}, [handleMessage, navigate]);
|
||||
|
||||
/**
|
||||
* A question the page asked on the reader's behalf.
|
||||
*
|
||||
* Continue on a draft card is the only source today: the card knows which
|
||||
* record it is, and the work of finishing it lives here. Deferred while a
|
||||
* response is streaming — the effect re-runs when the panel is free, so the
|
||||
* request waits its turn rather than being dropped for arriving mid-answer.
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!panelRequest || busy) return;
|
||||
handleMessage(panelRequest.question, null, panelRequest.positionId);
|
||||
consumeRequest(panelRequest.id);
|
||||
}, [panelRequest, busy, handleMessage, consumeRequest]);
|
||||
|
||||
const composer = (
|
||||
<PromptInput
|
||||
value={input}
|
||||
|
||||
@@ -12,6 +12,9 @@ import { pct, plural, verb } from '../insights';
|
||||
one thing in the library and another in the panel beside it. */
|
||||
import { TYPE_LABEL } from '@/components/forge/challengeMeta';
|
||||
import { toFICO } from '@/lib/talentHome';
|
||||
/* The vetting criteria's own labels. Read from the position model rather than
|
||||
restated here, so the panel calls a weight what the form calls it. */
|
||||
import { CRITERIA_LABELS } from '@/lib/positionModel';
|
||||
import { PERMISSIONS, PROFILE_ACTIONS } from '@/pages/admin/permissions';
|
||||
|
||||
/**
|
||||
|
||||
@@ -451,11 +451,18 @@ const PROMPTS = {
|
||||
*/
|
||||
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
|
||||
if (drafts.length) {
|
||||
out.push({
|
||||
label: drafts.length === 1 ? `Continue the ${drafts[0].title} draft` : `${drafts.length} drafts to finish`,
|
||||
prompt: 'Which positions are still in draft?',
|
||||
capability: 'position-drafts',
|
||||
});
|
||||
out.push(drafts.length === 1
|
||||
/* Names the draft, so the answer continues *that* record in the panel
|
||||
rather than listing every draft and sending the reader to a form. */
|
||||
? {
|
||||
label: `Continue the ${drafts[0].title} draft`,
|
||||
prompt: `Continue the ${drafts[0].title} draft`,
|
||||
}
|
||||
: {
|
||||
label: `${drafts.length} drafts to finish`,
|
||||
prompt: 'Which positions are still in draft?',
|
||||
capability: 'position-drafts',
|
||||
});
|
||||
}
|
||||
|
||||
const current = workforce?.currentPositionId
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
matchOwliverSkill, owliverSkillsForContext, presentable, resolveOwliverResponse,
|
||||
responseTitle, summaryLines,
|
||||
} from '@/lib/skills/owliverResolver';
|
||||
import {
|
||||
draftActions, draftContinuedReply, explainWeightsReply, matchDraftIntent, noDraftReply,
|
||||
resolveDraft, weightsFromQuestion, weightsReply, weightsUnchangedReply, whichDraftReply,
|
||||
} from '@/lib/skills/draftFlow';
|
||||
import {
|
||||
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
|
||||
candidateDetail, candidateMatches, candidateRoute, interviewReady, matchWorkforceIntent,
|
||||
@@ -707,12 +711,121 @@ function resolveSkill({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A draft question, resolved against the position records the panel holds.
|
||||
*
|
||||
* Returns an intent the panel executes. The two that write — generating a
|
||||
* description and setting weights, plus publishing — carry a `perform` block
|
||||
* rather than performing anything here: this module stays pure, and the write
|
||||
* goes through the mutation the Create Position form already uses.
|
||||
*/
|
||||
function resolveDraftAction(question, workforce, positionId = null) {
|
||||
const action = matchDraftIntent(question);
|
||||
if (!action) return null;
|
||||
|
||||
const positions = workforce.positions || [];
|
||||
/* `positionId` is the record a control was built from — the Continue button on
|
||||
a card, or a chip this flow wrote. It settles the question before any
|
||||
guessing from the wording starts. */
|
||||
const { position, ambiguous } = resolveDraft(
|
||||
question, positions, workforce.currentPositionId, positionId
|
||||
);
|
||||
|
||||
if (ambiguous) return { kind: 'draft', ...whichDraftReply(ambiguous) };
|
||||
|
||||
/**
|
||||
* Nothing resolved. Only a question explicitly about drafts is answered with
|
||||
* "there are none" — everything else is handed back.
|
||||
*
|
||||
* "Summarize these vetting weights" on the Create Position form is a question
|
||||
* about the form, and the page has answered it since long before drafts could
|
||||
* be continued here. Claiming it because it contains the word "weights" and
|
||||
* then reporting that no draft exists would be this flow taking a question it
|
||||
* cannot answer.
|
||||
*/
|
||||
if (!position) {
|
||||
return action === 'continue' || action === 'list'
|
||||
? { kind: 'draft', doc: noDraftReply() }
|
||||
: null;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
/* One draft is continued; several are offered by name. Either way the
|
||||
answer is here, and neither opens a form. */
|
||||
case 'list':
|
||||
return positions.filter((p) => p.status === 'draft').length > 1
|
||||
? { kind: 'draft', ...whichDraftReply(positions.filter((p) => p.status === 'draft')) }
|
||||
: {
|
||||
kind: 'draft',
|
||||
doc: draftContinuedReply(position),
|
||||
followUp: draftActions(position),
|
||||
};
|
||||
|
||||
case 'continue':
|
||||
return {
|
||||
kind: 'draft',
|
||||
doc: draftContinuedReply(position),
|
||||
/* Never "continue" again — the reply *is* the continuation. */
|
||||
followUp: draftActions(position),
|
||||
};
|
||||
|
||||
case 'show_weights':
|
||||
return { kind: 'draft', doc: weightsReply(position), followUp: draftActions(position) };
|
||||
|
||||
case 'explain_weights':
|
||||
return { kind: 'draft', doc: explainWeightsReply(position), followUp: draftActions(position) };
|
||||
|
||||
case 'set_weights': {
|
||||
const next = weightsFromQuestion(question, position);
|
||||
if (!next) {
|
||||
return { kind: 'draft', doc: weightsUnchangedReply(position), followUp: draftActions(position) };
|
||||
}
|
||||
return { kind: 'draft', perform: { action: 'set_weights', position, weights: next } };
|
||||
}
|
||||
|
||||
case 'generate_description':
|
||||
return { kind: 'draft', perform: { action: 'generate_description', position } };
|
||||
|
||||
case 'publish':
|
||||
return position.status === 'draft'
|
||||
? { kind: 'draft', perform: { action: 'publish', position } }
|
||||
: {
|
||||
kind: 'draft',
|
||||
doc: doc(text(`**${position.title}** is already published.`)),
|
||||
followUp: draftActions(position),
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveIntent({
|
||||
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
||||
courses = [], workforce = null, skillContext = null,
|
||||
/* The record the control that raised this question was built from, when there
|
||||
was one. Only the draft flow reads it; a typed question carries none and
|
||||
resolves exactly as it always did. */
|
||||
positionId = null,
|
||||
}) {
|
||||
const context = ASSISTANT_CONTEXTS[contextId] ?? null;
|
||||
|
||||
/**
|
||||
* 1. Finishing a draft, wherever the reader is standing.
|
||||
*
|
||||
* Ahead of everything else because these questions name a record and an act
|
||||
* on it — continuing, generating, weighting, publishing — and every other
|
||||
* matcher would read them as words about positions in general. It runs only
|
||||
* where the panel actually has the positions to act on.
|
||||
*/
|
||||
/* Resolved even where the panel carries no workforce: a question about
|
||||
continuing a draft is answered in the panel or answered as "there is no
|
||||
draft", never handed to keyword routing — which is what used to send
|
||||
"Continue the Bartender draft" into the authoring form, because Create
|
||||
Position lists `position` and `role` among its topics. */
|
||||
const draftIntent = resolveDraftAction(question, workforce || { positions: [] }, positionId);
|
||||
if (draftIntent) return draftIntent;
|
||||
|
||||
/* 2. Current page skills — specific triggers, ahead of the general reader. */
|
||||
const skill = resolveSkill({
|
||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||
|
||||
@@ -10,6 +10,10 @@ import { suggestionsForPosition } from '@/lib/skills/owliverResolver';
|
||||
import {
|
||||
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
||||
} from '@/lib/skills/positionFlow';
|
||||
import {
|
||||
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
|
||||
publishedReply, weightsSetReply, weightsUnchangedReply,
|
||||
} from '@/lib/skills/draftFlow';
|
||||
import {
|
||||
assignmentDone, assignmentFailed, assignmentFollowUp, interviewDone, interviewFailed,
|
||||
} from '@/lib/skills/workforceFlow';
|
||||
@@ -118,6 +122,58 @@ async function streamDocument({ document, signal, onFrame, onDone }) {
|
||||
*/
|
||||
const AUTHORING_ROUTE = '/admin/positions/new';
|
||||
|
||||
/**
|
||||
* The one wording every draft-continuation control resolves to.
|
||||
*
|
||||
* Named once because two things have to agree about it: the chip a suggestion
|
||||
* writes today, and the chip a thread wrote months ago. Both must reach the
|
||||
* same in-panel handler, so both are normalized to this phrasing rather than
|
||||
* one being special-cased.
|
||||
*/
|
||||
const CONTINUE_DRAFT = /^continue the (.+) draft$/i;
|
||||
|
||||
/** The wording older builds used for the drafts question. */
|
||||
const OLD_DRAFTS_PROMPT = /which positions are still in draft/i;
|
||||
|
||||
/**
|
||||
* A stored chip, brought up to the current draft behavior.
|
||||
*
|
||||
* Threads persist as the blocks and chips they rendered, so a conversation
|
||||
* written before draft continuation moved into the panel still carries the
|
||||
* controls of that era: a `capability` that runs the read-only drafts report,
|
||||
* or a prompt phrased so loosely that keyword routing sent it to the authoring
|
||||
* form. Rewriting them in code was not enough — the ones already written down
|
||||
* had to change behavior too.
|
||||
*
|
||||
* Deliberately narrow. Only chips that are *about continuing a draft* are
|
||||
* touched, and only their routing metadata: the label the reader saw is left
|
||||
* exactly as it was, so a thread reads as it always did and simply does the
|
||||
* right thing when clicked.
|
||||
*/
|
||||
function normalizeDraftChip(chip) {
|
||||
if (!chip) return chip;
|
||||
|
||||
const label = String(chip.label || '');
|
||||
const prompt = String(chip.prompt || '');
|
||||
|
||||
/* "Continue the Bartender draft", however it was stored: the label is the
|
||||
question, and no capability or route may intercept it. */
|
||||
if (CONTINUE_DRAFT.test(label) || CONTINUE_DRAFT.test(prompt)) {
|
||||
const { capability, route, ...rest } = chip;
|
||||
return { ...rest, prompt: CONTINUE_DRAFT.test(label) ? label : prompt };
|
||||
}
|
||||
|
||||
/* The old drafts question ran a capability that could only describe drafts
|
||||
and told the reader to open the form. The same words now resolve to the
|
||||
draft flow, which continues one or offers the several by name. */
|
||||
if (OLD_DRAFTS_PROMPT.test(prompt) || chip.capability === 'position-drafts') {
|
||||
const { capability, route, ...rest } = chip;
|
||||
return { ...rest, prompt: prompt || 'Which positions are still in draft?' };
|
||||
}
|
||||
|
||||
return chip;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored thread, with any completed-then-reopen action stripped out.
|
||||
*
|
||||
@@ -138,7 +194,9 @@ function withoutAuthoringActions(messages = []) {
|
||||
return messages.map((message) => {
|
||||
if (message.role !== 'assistant') return message;
|
||||
|
||||
const followUp = message.followUp?.filter((chip) => !leadsToForm(chip.route));
|
||||
const followUp = message.followUp
|
||||
?.filter((chip) => !leadsToForm(chip.route))
|
||||
.map(normalizeDraftChip);
|
||||
const blocks = message.blocks?.map((block) => (
|
||||
block?.type === 'insights'
|
||||
? {
|
||||
@@ -168,6 +226,10 @@ function withoutAuthoringActions(messages = []) {
|
||||
*/
|
||||
export function useConversation({
|
||||
contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview,
|
||||
/* Finishing a draft: the same two mutations the Create Position form calls.
|
||||
Passed in rather than reached for, so this layer still writes nothing
|
||||
itself and there is one update path for a position. */
|
||||
onUpdatePosition, onGenerateDescription,
|
||||
workforce = null, disabledSkills = [], customSkills = [],
|
||||
roles = [], skillCategories = [], courses = [], skillContext = null,
|
||||
/* The page's own name, recorded with an archived thread so History can say
|
||||
@@ -281,7 +343,7 @@ export function useConversation({
|
||||
const messagesRef = React.useRef(messages);
|
||||
messagesRef.current = messages;
|
||||
|
||||
const send = React.useCallback(async ({ question, capability = null }) => {
|
||||
const send = React.useCallback(async ({ question, capability = null, positionId = null }) => {
|
||||
const text = String(question).trim();
|
||||
if (!text) return;
|
||||
|
||||
@@ -323,7 +385,7 @@ export function useConversation({
|
||||
? { kind: 'answer' }
|
||||
: resolveIntent({
|
||||
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
|
||||
courses, workforce, skillContext,
|
||||
courses, workforce, skillContext, positionId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -377,6 +439,59 @@ export function useConversation({
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finishing a draft. Same contract as every other write in this file: it
|
||||
* happens before the reply, because the reply states the outcome.
|
||||
*
|
||||
* Each branch calls the mutation the form already uses and then says what
|
||||
* is now true of the record — no second store, and no navigation.
|
||||
*/
|
||||
if (intent.kind === 'draft' && intent.perform) {
|
||||
const { action, position } = intent.perform;
|
||||
|
||||
if (action === 'generate_description') {
|
||||
let result = null;
|
||||
try {
|
||||
result = await onGenerateDescription?.(position);
|
||||
} catch {
|
||||
result = null;
|
||||
}
|
||||
intent = {
|
||||
...intent,
|
||||
doc: result?.description ? descriptionReply(position, result) : descriptionFailedReply(position),
|
||||
followUp: draftActions(result?.description ? { ...position, description: result.description } : position),
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'set_weights') {
|
||||
let saved = null;
|
||||
try {
|
||||
saved = await onUpdatePosition?.(position.id, { vetting_criteria: intent.perform.weights });
|
||||
} catch {
|
||||
saved = null;
|
||||
}
|
||||
intent = {
|
||||
...intent,
|
||||
doc: saved ? weightsSetReply(position, intent.perform.weights) : weightsUnchangedReply(position),
|
||||
followUp: draftActions(saved || position),
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'publish') {
|
||||
let published = null;
|
||||
try {
|
||||
published = await onUpdatePosition?.(position.id, { status: 'active' });
|
||||
} catch {
|
||||
published = null;
|
||||
}
|
||||
intent = {
|
||||
...intent,
|
||||
doc: published ? publishedReply(position) : publishFailedReply(position),
|
||||
followUp: published ? publishedFollowUp(published) : draftActions(position),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The workforce write, on the same terms as the one above: it happens
|
||||
* before the reply, because the reply states the new counts and those have
|
||||
@@ -481,13 +596,19 @@ export function useConversation({
|
||||
}
|
||||
} catch (e) {
|
||||
if (e?.name !== 'AbortError') {
|
||||
/* The reader gets one sentence; the developer gets the exception. A
|
||||
capability that throws used to leave no trace anywhere, so "I could
|
||||
not complete that" was the only evidence anything had gone wrong —
|
||||
and it named neither the action nor the reason. */
|
||||
if (import.meta.env.DEV) console.error('[owliver] action failed', e);
|
||||
setError('I could not complete that. Try again in a moment.');
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
abortRef.current = null;
|
||||
}
|
||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onAssignWorkers,
|
||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onUpdatePosition,
|
||||
onGenerateDescription, onAssignWorkers,
|
||||
onScheduleInterview,
|
||||
workforce, setFlow, disabledSkills,
|
||||
customSkills, roles, skillCategories, courses, skillContext]);
|
||||
|
||||
361
src/lib/skills/draftFlow.js
Normal file
361
src/lib/skills/draftFlow.js
Normal file
@@ -0,0 +1,361 @@
|
||||
import { doc, heading, insights, list, note, text } from '@/components/ai-assistant/blocks';
|
||||
import { CRITERIA_LABELS, ENGLISH_LEVELS, payLabel } from '@/lib/positionModel';
|
||||
import { levelLabel, skillName } from '@/lib/skillGraph';
|
||||
|
||||
/**
|
||||
* Continuing a saved draft, in the conversation that saved it.
|
||||
*
|
||||
* A draft is a `JobPosting` with `status: 'draft'` — the same record the form
|
||||
* writes and the Positions list shows. Finishing one used to mean leaving the
|
||||
* panel for the Create Position page, which threw away the conversation that
|
||||
* produced it and made the assistant a launcher for a form rather than a place
|
||||
* to work.
|
||||
*
|
||||
* So the remaining steps happen here: read the draft back, generate its
|
||||
* description, read and set its vetting weights, publish it. Every one of them
|
||||
* acts on the position record through the mutations the form already uses —
|
||||
* this module holds no draft of its own, no copy of the weights, and no second
|
||||
* description. It decides what was asked and what to say; the panel performs
|
||||
* the write.
|
||||
*/
|
||||
|
||||
/* ── Which question is this? ───────────────────────────────────────────── */
|
||||
|
||||
const has = (q, ...terms) => terms.some((t) => q.includes(t));
|
||||
|
||||
/**
|
||||
* The draft intent behind a question, or null to let the page answer it.
|
||||
*
|
||||
* Ordered by specificity: setting weights before showing them, publishing
|
||||
* before continuing, so "publish the bartender draft" is not read as a request
|
||||
* to reopen it.
|
||||
*/
|
||||
export function matchDraftIntent(question) {
|
||||
const q = String(question).toLowerCase();
|
||||
|
||||
if (has(q, 'generate job description', 'generate the job description', 'generate a job description',
|
||||
'write the job description', 'generate description')) return 'generate_description';
|
||||
|
||||
if (has(q, 'set ai vetting', 'set the vetting', 'set vetting', 'change the vetting', 'change vetting',
|
||||
'adjust the vetting', 'adjust vetting', 'update the vetting', 'update vetting')) return 'set_weights';
|
||||
|
||||
if (has(q, 'explain the vetting', 'explain vetting', 'why these weights', 'what do the vetting weights mean')) {
|
||||
return 'explain_weights';
|
||||
}
|
||||
|
||||
if (has(q, 'vetting weight', 'vetting weights', 'ai vetting')) return 'show_weights';
|
||||
|
||||
if (has(q, 'publish this position', 'publish the position', 'publish this draft', 'publish the draft',
|
||||
'publish it')) return 'publish';
|
||||
|
||||
if (has(q, 'continue the', 'continue this draft', 'continue draft', 'finish the draft',
|
||||
'finish this draft', 'open the draft')) return 'continue';
|
||||
|
||||
/**
|
||||
* Asking about drafts in general.
|
||||
*
|
||||
* Claimed here rather than left to fall through, and that is the fix for the
|
||||
* navigation: an unclaimed question containing the word "position" is routed
|
||||
* by keyword, and Create Position lists `position` and `role` among its
|
||||
* topics — so "which positions are still in draft?" was answered by *opening
|
||||
* the form*. It is a question about records that exist; it is answered where
|
||||
* it was asked. Older chips carrying this wording are caught by the same
|
||||
* match, so a thread written before this still behaves.
|
||||
*/
|
||||
if (has(q, 'still in draft', 'in draft', 'drafts to finish', 'unfinished draft',
|
||||
'which drafts', 'my drafts', 'any drafts')) return 'list';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft a question is about.
|
||||
*
|
||||
* A named record first — a Continue control on a card, or a chip this flow
|
||||
* wrote, knows exactly which position it means and says so — then the title in
|
||||
* the sentence, then the position the page has open, then the only draft there
|
||||
* is. Nothing is assumed when more than one draft could be meant: the caller
|
||||
* asks instead, because acting on the wrong record is not recoverable.
|
||||
*/
|
||||
export function resolveDraft(question, positions = [], currentId = null, pinnedId = null) {
|
||||
const pinned = pinnedId ? positions.find((p) => p.id === pinnedId) : null;
|
||||
if (pinned) return { position: pinned };
|
||||
|
||||
const q = String(question).toLowerCase();
|
||||
const drafts = positions.filter((p) => p.status === 'draft');
|
||||
|
||||
const named = drafts
|
||||
.filter((p) => p.title && q.includes(String(p.title).toLowerCase()))
|
||||
.sort((a, b) => String(b.title).length - String(a.title).length)[0];
|
||||
if (named) return { position: named };
|
||||
|
||||
/* The record the page is already working on, draft or not: "generate the job
|
||||
description" on an open position means that position. */
|
||||
const current = currentId ? positions.find((p) => p.id === currentId) : null;
|
||||
if (current) return { position: current };
|
||||
|
||||
if (drafts.length === 1) return { position: drafts[0] };
|
||||
if (drafts.length > 1) return { ambiguous: drafts };
|
||||
return {};
|
||||
}
|
||||
|
||||
/* ── Reading the record back ───────────────────────────────────────────── */
|
||||
|
||||
const englishLabelOf = (value) =>
|
||||
ENGLISH_LEVELS.find((l) => l.value === value)?.label || null;
|
||||
|
||||
/** `Bartending (Advanced), Customer Service (Intermediate)`, or null. */
|
||||
const skillsLabelOf = (position) => (
|
||||
position.skill_requirements?.length
|
||||
? position.skill_requirements
|
||||
.map((s) => `${skillName(s.skill_id)} (${levelLabel(s.level)})`)
|
||||
.join(', ')
|
||||
: null
|
||||
);
|
||||
|
||||
/** Everything the draft states, in the order the form asks for it. */
|
||||
function draftLines(position) {
|
||||
const custom = String(position.custom_requirements || '').trim();
|
||||
|
||||
return [
|
||||
position.company ? `Company: ${position.company}` : null,
|
||||
position.title ? `Role: ${position.title}` : null,
|
||||
position.role_category && position.role_category !== position.title
|
||||
? `Category: ${position.role_category}` : null,
|
||||
position.location ? `Location: ${position.location}` : null,
|
||||
payLabel(position) ? `Pay: ${payLabel(position)}` : null,
|
||||
position.min_experience_years
|
||||
? `Experience: ${position.min_experience_years}+ years`
|
||||
: 'Experience: no minimum',
|
||||
englishLabelOf(position.english_required) ? `English: ${englishLabelOf(position.english_required)}` : null,
|
||||
position.certifications_required?.length
|
||||
? `Certifications: ${position.certifications_required.join(', ')}`
|
||||
: null,
|
||||
/* The two fields the form asks for that the earlier read-back skipped. Both
|
||||
are things the author typed and would otherwise have to reopen the form to
|
||||
check — which is the one thing continuing here is meant to avoid. */
|
||||
skillsLabelOf(position) ? `Required skills: ${skillsLabelOf(position)}` : null,
|
||||
custom ? `Custom requirements: ${custom}` : null,
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions a draft actually invites, in the order they are usually taken.
|
||||
*
|
||||
* Only what is valid for this record: a draft that already has a description is
|
||||
* not offered generation again as though nothing had happened, and "continue"
|
||||
* is never offered by the reply that just continued it.
|
||||
*/
|
||||
export function draftActions(position) {
|
||||
const isDraft = position.status === 'draft';
|
||||
const { entries, total } = weightsOf(position);
|
||||
|
||||
/**
|
||||
* Every chip names the record it acts on.
|
||||
*
|
||||
* A prompt alone resolves by title, which is right for something typed but
|
||||
* wrong for a control that was built from a specific position: two drafts
|
||||
* called Bartender would make the panel ask which one, having just been told.
|
||||
* `positionId` is the answer it already has, carried forward.
|
||||
*/
|
||||
const on = (label, prompt) => ({ label, prompt, positionId: position.id });
|
||||
|
||||
return [
|
||||
/* The form's own wording, so the same act is called the same thing in both
|
||||
places. The two weight actions stay separate: reading the split and
|
||||
understanding it are different questions. */
|
||||
on(
|
||||
position.description ? 'Regenerate Job Description with AI' : 'Generate Job Description with AI',
|
||||
`Generate job description for ${position.title}`
|
||||
),
|
||||
on(
|
||||
entries.length ? `AI Vetting Weights — Total ${total}%` : 'Show AI Vetting Weights',
|
||||
`Show AI vetting weights for ${position.title}`
|
||||
),
|
||||
on('Explain Vetting Weights', `Explain vetting weights for ${position.title}`),
|
||||
isDraft
|
||||
? on('Publish this position', `Publish this position: ${position.title}`)
|
||||
: { label: 'Match candidates', prompt: `Who matches ${position.title}?` },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft, read back where it was saved.
|
||||
*
|
||||
* Everything the record states, including the two things the reader would
|
||||
* otherwise reopen the form for: the weights as they actually stand, and the
|
||||
* description if one has been written.
|
||||
*/
|
||||
export const draftContinuedReply = (position) => {
|
||||
const { entries, total } = weightsOf(position);
|
||||
|
||||
return doc(
|
||||
heading(`Continuing the ${position.title} draft`, position.status === 'draft' ? 'Saved, not published' : 'Published'),
|
||||
list(draftLines(position)),
|
||||
entries.length
|
||||
? text(`**AI vetting weights** — ${entries.map(([key, value]) => `${CRITERIA_LABELS[key] || key} ${value}%`).join(' · ')}. Total ${total}%.`)
|
||||
: null,
|
||||
position.description ? heading('Job description', 'Already written') : null,
|
||||
position.description ? text(position.description) : null,
|
||||
position.description
|
||||
? note('Regenerating replaces it.')
|
||||
: note('Nothing is written until you choose one of these.')
|
||||
);
|
||||
};
|
||||
|
||||
/** Ambiguity, stated rather than guessed at. */
|
||||
export const whichDraftReply = (drafts) => ({
|
||||
doc: doc(
|
||||
text('Which draft should I continue?'),
|
||||
list(drafts.map((p) => [p.title, p.company, p.location].filter(Boolean).join(' · ')))
|
||||
),
|
||||
followUp: drafts.slice(0, 4).map((p) => ({
|
||||
label: p.title,
|
||||
prompt: `Continue the ${p.title} draft`,
|
||||
/* Named by id, so two drafts sharing a title are still two answers. */
|
||||
positionId: p.id,
|
||||
})),
|
||||
});
|
||||
|
||||
/** Nothing to continue. */
|
||||
export const noDraftReply = () => doc(
|
||||
text('There is no draft to continue — every position here is published or closed.'),
|
||||
note('Ask me to create a position and I will collect it here.')
|
||||
);
|
||||
|
||||
/* ── Vetting weights ───────────────────────────────────────────────────── */
|
||||
|
||||
/** The weights this position actually carries, never a default dressed as one. */
|
||||
export function weightsOf(position) {
|
||||
const criteria = position?.vetting_criteria || {};
|
||||
const entries = Object.entries(criteria).filter(([, value]) => Number.isFinite(Number(value)));
|
||||
return {
|
||||
entries,
|
||||
total: entries.reduce((sum, [, value]) => sum + Number(value), 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function weightsReply(position) {
|
||||
const { entries, total } = weightsOf(position);
|
||||
|
||||
if (!entries.length) {
|
||||
return doc(
|
||||
heading('AI vetting weights', position.title),
|
||||
text('This position carries no vetting weights, so screening scores every dimension evenly.')
|
||||
);
|
||||
}
|
||||
|
||||
return doc(
|
||||
heading('AI vetting weights', position.title),
|
||||
insights(entries.map(([key, value]) => ({
|
||||
tone: 'neutral',
|
||||
title: `${CRITERIA_LABELS[key] || key} — ${value}%`,
|
||||
body: null,
|
||||
}))),
|
||||
note(total === 100
|
||||
? 'Totals 100%. Screening weighs each dimension by these shares.'
|
||||
: `These total ${total}%. Screening expects 100% — tell me the new split and I will set it.`)
|
||||
);
|
||||
}
|
||||
|
||||
export const explainWeightsReply = (position) => {
|
||||
const { entries } = weightsOf(position);
|
||||
const leading = [...entries].sort((a, b) => b[1] - a[1])[0];
|
||||
|
||||
return doc(
|
||||
heading('What the vetting weights do', position.title),
|
||||
text('Screening scores every applicant on each dimension, then weights those scores by these shares to produce the KROW score you see on a candidate card.'),
|
||||
leading
|
||||
? text(`On this position **${CRITERIA_LABELS[leading[0]] || leading[0]}** carries the most weight at **${leading[1]}%**, so a candidate strong there scores higher than one strong elsewhere.`)
|
||||
: text('This position carries no weights, so no dimension counts more than another.'),
|
||||
note('Tell me a new split — "set experience to 40 and english to 20" — and I will write it to this position.')
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A new split, read from a sentence.
|
||||
*
|
||||
* Only the dimensions the position already defines can be set, and only to
|
||||
* numbers the sentence actually states. Returns `null` when nothing was named,
|
||||
* so a vague request is asked about rather than guessed at.
|
||||
*/
|
||||
export function weightsFromQuestion(question, position) {
|
||||
const q = String(question).toLowerCase();
|
||||
const current = position?.vetting_criteria || {};
|
||||
const patch = {};
|
||||
|
||||
for (const key of Object.keys(current)) {
|
||||
const label = (CRITERIA_LABELS[key] || key).toLowerCase();
|
||||
const pattern = new RegExp(`${label}[^0-9]{0,12}(\\d{1,3})`);
|
||||
const found = pattern.exec(q);
|
||||
if (found) {
|
||||
const value = Math.max(0, Math.min(100, Number(found[1])));
|
||||
patch[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(patch).length ? { ...current, ...patch } : null;
|
||||
}
|
||||
|
||||
export const weightsSetReply = (position, next) => {
|
||||
const total = Object.values(next).reduce((sum, value) => sum + Number(value), 0);
|
||||
|
||||
return doc(
|
||||
heading('Vetting weights updated', position.title),
|
||||
insights(Object.entries(next).map(([key, value]) => ({
|
||||
tone: 'neutral',
|
||||
title: `${CRITERIA_LABELS[key] || key} — ${value}%`,
|
||||
}))),
|
||||
note(total === 100
|
||||
? 'Saved to this position. Screening from now on uses this split.'
|
||||
: `Saved, but these total ${total}%. Screening expects 100% — say the rest and I will adjust it.`)
|
||||
);
|
||||
};
|
||||
|
||||
export const weightsUnchangedReply = (position) => doc(
|
||||
text(`I could not tell which weights to change on **${position.title}**.`),
|
||||
note('Name the dimension and the number — "set experience to 40" — and I will write it.')
|
||||
);
|
||||
|
||||
/* ── Job description ───────────────────────────────────────────────────── */
|
||||
|
||||
export const descriptionReply = (position, result) => doc(
|
||||
heading('Job description', position.title),
|
||||
text(result.description),
|
||||
result.responsibilities?.length ? list(result.responsibilities) : null,
|
||||
note('Saved to the position. Regenerate any time — it replaces what is there.')
|
||||
);
|
||||
|
||||
export const descriptionFailedReply = (position) => doc(
|
||||
text(`I could not generate a description for **${position.title}**.`),
|
||||
note('Nothing was changed. Try again, or write it on the position itself.')
|
||||
);
|
||||
|
||||
/* ── Publish ───────────────────────────────────────────────────────────── */
|
||||
|
||||
export const publishedReply = (position) => doc(
|
||||
heading('Published', position.title),
|
||||
text(`**${position.title}** is live on the Positions list. Applications can arrive against it and screening runs as they do.`)
|
||||
);
|
||||
|
||||
export const publishFailedReply = (position) => doc(
|
||||
text(`I could not publish **${position.title}**.`),
|
||||
note('It is still a draft, with everything you entered intact.')
|
||||
);
|
||||
|
||||
export const publishedFollowUp = (position) => [
|
||||
{ label: 'Match candidates', prompt: `Who matches ${position.title}?` },
|
||||
{ label: 'View position', route: `/admin/positions/${position.id}` },
|
||||
];
|
||||
|
||||
/**
|
||||
* Continuing a named draft, as the question the panel already answers.
|
||||
*
|
||||
* The Continue control on a Positions card resolves to this — one wording, so a
|
||||
* card, a chip and a typed sentence all reach the same in-panel handler rather
|
||||
* than the card growing a second path of its own.
|
||||
*/
|
||||
export const continueDraftRequest = (position) => ({
|
||||
question: `Continue the ${position.title} draft`,
|
||||
positionId: position.id,
|
||||
});
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
PositionCustomRequirements, PositionOverview, PositionRequirements,
|
||||
} from '@/components/krow/PositionDetails';
|
||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
||||
import { usePublishPageContext } from '@/components/ai-assistant';
|
||||
import { useAssistantPanel, usePublishPageContext } from '@/components/ai-assistant';
|
||||
import { continueDraftRequest } from '@/lib/skills/draftFlow';
|
||||
import { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
|
||||
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
|
||||
@@ -505,7 +506,7 @@ function candidateCriteria(position) {
|
||||
/**
|
||||
* One position. The whole card is the target, so there is no hunting for a link.
|
||||
*
|
||||
* A draft opens where a draft belongs — back in the form that was writing it —
|
||||
* A draft continues where the work now happens — in the panel beside the list —
|
||||
* while everything else opens the drawer it always did. That is the only
|
||||
* difference between the two states on this card: same layout, same figures,
|
||||
* and the status pill the card has always drawn now reads Draft.
|
||||
@@ -608,8 +609,8 @@ function PositionCard({ position, onOpen, isRecent = false }) {
|
||||
: 'Manual'}
|
||||
</span>
|
||||
{/* A draft is unfinished work, so the card says what happens next on it:
|
||||
it goes back to the form, not to a hiring drawer for a role nobody
|
||||
can apply to yet. */}
|
||||
it is continued in Owliver, not opened in a hiring drawer for a role
|
||||
nobody can apply to yet. */}
|
||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-krow-blue">
|
||||
{isDraft ? 'Continue' : 'View position'}
|
||||
<ArrowRight className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5" aria-hidden="true" />
|
||||
@@ -868,6 +869,8 @@ function PositionDrawer({ position, onClose, onViewCandidates, onOpenPosition })
|
||||
|
||||
export default function AdminPositions() {
|
||||
const navigate = useNavigate();
|
||||
/* How this page starts a turn in the panel beside it — see `openPosition`. */
|
||||
const { ask } = useAssistantPanel();
|
||||
|
||||
/* No create form on this page, in any form — no drawer, no modal, no route.
|
||||
Creating a position is a conversation with Owliver beside the list (see
|
||||
@@ -898,15 +901,21 @@ export default function AdminPositions() {
|
||||
const recentIds = useRecentlySavedIds(postings);
|
||||
|
||||
/**
|
||||
* Continuing a draft.
|
||||
* Continuing a draft, in the panel beside the list.
|
||||
*
|
||||
* The same authoring form, on the same route, told which record it is
|
||||
* finishing. Not a second editor and not a copy of the values: the form loads
|
||||
* the draft and saves back to it, so publishing from there is the one publish
|
||||
* action there has ever been.
|
||||
* It used to open `/admin/positions/new?draft=<id>`, which answered the wrong
|
||||
* question: the reader had not asked for the form back, they had asked to
|
||||
* finish the position — and the form is only where finishing it used to have
|
||||
* to happen. Owliver can generate the description, read and set the vetting
|
||||
* weights, and publish, all through the mutations the form calls, so the work
|
||||
* no longer needs the page it was written on.
|
||||
*
|
||||
* So the card asks Owliver instead, naming the record by id, and the URL stays
|
||||
* `/admin/positions`. The form is untouched and still reachable — someone who
|
||||
* opens `?draft=<id>` deliberately gets exactly what they always did.
|
||||
*/
|
||||
const openPosition = (p) => (p.status === 'draft'
|
||||
? navigate(`/admin/positions/new?draft=${encodeURIComponent(p.id)}`)
|
||||
? ask(continueDraftRequest(p))
|
||||
: setSelected(p));
|
||||
|
||||
/**
|
||||
@@ -964,7 +973,7 @@ export default function AdminPositions() {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return rows
|
||||
const ordered = rows
|
||||
.filter((p) => {
|
||||
if (q && !`${p.title} ${p.company || ''} ${p.role_category} ${p.location}`.toLowerCase().includes(q)) return false;
|
||||
if (status !== 'all' && p.status !== status) return false;
|
||||
@@ -973,6 +982,27 @@ export default function AdminPositions() {
|
||||
return true;
|
||||
})
|
||||
.sort(SORTS[sort].compare);
|
||||
|
||||
/**
|
||||
* The most recently created position leads.
|
||||
*
|
||||
* A position saved from the panel is the thing the reader was just doing,
|
||||
* and under "needs attention" a draft sorts to the bottom — it has no
|
||||
* applicants, so it reads as the least urgent row on the board. It is not:
|
||||
* it is unfinished work they just put down.
|
||||
*
|
||||
* Only in the default ordering. Someone who has explicitly chosen "Title
|
||||
* A–Z" or "Most applicants" asked for that order and gets it, and no
|
||||
* preference is written either way — this is one hoist over the sorted
|
||||
* list, not a change to how anything is stored.
|
||||
*/
|
||||
if (sort !== 'attention' || ordered.length < 2) return ordered;
|
||||
|
||||
const newest = ordered.reduce(
|
||||
(best, p) => (new Date(p.created_date) > new Date(best.created_date) ? p : best),
|
||||
ordered[0]
|
||||
);
|
||||
return [newest, ...ordered.filter((p) => p.id !== newest.id)];
|
||||
}, [rows, search, status, department, location, sort]);
|
||||
|
||||
const isFiltered = Boolean(search) || [status, department, location].some((v) => v !== 'all');
|
||||
|
||||
Reference in New Issue
Block a user