Ctrl K
ComunidadGuías…

contact-extract.mjs

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

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

/**
 * contact-extract.mjs — discover a recruiter/interviewer contact from a pasted
 * reply, and save it to data/contacts.tsv for a later re-engagement follow-up
 * (#4361).
 *
 * Problem this closes: once a role goes to Rejected, whatever recruiter or
 * interviewer identity passed through an interview invite or rejection email
 * is effectively gone. There is no path from "I had a good interview with
 * this person months ago" to "here is their email, drafted into a follow-up"
 * when a new, different opening appears at the same company later.
 *
 * This is the CORE (local-only, no Gmail dependency) layer of #4361's
 * two-layer design. It reuses the exact input shape paste-reply.mjs already
 * established (#1802) — paste an email's Subject/From/body, or read it from a
 * file — so anyone who doesn't want to grant any tool mailbox access can
 * still use it. The opt-in Gmail-plugin layer (#1583) is future work and can
 * feed this same pipeline once it exists, because both produce the identical
 * {subject, from, body} shape this script already consumes.
 *
 * What it does, in order:
 *   1. Parse the pasted email into {subject, from, body} (via
 *      paste-reply.mjs's parseFileInput/collectInteractive — no duplicated
 *      parsing logic).
 *   2. Parse the From header into {name, email} (parseFromHeader).
 *   3. Match the email to a tracker row using reply-matcher.mjs's own
 *      matchCandidates() — the same matcher reply-watch.mjs already trusts —
 *      so this never re-implements company/role matching.
 *   4. Classify the reply (classifyReply) and infer a contact `type`
 *      (recruiter / hiring-manager / interviewer) from the email text.
 *   5. Confirm with the user (or --yes for non-interactive use) before
 *      creating or updating one row in data/contacts.tsv.
 *
 * This script NEVER writes to data/applications.md, NEVER sends anything, and
 * NEVER classifies or updates tracker status — same boundary as
 * paste-reply.mjs and reply-watch.mjs. A tracker row is required to attach the
 * contact to (via auto-match or an explicit --company/--tracker override);
 * without one, nothing is written and the script exits 0 with an explanation.
 *
 * Usage:
 *   node contact-extract.mjs --file email.txt
 *   node contact-extract.mjs --file email.txt --yes
 *   node contact-extract.mjs --file email.txt --company "Acme Inc" --tracker 42
 *   node contact-extract.mjs --file email.txt --type interviewer
 *   node contact-extract.mjs                      # interactive, same prompts as paste-reply.mjs
 *   node contact-extract.mjs --help
 *
 * --file format is identical to paste-reply.mjs's:
 *   Subject: <subject line>
 *   From: <sender name and/or email>
 *
 *   <body text, any number of lines>
 *
 * The contacts path is always data/contacts.tsv under the resolved career-ops
 * data root, matching contacts.mjs.
 */

import fs from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
import { resolveTrackerPath, writeFileAtomic } from './tracker-utils.mjs';
import { withPipelineLock } from './pipeline-lock.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { localToday } from './lib/local-today.mjs';
import { validateFlags, hasFlag, flagValue } from './lib/cli-flags.mjs';
import { getCareerOpsRoot } from './path-resolver.mjs';
import { parseFileInput, collectInteractive } from './paste-reply.mjs';
import { matchCandidates, classifyReply } from './reply-matcher.mjs';
import { resolveColumns, parseTrackerRow } from './tracker-parse.mjs';
import { parseFollowups } from './followup-cadence.mjs';
import { escapeFormulaCell, unescapeFormulaCell } from './lib/tsv-formula-escape.mjs';

const DATA_ROOT = getCareerOpsRoot();
const CONTACTS_PATH = path.join(DATA_ROOT, 'data', 'contacts.tsv');
const APPS_FILE = resolveTrackerPath(DATA_ROOT);
const FOLLOWUPS_FILE = path.join(DATA_ROOT, 'data', 'follow-ups.md');

// Kept in sync by hand with contacts.mjs's own VALID_TYPES — both are small,
// stable enums describing the same TSV column, and contacts.mjs does not
// export its copy. internal-referral (#4691) added to both at the same time.
export const VALID_TYPES = new Set(['recruiter', 'hiring-manager', 'peer', 'interviewer', 'internal-referral', 'other']);

