Ctrl K
ComunidadGuías…

reply-watch.mjs

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

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

/**
 * reply-watch.mjs — Classify employer replies and generate a review digest (RFC #1585).
 *
 * Reads candidate replies from a JSON file, matches them against the application tracker,
 * classifies the reply types (e.g. Interview, Rejected, Noise), and prints a concise
 * review digest. Prompts the user to approve recommended tracker status updates.
 *
 * Usage:
 *   node reply-watch.mjs [path/to/candidates.json]
 */

import fs from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { matchCandidates, classifyReply } from './reply-matcher.mjs';
import { resolveColumns, parseTrackerRow, trackerRowIdentity } from './tracker-parse.mjs';
import { resolveTrackerPath, loadCanonicalStates } from './tracker-utils.mjs';
import { readReplyProposals, hasProposalReceipt } from './lib/reply-proposals.mjs';
import { getCareerOpsRoot } from './path-resolver.mjs';
import { validateFlags } from './lib/cli-flags.mjs';
import { isMainModule } from './lib/is-main-module.mjs';
import { parseFollowups } from './followup-cadence.mjs';
import { performance } from 'node:perf_hooks';

// Every file here is user layer, so it resolves against the data root
// (CAREER_OPS_ROOT / CAREER_OPS_DATA_DIR / .career-ops-data marker), never the
// script's own directory — which is only the default when none is configured.
const DATA_ROOT = getCareerOpsRoot();
export const DEFAULT_CANDIDATES_PATH = process.env.CAREER_OPS_REPLY_CANDIDATES
  || path.join(DATA_ROOT, 'data', 'reply-candidates.json');
export const APPS_FILE = resolveTrackerPath(DATA_ROOT);
export const FOLLOWUPS_FILE = path.join(DATA_ROOT, 'data', 'follow-ups.md');
const CODE_ROOT = path.dirname(fileURLToPath(import.meta.url));
// CAREER_OPS_DEBUG=1 turns on the timing output (it always reports the sync
// time and forwards tracker.mjs's parse/ledger/write timing), and a sync slower
// than 5s is reported on stderr even without it.
const DEBUG = process.env.CAREER_OPS_DEBUG === '1';

// Helper to ask a question in the CLI
function askQuestion(query) {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout
  });
  return new Promise((resolve) => {
    rl.once('close', () => resolve(''));
    rl.question(query, (ans) => {
      resolve(ans);
      rl.close();
    });
  });
}

// Generate custom signal description based on keywords
function getSignalDesc(text, signal) {
  const parts = [];
  if (text.includes('简历通过')) {
    parts.push('resume passed');
  }
  if (text.includes('微信小程序') || text.includes('WeChat mini-program') || text.includes('AI微信小程序')) {
    parts.push('AI WeChat mini-program interview');
  }
  if (parts.length > 0) {
    return parts.join(' + ');
  }
  return signal || 'none';
}

// Create a default set of mock candidates if the file doesn't exist
function ensureCandidatesFile(filePath) {
  if (fs.existsSync(filePath)) return;

  const mockCandidates = [
    {
      message_id: 'msg1',
      from: 'recruiter@wingyun.com',
      subject: '恭喜简历通过,杭州赢云贸易有限公司邀您面试',
      body_snippet: '您的首轮面试是AI微信小程序面试。面试形式:AI微信小程序面试,面试时长:约15~30分钟',
      signal: 'interview_invite'
    },
    {
      message_id: 'msg2',
      from: 'hr@examplelabs.com',
      subject: 'Update on your application for Full-stack Engineer',
      body_snippet: '很遗憾地通知您,您的简历与我们当前岗位的需求暂不匹配,不合适我司的要求,未能进入下一轮。',
      signal: 'rejection'
    },
    {
      message_id: 'msg3',
      from: 'alerts@zhaopin.com',
      subject: 'Zhaopin job alert',
      body_snippet: '我们为您推荐了以下职位:邀请投递测试工程师岗位,现在沟通,抢面试先机!近期热招职位,立即投递!',
      signal: null
    },
    {
      message_id: 'msg4',
      from: 'hr@somecompany.com',
      subject: '补充信息',
      body_snippet: '邀请您在面试/入职之前更新或补充最新的应聘信息。',
      signal: null
    }
  ];

  fs.mkdirSync(path.dirname(filePath), { recursive: true });
  fs.writeFileSync(filePath, JSON.stringify(mockCandidates, null, 2), 'utf-8');
  console.log(`Created default mock candidates file at ${filePath}`);
}

