kritmatta-lead-scorer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # kritmatta-lead-scorer
2
+
3
+ Single source of truth for lead qualification scoring. Consumed by both the Vega
4
+ app (`hire-chat-next-app`) and the enrichment worker (`enrichment-worker`) so the
5
+ scoring logic can never drift between them.
6
+
7
+ ## What's in it
8
+ - `scorer.js` - weight-profile scorer with per-ICP `signal_overrides` (the app UI
9
+ and the worker score identically).
10
+ - `decay.js` - intent-signal time decay.
11
+ - `reasoningRewriter.js` - optional LLM rephrasing of the deterministic reasoning.
12
+ The AI call is **dependency-injected** so this package has no app/worker-specific
13
+ requires.
14
+
15
+ ## Install
16
+ Public package on npmjs.com - no auth or `.npmrc` needed:
17
+
18
+ ```
19
+ npm install kritmatta-lead-scorer
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ App (deterministic, no AI):
25
+ ```js
26
+ const { scoreLeadFromEnrichment } = require('kritmatta-lead-scorer');
27
+ const { score } = scoreLeadFromEnrichment(lead, icpCriteria);
28
+ ```
29
+
30
+ Worker (with LLM-rewritten reasoning - inject callAI once at startup):
31
+ ```js
32
+ const scorer = require('kritmatta-lead-scorer');
33
+ const { callAI } = require('./utils/ai');
34
+ scorer.setCallAI(callAI); // enable reasoning rewriting
35
+ await scorer.scoreLeadsMultiIcp(leads, job, supabase);
36
+ ```
37
+
38
+ If `setCallAI` is never called, `scoreLeadsMultiIcp` still works and just returns
39
+ the deterministic reasoning (that's the app's mode).
40
+
41
+ ## Publishing (maintainers)
42
+ Public npm. Bump `version` in `package.json`, then:
43
+ ```
44
+ npm login # once, to npmjs.com
45
+ npm publish # public by default (unscoped name)
46
+ ```
package/decay.js ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Signal Decay Utility
3
+ *
4
+ * Applies exponential decay to intent signals based on their age.
5
+ * Each signal type has a different half-life reflecting how quickly
6
+ * the signal loses relevance for outreach timing.
7
+ *
8
+ * Freshness boost: signals < 48h old get a multiplier because
9
+ * reaching a prospect within 24-48h of a signal increases reply
10
+ * rates by up to 40% (Autobound/Gong data).
11
+ */
12
+
13
+ const DECAY_HALF_LIVES = {
14
+ recent_funding: 60, // days - funding rounds stay relevant longer
15
+ leadership_change: 45, // days - new leaders settle in over weeks
16
+ urgent_hiring: 21, // days - urgent roles fill fast
17
+ team_expansion: 45, // days - expansion plans unfold over weeks
18
+ first_hire: 30, // days - first hires happen quickly once decided
19
+ };
20
+
21
+ const DEFAULT_HALF_LIFE = 30;
22
+
23
+ /**
24
+ * Per-signal freshness boost curves. Funding announcements specifically
25
+ * show a 4x reply-rate uplift within 48h (vs ~1.4x for other signals per
26
+ * generic urgency studies), so they get a steeper freshness curve. Other
27
+ * signal types keep the uniform 1.5x/1.25x pattern.
28
+ *
29
+ * Curve format: [{ maxAgeDays, multiplier }, ...] ordered ascending.
30
+ * First matching bucket wins; anything beyond returns 1.0.
31
+ */
32
+ const FRESHNESS_CURVES = {
33
+ recent_funding: [
34
+ { maxAgeDays: 1, multiplier: 2.0 }, // within 24h = 2x
35
+ { maxAgeDays: 2, multiplier: 1.75 }, // 24-48h = 1.75x
36
+ { maxAgeDays: 3, multiplier: 1.5 }, // 48-72h = 1.5x still meaningful
37
+ { maxAgeDays: 7, multiplier: 1.25 }, // first week gets some boost
38
+ ],
39
+ leadership_change: [
40
+ { maxAgeDays: 1, multiplier: 1.75 }, // new leaders act fastest in first 90 days
41
+ { maxAgeDays: 2, multiplier: 1.4 },
42
+ { maxAgeDays: 7, multiplier: 1.15 },
43
+ ],
44
+ urgent_hiring: [
45
+ { maxAgeDays: 1, multiplier: 1.5 },
46
+ { maxAgeDays: 2, multiplier: 1.25 },
47
+ ],
48
+ };
49
+
50
+ const DEFAULT_FRESHNESS_CURVE = [
51
+ { maxAgeDays: 1, multiplier: 1.5 },
52
+ { maxAgeDays: 2, multiplier: 1.25 },
53
+ ];
54
+
55
+ /**
56
+ * Get a freshness boost multiplier for very recent signals.
57
+ * Per-signal-type curve when defined, else the default curve.
58
+ * recent_funding <24h: 2.0x (was 1.5x) - playbook: 4x reply rate within 48h
59
+ * leadership_change <24h: 1.75x
60
+ * other <24h: 1.5x
61
+ */
62
+ function freshnessBoost(ageDays, signalType = null) {
63
+ const curve = (signalType && FRESHNESS_CURVES[signalType]) || DEFAULT_FRESHNESS_CURVE;
64
+ for (const band of curve) {
65
+ if (ageDays <= band.maxAgeDays) return band.multiplier;
66
+ }
67
+ return 1.0;
68
+ }
69
+
70
+ /**
71
+ * Apply exponential decay to a raw score based on signal age.
72
+ * Includes a freshness boost for signals < 48 hours old.
73
+ *
74
+ * Formula: decayed = rawScore * freshnessBoost * (0.5 ^ (ageDays / halfLife))
75
+ *
76
+ * @param {number} rawScore - The original signal score
77
+ * @param {string} signalType - One of the DECAY_HALF_LIVES keys
78
+ * @param {string|Date} signalDate - When the signal was observed
79
+ * @param {Date} [now] - Reference date (defaults to current time)
80
+ * @returns {number} Decayed score, floored at 0
81
+ */
82
+ function decayScore(rawScore, signalType, signalDate, now = new Date()) {
83
+ if (!rawScore || rawScore <= 0) {
84
+ return 0;
85
+ }
86
+
87
+ const signalTime = signalDate instanceof Date ? signalDate : new Date(signalDate);
88
+ if (isNaN(signalTime.getTime())) {
89
+ // If the date is invalid, return the raw score unmodified
90
+ return rawScore;
91
+ }
92
+
93
+ const ageDays = (now.getTime() - signalTime.getTime()) / (1000 * 60 * 60 * 24);
94
+ if (ageDays <= 0) {
95
+ return Math.round(rawScore * freshnessBoost(0, signalType) * 100) / 100;
96
+ }
97
+
98
+ const halfLife = DECAY_HALF_LIVES[signalType] || DEFAULT_HALF_LIFE;
99
+ const boost = freshnessBoost(ageDays, signalType);
100
+ const decayed = rawScore * boost * Math.pow(0.5, ageDays / halfLife);
101
+
102
+ return Math.max(0, Math.round(decayed * 100) / 100);
103
+ }
104
+
105
+ module.exports = {
106
+ decayScore,
107
+ freshnessBoost,
108
+ DECAY_HALF_LIVES,
109
+ DEFAULT_HALF_LIFE,
110
+ FRESHNESS_CURVES,
111
+ DEFAULT_FRESHNESS_CURVE,
112
+ };
package/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @duncanwilliam1976/lead-scorer
3
+ *
4
+ * Single source of truth for lead qualification scoring, shared by the Vega
5
+ * app (deterministic scoreLeadFromEnrichment) and the enrichment worker
6
+ * (scoreLeadsMultiIcp with LLM-rewritten reasoning).
7
+ *
8
+ * The AI used for reasoning rewriting is injected via setCallAI() - the worker
9
+ * calls it once at startup; the app leaves it unset and gets deterministic
10
+ * reasoning.
11
+ */
12
+
13
+ const scorer = require('./scorer');
14
+ const { setCallAI } = require('./reasoningRewriter');
15
+
16
+ module.exports = {
17
+ ...scorer,
18
+ setCallAI,
19
+ };
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "kritmatta-lead-scorer",
3
+ "version": "1.0.0",
4
+ "description": "Lead qualification scorer - weight-profile scoring with per-ICP signal overrides. Single source of truth shared by the Vega app and the enrichment worker.",
5
+ "main": "index.js",
6
+ "files": [
7
+ "index.js",
8
+ "scorer.js",
9
+ "decay.js",
10
+ "reasoningRewriter.js"
11
+ ],
12
+ "scripts": {
13
+ "test": "node -e \"require('./index.js'); console.log('loads OK')\""
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/manjitjohal/lead-scorer.git"
18
+ },
19
+ "license": "UNLICENSED",
20
+ "engines": {
21
+ "node": ">=18"
22
+ }
23
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Reasoning Rewriter
3
+ *
4
+ * Takes the deterministic scoring breakdown produced by scorer.js and
5
+ * rewrites each clause in language that reflects the ICP's product, pain
6
+ * points, and fit signals. Score deltas are preserved exactly so totals
7
+ * stay consistent with the deterministic score.
8
+ *
9
+ * The AI call is dependency-injected via setCallAI() so this package has no
10
+ * app/worker-specific requires. Consumers that want LLM-rewritten reasoning
11
+ * (the enrichment worker) call setCallAI(callAI) once at startup. Consumers
12
+ * that only need deterministic scoring (the app) never inject one, and this
13
+ * function simply returns the deterministic reasoning unchanged.
14
+ */
15
+
16
+ let _callAI = null;
17
+
18
+ /**
19
+ * Inject the hybrid AI callAI(messages, options) function used for rewriting.
20
+ * If never called, rewriteReasoning() is a no-op that returns the deterministic
21
+ * reasoning (scoring still works, just without ICP-flavoured rephrasing).
22
+ */
23
+ function setCallAI(fn) {
24
+ _callAI = typeof fn === 'function' ? fn : null;
25
+ }
26
+
27
+ const SYSTEM_PROMPT = `You rewrite lead-scoring reasoning to be specific to a chosen ICP (Ideal Customer Profile) instead of using generic SaaS-flavoured language.
28
+
29
+ You will be given:
30
+ - The ICP name and freeform context (product, pain points, fit signals, disqualifiers)
31
+ - A summary of the lead's enrichment data
32
+ - The deterministic scoring breakdown: a semicolon-separated list of "Reason (+N)" or "Reason (-N)" items
33
+
34
+ Your job: rewrite each clause so it reads as ICP-specific. Reference the ICP's product or pain when it makes the line more meaningful. Drop nothing - every input clause must appear in the output.
35
+
36
+ You may add at most ONE new clause if there is a strong fit signal in the enrichment that the deterministic scorer missed - use (+0) for it so totals do not change.
37
+
38
+ Output rules:
39
+ - Same format as input: "Reason (+N); Reason (-N); ..."
40
+ - Each reason: short, plain English, ICP-flavoured.
41
+ - Preserve every existing (+N) / (-N) value exactly. Never invent new score values.
42
+ - Never add commentary, headers, markdown, or quoting.
43
+ - Output ONLY the rewritten reasoning string.`;
44
+
45
+ function buildIcpSummary(name, context) {
46
+ const c = context || {};
47
+ const lines = [`ICP name: ${name || 'unnamed'}`];
48
+ if (c.product_description) lines.push(`Product: ${c.product_description}`);
49
+ if (c.pain_solved) lines.push(`Pain solved: ${c.pain_solved}`);
50
+ if (c.great_fit_signals) lines.push(`Great fit signals: ${c.great_fit_signals}`);
51
+ if (c.bad_fit_signals) lines.push(`Bad fit signals: ${c.bad_fit_signals}`);
52
+ if (c.competitive_positioning) lines.push(`Positioning: ${c.competitive_positioning}`);
53
+ return lines.join('\n');
54
+ }
55
+
56
+ function summariseFunding(funding) {
57
+ if (!funding || typeof funding !== 'object') return null;
58
+ const bits = [];
59
+ const stage =
60
+ funding.latest_round_stage ||
61
+ funding.latest_stage ||
62
+ funding.last_round_type;
63
+ const date =
64
+ funding.latest_round_date ||
65
+ funding.last_round_date ||
66
+ funding.latest_date;
67
+ const total = funding.total_raised || funding.total_funding;
68
+ const count = funding.count || funding.rounds_count;
69
+ if (stage) bits.push(`latest stage ${stage}`);
70
+ if (date) bits.push(`on ${date}`);
71
+ if (total) bits.push(`total ${total}`);
72
+ if (count) bits.push(`${count} round${count > 1 ? 's' : ''}`);
73
+ return bits.length ? bits.join(', ') : null;
74
+ }
75
+
76
+ function buildLeadSummary(lead, signalsUsed) {
77
+ const enr = lead.enrichment_data || {};
78
+ const t0 = enr.tier0 || {};
79
+ const t2 = enr.tier2 || {};
80
+ const t4 = enr.tier4 || {};
81
+ const prospeo = enr.prospeo_enrich || {};
82
+ const pPerson = prospeo.person || {};
83
+ const pCo = prospeo.company || {};
84
+
85
+ const lines = [
86
+ `Person: ${lead.title || '(unknown title)'} at ${lead.company_name || '(unknown company)'}`,
87
+ ];
88
+ if (pPerson.headline) lines.push(`LinkedIn headline: ${pPerson.headline}`);
89
+ if (pPerson.last_job_change_detected_at) {
90
+ lines.push(`Last job change detected: ${pPerson.last_job_change_detected_at}`);
91
+ }
92
+ if (lead.industry || t0.industry || t2.industry) {
93
+ lines.push(`Industry: ${lead.industry || t0.industry || t2.industry}`);
94
+ }
95
+ const companyDesc = pCo.description_ai || pCo.description;
96
+ if (companyDesc) {
97
+ lines.push(`Company description: ${String(companyDesc).slice(0, 300)}`);
98
+ }
99
+ if (t2.business_model) lines.push(`Business model: ${t2.business_model}`);
100
+ if (t2.company_stage) lines.push(`Company stage: ${t2.company_stage}`);
101
+ const fundingSummary = summariseFunding(pCo.funding);
102
+ if (fundingSummary) lines.push(`Funding: ${fundingSummary}`);
103
+ const ap = pCo.job_postings;
104
+ if (ap?.active_count > 0) {
105
+ const titles =
106
+ Array.isArray(ap.active_titles) && ap.active_titles.length
107
+ ? ` (${ap.active_titles.slice(0, 5).join(', ')})`
108
+ : '';
109
+ lines.push(
110
+ `Active hiring: ${ap.active_count} role${ap.active_count > 1 ? 's' : ''}${titles}`
111
+ );
112
+ }
113
+ if (Array.isArray(t2.pain_indicators) && t2.pain_indicators.length) {
114
+ lines.push(`Pain indicators detected: ${t2.pain_indicators.join('; ')}`);
115
+ }
116
+ if (Array.isArray(t4.signals) && t4.signals.length) {
117
+ const types = t4.signals.map((s) => s.type).filter(Boolean);
118
+ if (types.length) lines.push(`Intent signals: ${types.join(', ')}`);
119
+ }
120
+ if (Array.isArray(signalsUsed) && signalsUsed.length) {
121
+ lines.push(`Signals scored: ${signalsUsed.join(', ')}`);
122
+ }
123
+ return lines.join('\n');
124
+ }
125
+
126
+ const SCORE_DELTA_REGEX = /\([+-]\d+(?:\.\d+)?\)/;
127
+
128
+ /**
129
+ * Rewrite a deterministic reasoning string in ICP-specific language.
130
+ * Returns the original deterministic reasoning if no callAI is injected or on
131
+ * any failure - never throws.
132
+ */
133
+ async function rewriteReasoning(args) {
134
+ const {
135
+ icpName,
136
+ icpContext,
137
+ lead,
138
+ deterministicReasoning,
139
+ signalsUsed,
140
+ options = {},
141
+ } = args;
142
+
143
+ // No AI injected (e.g. the app, which only needs deterministic scoring).
144
+ if (!_callAI) return deterministicReasoning;
145
+
146
+ if (
147
+ !icpContext ||
148
+ !deterministicReasoning ||
149
+ deterministicReasoning === 'No signals found'
150
+ ) {
151
+ return deterministicReasoning;
152
+ }
153
+
154
+ const userMsg = `${buildIcpSummary(icpName, icpContext)}
155
+
156
+ Lead:
157
+ ${buildLeadSummary(lead, signalsUsed)}
158
+
159
+ Deterministic reasoning:
160
+ ${deterministicReasoning}
161
+
162
+ Rewrite the deterministic reasoning following the rules above.`;
163
+
164
+ try {
165
+ const result = await _callAI(
166
+ [
167
+ { role: 'system', content: SYSTEM_PROMPT },
168
+ { role: 'user', content: userMsg },
169
+ ],
170
+ {
171
+ context: 'summarization',
172
+ forceProvider: 'gemini',
173
+ maxTokens: 1024,
174
+ temperature: 0.3,
175
+ timeout: options.timeout || 35000,
176
+ thinkingConfig: { thinkingBudget: 0 },
177
+ }
178
+ );
179
+ const raw = (result?.content || '').trim();
180
+ const cleaned = raw.replace(/^```\w*\s*/, '').replace(/\s*```$/, '').trim();
181
+ if (!cleaned || !SCORE_DELTA_REGEX.test(cleaned)) {
182
+ return deterministicReasoning;
183
+ }
184
+ return cleaned;
185
+ } catch (err) {
186
+ return deterministicReasoning;
187
+ }
188
+ }
189
+
190
+ module.exports = { rewriteReasoning, setCallAI };