Ctrl K
ComunidadGuías…

career-profile.mjs

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node

/**
 * Import CV facts into a reviewable, source-backed Master Career Profile.
 * Parsing is deliberately conservative: the CV is evidence, not an instruction.
 */

import { createHash } from 'crypto';
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'fs';
import { dirname, isAbsolute, join, relative, resolve, sep } from 'path';
import { createInterface } from 'readline';
import * as yaml from 'js-yaml';
import { loadYaml } from './lib/yaml-document.mjs';
import { getCareerOpsRoot } from './path-resolver.mjs';
import { withPipelineLock } from './pipeline-lock.mjs';
import { validateProfile, loadValidatedProfile } from './lib/career-profile.mjs';
import { prepareCvSelection, verifyCvSelection } from './lib/profile-cv-selection.mjs';
import { flagValue, validateFlags } from './lib/cli-flags.mjs';

const root = getCareerOpsRoot();
const profilePath = join(root, 'data', 'career-profile.yml');
const HELP = `Master Career Profile

  node career-profile.mjs import [cv.md] [--review]
    Preview candidates by default. --review asks you to approve, edit, or skip
    each item; only approved items are saved to data/career-profile.yml.

  node career-profile.mjs validate [profile.yml]
    Check the profile structure and source evidence.

  node career-profile.mjs prepare-cv --payload base.json --selection selection.json
    Print {payload, mapping} as JSON. Uses data/career-profile.yml by default.

  node career-profile.mjs verify-cv --payload base.json --selection selection.json --prepared prepared.json
    Recheck against the current profile and original inputs. Both commands
    accept --profile profile.yml; all paths resolve from the Data Root.
    Neither command renders a CV or replaces the existing CV fact gates.
`;

function normalizeKey(value) {
  return String(value).normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase('en-US');
}

function stableId(kind, value, source, parent = '') {
  let normalizedSource = source.replaceAll('\\', '/').replace(/\/+/g, '/');
  if (process.platform === 'win32') normalizedSource = normalizedSource.toLocaleLowerCase('en-US');
  const identity = [kind, normalizedSource, normalizeKey(parent), normalizeKey(value)].join('\0');
  return `${kind}-${createHash('sha256').update(identity).digest('hex').slice(0, 12)}`;
}

function evidenceKey(item) {
  return `${item?.evidence?.source ?? ''}\0${normalizeKey(item?.evidence?.quote ?? '')}`;
}

function entityEvidenceKey(entry) {
  const facts = (entry.facts ?? []).map(evidenceKey).sort();
  return `${evidenceKey(entry)}\0${facts.join('\0')}`;
}

function classifyHeading(heading) {
  const text = heading.toLowerCase();
  if (/^(experience|work experience|employment|professional experience|pengalaman kerja|pengalaman)$/.test(text)) return 'experiences';
  if (/^(projects?|selected projects|portfolio|proyek|proyek pilihan)$/.test(text)) return 'projects';
  if (/^(education|academic background|pendidikan)$/.test(text)) return 'education';
  if (/^(skills?|technical skills|core competencies|keahlian|keterampilan)$/.test(text)) return 'skills';
  if (/^(certifications?|certificates?|licenses?|sertifikasi|sertifikat)$/.test(text)) return 'certifications';
  if (/^(summary|profile|about|professional summary|ringkasan|profil)$/.test(text)) return 'summary';
  return null;
}

