From 69b38abced17b210c24e76b05f59760293864a7b Mon Sep 17 00:00:00 2001 From: Aravind Date: Sat, 15 Aug 2026 23:24:06 +0530 Subject: [PATCH] update draft issue --- .../ai-assistant/AssistantPanelContext.jsx | 47 ++- src/components/ai-assistant/KrowAssistant.jsx | 56 ++- .../ai-assistant/capabilities/admin.js | 3 + src/components/ai-assistant/dynamic.js | 17 +- src/components/ai-assistant/routing.js | 113 ++++++ src/components/ai-assistant/useAssistant.js | 129 ++++++- src/lib/skills/draftFlow.js | 361 ++++++++++++++++++ src/pages/admin/Positions.jsx | 52 ++- 8 files changed, 753 insertions(+), 25 deletions(-) create mode 100644 src/lib/skills/draftFlow.js diff --git a/src/components/ai-assistant/AssistantPanelContext.jsx b/src/components/ai-assistant/AssistantPanelContext.jsx index 8ffdc79..210850b 100644 --- a/src/components/ai-assistant/AssistantPanelContext.jsx +++ b/src/components/ai-assistant/AssistantPanelContext.jsx @@ -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: () => {}, }; } diff --git a/src/components/ai-assistant/KrowAssistant.jsx b/src/components/ai-assistant/KrowAssistant.jsx index 91755a5..00d3054 100644 --- a/src/components/ai-assistant/KrowAssistant.jsx +++ b/src/components/ai-assistant/KrowAssistant.jsx @@ -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 = ( 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 diff --git a/src/components/ai-assistant/routing.js b/src/components/ai-assistant/routing.js index 6ef0c70..1d4c394 100644 --- a/src/components/ai-assistant/routing.js +++ b/src/components/ai-assistant/routing.js @@ -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, diff --git a/src/components/ai-assistant/useAssistant.js b/src/components/ai-assistant/useAssistant.js index 4a4407d..f04e3b2 100644 --- a/src/components/ai-assistant/useAssistant.js +++ b/src/components/ai-assistant/useAssistant.js @@ -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]); diff --git a/src/lib/skills/draftFlow.js b/src/lib/skills/draftFlow.js new file mode 100644 index 0000000..566ae74 --- /dev/null +++ b/src/lib/skills/draftFlow.js @@ -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, +}); diff --git a/src/pages/admin/Positions.jsx b/src/pages/admin/Positions.jsx index 382805d..8d99f7f 100644 --- a/src/pages/admin/Positions.jsx +++ b/src/pages/admin/Positions.jsx @@ -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'} {/* 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. */} {isDraft ? 'Continue' : 'View position'}