/* The part the client actually cares about: personalization that is not made up. The rule is structural, not a polite instruction in a prompt. A message is generated from a Signal object. If no Signal survives validation there is nothing to generate from, so the prospect is flagged and no copy is written. A model cannot talk its way past a missing argument. */ export const MAX_AGE_DAYS = 120; /* Signal kinds we accept, roughly in the order a salesperson would rank them. Weight feeds the personalization score. */ export const KINDS = { funding: 5, acquisition: 5, launch: 4, partnership: 4, licence: 4, expansion: 3, exec_hire: 3, media: 2, conference: 2 }; export class Signal { constructor({ kind, summary, url, published, quote }) { this.kind = kind; this.summary = summary; this.url = url; this.published = published; this.quote = quote || null; } } /* Every one of these has to pass. Anything that fails is not a signal, it is a guess wearing a source. */ export function validate(sig, today = new Date()) { const fail = []; if (!KINDS[sig?.kind]) fail.push('unknown_kind'); if (!sig?.url || !/^https?:\/\//.test(sig.url)) fail.push('no_source_url'); if (!sig?.published || Number.isNaN(Date.parse(sig.published))) fail.push('no_publish_date'); else { const age = (today - new Date(sig.published)) / 864e5; if (age > MAX_AGE_DAYS) fail.push(`stale_${Math.round(age)}d`); if (age < -1) fail.push('future_dated'); } if (!sig?.summary || sig.summary.length < 25) fail.push('summary_too_thin'); /* the summary has to be about this company, not the sector */ return { ok: fail.length === 0, fail }; } /* ICP score is mechanical: it comes from fields Apollo already gave us, so it is reproducible and arguable. No model opinion in here. */ export function icpScore(p, icp) { let s = 0; if (icp.titles.some((t) => (p.title || '').toLowerCase().includes(t.toLowerCase()))) s += 2; else if (['founder', 'c_suite', 'owner', 'vp'].includes(p.seniority)) s += 1; const n = p.headcount || 0; if (n >= icp.min_headcount && n <= icp.max_headcount) s += 1; if ((p.city || '').toLowerCase() === icp.city.toLowerCase()) s += 1; if (p.linkedin_url) s += 1; return Math.max(1, Math.min(5, s)); } /* Personalization score is the strength of the evidence, not the prose. */ export function persScore(sig, today = new Date()) { if (!sig) return 1; const age = (today - new Date(sig.published)) / 864e5; let s = KINDS[sig.kind] || 1; if (age > 60) s -= 1; if (sig.quote) s += 1; return Math.max(1, Math.min(5, s)); } /* The gate. Returns either a writable prospect or a flagged one. */ export function decide(p, sig, icp, today = new Date()) { const v = validate(sig, today); const icp_score = icpScore(p, icp); if (!v.ok) { return { ...p, icp_score, personalization_score: 1, status: 'flagged', flag_reason: v.fail.join(', '), icebreaker: null, email_body: null, linkedin_message: null }; } return { ...p, icp_score, personalization_score: persScore(sig, today), status: 'ready', trigger_kind: sig.kind, trigger_summary: sig.summary, trigger_source: sig.url, trigger_date: sig.published }; } /* Length rules from the brief, checked after generation rather than hoped for in a prompt. Anything out of range goes back once, then gets flagged. */ export const LIMITS = { email: [60, 100], linkedin: [30, 60] }; export const words = (t) => (t || '').trim().split(/\s+/).filter(Boolean).length; export function inRange(kind, text) { const [lo, hi] = LIMITS[kind]; const n = words(text); return { n, ok: n >= lo && n <= hi }; }