function parseCv(text, source) {
  const lines = text.replace(/\r\n?/g, '\n').split('\n');
  const result = { candidate: {}, summary: [], experiences: [], projects: [], education: [], certifications: [], skills: [] };
  let section = null;
  let entity = null;
  const addFact = (collection, value, line) => {
    const fact = { id: stableId(collection, value, source, entity?.label ?? ''), text: value,
      evidence: { source, line, quote: lines[line - 1].trim() }, review_status: 'needs_review' };
    if (collection === 'summary' || collection === 'certifications' || collection === 'skills') result[collection].push(fact);
    else if (entity) entity.facts.push(fact);
    else {
      entity = { id: stableId(collection, `entry:${value}`, `${source}:${line}`), label: value,
        evidence: { source, line, quote: lines[line - 1].trim() }, facts: [] };
      result[collection].push(entity);
    }
  };

  lines.forEach((raw, index) => {
    const lineNo = index + 1;
    const line = raw.trim();
    if (!line) return;
    const heading = line.match(/^(#{1,6})\s+(.+?)\s*#*$/);
    if (heading) {
      const title = heading[2].trim();
      const found = classifyHeading(title);
      if (found) { section = found; entity = null; return; }
      if (section && ['experiences', 'projects', 'education'].includes(section)) {
        entity = { id: stableId(section, title, source), label: title,
          evidence: { source, line: lineNo, quote: raw.trim() }, facts: [] };
        result[section].push(entity);
      }
      return;
    }
    if (!section) return;
    const value = line.replace(/^\s*(?:[-*+]\s+|\d+[.)]\s+)/, '').replace(/^\*\*(.+?)\*\*:?\s*/, '$1: ').trim();
    if (!value) return;
    if (section === 'summary') addFact(section, value, lineNo);
    else if (section === 'skills') {
      for (const skill of value.split(/[,;|]/).map((s) => s.trim()).filter(Boolean)) addFact(section, skill, lineNo);
    } else if (section === 'certifications') addFact(section, value, lineNo);
    else if (['experiences', 'projects', 'education'].includes(section)) {
      if (/^\s*(?:[-*+]\s+|\d+[.)]\s+)/.test(raw) && entity) addFact(section, value, lineNo);
      else {
        entity = { id: stableId(section, value, source), label: value,
          evidence: { source, line: lineNo, quote: raw.trim() }, facts: [] };
        result[section].push(entity);
      }
    }
  });
  for (const key of ['summary', 'certifications', 'skills']) {
    const unique = new Map();
    for (const fact of result[key]) if (!unique.has(fact.id)) unique.set(fact.id, fact);
    result[key] = [...unique.values()];
  }
  for (const key of ['experiences', 'projects', 'education']) {
    const groups = new Map();
    for (const entry of result[key]) {
      const labelKey = normalizeKey(entry.label);
      const factKeys = new Set();
      entry.facts = entry.facts.filter((fact) => {
        const factKey = normalizeKey(fact.text);
        if (factKeys.has(factKey)) return false;
        factKeys.add(factKey);
        return true;
      });
      const signature = `${labelKey}\0${[...factKeys].sort().join('\0')}`;
      if (!groups.has(labelKey)) groups.set(labelKey, new Map());
      const entriesBySignature = groups.get(labelKey);
      if (entriesBySignature.has(signature)) continue;
      entriesBySignature.set(signature, entry);
    }
    const uniqueEntries = [];
    for (const [labelKey, entriesBySignature] of groups) {
      const signatures = [...entriesBySignature.keys()].sort();
      const baseSignature = signatures[0];
      for (const signature of signatures) {
        const entry = entriesBySignature.get(signature);
        const baseId = stableId(key, entry.label, source);
        if (signature !== baseSignature) {
          entry.id = stableId(key, `entry:${signature}`, source);
          for (const fact of entry.facts) fact.id = stableId(key, fact.text, source, entry.id);
        } else entry.id = baseId;
        uniqueEntries.push(entry);
      }
    }
    result[key] = uniqueEntries;
  }
  return result;
}

function readProfile(file) {
  if (!existsSync(file)) return { schema_version: 1, candidate: {}, summary: [], experiences: [], projects: [], education: [], certifications: [], skills: [] };
  return loadValidatedProfile({ file });
}

async function ask(lines, question) {
  process.stdout.write(question);
  const answer = await lines.next();
  return answer.done ? null : answer.value.trim();
}

async function reviewFact(fact, label, lines) {
  process.stdout.write(`\n${label}: ${fact.text}\n  Evidence: ${fact.evidence.source}:${fact.evidence.line} — ${fact.evidence.quote}\n`);
  while (true) {
    const response = await ask(lines, '  [y] approve / [e] edit / [n] skip / [q] finish: ');
    if (response === null) return { quit: true };
    const answer = response.toLowerCase();
    if (answer === 'y') { fact.review_status = 'verified'; return { fact, edited: false }; }
    if (answer === 'n') return { fact: null, edited: false };
    if (answer === 'q') return { quit: true };
    if (answer === 'e') {
      const edited = await ask(lines, '  Revised wording (blank cancels): ');
      if (edited === null) return { quit: true };
      if (edited) { fact.text = edited; fact.review_status = 'verified'; return { fact, edited: true }; }
    }
  }
}

