项目文件夹

文件
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

258 行
9.1 KiB
JavaScript

#!/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).
*
* 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 { fileURLToPath } from 'url';
// ── Config ──────────────────────────────────────────────────────────
const STORY_BANK_PATH = 'interview-prep/story-bank.md';
const args = process.argv.slice(2);
const LIST_MODE = args.includes('--list');
const jdFlag = args.indexOf('--jd');
const jdPath = jdFlag !== -1 ? args[jdFlag + 1] : null;
const topFlag = args.indexOf('--top');
const topRaw = topFlag !== -1 ? parseInt(args[topFlag + 1], 10) : NaN;
const TOP_N = Number.isInteger(topRaw) && topRaw > 0 ? topRaw : 1;
// Exclude flag operands by index position, not by value, to preserve repeated text in the question
const excludeIdx = new Set([
...(jdFlag !== -1 ? [jdFlag + 1] : []),
...(topFlag !== -1 ? [topFlag + 1] : []),
]);
const question = args
.filter((a, i) => !a.startsWith('--') && !excludeIdx.has(i))
.join(' ').trim();
// ── Parser ───────────────────────────────────────────────────────────
/**
* Parse story-bank.md into structured story objects.
* @param {string} content
* @returns {Array<{title, theme, source, situation, task, action, result, reflection, tags}>}
*/
function parseStories(content) {
const stories = [];
// Split on story headings: ### [Theme] Title
const blocks = content.split(/^### /m).slice(1);
for (const block of blocks) {
const lines = block.trim().split('\n');
const header = lines[0].trim();
// Extract theme from [Theme] prefix if present
const themeMatch = header.match(/^\[([^\]]+)\]\s*(.+)/);
const theme = themeMatch ? themeMatch[1].trim() : '';
const title = themeMatch ? themeMatch[2].trim() : header;
const get = (label) => {
const re = new RegExp(`\\*\\*${label}:\\*\\*\\s*(.+)`);
const hit = block.match(re);
return hit ? hit[1].trim() : '';
};
const tagsRaw = get('Best for questions about');
const tags = tagsRaw
? tagsRaw.split(/[,;]/).map(t => t.trim().toLowerCase()).filter(Boolean)
: [];
if (!title || (!get('A \\(Action\\)') && !get('Action'))) continue; // skip template/empty blocks
stories.push({
title,
theme,
source: get('Source'),
situation: get('S \\(Situation\\)') || get('Situation'),
task: get('T \\(Task\\)') || get('Task'),
action: get('A \\(Action\\)') || get('Action'),
result: get('R \\(Result\\)') || get('Result'),
reflection: get('Reflection'),
tags,
});
}
return stories;
}
// ── Scoring ──────────────────────────────────────────────────────────
/**
* Tokenize text into lowercase words, stripping punctuation.
* @param {string} text
* @returns {string[]}
*/
function tokenize(text) {
return text.toLowerCase().replace(/[^a-z0-9\s]/g, ' ').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)
const tagText = story.tags.join(' ');
for (const token of signal) {
if (tagText.includes(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 ────────────────────────────────────────────────────────
/**
* Format a STAR story as ATS-ready prose (250–500 words).
* @param {object} story
* @param {string} question
* @returns {string}
*/
function formatAts(story, question) {
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);
const wordArr = parts.filter(Boolean).join(' ').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 [
`— ${story.title}${story.theme ? ` [${story.theme}]` : ''}`,
story.source ? ` Source: ${story.source}` : '',
story.tags.length ? ` Tags: ${story.tags.join(', ')}` : '',
'',
prose,
'',
` (~${words} words)${notice}`,
].filter(s => s !== null).join('\n');
}
// ── Exports (for test-all.mjs and other consumers) ───────────────────
export { parseStories, tokenize, score, STOPWORDS };
// ── Main ─────────────────────────────────────────────────────────────
if (process.argv[1] === fileURLToPath(import.meta.url)) {
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');
const stories = parseStories(content);
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: node match-star.mjs "<behavioural question>" [--jd <file>] [--top <n>]');
console.error(' node match-star.mjs --list');
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));
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