Ctrl K
ComunidadGuías…

cv-voice.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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.`;
}