#!/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 { fileURLToPath } from 'node:url'; import { matchCandidates, classifyReply } from './reply-matcher.mjs'; import { resolveColumns, parseTrackerRow } from './tracker-parse.mjs'; import { rebuildRow } from './tracker-utils.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const DEFAULT_CANDIDATES_PATH = path.join(__dirname, 'data', 'reply-candidates.json'); const APPS_FILE = path.join(__dirname, 'data', 'applications.md'); const FOLLOWUPS_FILE = path.join(__dirname, 'data', 'follow-ups.md'); // 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.question(query, (ans) => { rl.close(); resolve(ans); })); } // 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 []; } const content = fs.readFileSync(FOLLOWUPS_FILE, 'utf-8'); const lines = content.split('\n'); const followups = []; for (const line of lines) { if (!line.startsWith('|')) continue; const parts = line.split('|').map(s => s.trim()); if (parts.length < 8) continue; const num = parseInt(parts[1], 10); const appNum = parseInt(parts[2], 10); if (isNaN(num) || isNaN(appNum)) continue; followups.push({ num, appNum, date: parts[3], company: parts[4], role: parts[5], channel: parts[6], contact: parts[7], notes: parts[8] || '' }); } return followups; } // Update the status of a specific row in the tracker markdown file function updateTrackerStatus(appNum, newStatus) { const content = fs.readFileSync(APPS_FILE, 'utf-8'); const lines = content.split('\n'); const colmap = resolveColumns(lines); let updated = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const row = parseTrackerRow(line, colmap); if (row && row.num === appNum) { const parts = line.split('|').map(s => s.trim()); parts[colmap.status] = newStatus; lines[i] = rebuildRow(parts); updated = true; break; } } if (updated) { fs.writeFileSync(APPS_FILE, lines.join('\n'), 'utf-8'); } return updated; } async function main() { const candidatesPath = process.argv[2] || DEFAULT_CANDIDATES_PATH; ensureCandidatesFile(candidatesPath); if (!fs.existsSync(candidatesPath)) { console.error(`Error: candidates file not found at ${candidatesPath}`); process.exit(1); } let candidates; try { candidates = 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 = ''; if (match.application_num !== null) { const app = apps.find(a => a.num === match.application_num); headerStr = `${app.company} — ${app.role}`; } 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 (match.application_num !== null && classification.suggestedTrackerUpdate !== 'none' && classification.suggestedTrackerUpdate !== 'Needs Review') { const app = apps.find(a => a.num === match.application_num); if (app && app.status !== classification.suggestedTrackerUpdate) { recommendations.push({ num: app.num, company: app.company, role: app.role, oldStatus: app.status, newStatus: classification.suggestedTrackerUpdate }); } } }); if (recommendations.length > 0) { console.log('Suggested status updates to apply:'); recommendations.forEach(r => { console.log(` #${r.num} ${r.company} (${r.role}): ${r.oldStatus} → ${r.newStatus}`); }); console.log(''); const answer = await askQuestion('Apply recommended status updates to data/applications.md? (y/N): '); if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') { for (const r of recommendations) { updateTrackerStatus(r.num, r.newStatus); console.log(`Updated #${r.num} to ${r.newStatus}`); } console.log('\n✅ All updates written to data/applications.md'); // Sync tracker DB if tracker.mjs exists try { const { execSync } = await import('child_process'); execSync('node tracker.mjs sync', { stdio: 'ignore' }); console.log('Synced database index (applications.db).'); } catch (e) { // ignore } } else { console.log('Updates skipped.'); } } } main().catch(err => { console.error('Fatal:', err); process.exit(1); });