cv-voice.mjs
Rama main · réplica de lectura del repositorio original.
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { metricClaims, stripMarkup } from './verify-cv-facts.mjs';
export const BANNED_PHRASES = [
'passionate about',
'results-oriented',
'results-driven',
'proven track record',
'leveraged',
'leveraging',
'leverage',
'spearheaded',
'spearheading',
'spearhead',
'facilitated',
'facilitating',
'synergies',
'synergy',
'cutting-edge',
'cutting edge',
"in today's fast-paced world",
'demonstrated ability to',
'best practices',
'significant impact',
'measurable impact',
'orchestrated',
'championed',
'utilized',
'delve',
'tapestry',
'pivotal',
];
const EM_DASH = '\u2014';
const EN_DASH = '\u2013';
function norm(text) {
return String(text).toLowerCase().replace(/\s+/g, ' ').trim();
}
// A dash between two years is how a range is written, not a stock tell.
// A month may sit before the end year: "Mar 2020 – Dec 2022".
// The HTML builder prints "2022–2024" from the start and end fields itself.
// A metric such as "20–30%" is not a date, so it stays a tell.
const MONTH = 'jan(?:uary)?|feb(?:ruary)?|mar(?:ch)?|apr(?:il)?|may|jun(?:e)?|jul(?:y)?|aug(?:ust)?|sep(?:t(?:ember)?)?|oct(?:ober)?|nov(?:ember)?|dec(?:ember)?';
const DATE_RANGE_DASH = new RegExp(
`((?:19|20)\\d{2})\\s*[\\u2013\\u2014]\\s*(?=(?:(?:${MONTH})\\b\\.?\\s+)?(?:(?:19|20)\\d{2}\\b|present\\b|current\\b|now\\b|today\\b))`,
'giu',
);
function linesOf(text) {
const broken = String(text)
// The <head> is not printed. Its <title> carries the template's own
// "Name — CV" separator, which the candidate never wrote.
.replace(/<head[\s>][\s\S]*?<\/head>/gi, '\n')
.replace(/<!--[\s\S]*?-->/g, '\n')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(?:p|li|h\d|div|tr|section|article)>/gi, '\n');
return stripMarkup(broken, { keepLineBreaks: true })
.split(/\n+/)
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter(Boolean);
}
// The PDF path prints a long dash as "-", a curly quote as a straight one, and
// a no-break space as a space. The phrase check reads both sides that way, so
// "Results\u2013driven" is still "results-driven". The dash warning reads the raw line.
function wordingOf(text) {
return String(text)
.replace(/[\u2010\u2011\u2012\u2013\u2014\u2015\u2212]/g, '-')
.replace(/[\u2018\u2019\u201A\u201B]/g, "'")
.replace(/\u00A0/g, ' ');
}
function hasPhrase(text, phrase) {
// stripMarkup puts a space where bold or a tag ended, so "**Results**-driven"
// reads "Results -driven". A space on either side of a hyphen still matches.
const body = phrase.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/-/g, ' ?- ?');
return new RegExp(`(?<![\\p{L}\\p{N}])${body}(?![\\p{L}\\p{N}])`, 'iu').test(text);
}
// A heading that lists words to avoid is not the candidate's own wording.
// The shipped template uses both "banned" and "patterns to avoid".
function isAvoidHeading(title) {
return /\bbanned\b/i.test(title) || /\bpatterns to avoid\b/i.test(title);
}
function wordingFromVoiceDna(text) {
const lines = String(text).replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
const kept = [];
let skipping = false;
let skipLevel = 0;
for (const line of lines) {
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) {
const level = heading[1].length;
if (skipping && level <= skipLevel) skipping = false;
if (!skipping && isAvoidHeading(heading[2])) {
skipping = true;
skipLevel = level;
}
}
if (!skipping) kept.push(line);
}
return kept.join('\n');
}
function wordingFromFacts(text) {
let data;
try {
data = JSON.parse(text);
} catch {
return '';
}
if (!data || typeof data !== 'object') return '';
const parts = [];
for (const key of ['allow_facts', 'allow_metrics']) {
const list = data[key];
if (!Array.isArray(list)) continue;
for (const item of list) {
if (typeof item === 'string' && item.trim()) parts.push(item);
}
}
return parts.join('\n');
}
/**
* Words the candidate wrote down, outside cv.md. A missing file is normal.
* A cv-facts.json that does not parse adds nothing, so a broken file cannot
* allow a stock phrase by accident. forbidden_phrases stay blocked.
*
* @param {string} root
* @returns {string}
*/
export function candidateOwnWords(root) {
const chunks = [];
const dnaPath = join(root, 'voice-dna.md');
if (existsSync(dnaPath)) chunks.push(wordingFromVoiceDna(readFileSync(dnaPath, 'utf8')));
const factsPath = join(root, 'config', 'cv-facts.json');
if (existsSync(factsPath)) chunks.push(wordingFromFacts(readFileSync(factsPath, 'utf8')));
return chunks.filter(Boolean).join('\n');
}
/**
* @param {string} generated
* @param {string} source
* @returns {{kind: 'banned-phrase'|'em-dash'|'invented-number', value: string}[]}
*/
export function voiceIssues(generated, source) {
const sourceText = String(source ?? '');
const sourceLineNorms = linesOf(sourceText).map(norm);
const sourceLines = new Set(sourceLineNorms);
// Emphasis is still the candidate's phrase: "best **practices**" matches
// "best practices". Line breaks stay, so two neighboring lines do not join.
const sourcePhrases = wordingOf(stripMarkup(sourceText, { keepLineBreaks: true }));
const issues = [];
const seen = new Set();
const push = (kind, value) => {
const key = `${kind}:${value.toLowerCase()}`;
if (seen.has(key)) return;
seen.add(key);
issues.push({ kind, value });
};
for (const line of linesOf(generated)) {
// A whole source line counts at any length, so a copied "2021–2024" stays.
// A fragment has to be long enough, and it has to sit inside one source
// line. Searching the collapsed CV would join "best" and "practices".
const lineNorm = norm(line);
const alreadyInSource = sourceLines.has(lineNorm)
|| (lineNorm.length >= 12 && sourceLineNorms.some((sourceLine) => sourceLine.includes(lineNorm)));
if (alreadyInSource) continue;
const prose = line.replace(DATE_RANGE_DASH, '$1 ');
if (prose.includes(EM_DASH)) push('em-dash', 'em dash');
if (prose.includes(EN_DASH)) push('em-dash', 'en dash');
const wording = wordingOf(line);
for (const phrase of BANNED_PHRASES) {
if (!hasPhrase(wording, phrase)) continue;
if (hasPhrase(sourcePhrases, phrase)) continue;
push('banned-phrase', phrase);
}
}
const allowed = metricClaims(sourceText);
for (const claim of metricClaims(String(generated ?? ''))) {
if (!allowed.has(claim)) push('invented-number', claim);
}
return issues;
}
/**
* The wording failure a renderer shows. A long dash is a warning, not a
* failure: the PDF path already normalizes it. Null when nothing blocks.
*
* @param {{kind: string, value: string}[]} issues
* @param {string} [label]
* @returns {string|null}
*/
export function voiceTellMessage(issues, label) {
const tells = issues.filter((issue) => issue.kind === 'banned-phrase');
if (!tells.length) return null;
const where = label ? ` for ${label}` : '';
const bits = tells.map((issue) => `"${issue.value}"`);
return `Voice check failed${where}: ${bits.join(', ')}. That wording is not in the source CV. Keep the candidate's own words.`;
}
/**
* @param {{kind: string, value: string}[]} issues
* @param {string} [label]
* @returns {string|null}
*/
export function voiceDashWarning(issues, label) {
const dashes = issues.filter((issue) => issue.kind === 'em-dash');
if (!dashes.length) return null;
const where = label ? ` for ${label}` : '';
const bits = [...new Set(dashes.map((issue) => issue.value))];
return `Voice check warning${where}: ${bits.map((value) => `an ${value}`).join(', ')}. The build continues. PDF generation normalizes long dashes for ATS. LaTeX and CV Studio leave the dash in the text.`;
}