const KNOWN_FLAGS = ['--file', '--yes', '--company', '--tracker', '--type', '--help', '-h'];
const USAGE = `Usage:
  node contact-extract.mjs --file <path>                     read subject/from/body from a file
  node contact-extract.mjs                                   interactive: prompts for subject, from, body
  node contact-extract.mjs --file <path> --yes                skip the confirm-before-write prompt
  node contact-extract.mjs --file <path> --company "Acme Inc" --tracker 42
                                                               attach to a specific tracker row when
                                                               auto-matching finds none or is ambiguous
  node contact-extract.mjs --file <path> --type interviewer   override the inferred contact type
  node contact-extract.mjs --help`;

/**
 * Parse an email "From:" header into {name, email}. Handles the three shapes
 * real mail clients produce: `"Name" <email>`, `Name <email>`, and a bare
 * address with no display name. A value that is neither is treated as a bare
 * name with no email (still useful — a contact can be saved without one, just
 * not looked up for a follow-up draft later).
 *
 * Exported for direct unit testing.
 */
export function parseFromHeader(from) {
  if (!from || !from.trim()) return { name: '', email: null };
  const angleMatch = from.match(/^\s*"?([^"<]*?)"?\s*<([^<>]+)>\s*$/);
  if (angleMatch) {
    const email = angleMatch[2].trim();
    return {
      name: angleMatch[1].trim(),
      email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) ? email.toLowerCase() : null,
    };
  }
  const trimmed = from.trim();
  if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
    return { name: '', email: trimmed.toLowerCase() };
  }
  return { name: trimmed, email: null };
}

/**
 * Infer a contacts.tsv `type` value from the reply text. Defaults to
 * 'recruiter' — the common case for an interview-invite or rejection sender —
 * and only overrides that when the body itself names a more specific role.
 * Checked in this order because a rejection sent BY a hiring manager who
 * calls themselves that in the signature is a stronger, more specific signal
 * than a generic mention of "the interview panel" elsewhere in the same email.
 *
 * Exported for direct unit testing.
 */
export function inferContactType(text) {
  const t = (text || '').toLowerCase();
  if (/\bhiring manager\b/.test(t)) return 'hiring-manager';
  if (/\b(interview panel|interview team|panelist|panellist|interviewer)\b/.test(t)) return 'interviewer';
  return 'recruiter';
}

/** One TSV cell must never contain a tab or newline — either would shift or
 * split the row. Exported for direct unit testing. */
export function sanitizeCell(value) {
  const cell = String(value ?? '').replace(/[\t\r\n]+/g, ' ').trim();
  return escapeFormulaCell(cell);
}

/**
 * Create or update one contact row in data/contacts.tsv. Existing contacts are
 * matched by case-insensitive name + company, the same identity used by the
 * contacto workflow. Empty incoming optional fields preserve saved details.
 *
 * Exported for direct unit testing. Returns the file's total data-row count
 * after the write.
 */