async function doImport(args) {
  const review = args.includes('--review');
  const sourceArg = args.find((arg) => !arg.startsWith('--')) || 'cv.md';
  const sourcePath = resolve(root, sourceArg);
  if (!existsSync(sourcePath)) throw new Error(`CV file not found: ${sourcePath}`);
  const relativeSource = relative(root, sourcePath);
  const outsideRoot = relativeSource === '..' || relativeSource.startsWith(`..${sep}`) || isAbsolute(relativeSource);
  const source = outsideRoot ? sourcePath : relativeSource.split(sep).join('/');
  const extracted = parseCv(readFileSync(sourcePath, 'utf8'), source);
  const candidates = [];
  for (const key of ['summary', 'experiences', 'projects', 'education', 'certifications', 'skills']) {
    for (const item of extracted[key]) {
      if ('facts' in item) {
        candidates.push({ key, item, label: `${key} heading` });
        item.facts.forEach((fact) => candidates.push({ key, item, fact, label: `${key}: ${item.label}` }));
      } else candidates.push({ key, fact: item, label: key });
    }
  }
  process.stdout.write(`Found ${candidates.length} review candidates in ${source}.\n`);
  if (!review) {
    for (const { fact, item, label } of candidates) process.stdout.write(`- ${label}: ${fact?.text ?? item.label}\n`);
    process.stdout.write('Preview only; rerun with --review to approve items and save.\n');
    return;
  }

  const approved = { ...extracted, candidate: { ...extracted.candidate }, summary: [], experiences: [], projects: [], education: [], certifications: [], skills: [] };
  const skippedEntities = new Set();
  const editedIds = new Set();
  let quit = false;
  const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
  const lines = input[Symbol.asyncIterator]();
  try {
    for (const entry of candidates) {
      if (quit) break;
      if (entry.fact && entry.item && skippedEntities.has(entry.item.id)) continue;
      const target = entry.fact ?? { id: entry.item.id, text: entry.item.label, evidence: entry.item.evidence, review_status: 'needs_review' };
      const decision = await reviewFact(target, entry.label, lines);
      if (decision.quit) { quit = true; break; }
      if (!decision.fact) {
        if (!entry.fact) skippedEntities.add(entry.item.id);
        continue;
      }
      const result = decision.fact;
      if (decision.edited) editedIds.add(result.id);
      if (entry.fact) {
        if (entry.item && 'facts' in entry.item) {
          const kept = approved[entry.key].find((x) => x.id === entry.item.id);
          if (kept) kept.facts.push(result);
          else approved[entry.key].push({ ...entry.item, review_status: 'verified', facts: [result] });
        } else approved[entry.key].push(result);
      } else approved[entry.key].push({ ...entry.item, label: result.text, review_status: 'verified', facts: [] });
    }
  } finally {
    input.close();
  }

  const count = Object.values(approved).filter(Array.isArray).reduce((n, items) => n + items.length, 0);
  if (!count) { process.stdout.write('No approved items; profile was not changed.\n'); return; }
  await withPipelineLock(profilePath, () => {
    const existing = readProfile(profilePath);
    const merged = { ...existing, schema_version: 1, candidate: { ...existing.candidate, ...approved.candidate } };
    for (const key of ['summary', 'experiences', 'projects', 'education', 'certifications', 'skills']) {
      const existingItems = existing[key] ?? [];
      const reidentified = new Map();
      const migratedIds = new Set();
      for (const item of approved[key]) {
        if (!('facts' in item)) continue;
        const identity = entityEvidenceKey(item);
        const prior = existingItems.find((candidate) => !migratedIds.has(candidate.id) && 'facts' in candidate && entityEvidenceKey(candidate) === identity);
        if (!prior) continue;
        reidentified.set(item.id, prior);
        migratedIds.add(prior.id);
      }
      const byId = new Map(existingItems.filter((item) => !migratedIds.has(item.id)).map((item) => [item.id, item]));
      for (const [id, prior] of reidentified) {
        const incoming = approved[key].find((item) => item.id === id);
        const incomingFactsByEvidence = new Map((incoming?.facts ?? []).map((fact) => [evidenceKey(fact), fact]));
        const facts = (prior.facts ?? []).map((fact) => {
          const fresh = incomingFactsByEvidence.get(evidenceKey(fact));
          return fresh ? { ...fact, id: fresh.id } : fact;
        });
        byId.set(id, { ...prior, id, facts });
      }
      for (const item of approved[key]) {
        if (!byId.has(item.id)) byId.set(item.id, item);
        else if ('facts' in item) {
          const old = byId.get(item.id);
          const facts = new Map((old.facts ?? []).map((fact) => [fact.id, fact]));
          for (const fact of item.facts ?? []) {
            const existingFact = facts.get(fact.id);
            facts.set(fact.id, existingFact
              ? { ...existingFact, ...(editedIds.has(fact.id) ? { text: fact.text } : {}), evidence: fact.evidence, review_status: fact.review_status }
              : fact);
          }
          byId.set(item.id, { ...old, ...(editedIds.has(item.id) ? { label: item.label } : {}), evidence: item.evidence, review_status: item.review_status, facts: [...facts.values()] });
        }
        else byId.set(item.id, {
          ...byId.get(item.id),
          ...(editedIds.has(item.id) ? { text: item.text } : {}),
          evidence: item.evidence,
          review_status: item.review_status,
        });
      }
      merged[key] = [...byId.values()];
    }
    const errors = validateProfile(merged);
    if (errors.length) throw new Error(`Import rejected:\n- ${errors.join('\n- ')}`);
    mkdirSync(dirname(profilePath), { recursive: true });
    const tmpPath = `${profilePath}.tmp`;
    try {
      writeFileSync(tmpPath, yaml.dump(merged, { noRefs: true, lineWidth: 100 }), { encoding: 'utf8', flag: 'w' });
      renameSync(tmpPath, profilePath);
    } catch (error) {
      try { rmSync(tmpPath, { force: true }); } catch { /* keep the save error, not the cleanup one */ }
      throw error;
    }
    process.stdout.write(`Saved ${profilePath}. Approved items are marked verified; existing entries were preserved.\n`);
  });
}

