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