fullstackgtm 0.39.0 → 0.41.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/CHANGELOG.md +74 -0
- package/README.md +42 -1
- package/dist/cli.js +615 -6
- package/dist/connectors/atsBoards.d.ts +60 -0
- package/dist/connectors/atsBoards.js +179 -0
- package/dist/connectors/prospectSources.d.ts +7 -5
- package/dist/connectors/prospectSources.js +82 -47
- package/dist/draft.d.ts +182 -0
- package/dist/draft.js +333 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/judge.d.ts +209 -0
- package/dist/judge.js +490 -0
- package/dist/judgeEval.d.ts +141 -0
- package/dist/judgeEval.js +331 -0
- package/dist/llm.d.ts +13 -0
- package/dist/llm.js +18 -2
- package/dist/schedule.js +11 -1
- package/dist/signals.d.ts +197 -0
- package/dist/signals.js +515 -0
- package/docs/api.md +2 -2
- package/package.json +1 -1
- package/src/cli.ts +696 -7
- package/src/connectors/atsBoards.ts +242 -0
- package/src/connectors/prospectSources.ts +94 -43
- package/src/draft.ts +463 -0
- package/src/index.ts +94 -0
- package/src/judge.ts +661 -0
- package/src/judgeEval.ts +459 -0
- package/src/llm.ts +31 -2
- package/src/schedule.ts +11 -1
- package/src/signals.ts +685 -0
package/dist/judge.js
ADDED
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.js";
|
|
4
|
+
import { forcedToolCall } from "./llm.js";
|
|
5
|
+
import { scoreProspectAgainstIcp } from "./icp.js";
|
|
6
|
+
import { computeWeights, normalizeAccountDomain } from "./signals.js";
|
|
7
|
+
export function scoreBand(score) {
|
|
8
|
+
if (score >= 80)
|
|
9
|
+
return "strong";
|
|
10
|
+
if (score >= 50)
|
|
11
|
+
return "good";
|
|
12
|
+
if (score >= 20)
|
|
13
|
+
return "weak";
|
|
14
|
+
return "noise";
|
|
15
|
+
}
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
// Stable hashing (FNV-1a) — duplicated to keep this file importable without the
|
|
18
|
+
// audit engine (the signals.ts/market.ts/enrich.ts precedent).
|
|
19
|
+
function fnv1a(value) {
|
|
20
|
+
let hash = 0x811c9dc5;
|
|
21
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
22
|
+
hash ^= value.charCodeAt(i);
|
|
23
|
+
hash = Math.imul(hash, 0x01000193);
|
|
24
|
+
}
|
|
25
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
26
|
+
}
|
|
27
|
+
export function judgeRunId(runLabel) {
|
|
28
|
+
return `jdg_${fnv1a(runLabel)}`;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Whitespace/punctuation-spacing-normalized, lowercased match — the EXACT rule
|
|
32
|
+
* `call`/`market` use for verbatim evidence gates (llm.ts normalizeSpan). A
|
|
33
|
+
* local copy keeps this file importable without the LLM engine (the cross-module
|
|
34
|
+
* duplication precedent in signals.ts/market.ts).
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeSpan(value) {
|
|
37
|
+
return value
|
|
38
|
+
.replace(/\s+([.,;:!?])/g, "$1")
|
|
39
|
+
.replace(/\s+/g, " ")
|
|
40
|
+
.trim()
|
|
41
|
+
.toLowerCase();
|
|
42
|
+
}
|
|
43
|
+
/** Verbatim evidence gate: `produced` must be a ≥12-char normalized substring of `source`. */
|
|
44
|
+
export function isGroundedSpan(produced, source) {
|
|
45
|
+
const span = normalizeSpan(produced);
|
|
46
|
+
if (span.length < 12)
|
|
47
|
+
return false;
|
|
48
|
+
return normalizeSpan(source).includes(span);
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Scoring: timing × fit × memory
|
|
52
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
53
|
+
/** Default freshness/clustering knobs. Editable via the deterministic scorer's opts. */
|
|
54
|
+
const RECENCY_FLOOR = 0.4;
|
|
55
|
+
const RECENCY_WINDOW_DAYS = 30;
|
|
56
|
+
/** Flat timing-norm boost when ≥2 fresh signals cluster (the "strong fit + high intent" band). */
|
|
57
|
+
const CLUSTER_BONUS = 0.25;
|
|
58
|
+
const FRESH_SIGNAL_DAYS = 14;
|
|
59
|
+
/** "Touched recently" memory window — pile-on guard. */
|
|
60
|
+
const TOUCHED_WINDOW_DAYS = 7;
|
|
61
|
+
function recencyFactor(firstSeen, now, windowDays = RECENCY_WINDOW_DAYS) {
|
|
62
|
+
const seen = Date.parse(firstSeen);
|
|
63
|
+
if (!Number.isFinite(seen))
|
|
64
|
+
return 1;
|
|
65
|
+
const ageDays = Math.max(0, (now.getTime() - seen) / DAY_MS);
|
|
66
|
+
if (windowDays <= 0)
|
|
67
|
+
return 1;
|
|
68
|
+
const decayed = 1 - (1 - RECENCY_FLOOR) * Math.min(1, ageDays / windowDays);
|
|
69
|
+
return Math.max(RECENCY_FLOOR, decayed);
|
|
70
|
+
}
|
|
71
|
+
function isFresh(firstSeen, now) {
|
|
72
|
+
const seen = Date.parse(firstSeen);
|
|
73
|
+
if (!Number.isFinite(seen))
|
|
74
|
+
return false;
|
|
75
|
+
return now.getTime() - seen <= FRESH_SIGNAL_DAYS * DAY_MS;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Score one account on timing × fit × memory and map to a 0-100 score + band +
|
|
79
|
+
* decision. Deterministic.
|
|
80
|
+
*
|
|
81
|
+
* - **timing**: each credited signal's bucket weight (learned overlay over
|
|
82
|
+
* config defaults via `computeWeights`) × recency decay × its already-baked
|
|
83
|
+
* repost boost; the account's timing is the MAX credited signal weight, plus a
|
|
84
|
+
* cluster bonus when ≥2 signals are fresh (the article's compounding "fresh
|
|
85
|
+
* round AND a live req" case → 80-100 band).
|
|
86
|
+
* - **fit**: `scoreProspectAgainstIcp(bestContact, icp)` (0..1). No ICP / no
|
|
87
|
+
* contact → a neutral 0.5 so a strong, well-grounded timing signal can still
|
|
88
|
+
* surface (fit then doesn't sink an otherwise-hot account).
|
|
89
|
+
* - **memory** (`recentlyTouched`): touched within `TOUCHED_WINDOW_DAYS` → never
|
|
90
|
+
* pile on: cap the decision at `nurture` (or `skip` if already lukewarm).
|
|
91
|
+
*
|
|
92
|
+
* `decision` from the band: strong→send, good→nurture, weak/noise→skip — then
|
|
93
|
+
* the memory cap applies. `skip` is a first-class output: a lone low-intent
|
|
94
|
+
* signal (e.g. a single `social` blip) lands in `weak`/`noise` and is skipped
|
|
95
|
+
* with a reason, not mailed.
|
|
96
|
+
*/
|
|
97
|
+
export function scoreAccount(opts) {
|
|
98
|
+
const now = opts.now ?? new Date();
|
|
99
|
+
const domain = normalizeAccountDomain(opts.accountDomain);
|
|
100
|
+
const signals = opts.signals.filter((s) => normalizeAccountDomain(s.accountDomain) === domain);
|
|
101
|
+
if (signals.length === 0) {
|
|
102
|
+
throw new Error(`scoreAccount: no signals for ${opts.accountDomain}`);
|
|
103
|
+
}
|
|
104
|
+
// Timing: per-signal effective weight = learned bucket weight × recency,
|
|
105
|
+
// scaled by the signal's already-baked repost/recency weight ratio so the
|
|
106
|
+
// store's repost boost still counts. The account's timing is the max.
|
|
107
|
+
let topSignal = signals[0];
|
|
108
|
+
let maxWeight = -Infinity;
|
|
109
|
+
for (const signal of signals) {
|
|
110
|
+
const bucketWeight = opts.weights[signal.bucket] ?? 0;
|
|
111
|
+
const effective = bucketWeight * recencyFactor(signal.firstSeen, now) + repostBoostOf(signal);
|
|
112
|
+
if (effective > maxWeight) {
|
|
113
|
+
maxWeight = effective;
|
|
114
|
+
topSignal = signal;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const freshCount = signals.filter((s) => isFresh(s.firstSeen, now)).length;
|
|
118
|
+
const isCluster = freshCount >= 2;
|
|
119
|
+
const timing = Math.max(0, maxWeight);
|
|
120
|
+
// Fit: best-matching contact vs ICP, neutral 0.5 when unknown so timing leads.
|
|
121
|
+
const fit = opts.icp && opts.bestContact ? scoreProspectAgainstIcp(opts.bestContact, opts.icp).score : 0.5;
|
|
122
|
+
// Map timing×fit to 0-100 along two axes (the article rubric):
|
|
123
|
+
// - timingNorm (0..1): timing relative to TIMING_REF (~a single strong-bucket
|
|
124
|
+
// fresh signal at funding/demand weight). A lone strong signal lands in the
|
|
125
|
+
// "good" band; a lone low-intent signal (social ≈ 0.4) stays weak/noise.
|
|
126
|
+
// - A fresh ≥2-signal CLUSTER adds a flat boost so "a fresh round AND a live
|
|
127
|
+
// req" reaches the 80-100 strong band — the rubric's compounding case.
|
|
128
|
+
// - fitMultiplier (0.7..1.0): fit SHAPES the score ±30% but never zeroes a
|
|
129
|
+
// well-timed account; unknown fit (0.5) sits in the middle at 0.85.
|
|
130
|
+
const TIMING_REF = 2.0;
|
|
131
|
+
let timingNorm = Math.min(1, timing / TIMING_REF);
|
|
132
|
+
if (isCluster)
|
|
133
|
+
timingNorm = Math.min(1, timingNorm + CLUSTER_BONUS);
|
|
134
|
+
const fitMultiplier = 0.7 + 0.3 * fit;
|
|
135
|
+
const raw = timingNorm * fitMultiplier;
|
|
136
|
+
let score = Math.round(Math.max(0, Math.min(1, raw)) * 100);
|
|
137
|
+
const band = scoreBand(score);
|
|
138
|
+
let decision = band === "strong" ? "send" : band === "good" ? "nurture" : "skip";
|
|
139
|
+
let skippedReason;
|
|
140
|
+
if (decision === "skip") {
|
|
141
|
+
skippedReason =
|
|
142
|
+
band === "noise"
|
|
143
|
+
? "off-ICP / low-intent noise — no fresh, in-persona trigger"
|
|
144
|
+
: "weak lone signal — one low-intent trigger, not enough to reach out about";
|
|
145
|
+
}
|
|
146
|
+
// Memory cap: touched within the window → never pile on. send→nurture,
|
|
147
|
+
// nurture→skip, skip stays skip.
|
|
148
|
+
const recentlyTouched = Boolean(opts.recentlyTouched);
|
|
149
|
+
if (recentlyTouched && decision !== "skip") {
|
|
150
|
+
if (decision === "send") {
|
|
151
|
+
decision = "nurture";
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
decision = "skip";
|
|
155
|
+
skippedReason = `touched within ${TOUCHED_WINDOW_DAYS}d — already engaged; don't pile on`;
|
|
156
|
+
}
|
|
157
|
+
// Reflect the pile-on guard in the score so ranking de-prioritizes it, but
|
|
158
|
+
// keep the band-decision mapping coherent for nurture (50-79).
|
|
159
|
+
if (decision === "nurture" && score >= 80)
|
|
160
|
+
score = 79;
|
|
161
|
+
}
|
|
162
|
+
// Evidence = the credited cluster (all signals on this account this run).
|
|
163
|
+
const evidence = signals.map((s) => s.id);
|
|
164
|
+
return {
|
|
165
|
+
accountDomain: domain,
|
|
166
|
+
score,
|
|
167
|
+
band,
|
|
168
|
+
decision,
|
|
169
|
+
topSignal,
|
|
170
|
+
evidence,
|
|
171
|
+
fit: Number(fit.toFixed(3)),
|
|
172
|
+
timing: Number(timing.toFixed(4)),
|
|
173
|
+
recentlyTouched,
|
|
174
|
+
...(skippedReason !== undefined ? { skippedReason } : {}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* The repost boost already baked into a signal's stored weight, recovered as
|
|
179
|
+
* weight − (weight without boost would be) — but since we don't have the
|
|
180
|
+
* un-boosted weight here, we credit a small fixed bump only when the signal is
|
|
181
|
+
* flagged `reposted`. Deterministic, additive, bounded.
|
|
182
|
+
*/
|
|
183
|
+
function repostBoostOf(signal) {
|
|
184
|
+
return signal.reposted ? 0.5 : 0;
|
|
185
|
+
}
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// Memory: read account activity from the canonical snapshot
|
|
188
|
+
/**
|
|
189
|
+
* Has this account been touched within `TOUCHED_WINDOW_DAYS`? Reads the snapshot:
|
|
190
|
+
* the account record's `lastActivityAt`, plus any `activities` whose `occurredAt`
|
|
191
|
+
* is recent and that link to the account (directly or via a contact at it). Pure;
|
|
192
|
+
* with no snapshot, returns false (the no-history path — caller passes `false`).
|
|
193
|
+
*/
|
|
194
|
+
export function accountRecentlyTouched(accountDomain, snapshot, now = new Date(), windowDays = TOUCHED_WINDOW_DAYS) {
|
|
195
|
+
const domain = normalizeAccountDomain(accountDomain);
|
|
196
|
+
const cutoff = now.getTime() - windowDays * DAY_MS;
|
|
197
|
+
const account = (snapshot.accounts ?? []).find((a) => normalizeAccountDomain(a.domain ?? "") === domain && domain !== "");
|
|
198
|
+
if (account?.lastActivityAt) {
|
|
199
|
+
const t = Date.parse(account.lastActivityAt);
|
|
200
|
+
if (Number.isFinite(t) && t >= cutoff)
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
if (!account)
|
|
204
|
+
return false;
|
|
205
|
+
const contactIds = new Set((snapshot.contacts ?? []).filter((c) => c.accountId === account.id).map((c) => c.id));
|
|
206
|
+
for (const contact of snapshot.contacts ?? []) {
|
|
207
|
+
if (contact.accountId !== account.id)
|
|
208
|
+
continue;
|
|
209
|
+
if (contact.lastActivityAt) {
|
|
210
|
+
const t = Date.parse(contact.lastActivityAt);
|
|
211
|
+
if (Number.isFinite(t) && t >= cutoff)
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const activity of snapshot.activities ?? []) {
|
|
216
|
+
const linked = activity.accountId === account.id || (activity.contactId && contactIds.has(activity.contactId));
|
|
217
|
+
if (!linked)
|
|
218
|
+
continue;
|
|
219
|
+
const t = Date.parse(activity.occurredAt);
|
|
220
|
+
if (Number.isFinite(t) && t >= cutoff)
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Find the best-matching contact for an account from the snapshot, for fit
|
|
227
|
+
* scoring: the account's contacts, preferring one with a title (a title is what
|
|
228
|
+
* fit scores on). Returns undefined when the account/contact isn't in snapshot.
|
|
229
|
+
*/
|
|
230
|
+
export function bestContactForAccount(accountDomain, snapshot) {
|
|
231
|
+
const domain = normalizeAccountDomain(accountDomain);
|
|
232
|
+
if (!domain)
|
|
233
|
+
return undefined;
|
|
234
|
+
const account = (snapshot.accounts ?? []).find((a) => normalizeAccountDomain(a.domain ?? "") === domain);
|
|
235
|
+
if (!account)
|
|
236
|
+
return undefined;
|
|
237
|
+
const contacts = (snapshot.contacts ?? []).filter((c) => c.accountId === account.id);
|
|
238
|
+
const withTitle = contacts.find((c) => c.title) ?? contacts[0];
|
|
239
|
+
if (!withTitle)
|
|
240
|
+
return undefined;
|
|
241
|
+
return { jobTitle: withTitle.title };
|
|
242
|
+
}
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Deterministic baseline decision
|
|
245
|
+
/**
|
|
246
|
+
* Build a `JudgeDecision` from an `AccountScore` with the deterministic baseline:
|
|
247
|
+
* whyNow taken VERBATIM from the top credited signal's quote (grounded by
|
|
248
|
+
* construction), no `play`, producedBy "deterministic".
|
|
249
|
+
*/
|
|
250
|
+
export function deterministicDecision(score) {
|
|
251
|
+
return {
|
|
252
|
+
accountDomain: score.accountDomain,
|
|
253
|
+
score: score.score,
|
|
254
|
+
decision: score.decision,
|
|
255
|
+
whyNow: deterministicWhyNow(score.topSignal),
|
|
256
|
+
play: "",
|
|
257
|
+
evidence: score.evidence,
|
|
258
|
+
...(score.skippedReason !== undefined ? { skippedReason: score.skippedReason } : {}),
|
|
259
|
+
producedBy: "deterministic",
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* A grounded why-now drawn verbatim from the signal's quote — capped to a
|
|
264
|
+
* sentence-ish span so it reads as a why-now, not the whole JD. We take the
|
|
265
|
+
* leading clause up to the first sentence boundary (or the whole quote if
|
|
266
|
+
* short), guaranteeing `isGroundedSpan(whyNow, quote)` holds.
|
|
267
|
+
*/
|
|
268
|
+
export function deterministicWhyNow(signal) {
|
|
269
|
+
const quote = signal.quote.trim();
|
|
270
|
+
// First sentence/clause boundary; keep ≥12 chars so the gate passes.
|
|
271
|
+
const match = quote.match(/^.{12,}?[.!?](\s|$)/);
|
|
272
|
+
const candidate = match ? match[0].trim() : quote;
|
|
273
|
+
// Cap length but only on a verbatim prefix (still a substring of the quote).
|
|
274
|
+
const capped = candidate.length > 180 ? candidate.slice(0, 180).trimEnd() : candidate;
|
|
275
|
+
// Guarantee groundedness against the full quote.
|
|
276
|
+
return isGroundedSpan(capped, quote) ? capped : quote.slice(0, Math.max(12, Math.min(quote.length, 180)));
|
|
277
|
+
}
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// LLM path: forced tool call + verbatim why-now gate, retry-once, then fall back
|
|
280
|
+
export const JUDGE_SCHEMA = {
|
|
281
|
+
type: "object",
|
|
282
|
+
required: ["decision", "whyNow", "play"],
|
|
283
|
+
properties: {
|
|
284
|
+
decision: { type: "string", enum: ["send", "nurture", "skip"] },
|
|
285
|
+
whyNow: {
|
|
286
|
+
type: "string",
|
|
287
|
+
description: "The reason to reach out NOW, quoted VERBATIM from one of the provided signal quotes. " +
|
|
288
|
+
"Copy a span character-for-character from a quote — never paraphrase, never invent. " +
|
|
289
|
+
"If no quote supports a reach-out, set decision to skip and whyNow to the strongest quote span anyway.",
|
|
290
|
+
},
|
|
291
|
+
play: {
|
|
292
|
+
type: "string",
|
|
293
|
+
description: "One line a rep could say out loud, grounded in the trigger. Max 25 words. No em dashes.",
|
|
294
|
+
},
|
|
295
|
+
skippedReason: { type: "string", description: "skip only: one phrase why this is not a send." },
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
export const DEFAULT_JUDGE_PROMPT = `You are a GTM judge deciding whether to reach out to an account based on fresh signals.
|
|
299
|
+
Score bands (the operator owns these — edit this prompt to change them):
|
|
300
|
+
- 80-100 (send): strong fit AND high intent — a live demand search, fresh funding, or >=2 clustered signals.
|
|
301
|
+
- 50-79 (nurture): good fit and one solid signal, but not enough to interrupt cold.
|
|
302
|
+
- 20-49 (skip): a weak or lone low-intent signal.
|
|
303
|
+
- 0-19 (skip): off-ICP or noise.
|
|
304
|
+
Rules:
|
|
305
|
+
- whyNow MUST be a verbatim span copied from one of the signal quotes below. If you cannot ground a why-now in a real quote, you cannot send.
|
|
306
|
+
- play is one line a rep could say out loud, tied to the trigger. No greeting, no "Hi {{firstName}}". No em dashes.
|
|
307
|
+
- Saying "skip" is a first-class, valuable answer. A judge that skips a lukewarm account is doing its job.`;
|
|
308
|
+
/**
|
|
309
|
+
* The LLM judge for one account. Forced tool call with `JUDGE_SCHEMA` + an
|
|
310
|
+
* editable prompt; then the verbatim-quote GATE: `whyNow` must be a normalized
|
|
311
|
+
* ≥12-char substring of one of the credited signal quotes. Retry once with
|
|
312
|
+
* corrective feedback (the market classify idiom), then fall back to the
|
|
313
|
+
* deterministic why-now — NEVER store an ungrounded why-now. The deterministic
|
|
314
|
+
* `score`/`band`/`decision`/`evidence` are authoritative; the model contributes
|
|
315
|
+
* `whyNow` (gated) and `play` only — score is not delegated to the model.
|
|
316
|
+
*/
|
|
317
|
+
export async function judgeDecisionLlm(score, promptTemplate, llm) {
|
|
318
|
+
const model = llm.model ?? "claude-haiku-4-5";
|
|
319
|
+
const producedBy = `llm:${llm.provider}:${model}`;
|
|
320
|
+
const quotes = score.evidence
|
|
321
|
+
.map((id, i) => {
|
|
322
|
+
const signal = findSignal(score, id);
|
|
323
|
+
return signal ? `[${i + 1}] (${signal.bucket}) "${signal.quote}"` : null;
|
|
324
|
+
})
|
|
325
|
+
.filter((line) => line !== null)
|
|
326
|
+
.join("\n");
|
|
327
|
+
const basePrompt = `${promptTemplate}
|
|
328
|
+
|
|
329
|
+
Account: ${score.accountDomain}
|
|
330
|
+
Computed band: ${score.band} (score ${score.score}/100; fit ${score.fit}, timing ${score.timing}${score.recentlyTouched ? "; touched within 7 days — do not pile on" : ""})
|
|
331
|
+
Signal quotes (your whyNow must be a verbatim span of ONE of these):
|
|
332
|
+
${quotes}`;
|
|
333
|
+
const attempt = async (feedback) => {
|
|
334
|
+
const result = (await forcedToolCall(feedback ? `${basePrompt}\n${feedback}` : basePrompt, "judge_account", JUDGE_SCHEMA, model, llm));
|
|
335
|
+
const whyNow = (result.whyNow ?? "").trim();
|
|
336
|
+
if (!grounded(score, whyNow))
|
|
337
|
+
return null; // gate failed
|
|
338
|
+
return {
|
|
339
|
+
accountDomain: score.accountDomain,
|
|
340
|
+
// Score/band/decision stay deterministic; memory cap already applied.
|
|
341
|
+
score: score.score,
|
|
342
|
+
decision: score.decision,
|
|
343
|
+
whyNow,
|
|
344
|
+
play: sanitizePlay(result.play ?? ""),
|
|
345
|
+
evidence: score.evidence,
|
|
346
|
+
...(score.skippedReason !== undefined ? { skippedReason: score.skippedReason } : {}),
|
|
347
|
+
producedBy,
|
|
348
|
+
};
|
|
349
|
+
};
|
|
350
|
+
const first = await attempt("");
|
|
351
|
+
if (first)
|
|
352
|
+
return first;
|
|
353
|
+
const retried = await attempt("\nYour previous whyNow was NOT a verbatim span of any signal quote. " +
|
|
354
|
+
"Copy a span character-for-character from one of the quotes above and answer again in full.");
|
|
355
|
+
if (retried)
|
|
356
|
+
return retried;
|
|
357
|
+
// Both attempts ungrounded — fall back to the deterministic (grounded) why-now,
|
|
358
|
+
// but keep the play empty (we won't ship an ungrounded play either).
|
|
359
|
+
return {
|
|
360
|
+
...deterministicDecision(score),
|
|
361
|
+
producedBy,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
function grounded(score, whyNow) {
|
|
365
|
+
for (const id of score.evidence) {
|
|
366
|
+
const signal = findSignal(score, id);
|
|
367
|
+
if (signal && isGroundedSpan(whyNow, signal.quote))
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
function findSignal(score, id) {
|
|
373
|
+
if (score.topSignal.id === id)
|
|
374
|
+
return score.topSignal;
|
|
375
|
+
return score._signalsById?.get(id);
|
|
376
|
+
}
|
|
377
|
+
/** Light play hygiene: strip em dashes (voice rule), collapse whitespace, cap length. */
|
|
378
|
+
function sanitizePlay(play) {
|
|
379
|
+
return play
|
|
380
|
+
.replace(/—/g, "-")
|
|
381
|
+
.replace(/\s+/g, " ")
|
|
382
|
+
.trim()
|
|
383
|
+
.slice(0, 240);
|
|
384
|
+
}
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Orchestration: group signals by account → score → decide (det. or LLM)
|
|
387
|
+
/**
|
|
388
|
+
* Judge a batch of signals: group by account, score each, and produce one
|
|
389
|
+
* decision per account (deterministic when no LLM options, gated-LLM otherwise).
|
|
390
|
+
* `--min-score` filtering and the snapshot/ICP wiring happen in the caller; this
|
|
391
|
+
* takes resolved inputs. Returns decisions sorted by score desc (the ranked list).
|
|
392
|
+
*/
|
|
393
|
+
export async function judgeSignals(opts) {
|
|
394
|
+
const now = opts.now ?? new Date();
|
|
395
|
+
const signalsById = new Map(opts.signals.map((s) => [s.id, s]));
|
|
396
|
+
const weights = computeWeights(opts.config, opts.outcomes ?? [], signalsById);
|
|
397
|
+
// Group signals by normalized account domain.
|
|
398
|
+
const byAccount = new Map();
|
|
399
|
+
for (const signal of opts.signals) {
|
|
400
|
+
const domain = normalizeAccountDomain(signal.accountDomain);
|
|
401
|
+
if (!domain)
|
|
402
|
+
continue;
|
|
403
|
+
const list = byAccount.get(domain) ?? [];
|
|
404
|
+
list.push(signal);
|
|
405
|
+
byAccount.set(domain, list);
|
|
406
|
+
}
|
|
407
|
+
const decisions = [];
|
|
408
|
+
for (const [domain, signals] of byAccount) {
|
|
409
|
+
const recentlyTouched = opts.withHistory && opts.snapshot ? accountRecentlyTouched(domain, opts.snapshot, now) : false;
|
|
410
|
+
const bestContact = opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
|
|
411
|
+
const score = scoreAccount({
|
|
412
|
+
accountDomain: domain,
|
|
413
|
+
signals,
|
|
414
|
+
weights,
|
|
415
|
+
icp: opts.icp,
|
|
416
|
+
bestContact,
|
|
417
|
+
recentlyTouched,
|
|
418
|
+
now,
|
|
419
|
+
});
|
|
420
|
+
// Attach the signal map so the LLM path can resolve credited quotes.
|
|
421
|
+
score._signalsById = signalsById;
|
|
422
|
+
const decision = opts.llm && opts.promptTemplate
|
|
423
|
+
? await judgeDecisionLlm(score, opts.promptTemplate, opts.llm)
|
|
424
|
+
: deterministicDecision(score);
|
|
425
|
+
decisions.push(decision);
|
|
426
|
+
}
|
|
427
|
+
return decisions.sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));
|
|
428
|
+
}
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// Store: per-label JSON judge runs, profile-scoped (mirrors the signal store).
|
|
431
|
+
export function judgeDir(baseDir) {
|
|
432
|
+
return join(baseDir ?? credentialsDir(), "judge");
|
|
433
|
+
}
|
|
434
|
+
export function createFileJudgeStore(directory) {
|
|
435
|
+
const dir = directory ?? judgeDir();
|
|
436
|
+
const runsDir = join(dir, "runs");
|
|
437
|
+
function fileFor(runLabel) {
|
|
438
|
+
if (!/^[\w.-]+$/.test(runLabel))
|
|
439
|
+
throw new Error(`Invalid run label: ${runLabel}`);
|
|
440
|
+
return join(runsDir, `${runLabel}.json`);
|
|
441
|
+
}
|
|
442
|
+
function read(runLabel) {
|
|
443
|
+
try {
|
|
444
|
+
return JSON.parse(readFileSync(fileFor(runLabel), "utf8"));
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
function write(run) {
|
|
451
|
+
// Run files carry account domains + verbatim source quotes; keep them
|
|
452
|
+
// owner-only like plan/enrich/signal files.
|
|
453
|
+
if (!directory)
|
|
454
|
+
ensureSecureHomeDir();
|
|
455
|
+
mkdirSync(runsDir, { recursive: true, mode: 0o700 });
|
|
456
|
+
writeSecureFile(fileFor(run.runLabel), `${JSON.stringify(run, null, 2)}\n`);
|
|
457
|
+
return run;
|
|
458
|
+
}
|
|
459
|
+
function listRunsSync() {
|
|
460
|
+
let names = [];
|
|
461
|
+
try {
|
|
462
|
+
names = readdirSync(runsDir).filter((name) => name.endsWith(".json"));
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return [];
|
|
466
|
+
}
|
|
467
|
+
return names
|
|
468
|
+
.map((name) => read(name.slice(0, -".json".length)))
|
|
469
|
+
.filter((run) => run !== null)
|
|
470
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
async appendRun(run) {
|
|
474
|
+
if (read(run.runLabel)) {
|
|
475
|
+
throw new Error(`Judge run "${run.runLabel}" already exists — runs are append-only; use a new run label`);
|
|
476
|
+
}
|
|
477
|
+
return write(run);
|
|
478
|
+
},
|
|
479
|
+
async getRun(runLabel) {
|
|
480
|
+
return read(runLabel);
|
|
481
|
+
},
|
|
482
|
+
async listRuns() {
|
|
483
|
+
return listRunsSync();
|
|
484
|
+
},
|
|
485
|
+
async latestRun() {
|
|
486
|
+
const runs = listRunsSync();
|
|
487
|
+
return runs.length ? runs[runs.length - 1] : null;
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { type JudgeDecision, type JudgeDecisionKind } from "./judge.ts";
|
|
2
|
+
import { type Signal, type SignalOutcome, type SignalsConfig } from "./signals.ts";
|
|
3
|
+
import type { Icp } from "./icp.ts";
|
|
4
|
+
/**
|
|
5
|
+
* The self-grading gate (`icp eval`). The judge (`icp judge`) makes a
|
|
6
|
+
* PROBABILISTIC claim — "this account is hot." Every other layer's correctness
|
|
7
|
+
* is a code review (audit rules are deterministic); the judge's is not. This
|
|
8
|
+
* module is the part every "AI SDR" tool skips: proving the judge is calibrated
|
|
9
|
+
* BEFORE it sends.
|
|
10
|
+
*
|
|
11
|
+
* Two gates, both read-only and free to run offline:
|
|
12
|
+
*
|
|
13
|
+
* 1. `gradeJudge(rows, judgeFn)` — run the judge over a labeled golden set
|
|
14
|
+
* ({signals, history, expectedDecision: send|skip}) and score agreement
|
|
15
|
+
* (accuracy + precision/recall on the binary send-vs-skip call, with a
|
|
16
|
+
* confusion matrix). `nurture` collapses to the non-send class: the live
|
|
17
|
+
* question the gate guards is "do we interrupt this account cold?" — and a
|
|
18
|
+
* nurture is a not-now, i.e. not a send. Ships a `DEFAULT_GOLDEN_SET` inline
|
|
19
|
+
* so `icp eval --golden default` runs with no file and no key.
|
|
20
|
+
*
|
|
21
|
+
* 2. `gradeAgainstOutcomes(decisions, outcomes)` — the EMPIRICAL gate: once real
|
|
22
|
+
* `signals outcome` results exist, do accounts the judge scored hot (>=80)
|
|
23
|
+
* book more than the ones it scored cold? `calibrated` is true iff
|
|
24
|
+
* hotBookRate > coldBookRate. "If it cannot prove hot beats cold, it does not
|
|
25
|
+
* send."
|
|
26
|
+
*
|
|
27
|
+
* The CLI wires both as exit-2 gates (the audit/`diff` convention) so they
|
|
28
|
+
* compose in front of any `apply`. This file is pure grading — no store, no
|
|
29
|
+
* network, no `process.exitCode` (the CLI owns the exit code).
|
|
30
|
+
*/
|
|
31
|
+
/** Account CRM memory for a golden row — `touched` caps a hot account to not-now. */
|
|
32
|
+
export type GoldenHistory = {
|
|
33
|
+
/** True if the account was touched within the judge's memory window. */
|
|
34
|
+
recentlyTouched?: boolean;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* One labeled example: the signals that fired on an account and the decision a
|
|
38
|
+
* human says the judge SHOULD reach (the binary send-vs-not the gate guards).
|
|
39
|
+
*/
|
|
40
|
+
export type GoldenRow = {
|
|
41
|
+
/** Optional human label, for readable failure output. */
|
|
42
|
+
label?: string;
|
|
43
|
+
signals: Signal[];
|
|
44
|
+
history?: GoldenHistory;
|
|
45
|
+
/** The ground-truth call: do we interrupt this account cold, or not? */
|
|
46
|
+
expectedDecision: "send" | "skip";
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* A judge under test: given a row's signals + history, return the judge's call.
|
|
50
|
+
* Accepts a `JudgeDecision`, a bare `JudgeDecisionKind`, or a `Promise` of
|
|
51
|
+
* either — so the real (async, LLM-capable) judge AND a one-line deterministic
|
|
52
|
+
* stub both fit. `nurture` is treated as not-send when graded.
|
|
53
|
+
*/
|
|
54
|
+
export type EvalJudgeFn = (row: GoldenRow) => JudgeDecision | JudgeDecisionKind | Promise<JudgeDecision | JudgeDecisionKind>;
|
|
55
|
+
/** 2x2 confusion matrix on the binary send-vs-skip call. */
|
|
56
|
+
export type Confusion = {
|
|
57
|
+
/** Predicted send, expected send. */
|
|
58
|
+
truePositive: number;
|
|
59
|
+
/** Predicted send, expected skip (a cold mail off a bad target). */
|
|
60
|
+
falsePositive: number;
|
|
61
|
+
/** Predicted skip, expected send (a missed hot account). */
|
|
62
|
+
falseNegative: number;
|
|
63
|
+
/** Predicted skip, expected skip (correctly held). */
|
|
64
|
+
trueNegative: number;
|
|
65
|
+
};
|
|
66
|
+
export type GradeResult = {
|
|
67
|
+
/** (tp + tn) / total — overall agreement. */
|
|
68
|
+
accuracy: number;
|
|
69
|
+
/** tp / (tp + fp) — of the accounts we'd mail, how many should we? 1 when none predicted send. */
|
|
70
|
+
precision: number;
|
|
71
|
+
/** tp / (tp + fn) — of the accounts we should mail, how many did we catch? 1 when none expected send. */
|
|
72
|
+
recall: number;
|
|
73
|
+
confusion: Confusion;
|
|
74
|
+
/** Rows graded (== rows.length). */
|
|
75
|
+
total: number;
|
|
76
|
+
};
|
|
77
|
+
export type OutcomeCalibration = {
|
|
78
|
+
/** Booked-meeting rate among decisions scored hot (>=80). */
|
|
79
|
+
hotBookRate: number;
|
|
80
|
+
/** Booked-meeting rate among decisions scored cold (<80). */
|
|
81
|
+
coldBookRate: number;
|
|
82
|
+
/** True iff hotBookRate > coldBookRate — the "hot beats cold" claim holds. */
|
|
83
|
+
calibrated: boolean;
|
|
84
|
+
/** Decisions in each bucket that had a recorded outcome (the denominators). */
|
|
85
|
+
hotCount: number;
|
|
86
|
+
coldCount: number;
|
|
87
|
+
};
|
|
88
|
+
/** Hot threshold for the empirical gate — mirrors the judge's strong band (>=80). */
|
|
89
|
+
export declare const HOT_SCORE = 80;
|
|
90
|
+
/** Default golden-set pass bar (`--min-accuracy`). */
|
|
91
|
+
export declare const DEFAULT_MIN_ACCURACY = 0.8;
|
|
92
|
+
/**
|
|
93
|
+
* Run `judgeFn` over each golden row and score agreement on the binary
|
|
94
|
+
* send-vs-skip call (nurture counts as not-send). Deterministic given a
|
|
95
|
+
* deterministic `judgeFn`; the only async is the judge itself (so an LLM judge
|
|
96
|
+
* can be graded with the same harness). Empty `rows` → perfect-but-vacuous
|
|
97
|
+
* scores (accuracy 1) — the CLI guards against an empty set separately.
|
|
98
|
+
*/
|
|
99
|
+
export declare function gradeJudge(rows: GoldenRow[], judgeFn: EvalJudgeFn): Promise<GradeResult>;
|
|
100
|
+
/**
|
|
101
|
+
* The deterministic judge wrapped as an `EvalJudgeFn`: group a row's signals by
|
|
102
|
+
* account, score the (single, by construction) account on timing × fit × memory
|
|
103
|
+
* via `scoreAccount`, and return the deterministic decision. This is the SAME
|
|
104
|
+
* scorer `icp judge` runs key-free, so `gradeJudge(rows, defaultJudgeFn())`
|
|
105
|
+
* grades the real baseline — not a re-implementation. A row with no signals is
|
|
106
|
+
* a skip (nothing changed → nothing to reach out about).
|
|
107
|
+
*/
|
|
108
|
+
export declare function defaultJudgeFn(opts?: {
|
|
109
|
+
config?: SignalsConfig;
|
|
110
|
+
icp?: Icp;
|
|
111
|
+
now?: Date;
|
|
112
|
+
}): EvalJudgeFn;
|
|
113
|
+
/**
|
|
114
|
+
* A starter labeled set covering the four rubric corners, so `icp eval --golden
|
|
115
|
+
* default` runs offline and the deterministic baseline passes it (>= the default
|
|
116
|
+
* min-accuracy). Each row is a single account.
|
|
117
|
+
*
|
|
118
|
+
* - SEND: a fresh clustered account (funding + reposted live req) — strong band.
|
|
119
|
+
* - SEND: a single high-weight demand/funding signal that still clears the bar.
|
|
120
|
+
* - SKIP: a lone low-intent social blip — weak band, first-class no.
|
|
121
|
+
* - SKIP: a single company-news mention with no in-persona trigger — weak.
|
|
122
|
+
* - SKIP: a hot account TOUCHED within the memory window — don't pile on.
|
|
123
|
+
*/
|
|
124
|
+
export declare const DEFAULT_GOLDEN_SET: GoldenRow[];
|
|
125
|
+
/**
|
|
126
|
+
* Parse a golden set from JSON or JSONL (one row per line). Validates each row
|
|
127
|
+
* up front and names the offending entry (the enrich/signals parse idiom). The
|
|
128
|
+
* literal "default" resolves to `DEFAULT_GOLDEN_SET` (handled by the CLI before
|
|
129
|
+
* reading a file; this parses an actual file's contents).
|
|
130
|
+
*/
|
|
131
|
+
export declare function parseGoldenSet(raw: string): GoldenRow[];
|
|
132
|
+
/**
|
|
133
|
+
* Grade the judge's decisions against accumulated `signals outcome` results: of
|
|
134
|
+
* the decisions scored hot (>=80) vs cold (<80), which books meetings more
|
|
135
|
+
* often? A decision is matched to outcomes by account domain (and an outcome
|
|
136
|
+
* "books" when its result is "meeting"). Accounts with no recorded outcome are
|
|
137
|
+
* excluded from the denominators. `calibrated` is true iff hot strictly beats
|
|
138
|
+
* cold — absent or inverted correlation is NOT calibrated (the CLI exits
|
|
139
|
+
* nonzero on it). Deterministic.
|
|
140
|
+
*/
|
|
141
|
+
export declare function gradeAgainstOutcomes(decisions: JudgeDecision[], outcomes: SignalOutcome[]): OutcomeCalibration;
|