export async function appendContact(contact, contactsPath = CONTACTS_PATH) {
  if (!sanitizeCell(contact.name)) throw new Error('Contact name is required');
  return withPipelineLock(contactsPath, () => {
    const header = '# name\tcompany\ttype\ttitle\tphone\temail\tlinkedin\ttracker\tnotes';
    let lines = [header];
    if (fs.existsSync(contactsPath)) {
      lines = fs.readFileSync(contactsPath, 'utf-8').split(/\r?\n/);
      if (lines.at(-1) === '') lines.pop();
      if (!lines.length) lines = [header];
    } else {
      fs.mkdirSync(path.dirname(contactsPath), { recursive: true });
    }

    const identityPart = (value, stored = false) => unescapeFormulaCell(stored ? value : sanitizeCell(value))
      .normalize('NFC').replace(/\s+/g, ' ').toLowerCase();
    const key = (name, company) => `${identityPart(name)}\0${identityPart(company)}`;
    const storedKey = (name, company) => `${identityPart(name, true)}\0${identityPart(company, true)}`;
    const incomingKey = key(contact.name, contact.company);
    const existingIndex = lines.findLastIndex((line) => {
      const trimmed = line.trim();
      if (!trimmed || trimmed.startsWith('#')) return false;
      const cells = line.split('\t');
      return storedKey(cells[0], cells[1]) === incomingKey;
    });
    const previous = existingIndex >= 0
      ? lines[existingIndex].split('\t').map(unescapeFormulaCell)
      : [];
    const values = [
      contact.name, contact.company, contact.type || previous[2] || '',
      contact.title || previous[3] || '', contact.phone || previous[4] || '',
      contact.email || previous[5] || '', contact.linkedin || previous[6] || '',
      contact.tracker || previous[7] || '-', contact.notes || previous.slice(8).join(' ') || '',
    ].map(sanitizeCell);
    const row = values.join('\t');
    if (existingIndex >= 0) lines[existingIndex] = row;
    else lines.push(row);

    writeFileAtomic(contactsPath, `${lines.join('\n')}\n`);
    return lines.filter((line) => line.trim() && !line.trim().startsWith('#')).length;
  });
}

function loadTrackerApps(appsFile = APPS_FILE) {
  if (!fs.existsSync(appsFile)) return [];
  const content = fs.readFileSync(appsFile, 'utf-8');
  const lines = content.split('\n');
  const colmap = resolveColumns(lines);
  const apps = [];
  for (const line of lines) {
    const row = parseTrackerRow(line, colmap);
    if (row) apps.push(row);
  }
  return apps;
}

function loadFollowups(followupsFile = FOLLOWUPS_FILE) {
  if (!fs.existsSync(followupsFile)) return [];
  return parseFollowups(fs.readFileSync(followupsFile, 'utf-8'));
}

function askYesNo(query) {
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
  return new Promise((resolve) => {
    let answered = false;
    // EOF on stdin (piped input that ends without a line) fires 'close'
    // without ever invoking the question callback below — decline rather
    // than hang forever waiting for an answer that will never arrive.
    rl.on('close', () => { if (!answered) resolve(false); });
    rl.question(query, (ans) => {
      answered = true;
      rl.close();
      resolve(/^y(es)?$/i.test(ans.trim()));
    });
  });
}

function printHelp() {
  console.log(`contact-extract.mjs — discover a recruiter/interviewer contact from a pasted reply and save it to data/contacts.tsv (#4361)

${USAGE}

Never touches data/applications.md, never sends anything, never classifies or
updates tracker status. Requires a tracker row to attach the contact to (via
auto-match against data/applications.md, or an explicit --company/--tracker
override); without one, nothing is written.`);
}

