update draft issue

This commit is contained in:
2026-08-15 23:24:06 +05:30
parent b349f2db61
commit 69b38abced
8 changed files with 753 additions and 25 deletions

View File

@@ -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: () => {},
};
}

View File

@@ -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}

View File

@@ -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';
/**

View File

@@ -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

View File

@@ -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,

View File

@@ -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
View 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,
});

View File

@@ -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');