function doValidate(args) {
  const pathArg = args.find((arg) => !arg.startsWith('--'));
  const file = pathArg ? resolve(root, pathArg) : profilePath;
  if (!existsSync(file)) throw new Error(`Profile not found: ${file}`);
  const errors = validateProfile(loadYaml(readFileSync(file, 'utf8')));
  if (errors.length) { process.stderr.write(`Invalid profile:\n- ${errors.join('\n- ')}\n`); process.exitCode = 1; }
  else process.stdout.write(`Valid Master Career Profile: ${file}\n`);
}

function doCvSelection(command, args) {
  const flags = ['--payload', '--selection', '--profile', ...(command === 'verify-cv' ? ['--prepared'] : [])];
  validateFlags(args, [...flags, '--help', '-h'], HELP, { valueFlags: flags, requireOperand: true });
  // Refuse ambiguous duplicate flags or stray positional arguments.
  const seen = new Set();
  for (let i = 0; i < args.length; i++) {
    const flag = args[i].split('=')[0];
    if (!flags.includes(flag) || seen.has(flag)) throw new Error(`Unexpected or repeated argument: ${args[i]}`);
    seen.add(flag);
    if (!args[i].includes('=')) i++;
  }
  const required = ['--payload', '--selection', ...(command === 'verify-cv' ? ['--prepared'] : [])];
  for (const flag of required) if (!seen.has(flag)) throw new Error(`${flag} is required`);
  const fileFor = flag => resolve(root, flagValue(args, flag));
  const readJson = flag => JSON.parse(readFileSync(fileFor(flag), 'utf8'));
  const profile = loadValidatedProfile({ dataRoot: root, ...(seen.has('--profile') ? { file: fileFor('--profile') } : {}) });
  const inputs = { profile, basePayload: readJson('--payload'), selection: readJson('--selection') };
  if (command === 'prepare-cv') process.stdout.write(`${JSON.stringify(prepareCvSelection(inputs), null, 2)}\n`);
  else {
    verifyCvSelection(inputs, readJson('--prepared'));
    process.stdout.write('Valid Profile CV selection; existing CV checks are still required before rendering.\n');
  }
}

const [command, ...args] = process.argv.slice(2);
try {
  if (!command || command === '--help' || command === '-h') process.stdout.write(HELP);
  else if (command === 'import') await doImport(args);
  else if (command === 'validate') doValidate(args);
  else if (command === 'prepare-cv' || command === 'verify-cv') doCvSelection(command, args);
  else throw new Error(`Unknown command: ${command}\n\n${HELP}`);
} catch (error) {
  const context = ['profile_entry_id', 'fact_id', 'experience_index']
    .filter(key => error[key] !== undefined)
    .map(key => `${key}=${JSON.stringify(error[key])}`);
  process.stderr.write(`${error.code ? `[${error.code}] ` : ''}${error.message}${context.length ? ` (${context.join(', ')})` : ''}\n`);
  process.exitCode = 1;
}