async function main() {
  const args = process.argv.slice(2);
  validateFlags(args, KNOWN_FLAGS, USAGE, {
    valueFlags: ['--file', '--company', '--tracker', '--type'],
    requireOperand: true,
  });

  if (args.includes('--help') || args.includes('-h')) {
    printHelp();
    return;
  }

  const filePath = flagValue(args, '--file');
  let input;
  if (filePath !== undefined) {
    if (!fs.existsSync(filePath)) {
      console.error(`Error: file not found: ${filePath}`);
      process.exitCode = 1;
      return;
    }
    input = parseFileInput(fs.readFileSync(filePath, 'utf-8'));
  } else {
    input = await collectInteractive();
  }

  if (!input.subject && !input.body) {
    console.error('Error: no subject or body text found — nothing to extract.');
    process.exitCode = 1;
    return;
  }

  const { name, email } = parseFromHeader(input.from);
  const candidate = {
    message_id: `extract-${Date.now()}`,
    from: input.from || '',
    subject: input.subject || '',
    body_snippet: input.body || '',
    signal: null,
  };

  const companyOverride = flagValue(args, '--company');
  const trackerOverride = flagValue(args, '--tracker');
  const apps = loadTrackerApps();

  let company;
  let trackerNum;
  let candidateApps = apps;

  // Company and tracker# must always describe the SAME row. When --tracker
  // is given, its row is the sole source of truth for company — resolved
  // here, atomically, rather than letting `company` drift in from a separate
  // matchCandidates() hit further down (which searches ALL rows off the
  // email body and has no idea a --tracker override already pinned one).
  if (trackerOverride !== undefined) {
    const trackerNumber = Number(trackerOverride.trim());
    const trackerRow = /^\d+$/.test(trackerOverride.trim()) && Number.isSafeInteger(trackerNumber)
      ? apps.find((app) => app.num === trackerNumber)
      : undefined;
    if (!trackerRow) {
      console.error(`Error: --tracker must name an existing tracker row; got "${trackerOverride}"`);
      process.exitCode = 1;
      return;
    }
    if (companyOverride && companyOverride.trim().toLowerCase() !== trackerRow.company.trim().toLowerCase()) {
      console.error(`Error: --company "${companyOverride}" does not match tracker #${trackerNumber}'s company "${trackerRow.company}"`);
      process.exitCode = 1;
      return;
    }
    trackerNum = String(trackerNumber);
    company = trackerRow.company;
  } else if (companyOverride) {
    const normalizedCompany = companyOverride.trim().toLowerCase();
    const companyApps = apps.filter((app) => app.company.trim().toLowerCase() === normalizedCompany);
    if (!companyApps.length) {
      console.error(`Error: --company must name a company in the tracker; got "${companyOverride}"`);
      process.exitCode = 1;
      return;
    }
    candidateApps = companyApps;
    const [match] = matchCandidates([candidate], companyApps, loadFollowups());
    const matchedRow = match?.application_num != null
      ? companyApps.find((app) => app.num === match.application_num)
      : companyApps.length === 1 ? companyApps[0] : undefined;
    if (matchedRow) {
      company = matchedRow.company;
      trackerNum = String(matchedRow.num);
    }
  }

  if (!company || !trackerNum) {
    const followups = loadFollowups();
    const [match] = matchCandidates([candidate], candidateApps, followups);
    if (match && match.application_num != null) {
      company = company || match.company_hint;
      trackerNum = trackerNum || String(match.application_num);
    }
  }

  if (!company || !trackerNum) {
    console.log('Could not match this reply to a single tracker row, and no --company/--tracker override was given.');
    console.log('Nothing was written to data/contacts.tsv. Re-run with --company "<name>" --tracker <num> to attach it manually.');
    return;
  }

  const typeOverride = flagValue(args, '--type');
  if (typeOverride && !VALID_TYPES.has(typeOverride)) {
    console.error(`Error: --type must be one of ${[...VALID_TYPES].join(', ')}; got "${typeOverride}"`);
    process.exitCode = 1;
    return;
  }
  const contactType = typeOverride || inferContactType(`${candidate.subject} ${candidate.body_snippet}`);

  const replyType = classifyReply(candidate).type;
  // The local day: this lands in the user's contacts.tsv, and the UTC day is
  // tomorrow all evening west of Greenwich (#3070).
  const today = localToday();
  const contact = {
    name,
    company,
    type: contactType,
    email,
    tracker: trackerNum,
    notes: `extracted from pasted reply, ${today}; reply type: ${replyType}`,
  };

  console.log('\nDiscovered contact:');
  console.log(`  name:     ${contact.name || '(none)'}`);
  console.log(`  email:    ${contact.email || '(none)'}`);
  console.log(`  company:  ${contact.company}`);
  console.log(`  type:     ${contact.type}`);
  console.log(`  tracker#: ${contact.tracker}`);

  if (!contact.name) {
    console.log('\nNo contact name could be parsed from the From header — add a display name before saving. Nothing was written.');
    return;
  }

  const skipConfirm = hasFlag(args, '--yes');
  if (!skipConfirm) {
    const proceed = await askYesNo('\nSave this contact to data/contacts.tsv? [y/N] ');
    if (!proceed) {
      console.log('Not saved.');
      return;
    }
  }

  const total = await appendContact(contact);
  console.log(`\nSaved. data/contacts.tsv now has ${total} contact row(s).`);
}

if (isMainModule(import.meta.url)) {
  main().catch((err) => {
    console.error('Fatal:', err);
    process.exit(1);
  });
}