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(
|
const context = React.useMemo(
|
||||||
() => resolveAssistantContext(role, pathname),
|
() => resolveAssistantContext(role, pathname),
|
||||||
[role, pathname]
|
[role, pathname]
|
||||||
@@ -118,6 +135,27 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
|||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [isOpen, isExpanded, setExpandedState, setOpenState]);
|
}, [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
|
/* Width is written on every drag frame, so persistence is debounced rather
|
||||||
than hitting sessionStorage sixty times a second. */
|
than hitting sessionStorage sixty times a second. */
|
||||||
const setWidthState = React.useCallback((next) => {
|
const setWidthState = React.useCallback((next) => {
|
||||||
@@ -148,7 +186,11 @@ export function AssistantPanelProvider({ role, pathname, children }) {
|
|||||||
toggle: () => setOpenState(!isOpen),
|
toggle: () => setOpenState(!isOpen),
|
||||||
expand: () => setExpandedState(true),
|
expand: () => setExpandedState(true),
|
||||||
restore: () => setExpandedState(false),
|
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
|
/* 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
|
both the page and the panel are inside it — a skill's data source resolves
|
||||||
@@ -182,5 +224,8 @@ export function useAssistantPanel() {
|
|||||||
toggle: () => {},
|
toggle: () => {},
|
||||||
expand: () => {},
|
expand: () => {},
|
||||||
restore: () => {},
|
restore: () => {},
|
||||||
|
ask: () => {},
|
||||||
|
request: null,
|
||||||
|
consumeRequest: () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { Surface } from '@/components/ds/Surface';
|
|||||||
import { IconButton } from '@/components/ds/IconButton';
|
import { IconButton } from '@/components/ds/IconButton';
|
||||||
import { Alert } from '@/components/ds/Alert';
|
import { Alert } from '@/components/ds/Alert';
|
||||||
import {
|
import {
|
||||||
useAssignments, useAssignWorkers, useCreateJobPosting, useMarkInterviewReady,
|
useAssignments, useAssignWorkers, useCreateJobPosting, useGenerateJobDescription,
|
||||||
|
useMarkInterviewReady, useUpdateJobPosting,
|
||||||
usePreferences, useRoleCategories,
|
usePreferences, useRoleCategories,
|
||||||
} from '@/lib/krowHooks';
|
} from '@/lib/krowHooks';
|
||||||
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
import { ROLE_CATEGORIES } from '@/lib/roleCategories';
|
||||||
@@ -19,6 +20,7 @@ import { owliverSuggestions } from '@/lib/skills/owliverResolver';
|
|||||||
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
import { useWorkforcePaths } from '@/lib/skills/usePageSkills';
|
||||||
import { profileForEmail } from '@/lib/skillGraph';
|
import { profileForEmail } from '@/lib/skillGraph';
|
||||||
import { groupByRecency } from './history';
|
import { groupByRecency } from './history';
|
||||||
|
import { useAssistantPanel } from './AssistantPanelContext';
|
||||||
import { usePageContext } from './PageContext';
|
import { usePageContext } from './PageContext';
|
||||||
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
|
import { useAssistantFacts, useConversation, useCurrentUserName } from './useAssistant';
|
||||||
import { buildIntro, buildPrompts } from './dynamic';
|
import { buildIntro, buildPrompts } from './dynamic';
|
||||||
@@ -214,6 +216,9 @@ export default function KrowAssistant({
|
|||||||
const userName = useCurrentUserName();
|
const userName = useCurrentUserName();
|
||||||
const navigate = useNavigate();
|
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
|
/* The app's own router, not a location assignment: a full page load would
|
||||||
discard the thread and the panel state along with it. */
|
discard the thread and the panel state along with it. */
|
||||||
const goToPage = React.useCallback(
|
const goToPage = React.useCallback(
|
||||||
@@ -320,6 +325,30 @@ export default function KrowAssistant({
|
|||||||
return createJob.mutateAsync(result.data);
|
return createJob.mutateAsync(result.data);
|
||||||
}, [createJob]);
|
}, [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.
|
* The workforce picture the panel reasons over.
|
||||||
*
|
*
|
||||||
@@ -386,6 +415,8 @@ export default function KrowAssistant({
|
|||||||
onNavigate: goToPage,
|
onNavigate: goToPage,
|
||||||
onAction: performAction,
|
onAction: performAction,
|
||||||
onCreatePosition: createPosition,
|
onCreatePosition: createPosition,
|
||||||
|
onUpdatePosition,
|
||||||
|
onGenerateDescription,
|
||||||
onAssignWorkers,
|
onAssignWorkers,
|
||||||
onScheduleInterview,
|
onScheduleInterview,
|
||||||
workforce,
|
workforce,
|
||||||
@@ -495,14 +526,14 @@ export default function KrowAssistant({
|
|||||||
* bypasses intent routing. A typed question carries none and is routed. Both
|
* 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.
|
* 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();
|
const text = String(question).trim();
|
||||||
if (!text || busy) return;
|
if (!text || busy) return;
|
||||||
setInput('');
|
setInput('');
|
||||||
/* Asking something while reading History returns to the conversation — the
|
/* Asking something while reading History returns to the conversation — the
|
||||||
answer is about to arrive there, and leaving the list up would hide it. */
|
answer is about to arrive there, and leaving the list up would hide it. */
|
||||||
setView('chat');
|
setView('chat');
|
||||||
send({ question: text, capability });
|
send({ question: text, capability, positionId });
|
||||||
}, [busy, send]);
|
}, [busy, send]);
|
||||||
|
|
||||||
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
|
const submit = React.useCallback((text) => handleMessage(text), [handleMessage]);
|
||||||
@@ -519,9 +550,26 @@ export default function KrowAssistant({
|
|||||||
navigate(prompt.route);
|
navigate(prompt.route);
|
||||||
return;
|
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]);
|
}, [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 = (
|
const composer = (
|
||||||
<PromptInput
|
<PromptInput
|
||||||
value={input}
|
value={input}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import { pct, plural, verb } from '../insights';
|
|||||||
one thing in the library and another in the panel beside it. */
|
one thing in the library and another in the panel beside it. */
|
||||||
import { TYPE_LABEL } from '@/components/forge/challengeMeta';
|
import { TYPE_LABEL } from '@/components/forge/challengeMeta';
|
||||||
import { toFICO } from '@/lib/talentHome';
|
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';
|
import { PERMISSIONS, PROFILE_ACTIONS } from '@/pages/admin/permissions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -451,11 +451,18 @@ const PROMPTS = {
|
|||||||
*/
|
*/
|
||||||
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
|
const drafts = (f.postings || []).filter((p) => p.status === 'draft');
|
||||||
if (drafts.length) {
|
if (drafts.length) {
|
||||||
out.push({
|
out.push(drafts.length === 1
|
||||||
label: drafts.length === 1 ? `Continue the ${drafts[0].title} draft` : `${drafts.length} drafts to finish`,
|
/* Names the draft, so the answer continues *that* record in the panel
|
||||||
prompt: 'Which positions are still in draft?',
|
rather than listing every draft and sending the reader to a form. */
|
||||||
capability: 'position-drafts',
|
? {
|
||||||
});
|
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
|
const current = workforce?.currentPositionId
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
matchOwliverSkill, owliverSkillsForContext, presentable, resolveOwliverResponse,
|
matchOwliverSkill, owliverSkillsForContext, presentable, resolveOwliverResponse,
|
||||||
responseTitle, summaryLines,
|
responseTitle, summaryLines,
|
||||||
} from '@/lib/skills/owliverResolver';
|
} from '@/lib/skills/owliverResolver';
|
||||||
|
import {
|
||||||
|
draftActions, draftContinuedReply, explainWeightsReply, matchDraftIntent, noDraftReply,
|
||||||
|
resolveDraft, weightsFromQuestion, weightsReply, weightsUnchangedReply, whichDraftReply,
|
||||||
|
} from '@/lib/skills/draftFlow';
|
||||||
import {
|
import {
|
||||||
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
|
appliedToday, askWhichPosition, assignmentPreview, assignmentToExecute, availability,
|
||||||
candidateDetail, candidateMatches, candidateRoute, interviewReady, matchWorkforceIntent,
|
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({
|
export function resolveIntent({
|
||||||
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
question, contextId, disabledSkills = [], customSkills = [], roles = [], skillCategories = [],
|
||||||
courses = [], workforce = null, skillContext = null,
|
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;
|
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. */
|
/* 2. Current page skills — specific triggers, ahead of the general reader. */
|
||||||
const skill = resolveSkill({
|
const skill = resolveSkill({
|
||||||
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
question, contextId, disabledSkills, customSkills, roles, skillCategories, courses,
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import { suggestionsForPosition } from '@/lib/skills/owliverResolver';
|
|||||||
import {
|
import {
|
||||||
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
advancePositionFlow, createdFollowUp, positionCreatedReply, positionFailedReply,
|
||||||
} from '@/lib/skills/positionFlow';
|
} from '@/lib/skills/positionFlow';
|
||||||
|
import {
|
||||||
|
descriptionFailedReply, descriptionReply, draftActions, publishFailedReply, publishedFollowUp,
|
||||||
|
publishedReply, weightsSetReply, weightsUnchangedReply,
|
||||||
|
} from '@/lib/skills/draftFlow';
|
||||||
import {
|
import {
|
||||||
assignmentDone, assignmentFailed, assignmentFollowUp, interviewDone, interviewFailed,
|
assignmentDone, assignmentFailed, assignmentFollowUp, interviewDone, interviewFailed,
|
||||||
} from '@/lib/skills/workforceFlow';
|
} from '@/lib/skills/workforceFlow';
|
||||||
@@ -118,6 +122,58 @@ async function streamDocument({ document, signal, onFrame, onDone }) {
|
|||||||
*/
|
*/
|
||||||
const AUTHORING_ROUTE = '/admin/positions/new';
|
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.
|
* A stored thread, with any completed-then-reopen action stripped out.
|
||||||
*
|
*
|
||||||
@@ -138,7 +194,9 @@ function withoutAuthoringActions(messages = []) {
|
|||||||
return messages.map((message) => {
|
return messages.map((message) => {
|
||||||
if (message.role !== 'assistant') return 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) => (
|
const blocks = message.blocks?.map((block) => (
|
||||||
block?.type === 'insights'
|
block?.type === 'insights'
|
||||||
? {
|
? {
|
||||||
@@ -168,6 +226,10 @@ function withoutAuthoringActions(messages = []) {
|
|||||||
*/
|
*/
|
||||||
export function useConversation({
|
export function useConversation({
|
||||||
contextId, facts, onNavigate, onAction, onCreatePosition, onAssignWorkers, onScheduleInterview,
|
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 = [],
|
workforce = null, disabledSkills = [], customSkills = [],
|
||||||
roles = [], skillCategories = [], courses = [], skillContext = null,
|
roles = [], skillCategories = [], courses = [], skillContext = null,
|
||||||
/* The page's own name, recorded with an archived thread so History can say
|
/* 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);
|
const messagesRef = React.useRef(messages);
|
||||||
messagesRef.current = 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();
|
const text = String(question).trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
||||||
@@ -323,7 +385,7 @@ export function useConversation({
|
|||||||
? { kind: 'answer' }
|
? { kind: 'answer' }
|
||||||
: resolveIntent({
|
: resolveIntent({
|
||||||
question: text, contextId, disabledSkills, customSkills, roles, skillCategories,
|
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
|
* 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
|
* before the reply, because the reply states the new counts and those have
|
||||||
@@ -481,13 +596,19 @@ export function useConversation({
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e?.name !== 'AbortError') {
|
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.');
|
setError('I could not complete that. Try again in a moment.');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setPending(null);
|
setPending(null);
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onAssignWorkers,
|
}, [contextId, facts, persist, onNavigate, onAction, onCreatePosition, onUpdatePosition,
|
||||||
|
onGenerateDescription, onAssignWorkers,
|
||||||
onScheduleInterview,
|
onScheduleInterview,
|
||||||
workforce, setFlow, disabledSkills,
|
workforce, setFlow, disabledSkills,
|
||||||
customSkills, roles, skillCategories, courses, skillContext]);
|
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,
|
PositionCustomRequirements, PositionOverview, PositionRequirements,
|
||||||
} from '@/components/krow/PositionDetails';
|
} from '@/components/krow/PositionDetails';
|
||||||
import { SkillSurface } from '@/components/skills/SkillSurface';
|
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 { AdminPage, SectionTitle } from '@/pages/admin/_shell';
|
||||||
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
|
import { RoleGlyph } from '@/pages/admin/RoleGlyph';
|
||||||
import { buildPosition, payLabel } from '@/pages/admin/positionInsights';
|
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.
|
* 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
|
* 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,
|
* difference between the two states on this card: same layout, same figures,
|
||||||
* and the status pill the card has always drawn now reads Draft.
|
* and the status pill the card has always drawn now reads Draft.
|
||||||
@@ -608,8 +609,8 @@ function PositionCard({ position, onOpen, isRecent = false }) {
|
|||||||
: 'Manual'}
|
: 'Manual'}
|
||||||
</span>
|
</span>
|
||||||
{/* A draft is unfinished work, so the card says what happens next on it:
|
{/* 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
|
it is continued in Owliver, not opened in a hiring drawer for a role
|
||||||
can apply to yet. */}
|
nobody can apply to yet. */}
|
||||||
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-krow-blue">
|
<span className="inline-flex items-center gap-1 text-[11px] font-semibold text-krow-blue">
|
||||||
{isDraft ? 'Continue' : 'View position'}
|
{isDraft ? 'Continue' : 'View position'}
|
||||||
<ArrowRight className="h-3 w-3 transition-transform duration-base group-hover:translate-x-0.5" aria-hidden="true" />
|
<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() {
|
export default function AdminPositions() {
|
||||||
const navigate = useNavigate();
|
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.
|
/* 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
|
Creating a position is a conversation with Owliver beside the list (see
|
||||||
@@ -898,15 +901,21 @@ export default function AdminPositions() {
|
|||||||
const recentIds = useRecentlySavedIds(postings);
|
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
|
* It used to open `/admin/positions/new?draft=<id>`, which answered the wrong
|
||||||
* finishing. Not a second editor and not a copy of the values: the form loads
|
* question: the reader had not asked for the form back, they had asked to
|
||||||
* the draft and saves back to it, so publishing from there is the one publish
|
* finish the position — and the form is only where finishing it used to have
|
||||||
* action there has ever been.
|
* 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'
|
const openPosition = (p) => (p.status === 'draft'
|
||||||
? navigate(`/admin/positions/new?draft=${encodeURIComponent(p.id)}`)
|
? ask(continueDraftRequest(p))
|
||||||
: setSelected(p));
|
: setSelected(p));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -964,7 +973,7 @@ export default function AdminPositions() {
|
|||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
return rows
|
const ordered = rows
|
||||||
.filter((p) => {
|
.filter((p) => {
|
||||||
if (q && !`${p.title} ${p.company || ''} ${p.role_category} ${p.location}`.toLowerCase().includes(q)) return false;
|
if (q && !`${p.title} ${p.company || ''} ${p.role_category} ${p.location}`.toLowerCase().includes(q)) return false;
|
||||||
if (status !== 'all' && p.status !== status) return false;
|
if (status !== 'all' && p.status !== status) return false;
|
||||||
@@ -973,6 +982,27 @@ export default function AdminPositions() {
|
|||||||
return true;
|
return true;
|
||||||
})
|
})
|
||||||
.sort(SORTS[sort].compare);
|
.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]);
|
}, [rows, search, status, department, location, sort]);
|
||||||
|
|
||||||
const isFiltered = Boolean(search) || [status, department, location].some((v) => v !== 'all');
|
const isFiltered = Boolean(search) || [status, department, location].some((v) => v !== 'all');
|
||||||
|
|||||||
Reference in New Issue
Block a user