match-star.mjs
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env node
/**
* match-star.mjs — Zero-LLM, zero-browser ATS behavioural question matcher.
*
* Parses interview-prep/story-bank.md, scores each STAR story against the
* question text (and optionally a JD file), and returns the top match(es)
* formatted to ATS paste length (250–500 words).
*
* The answer is meant to be pasted as-is, so its figures are checked here
* rather than left to whoever reads it (AGENTS.md, Source-of-Truth Boundary):
* a story marked `**Provenance:** user-cannot-confirm` has every number
* replaced by a placeholder, and every other figure that does not trace to
* cv.md gets a ⚠️ line, using story-provenance-check.mjs's classifier.
*
* Usage:
* node match-star.mjs "Tell me about a time you led a project under pressure"
* node match-star.mjs "Describe a conflict you resolved" --jd jds/acme.md
* node match-star.mjs "Give an example of handling ambiguity" --top 2
* node match-star.mjs --list # list all stories with their tags
*/
import { readFileSync, existsSync } from 'fs';
import { isMainModule } from './lib/is-main-module.mjs';
import { join } from 'path';
import { getCareerOpsRoot } from './path-resolver.mjs';
import { parseStories, splitStoryBlocks, isValidStory } from './lib/story-bank.mjs';
import { parseStoryBlocks, classifyEntries } from './story-provenance-check.mjs';
import { flagValue, safeIntFlag, validateFlags } from './lib/cli-flags.mjs';
// ── Config ──────────────────────────────────────────────────────────
// From the data root, not the cwd. interview-prep/ is USER_PATHS in
// update-system.mjs, and a bare relative path resolves against wherever the
// process was started — so this read the checkout's story bank (usually absent)
// for anyone whose data lives elsewhere.
const STORY_BANK_PATH = join(getCareerOpsRoot(), 'interview-prep', 'story-bank.md');
const CV_PATH = join(getCareerOpsRoot(), 'cv.md');
const UNCONFIRMED = '[unconfirmed figure]';
const USAGE = `Usage: node match-star.mjs "<behavioural question>" [--jd <file>] [--top <n>]
node match-star.mjs --list
--jd <file> Also score the stories against this JD
--top <n> Print the n best matches (default 1)
--list List the stories in the bank with their tags
--help, -h Show this help`;
const KNOWN_FLAGS = ['--jd', '--top', '--list', '--help', '-h'];
const VALUE_FLAGS = ['--jd', '--top'];
// flagValue() reads `--jd=jds/acme.md` as well as `--jd jds/acme.md`. The
// indexOf() lookup this replaces saw only the second, so the first ran without
// the JD, and `--top=2` printed one match.
const args = process.argv.slice(2);
const LIST_MODE = args.includes('--list');
const jdPath = flagValue(args, '--jd') ?? null;
const TOP_N = safeIntFlag(flagValue(args, '--top'), 1) || 1;
// Exclude flag operands by index position, not by value, to preserve repeated text in the question
const excludeIdx = new Set(args.flatMap((a, i) => (VALUE_FLAGS.includes(a) ? [i + 1] : [])));
const question = args
.filter((a, i) => !a.startsWith('--') && !excludeIdx.has(i))
.join(' ').trim();
// ── Parser ───────────────────────────────────────────────────────────
// parseStories lives in lib/story-bank.mjs so every story-bank reader shares
// one definition of a story (#4514). Re-exported below for existing callers.
// ── Scoring ──────────────────────────────────────────────────────────
/**
* Tokenize text into lowercase words, stripping punctuation.
* @param {string} text
* @returns {string[]}
*/
function tokenize(text) {
// Letters, marks and digits of ANY script. The previous `[^a-z0-9\s]` class
// deleted every non-Latin character, so a story bank written in Russian,
// Hindi, Greek or Ukrainian tokenized to [] and scored 0 against a question
// in the same language — the matcher was inert, not degraded, for anyone whose
// `language.output` is not English (#2847).
//
// \p{M} is included deliberately: without it Devanagari matras become spaces
// and shatter a word into fragments that match nothing (the mistake caught in
// #2781's review of the sibling role tokenizer).
return text.toLowerCase().replace(/[^\p{L}\p{M}\p{N}\s]/gu, ' ').split(/\s+/).filter(Boolean);
}
const STOPWORDS = new Set([
'a','an','the','and','or','but','in','on','at','to','for','of','with',
'you','me','my','your','i','we','they','it','is','was','were','are',
'be','been','have','had','has','do','did','does','tell','about','time',
'when','how','give','example','describe','situation','where','what',
]);
/**
* Score a story against a question + optional JD text.
* Higher = better match.
* @param {object} story
* @param {string[]} queryTokens
* @param {string[]} jdTokens
* @returns {number}
*/
function score(story, queryTokens, jdTokens) {
const signal = queryTokens.filter(t => !STOPWORDS.has(t));
let s = 0;
// Tag match: highest weight (tags are explicit "best for" labels).
// Tokenized exact membership (mirrors the JD-boost path below) so short query
// tokens (ai, ml, go, qa…) can't spuriously collide inside longer tag words.
const tagTokens = new Set(story.tags.flatMap(tokenize));
for (const token of signal) {
if (tagTokens.has(token)) s += 3;
}
// Title/theme match
const titleTokens = tokenize(story.title + ' ' + story.theme);
for (const token of signal) {
if (titleTokens.includes(token)) s += 2;
}
// Action + result match (the most substantive parts)
const bodyTokens = tokenize(story.action + ' ' + story.result);
for (const token of signal) {
if (bodyTokens.includes(token)) s += 1;
}
// JD boost: stopword-filtered JD tokens matched against tokenized tags (exact token overlap)
if (jdTokens.length > 0) {
const jdSignal = new Set(jdTokens.filter(t => !STOPWORDS.has(t)));
for (const tag of story.tags) {
const tagTokens = tokenize(tag);
if (tagTokens.some(t => jdSignal.has(t))) s += 2;
}
}
return s;
}
// ── Formatter ────────────────────────────────────────────────────────
// A number with what makes it a figure: a currency sign before it, and a
// magnitude, `%`, `+`, `x`, `h` or `/unit` rate after it ("$60/hr", "$40K",
// "3.5x", "1,200+", "8h"). Not inside a word, so "Q3" and "B2B" stay.
const FIGURE_RE = /(?<![\p{L}\p{N}])[$€£¥]?\d[\d,.]*(?:\s?(?:%|[kmbh]\b|x\b))?\+?(?:\/\p{L}+)?/giu;
/**
* Replace every number in `text` with a placeholder, so a story the user
* could not confirm can't carry its figures into a paste. Every number, not
* only the checker's claims: its patterns are not exhaustive (a rate like
* "$60/hr" is none of them), and a number the user could not confirm is not
* safe to paste whatever its shape.
* @param {string} text
* @returns {string}
*/
function maskFigures(text) {
return String(text ?? '').replace(FIGURE_RE, UNCONFIRMED);
}
/**
* ⚠️ lines for the figures in `prose` that do not trace to cv.md.
* @param {object} story
* @param {string} prose - the exact text about to be printed
* @param {string} cvText
* @returns {string[]}
*/
function provenanceWarnings(story, prose, cvText) {
if (story.provenance === 'user-cannot-confirm') {
return [` ⚠️ Provenance: user-cannot-confirm. Every number is replaced with ${UNCONFIRMED}: use this story as narrative only.`];
}
const buckets = classifyEntries([{ title: story.title, provenance: story.provenance ?? null, body: prose }], cvText);
return [
...buckets.derivedUnverified.map((c) => ` ⚠️ "${c.claim}" is not in cv.md (derived-unverified): confirm it before pasting.`),
...buckets.supportedByResume.map((c) => ` ⚠️ "${c.claim}": cv.md supports the fact but not this figure (supportedByResume): confirm it before pasting.`),
];
}
/**
* Format a STAR story as ATS-ready prose (250–500 words).
* @param {object} story - a parseStories() story, plus `provenance` (its
* Provenance marker, lowercased, or null)
* @param {string} question
* @param {{cvText?: string}} [opts] - cv.md, to check the figures against
* @returns {string}
*/
function formatAts(story, question, { cvText = '' } = {}) {
const parts = [];
if (story.situation) parts.push(story.situation);
if (story.task) parts.push(story.task);
if (story.action) parts.push(story.action);
if (story.result) parts.push(story.result);
if (story.reflection) parts.push(story.reflection);
// Every field this prints, not only the prose: a figure in a title or a tag
// is still a figure. Source is left as written: it says where the story
// came from ("Report #042"), which is what the user needs to check it.
const cannotConfirm = story.provenance === 'user-cannot-confirm';
const safe = (text) => (cannotConfirm ? maskFigures(text) : text);
const joined = safe(parts.filter(Boolean).join(' '));
const wordArr = joined.split(/\s+/).filter(Boolean);
// Enforce 500-word ceiling; prose below 250 words gets a warning
const prose = wordArr.slice(0, 500).join(' ');
const words = Math.min(wordArr.length, 500);
const notice = wordArr.length < 250
? '\n ⚠️ Under 250 words — consider expanding this story in story-bank.md.'
: '';
return [
`— ${safe(story.title)}${story.theme ? ` [${safe(story.theme)}]` : ''}`,
story.source ? ` Source: ${story.source}` : '',
story.tags.length ? ` Tags: ${story.tags.map(safe).join(', ')}` : '',
...provenanceWarnings(story, prose, cvText),
'',
prose,
'',
` (~${words} words)${notice}`,
].filter(s => s !== null).join('\n');
}
// ── Exports (for test-all.mjs and other consumers) ───────────────────
export { parseStories, tokenize, score, formatAts, STOPWORDS };
// ── Main ─────────────────────────────────────────────────────────────
if (isMainModule(import.meta.url)) {
// Before the story bank is read: a mistyped flag was dropped and its value
// joined the question, and --help without a bank printed the missing-bank error.
validateFlags(args, KNOWN_FLAGS, USAGE, { valueFlags: VALUE_FLAGS, requireOperand: true });
if (!existsSync(STORY_BANK_PATH)) {
console.error(`Error: ${STORY_BANK_PATH} not found.`);
console.error('Run /career-ops interview-prep on a role first to populate your story bank.');
process.exit(1);
}
const content = readFileSync(STORY_BANK_PATH, 'utf-8');
// The checker's own reading of each story's marker, matched by heading line,
// so a marker it honours is honoured here.
const provenanceAt = new Map(parseStoryBlocks(content)
.filter(e => e.kind === 'story').map(e => [e.line, e.provenance]));
const stories = parseStories(content).map(s => ({ ...s, provenance: provenanceAt.get(s.line) ?? null }));
const cvText = existsSync(CV_PATH) ? readFileSync(CV_PATH, 'utf-8') : '';
// Say what is in the file but cannot be read, so an empty or short bank is
// never mistaken for the whole story (#4514). Never rewrites the file: it is
// user data, and converting it is the agent's job, with the user's say-so.
const { blocks, tableRows } = splitStoryBlocks(content);
const invalid = blocks.filter(b => !isValidStory(b));
if (tableRows.length || invalid.length) {
console.error('⚠️ story-bank.md has entries this matcher cannot read:');
if (tableRows.length) console.error(` - ${tableRows.length} table row(s) (lines ${tableRows.map(r => r.line).join(', ')})`);
for (const b of invalid) console.error(` - "${b.title || '(untitled)'}" (line ${b.line}): no **A (Action):** line`);
console.error(' Ask your agent to convert them to the format in templates/story-bank.template.md.\n');
}
if (stories.length === 0) {
console.error('No stories found in story-bank.md yet.');
console.error('Run /career-ops interview-prep on a role to add your first stories.');
process.exit(1);
}
if (LIST_MODE) {
console.log(`\nStory Bank — ${stories.length} stories\n${'─'.repeat(40)}`);
stories.forEach((s, i) => {
console.log(`${i + 1}. ${s.title}${s.theme ? ` [${s.theme}]` : ''}`);
if (s.tags.length) console.log(` Tags: ${s.tags.join(', ')}`);
if (s.source) console.log(` ${s.source}`);
console.log('');
});
process.exit(0);
}
if (!question) {
console.error(USAGE);
process.exit(1);
}
const queryTokens = tokenize(question);
let jdTokens = [];
if (jdPath) {
if (!existsSync(jdPath)) {
console.error(`Error: JD file not found at ${jdPath}`);
process.exit(1);
}
jdTokens = tokenize(readFileSync(jdPath, 'utf-8'));
}
// Score and rank
const ranked = stories
.map(s => ({ story: s, score: score(s, queryTokens, jdTokens) }))
.sort((a, b) => b.score - a.score)
.slice(0, TOP_N);
console.log(`\nATS Behavioural Question Matcher`);
console.log('─'.repeat(40));
console.log(`Question: "${question}"`);
if (jdPath) console.log(`JD: ${jdPath}`);
console.log(`Stories: ${stories.length} in bank\n`);
for (let i = 0; i < ranked.length; i++) {
const { story, score: s } = ranked[i];
console.log(`${'─'.repeat(40)}`);
console.log(`Match ${i + 1} of ${ranked.length} (score: ${s})\n`);
console.log(formatAts(story, question, { cvText }));
console.log('');
}
if (ranked.length > 0 && ranked[0].score === 0) {
console.log('⚠️ No strong match found. Consider adding a story to story-bank.md that covers this competency.');
}
} // end CLI guard