// Load applications tracker rows
function loadTrackerApps() {
  if (!fs.existsSync(APPS_FILE)) {
    return [];
  }
  const content = fs.readFileSync(APPS_FILE, '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;
}

// Load followups history
function loadFollowups() {
  if (!fs.existsSync(FOLLOWUPS_FILE)) {
    return [];
  }
  return parseFollowups(fs.readFileSync(FOLLOWUPS_FILE, 'utf-8'));
}

// Apply an approved batch in one locked read/modify/write transaction. Reading
// after lock acquisition matters because the review prompt can remain open
// while another process merges or updates tracker rows.
function groupStatusRecommendations(recommendations) {
  const byApplication = new Map();
  for (const recommendation of recommendations) {
    if (!byApplication.has(recommendation.num)) byApplication.set(recommendation.num, new Map());
    const transitions = byApplication.get(recommendation.num);
    const key = `${recommendation.oldStatus}\0${recommendation.newStatus}`;
    const existing = transitions.get(key);
    if (existing) {
      existing.count++;
      existing.proposals.push(...(recommendation.proposals ?? []));
    } else {
      transitions.set(key, { ...recommendation, proposals: [...(recommendation.proposals ?? [])], count: 1 });
    }
  }

  const updates = [];
  const conflicts = [];
  for (const [num, transitions] of byApplication) {
    const choices = [...transitions.values()];
    if (choices.length === 1) updates.push(choices[0]);
    else conflicts.push({ num, choices });
  }
  return { updates, conflicts };
}

// Every accepted update is written by set-status.mjs, the canonical writer, so
// it runs the same status hooks as any other status change: the status-log
// row, the JD archive on a move into Interview, follow-up seeding on Applied
// (#5536). A cell edit here skipped all but the first, and the next hook added
// to set-status would have been skipped too.
//
// Returns the parsed JSON result, or the writer's structured error (`code`,
// plus `exitCode`, and `currentStatus` for a status-changed refusal).
function runSetStatus(args) {
  try {
    const output = execFileSync(process.execPath, [path.join(CODE_ROOT, 'set-status.mjs'), ...args, '--json'], {
      encoding: 'utf8',
      env: { ...process.env, CAREER_OPS_ROOT: DATA_ROOT, CAREER_OPS_TRACKER: APPS_FILE },
      // stdout is the JSON result. stderr is passed through: it carries what
      // the hooks have to tell the user directly, such as another session
      // already working this row, or a failed JD archive.
      stdio: ['ignore', 'pipe', 'inherit'],
    });
    return { result: JSON.parse(output) };
  } catch (err) {
    let failure = {};
    try { failure = JSON.parse(err.stdout); } catch { /* no structured result */ }
    return { error: { ...failure, exitCode: err.status ?? null } };
  }
}

// What the hooks did, from set-status's JSON. Under --json it prints only
// failures (to the stderr passed through above), so successes are reported
// here; the ledger warning is restated per row in reply-watch's own words.
function reportHooks(result, ledgerWarning) {
  if (result.statusLogged === false) console.warn(ledgerWarning);
  if (result.followupSeeded?.seeded) {
    console.log(`   Follow-up seeded for #${result.num}: next ${result.followupSeeded.nextDate}`);
  }
  const archive = result.jdArchiveTriggered;
  if (archive?.attempted && !archive.error) {
    console.log(`   Archived the JD for #${result.num} before the posting can close`);
  }
}

// A busy or unusable lock is not about one row: stop applying, as the
// in-process writer did when it could not open the tracker.
function isLockFailure(error) {
  return error.code === 'lock-timeout' || error.code === 'lock-error';
}

// Re-read after the prompt, then compare the entire tracker under the canonical
// writer's lock. This protects both the reviewed row and receipt absence across
// ALL rows, including two concurrent reviews of one message targeting different
// applications. Status + receipt share the writer's single atomic replacement.
function applyProposalUpdate(update) {
  const content = fs.readFileSync(APPS_FILE, 'utf8');
  const lines = content.split('\n');
  const columns = resolveColumns(lines);
  const apps = lines.map(line => parseTrackerRow(line, columns)).filter(Boolean);
  const matches = apps.filter(app => app.num === update.num);
  if (matches.length !== 1 || JSON.stringify(matches[0]) !== JSON.stringify(update.snapshot)) {
    console.warn(`Skipped #${update.num}: tracker row changed during review; review again`);
    return true;
  }
  if (update.proposals.some(p => hasProposalReceipt(apps, p.receipt))) {
    console.warn(`Skipped #${update.num}: proposal was already accepted during review`);
    return true;
  }
  const digest = createHash('sha256').update(content).digest('hex');
  const { result, error } = runSetStatus(['--row', String(update.num), update.newStatus, '--source', 'reply-watch',
    '--expect-tracker', digest, '--note', update.proposals.map(p => p.receipt).join('; ')]);
  if (result) {
    console.log(`Updated #${result.num} to ${result.newStatus}`);
    reportHooks(result, `Warning: status-log append failed for #${result.num}; status and receipt were saved`);
    return true;
  }
  console.warn(`Skipped #${update.num}: ${error.code ?? 'canonical writer failed; review again before retrying'}`);
  return !isLockFailure(error);
}

// The candidates path hands the writer the snapshot that was on screen, and the
// writer checks it under its lock: the row must still be the application shown
// (--expect-row, every cell but Status) and still hold the status shown
// (--expect-status). Consent belongs to the displayed application, not a
// reusable row number, so a replaced or duplicated row is refused (#5034). A
// write to some other row meanwhile is not a reason to refuse, which is why
// this does not use --expect-tracker the way the proposal path must.
//
// Returns false when the tracker lock could not be taken.
function applyReviewedUpdate(update) {
  const count = update.count > 1 ? ` (${update.count} replies)` : '';
  const { result, error } = runSetStatus(['--row', String(update.num), update.newStatus, '--source', 'reply-watch',
    '--expect-row', trackerRowIdentity(update.snapshot), '--expect-status', update.oldStatus]);
  if (result) {
    if (!result.changed) {
      console.log(`No change for #${update.num}: already ${update.newStatus}${count}`);
    } else {
      console.log(`Updated #${update.num} to ${update.newStatus}${count}`);
      reportHooks(result, `Warning: failed to append to status-log.tsv for #${update.num}; the status was saved`);
    }
    return true;
  }
  if (error.code === 'status-changed') {
    console.warn(`Skipped #${update.num}: status changed from ${update.oldStatus} to ${error.currentStatus} during review`);
  } else if (error.code === 'row-changed' || error.code === 'ambiguous') {
    console.warn(`Skipped #${update.num}: tracker row changed or became ambiguous during review; review again`);
  } else if (error.code === 'not-found') {
    console.warn(`Skipped #${update.num}: row no longer exists in the tracker`);
  } else {
    console.warn(`Skipped #${update.num}: ${error.error ?? error.code ?? 'canonical writer failed'}; review again before retrying`);
  }
  return !isLockFailure(error);
}

const KNOWN_FLAGS = ['--help', '-h'];
const USAGE = 'Usage: node reply-watch.mjs [path/to/candidates.json]';

async function main() {
  const args = process.argv.slice(2);

  const positional = args.filter(a => !a.startsWith('-'));
  validateFlags(args, KNOWN_FLAGS, USAGE);

  const candidatesPath = positional[0] || DEFAULT_CANDIDATES_PATH;
  // An integration-only review must not manufacture candidate emails. Preserve
  // the legacy demo behavior only when no proposal directory is present.
  const hasProposalDirectory = Boolean(fs.lstatSync(path.join(DATA_ROOT, 'data', 'reply-proposals'), { throwIfNoEntry: false }));
  if (!hasProposalDirectory) ensureCandidatesFile(candidatesPath);

  if (positional[0] && !fs.existsSync(candidatesPath)) {
    console.error(`Error: candidates file not found at ${candidatesPath}`);
    process.exit(1);
  }

  let candidates;
  try {
    candidates = fs.existsSync(candidatesPath) ? JSON.parse(fs.readFileSync(candidatesPath, 'utf-8')) : [];
  } catch (e) {
    console.error(`Error parsing candidates JSON: ${e.message}`);
    process.exit(1);
  }

  const apps = loadTrackerApps();
  const followups = loadFollowups();

  const matched = matchCandidates(candidates, apps, followups);

  console.log(`\nToday: ${candidates.length} application updates need review\n`);

  const recommendations = [];

  matched.forEach((match, index) => {
    const cand = candidates.find(c => c.message_id === match.message_id);
    const classification = classifyReply(cand);

    let headerStr = '';
    const matchedApplicationNums = Array.isArray(match.application_nums)
      ? match.application_nums
      : (match.application_num !== null ? [match.application_num] : []);

    if (matchedApplicationNums.length > 1) {
      headerStr = `${match.company_hint} — company-wide rejection (${matchedApplicationNums.length} applications)`;
    } else if (matchedApplicationNums.length === 1) {
      const applicationNum = matchedApplicationNums[0];
      const app = apps.find(a => a.num === applicationNum);
      headerStr = app ? `${app.company} — ${app.role}` : (cand.subject || match.company_hint || cand.from || 'Unknown');
    } else {
      headerStr = cand.subject || match.company_hint || cand.from || 'Unknown';
    }

    console.log(`${index + 1}. ${headerStr}`);
    console.log(`   Type: ${classification.type}`);

    // Print Signal for Interview classification when meaningful
    const signalDesc = getSignalDesc(cand.subject + ' ' + cand.body_snippet, cand.signal);
    if (classification.type === 'Interview' && signalDesc && signalDesc !== 'none') {
      console.log(`   Signal: ${signalDesc}`);
    }

    if (classification.evidence && classification.evidence.length > 0) {
      console.log(`   Evidence: ${classification.evidence.join('; ')}`);
    }

    console.log(`   Suggested tracker update: ${classification.suggestedTrackerUpdate}`);
    console.log('');

    if (matchedApplicationNums.length > 0 && classification.suggestedTrackerUpdate !== 'none' && classification.suggestedTrackerUpdate !== 'Needs Review') {
      for (const applicationNum of matchedApplicationNums) {
        const app = apps.find(a => a.num === applicationNum);
        if (app && app.status !== classification.suggestedTrackerUpdate) {
          recommendations.push({
            num: app.num,
            company: app.company,
            role: app.role,
            oldStatus: app.status,
            newStatus: classification.suggestedTrackerUpdate,
            snapshot: app,
          });
        }
      }
    }
  });

  const proposals = readReplyProposals(DATA_ROOT, APPS_FILE, apps,
    loadCanonicalStates(path.join(CODE_ROOT, 'templates', 'states.yml')));
  for (const warning of proposals.warnings) console.warn(warning);
  for (const recommendation of proposals.recommendations) {
    const proposal = recommendation.proposals[0];
    console.log(`Proposal for #${recommendation.num}: ${JSON.stringify(recommendation.company)} — ${JSON.stringify(recommendation.role)}`);
    console.log(`   Source (unverified): ${proposal.source.kind}/${proposal.source.account_id}/${proposal.source.message_id}`);
    console.log(`   Evidence (quoted, untrusted): ${JSON.stringify(proposal.evidence)}`);
    console.log(`   Suggested tracker update: ${recommendation.oldStatus} → ${recommendation.newStatus}\n`);
  }
  recommendations.push(...proposals.recommendations);

  const groupedRecommendations = groupStatusRecommendations(recommendations);
  if (groupedRecommendations.conflicts.length > 0) {
    console.warn('Conflicting status recommendations require manual review:');
    for (const conflict of groupedRecommendations.conflicts) {
      const summary = conflict.choices
        .map(choice => `${choice.newStatus} (${choice.count} ${choice.count === 1 ? 'reply' : 'replies'})`)
        .join(' vs ');
      console.warn(`  #${conflict.num}: ${summary} — no automatic update`);
    }
    console.log('');
  }

  if (groupedRecommendations.updates.length > 0) {
    const updates = groupedRecommendations.updates;
    console.log('Suggested status updates to apply:');
    updates.forEach(r => {
      const count = r.count > 1 ? ` (${r.count} replies)` : '';
      console.log(`  #${r.num} ${r.company} (${r.role}): ${r.oldStatus} → ${r.newStatus}${count}`);
    });
    console.log('');

    const answer = (await askQuestion(`Apply recommended status updates to ${APPS_FILE}? (y/N, or comma-separated row IDs): `)).trim().toLowerCase();
    const rowIds = /^\d+(?:\s*,\s*\d+)*$/.test(answer) ? answer.split(',').map(Number) : [];
    const acceptAll = answer === 'y' || answer === 'yes';
    const selected = acceptAll ? updates : rowIds.length > 0 && rowIds.every(num => updates.some(u => u.num === num))
      ? updates.filter(u => rowIds.includes(u.num)) : [];
    if (selected.length > 0) {
      // One row at a time, so each is checked against the tracker as it is
      // when its own write takes the lock.
      let lockAvailable = true;
      for (const update of selected) {
        lockAvailable = update.proposals.length === 0 ? applyReviewedUpdate(update) : applyProposalUpdate(update);
        if (!lockAvailable) break;
      }
      if (!lockAvailable) {
        console.error('\nCould not take the tracker lock; the remaining updates were not applied. Run reply-watch again.');
        process.exitCode = 1;
        return;
      }
      console.log('\n✅ Tracker review complete');

      // Sync tracker DB if tracker.mjs exists. Time the call so a stall shows
      // where the time goes; written to stderr so it cannot alter stdout output.
      const syncStartedAt = performance.now();
      try {
        const stdio = DEBUG ? ['ignore', 'ignore', 'inherit'] : 'ignore';
        execFileSync(process.execPath, [path.join(CODE_ROOT, 'tracker.mjs'), 'sync'], {
          stdio,
          env: { ...process.env, CAREER_OPS_ROOT: DATA_ROOT, CAREER_OPS_TRACKER: APPS_FILE },
        });
        console.log('Synced database index (applications.db).');
      } catch (e) {
        // ignore
      } finally {
        const syncDurationMs = Math.round(performance.now() - syncStartedAt);
        // Surface a slow sync even when debug is off, but keep stdout untouched
        // and the normal fast path silent. Debug builds forward tracker.mjs's
        // own stderr timing line through as well. Runs in finally so it also
        // fires when execFileSync throws.
        if (DEBUG || syncDurationMs > 5000) {
          console.error(`tracker.mjs sync took ${syncDurationMs}ms`);
        }
      }
    } else {
      console.log('Updates skipped.');
    }
  }
}

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