Ctrl K
ComunidadGuías…

lib/profile-cv-selection.mjs

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

Archivo original ↗Preguntar a KiBot
/** Explicit, verbatim Profile facts -> HTML CV payload. No source or PDF writes. */
import { createHash } from 'node:crypto';
import { validateProfile } from './career-profile.mjs';
import { validatePayload } from './cv-payload-schema.mjs';

export class SelectionError extends Error {
  constructor(code, message, context = {}) {
    super(message);
    this.name = 'SelectionError';
    this.code = code;
    Object.assign(this, context);
  }
}

function requireThat(condition, code, message, context) {
  if (!condition) throw new SelectionError(code, message, context);
}

// Object key order/JSON formatting is immaterial; array order is significant.
// Reject values JSON would silently drop or change, including cyclic objects.
export function selectionDigest(value) {
  const ancestors = new Set();
  function canonical(item) {
    if (item === null || typeof item === 'string' || typeof item === 'boolean') return JSON.stringify(item);
    if (typeof item === 'number' && Number.isFinite(item)) return JSON.stringify(item);
    requireThat(item && typeof item === 'object' && !ancestors.has(item)
      && (Array.isArray(item) || Object.getPrototypeOf(item) === Object.prototype),
    'non-json-input', 'Selection inputs must contain only JSON values.');
    ancestors.add(item);
    const result = Array.isArray(item)
      ? `[${Array.from(item, canonical).join(',')}]`
      : `{${Object.keys(item).sort().map(key => `${JSON.stringify(key)}:${canonical(item[key])}`).join(',')}}`;
    ancestors.delete(item);
    return result;
  }
  return createHash('sha256').update(canonical(value)).digest('hex');
}

const evidenceIdentity = evidence => ({ source: evidence.source, quote: evidence.quote });

/**
 * Revalidates the whole Profile and the existing HTML payload contract.
 * Selection v1 binds stable Profile IDs to exact base-payload experience slots.
 * Hashes detect stale/changed inputs; they certify neither truth nor consent.
 */
export function prepareCvSelection({ profile, basePayload, selection }) {
  const profileErrors = validateProfile(profile);
  requireThat(!profileErrors.length, 'invalid-profile', `Invalid profile:\n- ${profileErrors.join('\n- ')}`);
  const { errors } = validatePayload(basePayload, 'html');
  requireThat(!errors.length, 'invalid-payload', `Invalid HTML payload:\n- ${errors.join('\n- ')}`);
  const baseDigest = selectionDigest(basePayload);
  requireThat(selection?.schema === 'profile-selection.v1', 'selection-schema', 'Expected profile-selection.v1.');
  // Validate the entire manifest as JSON before examining any of its fields.
  const manifestDigest = selectionDigest(selection);
  requireThat(selection.base_payload_sha256 === baseDigest, 'base-payload-changed', 'Base payload changed; select facts again.');
  requireThat(Array.isArray(selection.bindings) && selection.bindings.length > 0, 'empty-bindings', 'Select at least one experience binding.');
  requireThat(Array.isArray(basePayload.experience), 'invalid-target', 'Base payload must have an experience list.');
  const payload = structuredClone(basePayload);
  const targets = new Set();
  const selectedIds = new Set();
  const selectedProfile = [];
  const bullets = [];
  for (const binding of selection.bindings) {
    const context = { profile_entry_id: binding?.profile_entry_id, experience_index: binding?.target?.experience_index };
    const entry = profile.experiences.find(item => item.id === binding?.profile_entry_id);
    requireThat(entry, 'missing-entry', 'Selected Profile experience does not exist.', context);
    requireThat(entry.review_status !== 'needs_review', 'entry-not-verified', 'Selected Profile experience needs review.', context);
    const index = binding.target?.experience_index;
    requireThat(Number.isInteger(index) && index >= 0 && index < payload.experience.length, 'invalid-target', 'Experience index is outside the base payload.', context);
    requireThat(!targets.has(index), 'duplicate-target', 'An experience may be bound only once.', context);
    targets.add(index);
    requireThat(binding.target.entry_sha256 === selectionDigest(basePayload.experience[index]), 'target-anchor-changed', 'Bound experience changed; select facts again.', context);
    requireThat(Array.isArray(binding.fact_ids) && binding.fact_ids.length > 0, 'empty-fact-selection', 'Select at least one fact for each binding.', context);
    const facts = [];
    for (const id of binding.fact_ids) {
      const factContext = { ...context, fact_id: id };
      requireThat(!selectedIds.has(id), 'duplicate-selected-fact', 'A fact may be selected only once.', factContext);
      selectedIds.add(id);
      const fact = entry.facts.find(item => item.id === id);
      requireThat(fact, 'missing-or-wrongly-bound-fact', 'Selected fact does not belong to the bound Profile experience.', factContext);
      requireThat(fact.review_status === 'verified', 'fact-not-verified', 'Selected fact needs review.', factContext);
      facts.push({ id: fact.id, text: fact.text, review_status: fact.review_status, evidence: evidenceIdentity(fact.evidence) });
      bullets.push({ experience_index: index, bullet_index: facts.length - 1,
        profile_entry_id: entry.id, fact_id: fact.id,
        text_sha256: selectionDigest(fact.text), evidence: evidenceIdentity(fact.evidence) });
    }
    payload.experience[index].bullets = facts.map(fact => fact.text);
    selectedProfile.push({ id: entry.id, label: entry.label, review_status: entry.review_status ?? 'verified',
      evidence: evidenceIdentity(entry.evidence), facts });
  }
  return {
    payload,
    mapping: {
      schema: 'profile-mapping.v1', base_payload_sha256: baseDigest,
      selection_sha256: manifestDigest, selected_profile_sha256: selectionDigest(selectedProfile),
      payload_sha256: selectionDigest(payload), bullets,
    },
  };
}

/** Reconstruct from independent originals, rather than trusting a sidecar hash. */
export function verifyCvSelection(inputs, prepared) {
  const expected = prepareCvSelection(inputs);
  requireThat(prepared && typeof prepared === 'object', 'invalid-prepared', 'Expected a prepared payload and mapping.');
  requireThat(selectionDigest(prepared.payload) === selectionDigest(expected.payload), 'prepared-payload-mismatch', 'Prepared payload differs from the current selection.');
  requireThat(selectionDigest(prepared.mapping) === selectionDigest(expected.mapping), 'prepared-mapping-mismatch', 'Prepared mapping differs from the current selection.');
  return true;
}