kritmatta-lead-scorer 1.2.0 → 1.2.2
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 +23 -5
- package/contextMatch.js +11 -4
- package/package.json +1 -1
- package/scorer.js +32 -21
- package/scorerV2.js +58 -20
package/README.md
CHANGED
|
@@ -5,16 +5,34 @@ app (`hire-chat-next-app`) and the enrichment worker (`enrichment-worker`) so th
|
|
|
5
5
|
scoring logic can never drift between them.
|
|
6
6
|
|
|
7
7
|
## What's in it
|
|
8
|
-
- `scorer.js` - weight-profile scorer with per-ICP `signal_overrides`
|
|
9
|
-
and the worker score identically). Hiring intent is read from either
|
|
10
|
-
scrape (`tier2.hiring_signals.hiring_velocity`) or a native
|
|
11
|
-
(`enrichment_data.hiring_signal`, from the app's
|
|
12
|
-
company hiring a relevant role scores even before
|
|
8
|
+
- `scorer.js` - the LIVE (v1) weight-profile scorer with per-ICP `signal_overrides`
|
|
9
|
+
(the app UI and the worker score identically). Hiring intent is read from either
|
|
10
|
+
the website scrape (`tier2.hiring_signals.hiring_velocity`) or a native
|
|
11
|
+
LinkedIn-jobs signal (`enrichment_data.hiring_signal`, from the app's
|
|
12
|
+
hiringSignalService) - so a company hiring a relevant role scores even before
|
|
13
|
+
any website enrichment. Also exports the weight-profile key allowlists
|
|
14
|
+
(`VALID_SIGNAL_OVERRIDE_KEYS`, `VALID_CATEGORY_MULTIPLIER_KEYS`,
|
|
15
|
+
`VALID_V2_CONFIG_KEYS`) - writers must validate against them because the
|
|
16
|
+
scorer silently ignores unknown keys.
|
|
17
|
+
- `scorerV2.js` - the SHADOW (v2) fit-gated scorer:
|
|
18
|
+
`score = 100 * fit^strictness * (floor + (1-floor) * quality) + capped intent`.
|
|
19
|
+
Fit is earned/evidenced over the ICP's defined core criteria (bonus criteria
|
|
20
|
+
only lift), disqualifiers hard-cap the score, and category multipliers act as
|
|
21
|
+
normalized intra-axis weights. `scoreLeadsMultiIcp` computes it alongside v1
|
|
22
|
+
on every rescore and persists `qualification.shadow_v2` - observability only,
|
|
23
|
+
nothing live reads it until cutover. A v2 load failure disables the shadow;
|
|
24
|
+
it can never break live v1 scoring. Per-ICP tunables: `weight_profile.v2`.
|
|
25
|
+
- `contextMatch.js` - stemmed token-overlap matching for ICP fit/bad-fit phrases
|
|
26
|
+
(used by v2; v1 still uses verbatim substring matching).
|
|
13
27
|
- `decay.js` - intent-signal time decay.
|
|
14
28
|
- `reasoningRewriter.js` - optional LLM rephrasing of the deterministic reasoning.
|
|
15
29
|
The AI call is **dependency-injected** so this package has no app/worker-specific
|
|
16
30
|
requires.
|
|
17
31
|
|
|
32
|
+
Consumer shims in both repos re-export the package ROOT (not the `/scorer`
|
|
33
|
+
subpath) so v1 and v2 entry points are reachable everywhere. Run `npm test`
|
|
34
|
+
(no server or credentials needed) before publishing.
|
|
35
|
+
|
|
18
36
|
## Install
|
|
19
37
|
Public package on npmjs.com - no auth or `.npmrc` needed:
|
|
20
38
|
|
package/contextMatch.js
CHANGED
|
@@ -39,20 +39,27 @@ function stem(word) {
|
|
|
39
39
|
return w;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/** Two stems match when one is a prefix of the other
|
|
43
|
-
*
|
|
42
|
+
/** Two stems match when equal, or when one is a prefix of the other with at
|
|
43
|
+
* most ONE trailing char of difference (absorbs stemmer roughness like
|
|
44
|
+
* hir/hire, plann/plan). A looser prefix rule produced semantic false
|
|
45
|
+
* positives (firm/firmware, care/career, part/partner) that are unacceptable
|
|
46
|
+
* because a single bad-fit phrase match hard-caps the score. */
|
|
44
47
|
function stemsMatch(a, b) {
|
|
45
48
|
if (a === b) return true;
|
|
46
49
|
if (a.length < 4 || b.length < 4) return false;
|
|
50
|
+
if (Math.abs(a.length - b.length) > 1) return false;
|
|
47
51
|
return a.startsWith(b) || b.startsWith(a);
|
|
48
52
|
}
|
|
49
53
|
|
|
50
|
-
/** Tokenize free text into deduplicated stemmed content words.
|
|
54
|
+
/** Tokenize free text into deduplicated stemmed content words. Two-char
|
|
55
|
+
* tokens are KEPT - domain acronyms (HR, AI, VC, UK, ML) carry the meaning
|
|
56
|
+
* of many ICP phrases, and dropping them degraded phrases like "no HR team"
|
|
57
|
+
* to a single generic word that matched everything. */
|
|
51
58
|
function contentStems(text) {
|
|
52
59
|
if (!text || typeof text !== 'string') return [];
|
|
53
60
|
const out = new Set();
|
|
54
61
|
for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
55
|
-
if (raw.length <
|
|
62
|
+
if (raw.length < 2) continue;
|
|
56
63
|
if (STOPWORDS.has(raw)) continue;
|
|
57
64
|
out.add(stem(raw));
|
|
58
65
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kritmatta-lead-scorer",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "Lead qualification scorer - weight-profile scoring with per-ICP signal overrides and provenance-scoped multi-product candidacy. Single source of truth shared by the Vega app and the enrichment worker.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"files": [
|
package/scorer.js
CHANGED
|
@@ -17,13 +17,19 @@
|
|
|
17
17
|
const { decayScore } = require('./decay');
|
|
18
18
|
|
|
19
19
|
// Lazy require: scorerV2 requires this module for shared helpers, so a
|
|
20
|
-
// top-level require here would be circular.
|
|
20
|
+
// top-level require here would be circular. Guarded so a v2 load failure can
|
|
21
|
+
// never take down live v1 scoring - shadow scoring just switches off.
|
|
21
22
|
let _scorerV2 = null;
|
|
22
23
|
function getScorerV2() {
|
|
23
24
|
if (_scorerV2 === null) {
|
|
24
|
-
|
|
25
|
+
try {
|
|
26
|
+
_scorerV2 = require('./scorerV2');
|
|
27
|
+
} catch (err) {
|
|
28
|
+
console.error('[scorer] scorerV2 failed to load - shadow scoring disabled:', err.message);
|
|
29
|
+
_scorerV2 = false;
|
|
30
|
+
}
|
|
25
31
|
}
|
|
26
|
-
return _scorerV2;
|
|
32
|
+
return _scorerV2 || null;
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
// Lazy require so the rewriter is only loaded when actually used; keeps the
|
|
@@ -1089,8 +1095,9 @@ async function scoreLeadsMultiIcp(leads, job, supabase, options = {}) {
|
|
|
1089
1095
|
// Shadow v2 (fit-gated model): computed alongside v1 for observability,
|
|
1090
1096
|
// never drives live scores. Tracks v2's own best ICP plus the v2 score for
|
|
1091
1097
|
// whichever ICP v1 picks, so distribution and best-ICP drift are both
|
|
1092
|
-
// measurable from persisted data.
|
|
1093
|
-
|
|
1098
|
+
// measurable from persisted data. v2mod is null if scorerV2 failed to
|
|
1099
|
+
// load - live v1 scoring proceeds without the shadow.
|
|
1100
|
+
const v2mod = getScorerV2();
|
|
1094
1101
|
const v2ByIcp = {};
|
|
1095
1102
|
let v2Best = null;
|
|
1096
1103
|
const icpRows = [];
|
|
@@ -1115,17 +1122,19 @@ async function scoreLeadsMultiIcp(leads, job, supabase, options = {}) {
|
|
|
1115
1122
|
_icp_context: icp.context,
|
|
1116
1123
|
}, globalConfig);
|
|
1117
1124
|
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1125
|
+
if (v2mod) {
|
|
1126
|
+
try {
|
|
1127
|
+
const v2 = v2mod.scoreLeadV2(lead, {
|
|
1128
|
+
...icp.criteria,
|
|
1129
|
+
_icp_name: icp.name,
|
|
1130
|
+
_icp_context: icp.context,
|
|
1131
|
+
}, globalConfig);
|
|
1132
|
+
v2ByIcp[icp.id] = v2;
|
|
1133
|
+
if (!v2Best || v2.score > v2Best.result.score) v2Best = { icpId: icp.id, result: v2 };
|
|
1134
|
+
} catch (v2Err) {
|
|
1135
|
+
// Shadow scoring must never break live scoring
|
|
1136
|
+
console.error(`[scorer] shadow v2 failed for lead ${lead.id} icp ${icp.id}:`, v2Err.message);
|
|
1137
|
+
}
|
|
1129
1138
|
}
|
|
1130
1139
|
|
|
1131
1140
|
icpRows.push({
|
|
@@ -1152,9 +1161,11 @@ async function scoreLeadsMultiIcp(leads, job, supabase, options = {}) {
|
|
|
1152
1161
|
if (icps.length === 0) {
|
|
1153
1162
|
bestResult = scoreLeadFromEnrichment(lead, null, globalConfig);
|
|
1154
1163
|
bestScore = bestResult.score;
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1164
|
+
if (v2mod) {
|
|
1165
|
+
try {
|
|
1166
|
+
v2Best = { icpId: null, result: v2mod.scoreLeadV2(lead, null, globalConfig) };
|
|
1167
|
+
} catch { /* shadow only */ }
|
|
1168
|
+
}
|
|
1158
1169
|
}
|
|
1159
1170
|
|
|
1160
1171
|
// For meaningful scores, ask the rewriter to rephrase reasoning in the
|
|
@@ -1194,9 +1205,9 @@ async function scoreLeadsMultiIcp(leads, job, supabase, options = {}) {
|
|
|
1194
1205
|
// Shadow v2 fit-gated score - additive, observability only. `icp_id`
|
|
1195
1206
|
// inside is v1's best ICP (same lead-level comparison basis as `score`);
|
|
1196
1207
|
// v2_best records which ICP v2 itself would pick.
|
|
1197
|
-
shadow_v2: v2ForV1Best
|
|
1208
|
+
shadow_v2: v2mod && v2ForV1Best
|
|
1198
1209
|
? {
|
|
1199
|
-
...toShadowSummary(v2ForV1Best, bestIcpId),
|
|
1210
|
+
...v2mod.toShadowSummary(v2ForV1Best, bestIcpId),
|
|
1200
1211
|
v2_best: v2Best ? { icp_id: v2Best.icpId, score: v2Best.result.score } : null,
|
|
1201
1212
|
}
|
|
1202
1213
|
: null,
|
package/scorerV2.js
CHANGED
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
const { decayScore } = require('./decay');
|
|
34
|
-
const { buildCorpusMatcher } = require('./contextMatch');
|
|
34
|
+
const { buildCorpusMatcher, contentStems } = require('./contextMatch');
|
|
35
35
|
const {
|
|
36
36
|
normalizeIcpCriteria,
|
|
37
37
|
_internal: {
|
|
@@ -127,18 +127,21 @@ function round2(x) {
|
|
|
127
127
|
function resolveV2Config(icp) {
|
|
128
128
|
const v2 = icp?.weight_profile?.v2 || {};
|
|
129
129
|
const ov = icp?.weight_profile?.signal_overrides || {};
|
|
130
|
+
const num = (x) => (typeof x === 'number' ? x : undefined);
|
|
130
131
|
const cfg = { ...V2_DEFAULTS };
|
|
131
132
|
for (const key of Object.keys(V2_DEFAULTS)) {
|
|
132
|
-
if (
|
|
133
|
+
if (num(v2[key]) !== undefined) cfg[key] = v2[key];
|
|
133
134
|
}
|
|
134
|
-
// Honor the v1 overrides that translate cleanly into v2 semantics.
|
|
135
|
-
|
|
135
|
+
// Honor the v1 overrides that translate cleanly into v2 semantics. Guards
|
|
136
|
+
// use the same numeric check as the v2 loop, so a malformed (non-numeric)
|
|
137
|
+
// v2 value falls through to the legacy override rather than to defaults.
|
|
138
|
+
if (num(ov.fit_signal_cap) !== undefined && num(v2.fit_signal_cap) === undefined) {
|
|
136
139
|
cfg.fit_signal_cap = ov.fit_signal_cap;
|
|
137
140
|
}
|
|
138
|
-
if (
|
|
141
|
+
if (num(ov.bad_signal_cap) !== undefined && num(v2.bad_signal_cap) === undefined) {
|
|
139
142
|
cfg.bad_signal_cap = ov.bad_signal_cap;
|
|
140
143
|
}
|
|
141
|
-
if (
|
|
144
|
+
if (num(ov.industry_mismatch) !== undefined && num(v2.mismatch_fraction) === undefined) {
|
|
142
145
|
// v1 default penalty is -25 against a +25-weight criterion; scale linearly
|
|
143
146
|
// (override of 0 = mismatch costs nothing, -50 = subtracts 1.44x weight).
|
|
144
147
|
cfg.mismatch_fraction = Math.abs(ov.industry_mismatch) / 25 * V2_DEFAULTS.mismatch_fraction;
|
|
@@ -185,6 +188,32 @@ function resolveAxisWeights(icp) {
|
|
|
185
188
|
return { fit, quality, intentMultiplier: m.intent };
|
|
186
189
|
}
|
|
187
190
|
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Context phrase eligibility
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
const NEGATION_LEAD = /^(no|not|without|non|never|zero|lacks?|lacking|doesn'?t|don'?t|isn'?t|aren'?t)\b/i;
|
|
196
|
+
const NUMERIC_COMPARISON = /\b(under|over|below|above|less than|fewer than|more than|at least|at most|maximum|minimum|min|max|<|>)\s*\d/i;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* A phrase participates in context matching only when presence-in-corpus is a
|
|
200
|
+
* meaningful test of it. Three classes are NOT and are skipped entirely
|
|
201
|
+
* (numerator AND denominator, so ratios stay fair):
|
|
202
|
+
* - single-content-word phrases ("investment", "carbon") - one generic word
|
|
203
|
+
* matches nearly any corpus, and on the bad-fit side one match hard-caps;
|
|
204
|
+
* - negation-led phrases ("no sustainability focus") - matching the words'
|
|
205
|
+
* PRESENCE inverts the author's intent;
|
|
206
|
+
* - numeric comparisons ("headcount under 10") - substring/token matching
|
|
207
|
+
* cannot evaluate a threshold.
|
|
208
|
+
* All three classes were exactly the false bad-fit caps observed in the
|
|
209
|
+
* 2026-09-08 full-population audit (94.8k leads).
|
|
210
|
+
*/
|
|
211
|
+
function presenceCheckable(phrase) {
|
|
212
|
+
if (NEGATION_LEAD.test(phrase)) return false;
|
|
213
|
+
if (NUMERIC_COMPARISON.test(phrase)) return false;
|
|
214
|
+
return contentStems(phrase).length >= 2;
|
|
215
|
+
}
|
|
216
|
+
|
|
188
217
|
// ---------------------------------------------------------------------------
|
|
189
218
|
// Fit axis
|
|
190
219
|
// ---------------------------------------------------------------------------
|
|
@@ -296,14 +325,15 @@ function scoreFitAxis(lead, icp, weights, cfg, matcher) {
|
|
|
296
325
|
add('geography', true, evidence, match ? 1 : 0, match ? 'Geography matches' : null);
|
|
297
326
|
}
|
|
298
327
|
|
|
299
|
-
// Funding stage
|
|
328
|
+
// Funding stage - check ALL observed stages (tier4 signal AND tier2), same
|
|
329
|
+
// as v1: a stale tier4 stage must not mask a matching tier2.funding_stage
|
|
300
330
|
const fundingDefined = (icp.funding_stages?.length || 0) > 0;
|
|
301
331
|
if (fundingDefined) {
|
|
302
332
|
const fundingSignal = tier4?.signals?.find?.((s) => s.type === 'recent_funding');
|
|
303
|
-
const
|
|
304
|
-
const evidence =
|
|
305
|
-
const match = evidence &&
|
|
306
|
-
(f) => f.toLowerCase() ===
|
|
333
|
+
const observedStages = [fundingSignal?.data?.stage, tier2?.funding_stage].filter(Boolean);
|
|
334
|
+
const evidence = observedStages.length > 0;
|
|
335
|
+
const match = evidence && observedStages.some((o) =>
|
|
336
|
+
icp.funding_stages.some((f) => f.toLowerCase() === o.toLowerCase())
|
|
307
337
|
);
|
|
308
338
|
if (match) signals.push('icp_funding_match');
|
|
309
339
|
add('funding', true, evidence, match ? 1 : 0, match ? 'Funding stage matches' : null);
|
|
@@ -326,23 +356,24 @@ function scoreFitAxis(lead, icp, weights, cfg, matcher) {
|
|
|
326
356
|
add('tech_stack', true, evidence, ratio, ratio > 0 ? 'Tech stack matches' : null);
|
|
327
357
|
}
|
|
328
358
|
|
|
329
|
-
// Context fit signals (great_fit_signals + pain_solved), token-overlap
|
|
359
|
+
// Context fit signals (great_fit_signals + pain_solved), token-overlap
|
|
360
|
+
// matched. Deduplicate BEFORE the denominator - a phrase repeated across
|
|
361
|
+
// both fields can only ever match once, so counting it twice in the
|
|
362
|
+
// denominator structurally depresses the ratio. Phrases that presence
|
|
363
|
+
// matching cannot evaluate are excluded entirely (see presenceCheckable).
|
|
330
364
|
const context = icp._icp_context;
|
|
331
365
|
const fitPhrases = context
|
|
332
|
-
? [
|
|
366
|
+
? [...new Set([
|
|
333
367
|
...tokenizeContextSignals(context.great_fit_signals),
|
|
334
368
|
...tokenizeContextSignals(context.pain_solved),
|
|
335
|
-
]
|
|
369
|
+
])].filter(presenceCheckable)
|
|
336
370
|
: [];
|
|
337
371
|
if (fitPhrases.length > 0) {
|
|
338
372
|
const evidence = !!matcher;
|
|
339
373
|
let matches = 0;
|
|
340
374
|
if (evidence) {
|
|
341
|
-
const seen = new Set();
|
|
342
375
|
for (const phrase of fitPhrases) {
|
|
343
376
|
if (matches >= cfg.fit_signal_cap) break;
|
|
344
|
-
if (seen.has(phrase)) continue;
|
|
345
|
-
seen.add(phrase);
|
|
346
377
|
if (matcher.phraseMatches(phrase)) {
|
|
347
378
|
matches++;
|
|
348
379
|
signals.push(`context_fit:${phrase}`);
|
|
@@ -354,8 +385,13 @@ function scoreFitAxis(lead, icp, weights, cfg, matcher) {
|
|
|
354
385
|
matches > 0 ? `${matches} fit signal match${matches > 1 ? 'es' : ''}` : null);
|
|
355
386
|
}
|
|
356
387
|
|
|
357
|
-
// Bad-fit signals - hard cap, not subtraction
|
|
358
|
-
|
|
388
|
+
// Bad-fit signals - hard cap, not subtraction. The presenceCheckable filter
|
|
389
|
+
// matters most here: the 2026-09-08 full-population audit showed every
|
|
390
|
+
// false bad-fit cap came from an un-checkable phrase (single generic word,
|
|
391
|
+
// negation, or numeric comparison).
|
|
392
|
+
const badPhrases = context
|
|
393
|
+
? tokenizeContextSignals(context.bad_fit_signals).filter(presenceCheckable)
|
|
394
|
+
: [];
|
|
359
395
|
if (badPhrases.length > 0 && matcher) {
|
|
360
396
|
let badMatches = 0;
|
|
361
397
|
const seenBad = new Set();
|
|
@@ -509,7 +545,9 @@ function scoreIntentBonus(lead, cfg, intentMultiplier) {
|
|
|
509
545
|
}
|
|
510
546
|
}
|
|
511
547
|
}
|
|
512
|
-
|
|
548
|
+
// 0 is a valid multiplier (tenant disabled intent) - only default when unset
|
|
549
|
+
const mult = typeof intentMultiplier === 'number' ? intentMultiplier : 1;
|
|
550
|
+
return { bonus: Math.min(points * mult, cfg.intent_cap), raw: points, signals };
|
|
513
551
|
}
|
|
514
552
|
|
|
515
553
|
// ---------------------------------------------------------------------------
|