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/src/judge.ts
ADDED
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
|
|
4
|
+
import { forcedToolCall, type LlmCallOptions } from "./llm.ts";
|
|
5
|
+
import { scoreProspectAgainstIcp, type Icp } from "./icp.ts";
|
|
6
|
+
import type { Signal, SignalBucket, SignalOutcome } from "./signals.ts";
|
|
7
|
+
import { computeWeights, normalizeAccountDomain } from "./signals.ts";
|
|
8
|
+
import type { CanonicalGtmSnapshot } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The judge layer: turn raw signals into a short ranked list of decisions on
|
|
12
|
+
* `timing × fit × memory`. Every other layer answers "is this CRM record
|
|
13
|
+
* correct?"; the judge answers "did something change at this account this week
|
|
14
|
+
* worth reaching out *about*, to the right person, whom we haven't already
|
|
15
|
+
* chased?" — and, crucially, says **no** when the answer is no.
|
|
16
|
+
*
|
|
17
|
+
* GOVERNANCE — structural, forever: judge is read-only. It reads signals + CRM
|
|
18
|
+
* history and writes a ranked DECISION artifact to the LOCAL judge store; it
|
|
19
|
+
* emits no `PatchOperation`, opens no plan, touches no provider record. `--save`
|
|
20
|
+
* persists a `JudgeRun` and stamps the consumed signals `judgedBy`. The write
|
|
21
|
+
* side is downstream and human-gated (`draft` → `plans approve` → `apply`).
|
|
22
|
+
*
|
|
23
|
+
* EVIDENCE GATE — structural: a decision's `whyNow` must be a verbatim
|
|
24
|
+
* normalized-whitespace substring of a credited signal's stored `quote`. The
|
|
25
|
+
* deterministic baseline takes the why-now verbatim from the top signal's quote
|
|
26
|
+
* (so it is grounded by construction); the LLM path is gated after the call —
|
|
27
|
+
* an ungrounded why-now is rejected (one retry, then fall back to the
|
|
28
|
+
* deterministic why-now). The brain NEVER stores an ungrounded why-now.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Types
|
|
33
|
+
|
|
34
|
+
export type JudgeDecisionKind = "send" | "nurture" | "skip";
|
|
35
|
+
|
|
36
|
+
export type JudgeDecision = {
|
|
37
|
+
accountDomain: string;
|
|
38
|
+
/** 0-100. */
|
|
39
|
+
score: number;
|
|
40
|
+
decision: JudgeDecisionKind;
|
|
41
|
+
/** MUST be a verbatim normalized-whitespace substring of a credited signal.quote. */
|
|
42
|
+
whyNow: string;
|
|
43
|
+
/** One line a rep could say (LLM), or "" (deterministic baseline). */
|
|
44
|
+
play: string;
|
|
45
|
+
/** Credited signal ids — the evidence anchors for this decision. */
|
|
46
|
+
evidence: string[];
|
|
47
|
+
skippedReason?: string;
|
|
48
|
+
/** "deterministic" | "llm:<provider>:<model>". */
|
|
49
|
+
producedBy: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export type JudgeRun = {
|
|
53
|
+
id: string;
|
|
54
|
+
runLabel: string;
|
|
55
|
+
/** The signal run this judgement consumed. */
|
|
56
|
+
signalRunLabel: string;
|
|
57
|
+
createdAt: string;
|
|
58
|
+
decisions: JudgeDecision[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Score bands (article rubric) — 80-100 / 50-79 / 20-49 / 0-19
|
|
63
|
+
|
|
64
|
+
export type JudgeBand = "strong" | "good" | "weak" | "noise";
|
|
65
|
+
|
|
66
|
+
export function scoreBand(score: number): JudgeBand {
|
|
67
|
+
if (score >= 80) return "strong";
|
|
68
|
+
if (score >= 50) return "good";
|
|
69
|
+
if (score >= 20) return "weak";
|
|
70
|
+
return "noise";
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Stable hashing (FNV-1a) — duplicated to keep this file importable without the
|
|
75
|
+
// audit engine (the signals.ts/market.ts/enrich.ts precedent).
|
|
76
|
+
|
|
77
|
+
function fnv1a(value: string): string {
|
|
78
|
+
let hash = 0x811c9dc5;
|
|
79
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
80
|
+
hash ^= value.charCodeAt(i);
|
|
81
|
+
hash = Math.imul(hash, 0x01000193);
|
|
82
|
+
}
|
|
83
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function judgeRunId(runLabel: string): string {
|
|
87
|
+
return `jdg_${fnv1a(runLabel)}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whitespace/punctuation-spacing-normalized, lowercased match — the EXACT rule
|
|
92
|
+
* `call`/`market` use for verbatim evidence gates (llm.ts normalizeSpan). A
|
|
93
|
+
* local copy keeps this file importable without the LLM engine (the cross-module
|
|
94
|
+
* duplication precedent in signals.ts/market.ts).
|
|
95
|
+
*/
|
|
96
|
+
export function normalizeSpan(value: string): string {
|
|
97
|
+
return value
|
|
98
|
+
.replace(/\s+([.,;:!?])/g, "$1")
|
|
99
|
+
.replace(/\s+/g, " ")
|
|
100
|
+
.trim()
|
|
101
|
+
.toLowerCase();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Verbatim evidence gate: `produced` must be a ≥12-char normalized substring of `source`. */
|
|
105
|
+
export function isGroundedSpan(produced: string, source: string): boolean {
|
|
106
|
+
const span = normalizeSpan(produced);
|
|
107
|
+
if (span.length < 12) return false;
|
|
108
|
+
return normalizeSpan(source).includes(span);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Scoring: timing × fit × memory
|
|
113
|
+
|
|
114
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
115
|
+
|
|
116
|
+
/** Default freshness/clustering knobs. Editable via the deterministic scorer's opts. */
|
|
117
|
+
const RECENCY_FLOOR = 0.4;
|
|
118
|
+
const RECENCY_WINDOW_DAYS = 30;
|
|
119
|
+
/** Flat timing-norm boost when ≥2 fresh signals cluster (the "strong fit + high intent" band). */
|
|
120
|
+
const CLUSTER_BONUS = 0.25;
|
|
121
|
+
const FRESH_SIGNAL_DAYS = 14;
|
|
122
|
+
/** "Touched recently" memory window — pile-on guard. */
|
|
123
|
+
const TOUCHED_WINDOW_DAYS = 7;
|
|
124
|
+
|
|
125
|
+
function recencyFactor(firstSeen: string, now: Date, windowDays = RECENCY_WINDOW_DAYS): number {
|
|
126
|
+
const seen = Date.parse(firstSeen);
|
|
127
|
+
if (!Number.isFinite(seen)) return 1;
|
|
128
|
+
const ageDays = Math.max(0, (now.getTime() - seen) / DAY_MS);
|
|
129
|
+
if (windowDays <= 0) return 1;
|
|
130
|
+
const decayed = 1 - (1 - RECENCY_FLOOR) * Math.min(1, ageDays / windowDays);
|
|
131
|
+
return Math.max(RECENCY_FLOOR, decayed);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isFresh(firstSeen: string, now: Date): boolean {
|
|
135
|
+
const seen = Date.parse(firstSeen);
|
|
136
|
+
if (!Number.isFinite(seen)) return false;
|
|
137
|
+
return now.getTime() - seen <= FRESH_SIGNAL_DAYS * DAY_MS;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export type AccountScore = {
|
|
141
|
+
accountDomain: string;
|
|
142
|
+
/** 0-100. */
|
|
143
|
+
score: number;
|
|
144
|
+
band: JudgeBand;
|
|
145
|
+
decision: JudgeDecisionKind;
|
|
146
|
+
/** The single highest-credited signal — the why-now anchor. */
|
|
147
|
+
topSignal: Signal;
|
|
148
|
+
/** All credited signal ids (the cluster). */
|
|
149
|
+
evidence: string[];
|
|
150
|
+
/** Raw 0..1 fit against the best-matching contact. */
|
|
151
|
+
fit: number;
|
|
152
|
+
/** Raw timing component (max bucket weight × recency [+cluster bonus]). */
|
|
153
|
+
timing: number;
|
|
154
|
+
/** True when the account was touched within the memory window (caps to nurture/skip). */
|
|
155
|
+
recentlyTouched: boolean;
|
|
156
|
+
skippedReason?: string;
|
|
157
|
+
/**
|
|
158
|
+
* Internal: every credited signal indexed by id, attached by `judgeSignals`
|
|
159
|
+
* so the LLM path can resolve quotes beyond `topSignal`. Not part of the
|
|
160
|
+
* stored artifact.
|
|
161
|
+
*/
|
|
162
|
+
_signalsById?: Map<string, Signal>;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Score one account on timing × fit × memory and map to a 0-100 score + band +
|
|
167
|
+
* decision. Deterministic.
|
|
168
|
+
*
|
|
169
|
+
* - **timing**: each credited signal's bucket weight (learned overlay over
|
|
170
|
+
* config defaults via `computeWeights`) × recency decay × its already-baked
|
|
171
|
+
* repost boost; the account's timing is the MAX credited signal weight, plus a
|
|
172
|
+
* cluster bonus when ≥2 signals are fresh (the article's compounding "fresh
|
|
173
|
+
* round AND a live req" case → 80-100 band).
|
|
174
|
+
* - **fit**: `scoreProspectAgainstIcp(bestContact, icp)` (0..1). No ICP / no
|
|
175
|
+
* contact → a neutral 0.5 so a strong, well-grounded timing signal can still
|
|
176
|
+
* surface (fit then doesn't sink an otherwise-hot account).
|
|
177
|
+
* - **memory** (`recentlyTouched`): touched within `TOUCHED_WINDOW_DAYS` → never
|
|
178
|
+
* pile on: cap the decision at `nurture` (or `skip` if already lukewarm).
|
|
179
|
+
*
|
|
180
|
+
* `decision` from the band: strong→send, good→nurture, weak/noise→skip — then
|
|
181
|
+
* the memory cap applies. `skip` is a first-class output: a lone low-intent
|
|
182
|
+
* signal (e.g. a single `social` blip) lands in `weak`/`noise` and is skipped
|
|
183
|
+
* with a reason, not mailed.
|
|
184
|
+
*/
|
|
185
|
+
export function scoreAccount(opts: {
|
|
186
|
+
accountDomain: string;
|
|
187
|
+
signals: Signal[];
|
|
188
|
+
weights: Record<SignalBucket, number>;
|
|
189
|
+
icp?: Icp;
|
|
190
|
+
bestContact?: { jobTitle?: string; jobLevel?: string; jobDepartment?: string; headline?: string };
|
|
191
|
+
recentlyTouched?: boolean;
|
|
192
|
+
now?: Date;
|
|
193
|
+
}): AccountScore {
|
|
194
|
+
const now = opts.now ?? new Date();
|
|
195
|
+
const domain = normalizeAccountDomain(opts.accountDomain);
|
|
196
|
+
const signals = opts.signals.filter((s) => normalizeAccountDomain(s.accountDomain) === domain);
|
|
197
|
+
if (signals.length === 0) {
|
|
198
|
+
throw new Error(`scoreAccount: no signals for ${opts.accountDomain}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Timing: per-signal effective weight = learned bucket weight × recency,
|
|
202
|
+
// scaled by the signal's already-baked repost/recency weight ratio so the
|
|
203
|
+
// store's repost boost still counts. The account's timing is the max.
|
|
204
|
+
let topSignal = signals[0];
|
|
205
|
+
let maxWeight = -Infinity;
|
|
206
|
+
for (const signal of signals) {
|
|
207
|
+
const bucketWeight = opts.weights[signal.bucket] ?? 0;
|
|
208
|
+
const effective = bucketWeight * recencyFactor(signal.firstSeen, now) + repostBoostOf(signal);
|
|
209
|
+
if (effective > maxWeight) {
|
|
210
|
+
maxWeight = effective;
|
|
211
|
+
topSignal = signal;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const freshCount = signals.filter((s) => isFresh(s.firstSeen, now)).length;
|
|
215
|
+
const isCluster = freshCount >= 2;
|
|
216
|
+
const timing = Math.max(0, maxWeight);
|
|
217
|
+
|
|
218
|
+
// Fit: best-matching contact vs ICP, neutral 0.5 when unknown so timing leads.
|
|
219
|
+
const fit =
|
|
220
|
+
opts.icp && opts.bestContact ? scoreProspectAgainstIcp(opts.bestContact, opts.icp).score : 0.5;
|
|
221
|
+
|
|
222
|
+
// Map timing×fit to 0-100 along two axes (the article rubric):
|
|
223
|
+
// - timingNorm (0..1): timing relative to TIMING_REF (~a single strong-bucket
|
|
224
|
+
// fresh signal at funding/demand weight). A lone strong signal lands in the
|
|
225
|
+
// "good" band; a lone low-intent signal (social ≈ 0.4) stays weak/noise.
|
|
226
|
+
// - A fresh ≥2-signal CLUSTER adds a flat boost so "a fresh round AND a live
|
|
227
|
+
// req" reaches the 80-100 strong band — the rubric's compounding case.
|
|
228
|
+
// - fitMultiplier (0.7..1.0): fit SHAPES the score ±30% but never zeroes a
|
|
229
|
+
// well-timed account; unknown fit (0.5) sits in the middle at 0.85.
|
|
230
|
+
const TIMING_REF = 2.0;
|
|
231
|
+
let timingNorm = Math.min(1, timing / TIMING_REF);
|
|
232
|
+
if (isCluster) timingNorm = Math.min(1, timingNorm + CLUSTER_BONUS);
|
|
233
|
+
const fitMultiplier = 0.7 + 0.3 * fit;
|
|
234
|
+
const raw = timingNorm * fitMultiplier;
|
|
235
|
+
let score = Math.round(Math.max(0, Math.min(1, raw)) * 100);
|
|
236
|
+
|
|
237
|
+
const band = scoreBand(score);
|
|
238
|
+
let decision: JudgeDecisionKind = band === "strong" ? "send" : band === "good" ? "nurture" : "skip";
|
|
239
|
+
let skippedReason: string | undefined;
|
|
240
|
+
if (decision === "skip") {
|
|
241
|
+
skippedReason =
|
|
242
|
+
band === "noise"
|
|
243
|
+
? "off-ICP / low-intent noise — no fresh, in-persona trigger"
|
|
244
|
+
: "weak lone signal — one low-intent trigger, not enough to reach out about";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Memory cap: touched within the window → never pile on. send→nurture,
|
|
248
|
+
// nurture→skip, skip stays skip.
|
|
249
|
+
const recentlyTouched = Boolean(opts.recentlyTouched);
|
|
250
|
+
if (recentlyTouched && decision !== "skip") {
|
|
251
|
+
if (decision === "send") {
|
|
252
|
+
decision = "nurture";
|
|
253
|
+
} else {
|
|
254
|
+
decision = "skip";
|
|
255
|
+
skippedReason = `touched within ${TOUCHED_WINDOW_DAYS}d — already engaged; don't pile on`;
|
|
256
|
+
}
|
|
257
|
+
// Reflect the pile-on guard in the score so ranking de-prioritizes it, but
|
|
258
|
+
// keep the band-decision mapping coherent for nurture (50-79).
|
|
259
|
+
if (decision === "nurture" && score >= 80) score = 79;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Evidence = the credited cluster (all signals on this account this run).
|
|
263
|
+
const evidence = signals.map((s) => s.id);
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
accountDomain: domain,
|
|
267
|
+
score,
|
|
268
|
+
band,
|
|
269
|
+
decision,
|
|
270
|
+
topSignal,
|
|
271
|
+
evidence,
|
|
272
|
+
fit: Number(fit.toFixed(3)),
|
|
273
|
+
timing: Number(timing.toFixed(4)),
|
|
274
|
+
recentlyTouched,
|
|
275
|
+
...(skippedReason !== undefined ? { skippedReason } : {}),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The repost boost already baked into a signal's stored weight, recovered as
|
|
281
|
+
* weight − (weight without boost would be) — but since we don't have the
|
|
282
|
+
* un-boosted weight here, we credit a small fixed bump only when the signal is
|
|
283
|
+
* flagged `reposted`. Deterministic, additive, bounded.
|
|
284
|
+
*/
|
|
285
|
+
function repostBoostOf(signal: Signal): number {
|
|
286
|
+
return signal.reposted ? 0.5 : 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------------------------------------------------------------------------
|
|
290
|
+
// Memory: read account activity from the canonical snapshot
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Has this account been touched within `TOUCHED_WINDOW_DAYS`? Reads the snapshot:
|
|
294
|
+
* the account record's `lastActivityAt`, plus any `activities` whose `occurredAt`
|
|
295
|
+
* is recent and that link to the account (directly or via a contact at it). Pure;
|
|
296
|
+
* with no snapshot, returns false (the no-history path — caller passes `false`).
|
|
297
|
+
*/
|
|
298
|
+
export function accountRecentlyTouched(
|
|
299
|
+
accountDomain: string,
|
|
300
|
+
snapshot: CanonicalGtmSnapshot,
|
|
301
|
+
now: Date = new Date(),
|
|
302
|
+
windowDays = TOUCHED_WINDOW_DAYS,
|
|
303
|
+
): boolean {
|
|
304
|
+
const domain = normalizeAccountDomain(accountDomain);
|
|
305
|
+
const cutoff = now.getTime() - windowDays * DAY_MS;
|
|
306
|
+
|
|
307
|
+
const account = (snapshot.accounts ?? []).find(
|
|
308
|
+
(a) => normalizeAccountDomain(a.domain ?? "") === domain && domain !== "",
|
|
309
|
+
);
|
|
310
|
+
if (account?.lastActivityAt) {
|
|
311
|
+
const t = Date.parse(account.lastActivityAt);
|
|
312
|
+
if (Number.isFinite(t) && t >= cutoff) return true;
|
|
313
|
+
}
|
|
314
|
+
if (!account) return false;
|
|
315
|
+
|
|
316
|
+
const contactIds = new Set(
|
|
317
|
+
(snapshot.contacts ?? []).filter((c) => c.accountId === account.id).map((c) => c.id),
|
|
318
|
+
);
|
|
319
|
+
for (const contact of snapshot.contacts ?? []) {
|
|
320
|
+
if (contact.accountId !== account.id) continue;
|
|
321
|
+
if (contact.lastActivityAt) {
|
|
322
|
+
const t = Date.parse(contact.lastActivityAt);
|
|
323
|
+
if (Number.isFinite(t) && t >= cutoff) return true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const activity of snapshot.activities ?? []) {
|
|
327
|
+
const linked = activity.accountId === account.id || (activity.contactId && contactIds.has(activity.contactId));
|
|
328
|
+
if (!linked) continue;
|
|
329
|
+
const t = Date.parse(activity.occurredAt);
|
|
330
|
+
if (Number.isFinite(t) && t >= cutoff) return true;
|
|
331
|
+
}
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Find the best-matching contact for an account from the snapshot, for fit
|
|
337
|
+
* scoring: the account's contacts, preferring one with a title (a title is what
|
|
338
|
+
* fit scores on). Returns undefined when the account/contact isn't in snapshot.
|
|
339
|
+
*/
|
|
340
|
+
export function bestContactForAccount(
|
|
341
|
+
accountDomain: string,
|
|
342
|
+
snapshot: CanonicalGtmSnapshot,
|
|
343
|
+
): { jobTitle?: string; jobLevel?: string; jobDepartment?: string; headline?: string } | undefined {
|
|
344
|
+
const domain = normalizeAccountDomain(accountDomain);
|
|
345
|
+
if (!domain) return undefined;
|
|
346
|
+
const account = (snapshot.accounts ?? []).find((a) => normalizeAccountDomain(a.domain ?? "") === domain);
|
|
347
|
+
if (!account) return undefined;
|
|
348
|
+
const contacts = (snapshot.contacts ?? []).filter((c) => c.accountId === account.id);
|
|
349
|
+
const withTitle = contacts.find((c) => c.title) ?? contacts[0];
|
|
350
|
+
if (!withTitle) return undefined;
|
|
351
|
+
return { jobTitle: withTitle.title };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Deterministic baseline decision
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Build a `JudgeDecision` from an `AccountScore` with the deterministic baseline:
|
|
359
|
+
* whyNow taken VERBATIM from the top credited signal's quote (grounded by
|
|
360
|
+
* construction), no `play`, producedBy "deterministic".
|
|
361
|
+
*/
|
|
362
|
+
export function deterministicDecision(score: AccountScore): JudgeDecision {
|
|
363
|
+
return {
|
|
364
|
+
accountDomain: score.accountDomain,
|
|
365
|
+
score: score.score,
|
|
366
|
+
decision: score.decision,
|
|
367
|
+
whyNow: deterministicWhyNow(score.topSignal),
|
|
368
|
+
play: "",
|
|
369
|
+
evidence: score.evidence,
|
|
370
|
+
...(score.skippedReason !== undefined ? { skippedReason: score.skippedReason } : {}),
|
|
371
|
+
producedBy: "deterministic",
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* A grounded why-now drawn verbatim from the signal's quote — capped to a
|
|
377
|
+
* sentence-ish span so it reads as a why-now, not the whole JD. We take the
|
|
378
|
+
* leading clause up to the first sentence boundary (or the whole quote if
|
|
379
|
+
* short), guaranteeing `isGroundedSpan(whyNow, quote)` holds.
|
|
380
|
+
*/
|
|
381
|
+
export function deterministicWhyNow(signal: Signal): string {
|
|
382
|
+
const quote = signal.quote.trim();
|
|
383
|
+
// First sentence/clause boundary; keep ≥12 chars so the gate passes.
|
|
384
|
+
const match = quote.match(/^.{12,}?[.!?](\s|$)/);
|
|
385
|
+
const candidate = match ? match[0].trim() : quote;
|
|
386
|
+
// Cap length but only on a verbatim prefix (still a substring of the quote).
|
|
387
|
+
const capped = candidate.length > 180 ? candidate.slice(0, 180).trimEnd() : candidate;
|
|
388
|
+
// Guarantee groundedness against the full quote.
|
|
389
|
+
return isGroundedSpan(capped, quote) ? capped : quote.slice(0, Math.max(12, Math.min(quote.length, 180)));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
// LLM path: forced tool call + verbatim why-now gate, retry-once, then fall back
|
|
394
|
+
|
|
395
|
+
export const JUDGE_SCHEMA = {
|
|
396
|
+
type: "object",
|
|
397
|
+
required: ["decision", "whyNow", "play"],
|
|
398
|
+
properties: {
|
|
399
|
+
decision: { type: "string", enum: ["send", "nurture", "skip"] },
|
|
400
|
+
whyNow: {
|
|
401
|
+
type: "string",
|
|
402
|
+
description:
|
|
403
|
+
"The reason to reach out NOW, quoted VERBATIM from one of the provided signal quotes. " +
|
|
404
|
+
"Copy a span character-for-character from a quote — never paraphrase, never invent. " +
|
|
405
|
+
"If no quote supports a reach-out, set decision to skip and whyNow to the strongest quote span anyway.",
|
|
406
|
+
},
|
|
407
|
+
play: {
|
|
408
|
+
type: "string",
|
|
409
|
+
description: "One line a rep could say out loud, grounded in the trigger. Max 25 words. No em dashes.",
|
|
410
|
+
},
|
|
411
|
+
skippedReason: { type: "string", description: "skip only: one phrase why this is not a send." },
|
|
412
|
+
},
|
|
413
|
+
} as const;
|
|
414
|
+
|
|
415
|
+
export const DEFAULT_JUDGE_PROMPT = `You are a GTM judge deciding whether to reach out to an account based on fresh signals.
|
|
416
|
+
Score bands (the operator owns these — edit this prompt to change them):
|
|
417
|
+
- 80-100 (send): strong fit AND high intent — a live demand search, fresh funding, or >=2 clustered signals.
|
|
418
|
+
- 50-79 (nurture): good fit and one solid signal, but not enough to interrupt cold.
|
|
419
|
+
- 20-49 (skip): a weak or lone low-intent signal.
|
|
420
|
+
- 0-19 (skip): off-ICP or noise.
|
|
421
|
+
Rules:
|
|
422
|
+
- 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.
|
|
423
|
+
- play is one line a rep could say out loud, tied to the trigger. No greeting, no "Hi {{firstName}}". No em dashes.
|
|
424
|
+
- Saying "skip" is a first-class, valuable answer. A judge that skips a lukewarm account is doing its job.`;
|
|
425
|
+
|
|
426
|
+
type JudgeLlmResult = {
|
|
427
|
+
decision?: JudgeDecisionKind;
|
|
428
|
+
whyNow?: string;
|
|
429
|
+
play?: string;
|
|
430
|
+
skippedReason?: string;
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* The LLM judge for one account. Forced tool call with `JUDGE_SCHEMA` + an
|
|
435
|
+
* editable prompt; then the verbatim-quote GATE: `whyNow` must be a normalized
|
|
436
|
+
* ≥12-char substring of one of the credited signal quotes. Retry once with
|
|
437
|
+
* corrective feedback (the market classify idiom), then fall back to the
|
|
438
|
+
* deterministic why-now — NEVER store an ungrounded why-now. The deterministic
|
|
439
|
+
* `score`/`band`/`decision`/`evidence` are authoritative; the model contributes
|
|
440
|
+
* `whyNow` (gated) and `play` only — score is not delegated to the model.
|
|
441
|
+
*/
|
|
442
|
+
export async function judgeDecisionLlm(
|
|
443
|
+
score: AccountScore,
|
|
444
|
+
promptTemplate: string,
|
|
445
|
+
llm: LlmCallOptions,
|
|
446
|
+
): Promise<JudgeDecision> {
|
|
447
|
+
const model = llm.model ?? "claude-haiku-4-5";
|
|
448
|
+
const producedBy = `llm:${llm.provider}:${model}`;
|
|
449
|
+
const quotes = score.evidence
|
|
450
|
+
.map((id, i) => {
|
|
451
|
+
const signal = findSignal(score, id);
|
|
452
|
+
return signal ? `[${i + 1}] (${signal.bucket}) "${signal.quote}"` : null;
|
|
453
|
+
})
|
|
454
|
+
.filter((line): line is string => line !== null)
|
|
455
|
+
.join("\n");
|
|
456
|
+
const basePrompt = `${promptTemplate}
|
|
457
|
+
|
|
458
|
+
Account: ${score.accountDomain}
|
|
459
|
+
Computed band: ${score.band} (score ${score.score}/100; fit ${score.fit}, timing ${score.timing}${
|
|
460
|
+
score.recentlyTouched ? "; touched within 7 days — do not pile on" : ""
|
|
461
|
+
})
|
|
462
|
+
Signal quotes (your whyNow must be a verbatim span of ONE of these):
|
|
463
|
+
${quotes}`;
|
|
464
|
+
|
|
465
|
+
const attempt = async (feedback: string): Promise<JudgeDecision | null> => {
|
|
466
|
+
const result = (await forcedToolCall(
|
|
467
|
+
feedback ? `${basePrompt}\n${feedback}` : basePrompt,
|
|
468
|
+
"judge_account",
|
|
469
|
+
JUDGE_SCHEMA,
|
|
470
|
+
model,
|
|
471
|
+
llm,
|
|
472
|
+
)) as JudgeLlmResult;
|
|
473
|
+
const whyNow = (result.whyNow ?? "").trim();
|
|
474
|
+
if (!grounded(score, whyNow)) return null; // gate failed
|
|
475
|
+
return {
|
|
476
|
+
accountDomain: score.accountDomain,
|
|
477
|
+
// Score/band/decision stay deterministic; memory cap already applied.
|
|
478
|
+
score: score.score,
|
|
479
|
+
decision: score.decision,
|
|
480
|
+
whyNow,
|
|
481
|
+
play: sanitizePlay(result.play ?? ""),
|
|
482
|
+
evidence: score.evidence,
|
|
483
|
+
...(score.skippedReason !== undefined ? { skippedReason: score.skippedReason } : {}),
|
|
484
|
+
producedBy,
|
|
485
|
+
};
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const first = await attempt("");
|
|
489
|
+
if (first) return first;
|
|
490
|
+
const retried = await attempt(
|
|
491
|
+
"\nYour previous whyNow was NOT a verbatim span of any signal quote. " +
|
|
492
|
+
"Copy a span character-for-character from one of the quotes above and answer again in full.",
|
|
493
|
+
);
|
|
494
|
+
if (retried) return retried;
|
|
495
|
+
|
|
496
|
+
// Both attempts ungrounded — fall back to the deterministic (grounded) why-now,
|
|
497
|
+
// but keep the play empty (we won't ship an ungrounded play either).
|
|
498
|
+
return {
|
|
499
|
+
...deterministicDecision(score),
|
|
500
|
+
producedBy,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function grounded(score: AccountScore, whyNow: string): boolean {
|
|
505
|
+
for (const id of score.evidence) {
|
|
506
|
+
const signal = findSignal(score, id);
|
|
507
|
+
if (signal && isGroundedSpan(whyNow, signal.quote)) return true;
|
|
508
|
+
}
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function findSignal(score: AccountScore, id: string): Signal | undefined {
|
|
513
|
+
if (score.topSignal.id === id) return score.topSignal;
|
|
514
|
+
return score._signalsById?.get(id);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Light play hygiene: strip em dashes (voice rule), collapse whitespace, cap length. */
|
|
518
|
+
function sanitizePlay(play: string): string {
|
|
519
|
+
return play
|
|
520
|
+
.replace(/—/g, "-")
|
|
521
|
+
.replace(/\s+/g, " ")
|
|
522
|
+
.trim()
|
|
523
|
+
.slice(0, 240);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ---------------------------------------------------------------------------
|
|
527
|
+
// Orchestration: group signals by account → score → decide (det. or LLM)
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Judge a batch of signals: group by account, score each, and produce one
|
|
531
|
+
* decision per account (deterministic when no LLM options, gated-LLM otherwise).
|
|
532
|
+
* `--min-score` filtering and the snapshot/ICP wiring happen in the caller; this
|
|
533
|
+
* takes resolved inputs. Returns decisions sorted by score desc (the ranked list).
|
|
534
|
+
*/
|
|
535
|
+
export async function judgeSignals(opts: {
|
|
536
|
+
signals: Signal[];
|
|
537
|
+
outcomes?: SignalOutcome[];
|
|
538
|
+
config: Parameters<typeof computeWeights>[0];
|
|
539
|
+
icp?: Icp;
|
|
540
|
+
snapshot?: CanonicalGtmSnapshot;
|
|
541
|
+
withHistory?: boolean;
|
|
542
|
+
promptTemplate?: string;
|
|
543
|
+
llm?: LlmCallOptions;
|
|
544
|
+
now?: Date;
|
|
545
|
+
}): Promise<JudgeDecision[]> {
|
|
546
|
+
const now = opts.now ?? new Date();
|
|
547
|
+
const signalsById = new Map(opts.signals.map((s) => [s.id, s]));
|
|
548
|
+
const weights = computeWeights(opts.config, opts.outcomes ?? [], signalsById);
|
|
549
|
+
|
|
550
|
+
// Group signals by normalized account domain.
|
|
551
|
+
const byAccount = new Map<string, Signal[]>();
|
|
552
|
+
for (const signal of opts.signals) {
|
|
553
|
+
const domain = normalizeAccountDomain(signal.accountDomain);
|
|
554
|
+
if (!domain) continue;
|
|
555
|
+
const list = byAccount.get(domain) ?? [];
|
|
556
|
+
list.push(signal);
|
|
557
|
+
byAccount.set(domain, list);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const decisions: JudgeDecision[] = [];
|
|
561
|
+
for (const [domain, signals] of byAccount) {
|
|
562
|
+
const recentlyTouched =
|
|
563
|
+
opts.withHistory && opts.snapshot ? accountRecentlyTouched(domain, opts.snapshot, now) : false;
|
|
564
|
+
const bestContact =
|
|
565
|
+
opts.icp && opts.snapshot ? bestContactForAccount(domain, opts.snapshot) : undefined;
|
|
566
|
+
const score = scoreAccount({
|
|
567
|
+
accountDomain: domain,
|
|
568
|
+
signals,
|
|
569
|
+
weights,
|
|
570
|
+
icp: opts.icp,
|
|
571
|
+
bestContact,
|
|
572
|
+
recentlyTouched,
|
|
573
|
+
now,
|
|
574
|
+
});
|
|
575
|
+
// Attach the signal map so the LLM path can resolve credited quotes.
|
|
576
|
+
score._signalsById = signalsById;
|
|
577
|
+
const decision =
|
|
578
|
+
opts.llm && opts.promptTemplate
|
|
579
|
+
? await judgeDecisionLlm(score, opts.promptTemplate, opts.llm)
|
|
580
|
+
: deterministicDecision(score);
|
|
581
|
+
decisions.push(decision);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return decisions.sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
// Store: per-label JSON judge runs, profile-scoped (mirrors the signal store).
|
|
589
|
+
|
|
590
|
+
export function judgeDir(baseDir?: string): string {
|
|
591
|
+
return join(baseDir ?? credentialsDir(), "judge");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export interface JudgeStore {
|
|
595
|
+
/** Append a new judge run; refuses an existing label (runs are append-only). */
|
|
596
|
+
appendRun(run: JudgeRun): Promise<JudgeRun>;
|
|
597
|
+
getRun(runLabel: string): Promise<JudgeRun | null>;
|
|
598
|
+
listRuns(): Promise<JudgeRun[]>;
|
|
599
|
+
latestRun(): Promise<JudgeRun | null>;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function createFileJudgeStore(directory?: string): JudgeStore {
|
|
603
|
+
const dir = directory ?? judgeDir();
|
|
604
|
+
const runsDir = join(dir, "runs");
|
|
605
|
+
|
|
606
|
+
function fileFor(runLabel: string): string {
|
|
607
|
+
if (!/^[\w.-]+$/.test(runLabel)) throw new Error(`Invalid run label: ${runLabel}`);
|
|
608
|
+
return join(runsDir, `${runLabel}.json`);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function read(runLabel: string): JudgeRun | null {
|
|
612
|
+
try {
|
|
613
|
+
return JSON.parse(readFileSync(fileFor(runLabel), "utf8")) as JudgeRun;
|
|
614
|
+
} catch {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function write(run: JudgeRun): JudgeRun {
|
|
620
|
+
// Run files carry account domains + verbatim source quotes; keep them
|
|
621
|
+
// owner-only like plan/enrich/signal files.
|
|
622
|
+
if (!directory) ensureSecureHomeDir();
|
|
623
|
+
mkdirSync(runsDir, { recursive: true, mode: 0o700 });
|
|
624
|
+
writeSecureFile(fileFor(run.runLabel), `${JSON.stringify(run, null, 2)}\n`);
|
|
625
|
+
return run;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function listRunsSync(): JudgeRun[] {
|
|
629
|
+
let names: string[] = [];
|
|
630
|
+
try {
|
|
631
|
+
names = readdirSync(runsDir).filter((name) => name.endsWith(".json"));
|
|
632
|
+
} catch {
|
|
633
|
+
return [];
|
|
634
|
+
}
|
|
635
|
+
return names
|
|
636
|
+
.map((name) => read(name.slice(0, -".json".length)))
|
|
637
|
+
.filter((run): run is JudgeRun => run !== null)
|
|
638
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return {
|
|
642
|
+
async appendRun(run) {
|
|
643
|
+
if (read(run.runLabel)) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
`Judge run "${run.runLabel}" already exists — runs are append-only; use a new run label`,
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
return write(run);
|
|
649
|
+
},
|
|
650
|
+
async getRun(runLabel) {
|
|
651
|
+
return read(runLabel);
|
|
652
|
+
},
|
|
653
|
+
async listRuns() {
|
|
654
|
+
return listRunsSync();
|
|
655
|
+
},
|
|
656
|
+
async latestRun() {
|
|
657
|
+
const runs = listRunsSync();
|
|
658
|
+
return runs.length ? runs[runs.length - 1] : null;
|
|
659
|
+
},
|
|
660
|
+
};
|
|
661
|
+
}
|