update draft issue
This commit is contained in:
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,
|
||||
});
|
||||
Reference in New Issue
Block a user