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 +46 -0
- package/decay.js +112 -0
- package/index.js +19 -0
- package/package.json +23 -0
- package/reasoningRewriter.js +190 -0
- package/scorer.js +1154 -0
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 };
|