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

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