career-profile.mjs
Rama main · réplica de lectura del repositorio original.
#!/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;
}