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/draft.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { forcedToolCall } from "./llm.js";
|
|
2
|
+
import { isGroundedSpan } from "./judge.js";
|
|
3
|
+
import { normalizeAccountDomain } from "./signals.js";
|
|
4
|
+
export const DRAFT_CHANNELS = ["email", "linkedin", "task"];
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Stable hashing (FNV-1a) — duplicated to keep this file importable without the
|
|
7
|
+
// audit engine (the judge.ts/signals.ts/market.ts/enrich.ts precedent).
|
|
8
|
+
function fnv1a(value) {
|
|
9
|
+
let hash = 0x811c9dc5;
|
|
10
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
11
|
+
hash ^= value.charCodeAt(i);
|
|
12
|
+
hash = Math.imul(hash, 0x01000193);
|
|
13
|
+
}
|
|
14
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
15
|
+
}
|
|
16
|
+
/** Stable patch-operation id for a draft op (mirrors enrich's `op_enr_<hash>`). */
|
|
17
|
+
export function draftOperationId(channel, accountDomain) {
|
|
18
|
+
return `op_draft_${fnv1a(`${channel}:${accountDomain}`)}`;
|
|
19
|
+
}
|
|
20
|
+
/** Stable evidence id for the signal a draft is grounded in. */
|
|
21
|
+
export function draftEvidenceId(signalId) {
|
|
22
|
+
return `ev_draft_${fnv1a(signalId)}`;
|
|
23
|
+
}
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Freshness / stale-trigger
|
|
26
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
27
|
+
/**
|
|
28
|
+
* Default freshness window for the self-discard rule. A draft whose grounding
|
|
29
|
+
* signal's `firstSeen` is older than this is flagged `staleTrigger` so the human
|
|
30
|
+
* reviewer catches a manufactured-relevance opener before approving it. 14 days
|
|
31
|
+
* mirrors the judge's `FRESH_SIGNAL_DAYS`.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_FRESHNESS_DAYS = 14;
|
|
34
|
+
export function isStaleTrigger(firstSeen, now, windowDays = DEFAULT_FRESHNESS_DAYS) {
|
|
35
|
+
const seen = Date.parse(firstSeen);
|
|
36
|
+
if (!Number.isFinite(seen))
|
|
37
|
+
return true; // an undateable trigger can't be proven fresh — treat as stale
|
|
38
|
+
return now.getTime() - seen > windowDays * DAY_MS;
|
|
39
|
+
}
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Voice rules / opener hygiene (the editable prompt's mechanically-checkable contract)
|
|
42
|
+
export const DEFAULT_DRAFT_PROMPT = `You are writing ONE cold opener for a single hot account, grounded in a real, fresh trigger.
|
|
43
|
+
The opener's FIRST LINE must quote the trigger back in the buyer's own words — copy a verbatim span of the why-now below into line 1. Never open with "Hi {{firstName}}" or any greeting placeholder.
|
|
44
|
+
Rules (the operator owns these — edit this prompt to change them):
|
|
45
|
+
- Line 1: open on the real trigger, in the buyer's words. It MUST contain a verbatim span of the why-now quote.
|
|
46
|
+
- One sentence tying the trigger to ONE problem you solve. Specific, not generic.
|
|
47
|
+
- End with ONE low-friction ask. No fake urgency, no "circling back".
|
|
48
|
+
- No em dashes. No "Hi {{firstName}}". Keep it short — a real trigger does not need nine sentences.`;
|
|
49
|
+
/**
|
|
50
|
+
* Strip em dashes (voice rule), collapse runs of whitespace WITHIN a line while
|
|
51
|
+
* preserving line breaks (line 1 grounding depends on the first line surviving
|
|
52
|
+
* intact), and trim trailing whitespace. Returns the cleaned opener.
|
|
53
|
+
*/
|
|
54
|
+
export function sanitizeOpener(opener) {
|
|
55
|
+
return opener
|
|
56
|
+
.replace(/—/g, "-")
|
|
57
|
+
.split("\n")
|
|
58
|
+
.map((line) => line.replace(/[ \t]+/g, " ").trimEnd())
|
|
59
|
+
.join("\n")
|
|
60
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
61
|
+
.trim();
|
|
62
|
+
}
|
|
63
|
+
/** The first non-empty line of an opener — the line the evidence gate checks. */
|
|
64
|
+
export function firstLine(opener) {
|
|
65
|
+
for (const line of opener.split("\n")) {
|
|
66
|
+
if (line.trim())
|
|
67
|
+
return line.trim();
|
|
68
|
+
}
|
|
69
|
+
return "";
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Does the opener's first line contain a verbatim ≥12-char normalized span of the
|
|
73
|
+
* decision's whyNow? This is the self-discard gate: a draft that doesn't ground
|
|
74
|
+
* line 1 in the trigger is rejected, not saved.
|
|
75
|
+
*/
|
|
76
|
+
export function firstLineGroundedInTrigger(opener, whyNow) {
|
|
77
|
+
return isGroundedSpan(whyNow, firstLine(opener));
|
|
78
|
+
}
|
|
79
|
+
/** Reject openers that lead with a banned greeting placeholder. */
|
|
80
|
+
export function hasBannedGreeting(opener) {
|
|
81
|
+
return /\bhi\s+\{\{\s*firstname\s*\}\}/i.test(opener) || /^\s*hi\s+\{\{/i.test(opener);
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Evidence builder (evidenceFor idiom)
|
|
85
|
+
/**
|
|
86
|
+
* Build a `GtmEvidence` from the signal a draft is grounded in — the trigger that
|
|
87
|
+
* justified the opener. `sourceSystem` is "web" for fetched signals (ATS boards
|
|
88
|
+
* via global fetch) and "manual" for ingested ones; `text` is the VERBATIM signal
|
|
89
|
+
* quote (the evidence anchor reused all the way from `signals fetch`).
|
|
90
|
+
*/
|
|
91
|
+
export function draftEvidence(signal, capturedAt) {
|
|
92
|
+
const fetched = signal.source !== "ingest";
|
|
93
|
+
return {
|
|
94
|
+
id: draftEvidenceId(signal.id),
|
|
95
|
+
sourceSystem: fetched ? "web" : "manual",
|
|
96
|
+
sourceObjectType: "signal",
|
|
97
|
+
sourceObjectId: signal.id,
|
|
98
|
+
title: `${signal.bucket} signal for ${signal.accountDomain}: ${signal.trigger}`,
|
|
99
|
+
text: signal.quote,
|
|
100
|
+
capturedAt,
|
|
101
|
+
metadata: {
|
|
102
|
+
bucket: signal.bucket,
|
|
103
|
+
trigger: signal.trigger,
|
|
104
|
+
sourceUrl: signal.sourceUrl,
|
|
105
|
+
firstSeen: signal.firstSeen,
|
|
106
|
+
signalSource: signal.source,
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
// Opener authoring: deterministic stub + gated LLM path
|
|
112
|
+
/**
|
|
113
|
+
* The no-key, honestly-degraded baseline. Emits a clearly-labeled template stub
|
|
114
|
+
* — NEVER fake authored copy. Line 1 is the verbatim whyNow (so the first-line
|
|
115
|
+
* evidence gate passes by construction), followed by the play (if any) as the
|
|
116
|
+
* "what to say" scaffold the operator fills in once they add a key.
|
|
117
|
+
*/
|
|
118
|
+
export function deterministicOpener(decision) {
|
|
119
|
+
const whyNow = decision.whyNow.trim();
|
|
120
|
+
const play = decision.play.trim();
|
|
121
|
+
const tail = play ? `\n→ ${play}` : "";
|
|
122
|
+
return `[[DRAFT - no LLM key]] ${whyNow}${tail}`;
|
|
123
|
+
}
|
|
124
|
+
export const DRAFT_SCHEMA = {
|
|
125
|
+
type: "object",
|
|
126
|
+
required: ["opener"],
|
|
127
|
+
properties: {
|
|
128
|
+
opener: {
|
|
129
|
+
type: "string",
|
|
130
|
+
description: "The full cold opener. The FIRST line MUST contain a verbatim span copied from the why-now quote. " +
|
|
131
|
+
"One problem sentence, one low-friction ask. No greeting, no 'Hi {{firstName}}', no em dashes.",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* The LLM drafter for one account. Forced tool call with `DRAFT_SCHEMA` + the
|
|
137
|
+
* editable prompt; then the FIRST-LINE evidence gate: line 1 must contain a
|
|
138
|
+
* verbatim ≥12-char span of the decision's whyNow. Retry once with corrective
|
|
139
|
+
* feedback (the judge/market idiom), then — rather than store an ungrounded
|
|
140
|
+
* opener — fall back to the deterministic stub. The brain NEVER ships an opener
|
|
141
|
+
* whose first line isn't grounded in the trigger.
|
|
142
|
+
*/
|
|
143
|
+
export async function draftOpenerLlm(decision, signal, promptTemplate, llm) {
|
|
144
|
+
const model = llm.model ?? "claude-haiku-4-5";
|
|
145
|
+
const producedBy = `llm:${llm.provider}:${model}`;
|
|
146
|
+
const basePrompt = `${promptTemplate}
|
|
147
|
+
|
|
148
|
+
Account: ${decision.accountDomain}
|
|
149
|
+
Why now (your FIRST line must contain a verbatim span of THIS): "${decision.whyNow}"
|
|
150
|
+
The trigger quote (verbatim source): "${signal.quote}"${decision.play ? `\nA play the judge suggested (do not copy verbatim): ${decision.play}` : ""}`;
|
|
151
|
+
const attempt = async (feedback) => {
|
|
152
|
+
const result = (await forcedToolCall(feedback ? `${basePrompt}\n${feedback}` : basePrompt, "draft_opener", DRAFT_SCHEMA, model, llm));
|
|
153
|
+
const opener = sanitizeOpener((result.opener ?? "").trim());
|
|
154
|
+
if (!opener)
|
|
155
|
+
return null;
|
|
156
|
+
if (hasBannedGreeting(opener))
|
|
157
|
+
return null;
|
|
158
|
+
if (!firstLineGroundedInTrigger(opener, decision.whyNow))
|
|
159
|
+
return null; // gate failed
|
|
160
|
+
return opener;
|
|
161
|
+
};
|
|
162
|
+
const first = await attempt("");
|
|
163
|
+
if (first)
|
|
164
|
+
return { opener: first, producedBy, grounded: true };
|
|
165
|
+
const retried = await attempt("\nYour previous opener's FIRST line did NOT contain a verbatim span of the why-now quote " +
|
|
166
|
+
"(or it used a banned greeting). Copy a span character-for-character from the why-now into line 1 and rewrite.");
|
|
167
|
+
if (retried)
|
|
168
|
+
return { opener: retried, producedBy, grounded: true };
|
|
169
|
+
// Both attempts ungrounded — never store an ungrounded opener. Fall back to the
|
|
170
|
+
// labeled deterministic stub (grounded by construction), but stamp the path that
|
|
171
|
+
// ran so the operator can see the model was reached and gated.
|
|
172
|
+
return { opener: deterministicOpener(decision), producedBy, grounded: false };
|
|
173
|
+
}
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Orchestration: judge decisions → drafts → governed plan
|
|
176
|
+
/**
|
|
177
|
+
* Author one opener per hot account and stage them as a `needs_approval` plan of
|
|
178
|
+
* `create_task` PatchOperations, each carrying a `GtmEvidence` (the signal quote)
|
|
179
|
+
* on `plan.evidence` and referenced via `op.evidenceIds`. Read-only: builds the
|
|
180
|
+
* plan in memory; the caller persists it via `createFilePlanStore().save(plan)`
|
|
181
|
+
* only on `--save`. NEVER sends.
|
|
182
|
+
*
|
|
183
|
+
* - Takes decisions with `decision === "send"` and `score >= minScore`.
|
|
184
|
+
* - Picks the credited signal whose quote grounds the whyNow as the trigger
|
|
185
|
+
* anchor (the evidence the opener traces back to).
|
|
186
|
+
* - Authors the opener: LLM (gated, first-line evidence) when `llm` is provided,
|
|
187
|
+
* else the labeled deterministic stub.
|
|
188
|
+
* - First-line evidence gate: an opener whose line 1 doesn't ground in the whyNow
|
|
189
|
+
* is REJECTED (surfaced in `rejected`, never an op). The deterministic stub and
|
|
190
|
+
* the LLM fallback are grounded by construction, so they always pass.
|
|
191
|
+
* - Stale-trigger: a draft whose grounding signal is older than the freshness
|
|
192
|
+
* window gets a `staleTrigger` warning appended to the op `reason`.
|
|
193
|
+
*/
|
|
194
|
+
export function draft(opts) {
|
|
195
|
+
const now = opts.now ?? new Date();
|
|
196
|
+
const nowIso = now.toISOString();
|
|
197
|
+
const minScore = opts.minScore ?? 80;
|
|
198
|
+
const channel = opts.channel ?? "task";
|
|
199
|
+
const freshnessDays = opts.freshnessDays ?? DEFAULT_FRESHNESS_DAYS;
|
|
200
|
+
// `task`/`email`/`linkedin` all write a CRM task through the same gate; the
|
|
201
|
+
// object the task hangs off is the contact when known, else the account.
|
|
202
|
+
const objectType = "contact";
|
|
203
|
+
const hot = opts.decisions
|
|
204
|
+
.filter((d) => d.decision === "send" && d.score >= minScore)
|
|
205
|
+
.sort((a, b) => b.score - a.score || a.accountDomain.localeCompare(b.accountDomain));
|
|
206
|
+
const operations = [];
|
|
207
|
+
const evidence = [];
|
|
208
|
+
const drafts = [];
|
|
209
|
+
const rejected = [];
|
|
210
|
+
for (const decision of hot) {
|
|
211
|
+
const domain = normalizeAccountDomain(decision.accountDomain);
|
|
212
|
+
const signal = creditedTriggerSignal(decision, opts.signalsById);
|
|
213
|
+
if (!signal) {
|
|
214
|
+
// No credited signal quote grounds the whyNow — we cannot trace this opener
|
|
215
|
+
// to a trigger, so we cannot draft it. Surface, never emit.
|
|
216
|
+
rejected.push({
|
|
217
|
+
accountDomain: domain,
|
|
218
|
+
opener: "",
|
|
219
|
+
reason: "no credited signal quote grounds the why-now — cannot anchor an opener",
|
|
220
|
+
});
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const authored = opts.openers?.get(domain);
|
|
224
|
+
const opener = authored ? authored.opener : deterministicOpener(decision);
|
|
225
|
+
const producedBy = authored ? authored.producedBy : "deterministic";
|
|
226
|
+
// First-line evidence gate — the self-discard rule. The deterministic stub and
|
|
227
|
+
// the LLM fallback are grounded by construction; an LLM opener that reached here
|
|
228
|
+
// already passed in draftOpenerLlm, but we re-check defensively (a caller could
|
|
229
|
+
// inject any opener).
|
|
230
|
+
if (!firstLineGroundedInTrigger(opener, decision.whyNow)) {
|
|
231
|
+
rejected.push({
|
|
232
|
+
accountDomain: domain,
|
|
233
|
+
opener,
|
|
234
|
+
reason: "first line does not contain a verbatim span of the why-now trigger",
|
|
235
|
+
});
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const stale = isStaleTrigger(signal.firstSeen, now, freshnessDays);
|
|
239
|
+
const ev = draftEvidence(signal, nowIso);
|
|
240
|
+
evidence.push(ev);
|
|
241
|
+
const reasonBase = `Signal-grounded opener for ${domain}: "${signal.trigger}"`;
|
|
242
|
+
const reason = stale
|
|
243
|
+
? `${reasonBase} [staleTrigger: grounding signal first seen ${signal.firstSeen}, older than ${freshnessDays}d — could this have gone out last month unchanged?]`
|
|
244
|
+
: reasonBase;
|
|
245
|
+
operations.push({
|
|
246
|
+
id: draftOperationId(channel, domain),
|
|
247
|
+
objectType,
|
|
248
|
+
objectId: domain,
|
|
249
|
+
operation: "create_task",
|
|
250
|
+
field: "follow_up_task",
|
|
251
|
+
beforeValue: null,
|
|
252
|
+
afterValue: opener.slice(0, 255),
|
|
253
|
+
reason,
|
|
254
|
+
sourceRuleOrPolicy: `draft:${channel}`,
|
|
255
|
+
riskLevel: "low",
|
|
256
|
+
approvalRequired: true,
|
|
257
|
+
rollback: "Close or delete the created task; nothing was sent.",
|
|
258
|
+
evidenceIds: [ev.id],
|
|
259
|
+
});
|
|
260
|
+
drafts.push({
|
|
261
|
+
accountDomain: domain,
|
|
262
|
+
opener,
|
|
263
|
+
decision,
|
|
264
|
+
signal,
|
|
265
|
+
producedBy,
|
|
266
|
+
staleTrigger: stale,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
const plan = {
|
|
270
|
+
id: `draft_${fnv1a(`${channel}:${nowIso}:${operations.map((o) => o.id).join(",")}`)}`,
|
|
271
|
+
title: `Draft openers (${channel}) — ${operations.length} hot account(s)`,
|
|
272
|
+
createdAt: nowIso,
|
|
273
|
+
status: operations.length > 0 ? "needs_approval" : "draft",
|
|
274
|
+
dryRun: true,
|
|
275
|
+
summary: `${operations.length} drafted opener(s) staged as create_task proposals` +
|
|
276
|
+
`${rejected.length ? `; ${rejected.length} rejected (ungrounded first line)` : ""}. ` +
|
|
277
|
+
"A draft is a proposal — nothing is sent until plans approve -> apply.",
|
|
278
|
+
findings: [],
|
|
279
|
+
evidence,
|
|
280
|
+
operations,
|
|
281
|
+
};
|
|
282
|
+
return { plan, drafts, rejected };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Pick the credited signal whose quote grounds the decision's whyNow — the
|
|
286
|
+
* trigger the opener traces back to. Prefers a signal whose quote verbatim
|
|
287
|
+
* contains the whyNow span; falls back to the first resolvable credited signal so
|
|
288
|
+
* the evidence chain is never empty when a credit exists.
|
|
289
|
+
*/
|
|
290
|
+
export function creditedTriggerSignal(decision, signalsById) {
|
|
291
|
+
let firstResolved;
|
|
292
|
+
for (const id of decision.evidence) {
|
|
293
|
+
const signal = signalsById.get(id);
|
|
294
|
+
if (!signal)
|
|
295
|
+
continue;
|
|
296
|
+
if (!firstResolved)
|
|
297
|
+
firstResolved = signal;
|
|
298
|
+
if (isGroundedSpan(decision.whyNow, signal.quote))
|
|
299
|
+
return signal;
|
|
300
|
+
}
|
|
301
|
+
return firstResolved;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Author openers for the hot decisions via the LLM path (or the deterministic
|
|
305
|
+
* stub when no `llm` is provided), returning the map `draft()` consumes. Kept
|
|
306
|
+
* separate from `draft()` so the plan-assembly stays pure/synchronous and the
|
|
307
|
+
* async authoring is testable in isolation.
|
|
308
|
+
*/
|
|
309
|
+
export async function authorOpeners(opts) {
|
|
310
|
+
const minScore = opts.minScore ?? 80;
|
|
311
|
+
const out = new Map();
|
|
312
|
+
for (const decision of opts.decisions) {
|
|
313
|
+
if (decision.decision !== "send" || decision.score < minScore)
|
|
314
|
+
continue;
|
|
315
|
+
const domain = normalizeAccountDomain(decision.accountDomain);
|
|
316
|
+
const signal = creditedTriggerSignal(decision, opts.signalsById);
|
|
317
|
+
if (!signal)
|
|
318
|
+
continue;
|
|
319
|
+
if (opts.llm && opts.promptTemplate) {
|
|
320
|
+
const { opener, producedBy, grounded } = await draftOpenerLlm(decision, signal, opts.promptTemplate, opts.llm);
|
|
321
|
+
out.set(domain, { opener, producedBy, grounded, signalId: signal.id });
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
out.set(domain, {
|
|
325
|
+
opener: deterministicOpener(decision),
|
|
326
|
+
producedBy: "deterministic",
|
|
327
|
+
grounded: true,
|
|
328
|
+
signalId: signal.id,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return out;
|
|
333
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -39,4 +39,9 @@ export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
|
39
39
|
export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, discoverCompetitors, type FetchText, type FetchBytes, type ResolveUrl, type DiscoveredVendor, type DiscoverCompetitorsOptions, } from "./marketSourcing.ts";
|
|
40
40
|
export { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, cronMatches, crontabSentinels, expectedFirings, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, scheduleRunsDir, schedulesPath, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, type CronExpression, type CrontabIo, type ScheduleEntry, type ScheduleProvider, type ScheduleRunRecord, type ScheduleRunStore, type ScheduleRunTrigger, type ScheduleStore, } from "./schedule.ts";
|
|
41
41
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
42
|
+
export { buildSignalsFromAts, computeWeights, createFileSignalStore, DEFAULT_SIGNALS_CONFIG, dedupKey, dedupeSignals, loadSignalsConfig, makeOutcome, normalizeAccountDomain, parseSignalsConfig, recencyFactor, SIGNAL_BUCKETS, SIGNALS_CONFIG_FILE_NAME, signalId, signalRunId, signalsDir, signalWeight, type Signal, type SignalBucket, type SignalBucketConfig, type SignalOutcome, type SignalOutcomeResult, type SignalRun, type SignalsConfig, type SignalStore, } from "./signals.ts";
|
|
43
|
+
export { fetchAtsJobs, snippetFor, type AtsBoardSource, type AtsJob, } from "./connectors/atsBoards.ts";
|
|
44
|
+
export { scoreBand, judgeRunId, normalizeSpan, isGroundedSpan, scoreAccount, accountRecentlyTouched, bestContactForAccount, deterministicDecision, deterministicWhyNow, JUDGE_SCHEMA, DEFAULT_JUDGE_PROMPT, judgeDecisionLlm, judgeSignals, judgeDir, createFileJudgeStore, type JudgeDecisionKind, type JudgeDecision, type JudgeRun, type JudgeBand, type AccountScore, type JudgeStore, } from "./judge.ts";
|
|
45
|
+
export { draft, authorOpeners, draftOpenerLlm, deterministicOpener, creditedTriggerSignal, draftEvidence, sanitizeOpener, firstLine, firstLineGroundedInTrigger, hasBannedGreeting, isStaleTrigger, draftOperationId, draftEvidenceId, DRAFT_CHANNELS, DRAFT_SCHEMA, DEFAULT_DRAFT_PROMPT, DEFAULT_FRESHNESS_DAYS, type Draft, type RejectedDraft, type DraftResult, type DraftChannel, } from "./draft.ts";
|
|
46
|
+
export { DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, HOT_SCORE, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet, type Confusion, type EvalJudgeFn, type GoldenHistory, type GoldenRow, type GradeResult, type OutcomeCalibration, } from "./judgeEval.ts";
|
|
42
47
|
export type { ApprovalStatus, AuditFinding, AuditFindingSeverity, CanonicalAccount, CanonicalActivity, CanonicalContact, CanonicalDeal, CanonicalGtmSnapshot, CanonicalUser, CrmProvider, GtmAuditRule, GtmConnector, GtmEvidence, GtmEvidenceSourceSystem, GtmObjectType, GtmPolicy, GtmRuleContext, GtmRuleResult, GtmSnapshotIndex, PatchOperation, PatchOperationResult, PatchOperationType, PatchPlan, PatchPlanRun, PatchPlanRunStatus, PatchVerification, PipelineFinding, PipelineFindingStatus, PipelineFindingType, ProviderIdentity, RiskLevel, SourceFreshness, } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -39,3 +39,8 @@ export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
|
|
|
39
39
|
export { registrableDomain, categoryKeywords, pickCategoryPage, extractLogoUrl, resolveFinalUrl, detectDrift, findCategoryPageInSitemap, findCategoryPage, fetchLogoDataUri, discoverCompetitors, } from "./marketSourcing.js";
|
|
40
40
|
export { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, cronMatches, crontabSentinels, expectedFirings, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, scheduleId, scheduleRunsDir, schedulesPath, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
|
|
41
41
|
export { suggestValues } from "./suggest.js";
|
|
42
|
+
export { buildSignalsFromAts, computeWeights, createFileSignalStore, DEFAULT_SIGNALS_CONFIG, dedupKey, dedupeSignals, loadSignalsConfig, makeOutcome, normalizeAccountDomain, parseSignalsConfig, recencyFactor, SIGNAL_BUCKETS, SIGNALS_CONFIG_FILE_NAME, signalId, signalRunId, signalsDir, signalWeight, } from "./signals.js";
|
|
43
|
+
export { fetchAtsJobs, snippetFor, } from "./connectors/atsBoards.js";
|
|
44
|
+
export { scoreBand, judgeRunId, normalizeSpan, isGroundedSpan, scoreAccount, accountRecentlyTouched, bestContactForAccount, deterministicDecision, deterministicWhyNow, JUDGE_SCHEMA, DEFAULT_JUDGE_PROMPT, judgeDecisionLlm, judgeSignals, judgeDir, createFileJudgeStore, } from "./judge.js";
|
|
45
|
+
export { draft, authorOpeners, draftOpenerLlm, deterministicOpener, creditedTriggerSignal, draftEvidence, sanitizeOpener, firstLine, firstLineGroundedInTrigger, hasBannedGreeting, isStaleTrigger, draftOperationId, draftEvidenceId, DRAFT_CHANNELS, DRAFT_SCHEMA, DEFAULT_DRAFT_PROMPT, DEFAULT_FRESHNESS_DAYS, } from "./draft.js";
|
|
46
|
+
export { DEFAULT_GOLDEN_SET, DEFAULT_MIN_ACCURACY, HOT_SCORE, defaultJudgeFn, gradeAgainstOutcomes, gradeJudge, parseGoldenSet, } from "./judgeEval.js";
|
package/dist/judge.d.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { type LlmCallOptions } from "./llm.ts";
|
|
2
|
+
import { type Icp } from "./icp.ts";
|
|
3
|
+
import type { Signal, SignalBucket, SignalOutcome } from "./signals.ts";
|
|
4
|
+
import { computeWeights } from "./signals.ts";
|
|
5
|
+
import type { CanonicalGtmSnapshot } from "./types.ts";
|
|
6
|
+
/**
|
|
7
|
+
* The judge layer: turn raw signals into a short ranked list of decisions on
|
|
8
|
+
* `timing × fit × memory`. Every other layer answers "is this CRM record
|
|
9
|
+
* correct?"; the judge answers "did something change at this account this week
|
|
10
|
+
* worth reaching out *about*, to the right person, whom we haven't already
|
|
11
|
+
* chased?" — and, crucially, says **no** when the answer is no.
|
|
12
|
+
*
|
|
13
|
+
* GOVERNANCE — structural, forever: judge is read-only. It reads signals + CRM
|
|
14
|
+
* history and writes a ranked DECISION artifact to the LOCAL judge store; it
|
|
15
|
+
* emits no `PatchOperation`, opens no plan, touches no provider record. `--save`
|
|
16
|
+
* persists a `JudgeRun` and stamps the consumed signals `judgedBy`. The write
|
|
17
|
+
* side is downstream and human-gated (`draft` → `plans approve` → `apply`).
|
|
18
|
+
*
|
|
19
|
+
* EVIDENCE GATE — structural: a decision's `whyNow` must be a verbatim
|
|
20
|
+
* normalized-whitespace substring of a credited signal's stored `quote`. The
|
|
21
|
+
* deterministic baseline takes the why-now verbatim from the top signal's quote
|
|
22
|
+
* (so it is grounded by construction); the LLM path is gated after the call —
|
|
23
|
+
* an ungrounded why-now is rejected (one retry, then fall back to the
|
|
24
|
+
* deterministic why-now). The brain NEVER stores an ungrounded why-now.
|
|
25
|
+
*/
|
|
26
|
+
export type JudgeDecisionKind = "send" | "nurture" | "skip";
|
|
27
|
+
export type JudgeDecision = {
|
|
28
|
+
accountDomain: string;
|
|
29
|
+
/** 0-100. */
|
|
30
|
+
score: number;
|
|
31
|
+
decision: JudgeDecisionKind;
|
|
32
|
+
/** MUST be a verbatim normalized-whitespace substring of a credited signal.quote. */
|
|
33
|
+
whyNow: string;
|
|
34
|
+
/** One line a rep could say (LLM), or "" (deterministic baseline). */
|
|
35
|
+
play: string;
|
|
36
|
+
/** Credited signal ids — the evidence anchors for this decision. */
|
|
37
|
+
evidence: string[];
|
|
38
|
+
skippedReason?: string;
|
|
39
|
+
/** "deterministic" | "llm:<provider>:<model>". */
|
|
40
|
+
producedBy: string;
|
|
41
|
+
};
|
|
42
|
+
export type JudgeRun = {
|
|
43
|
+
id: string;
|
|
44
|
+
runLabel: string;
|
|
45
|
+
/** The signal run this judgement consumed. */
|
|
46
|
+
signalRunLabel: string;
|
|
47
|
+
createdAt: string;
|
|
48
|
+
decisions: JudgeDecision[];
|
|
49
|
+
};
|
|
50
|
+
export type JudgeBand = "strong" | "good" | "weak" | "noise";
|
|
51
|
+
export declare function scoreBand(score: number): JudgeBand;
|
|
52
|
+
export declare function judgeRunId(runLabel: string): string;
|
|
53
|
+
/**
|
|
54
|
+
* Whitespace/punctuation-spacing-normalized, lowercased match — the EXACT rule
|
|
55
|
+
* `call`/`market` use for verbatim evidence gates (llm.ts normalizeSpan). A
|
|
56
|
+
* local copy keeps this file importable without the LLM engine (the cross-module
|
|
57
|
+
* duplication precedent in signals.ts/market.ts).
|
|
58
|
+
*/
|
|
59
|
+
export declare function normalizeSpan(value: string): string;
|
|
60
|
+
/** Verbatim evidence gate: `produced` must be a ≥12-char normalized substring of `source`. */
|
|
61
|
+
export declare function isGroundedSpan(produced: string, source: string): boolean;
|
|
62
|
+
export type AccountScore = {
|
|
63
|
+
accountDomain: string;
|
|
64
|
+
/** 0-100. */
|
|
65
|
+
score: number;
|
|
66
|
+
band: JudgeBand;
|
|
67
|
+
decision: JudgeDecisionKind;
|
|
68
|
+
/** The single highest-credited signal — the why-now anchor. */
|
|
69
|
+
topSignal: Signal;
|
|
70
|
+
/** All credited signal ids (the cluster). */
|
|
71
|
+
evidence: string[];
|
|
72
|
+
/** Raw 0..1 fit against the best-matching contact. */
|
|
73
|
+
fit: number;
|
|
74
|
+
/** Raw timing component (max bucket weight × recency [+cluster bonus]). */
|
|
75
|
+
timing: number;
|
|
76
|
+
/** True when the account was touched within the memory window (caps to nurture/skip). */
|
|
77
|
+
recentlyTouched: boolean;
|
|
78
|
+
skippedReason?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Internal: every credited signal indexed by id, attached by `judgeSignals`
|
|
81
|
+
* so the LLM path can resolve quotes beyond `topSignal`. Not part of the
|
|
82
|
+
* stored artifact.
|
|
83
|
+
*/
|
|
84
|
+
_signalsById?: Map<string, Signal>;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Score one account on timing × fit × memory and map to a 0-100 score + band +
|
|
88
|
+
* decision. Deterministic.
|
|
89
|
+
*
|
|
90
|
+
* - **timing**: each credited signal's bucket weight (learned overlay over
|
|
91
|
+
* config defaults via `computeWeights`) × recency decay × its already-baked
|
|
92
|
+
* repost boost; the account's timing is the MAX credited signal weight, plus a
|
|
93
|
+
* cluster bonus when ≥2 signals are fresh (the article's compounding "fresh
|
|
94
|
+
* round AND a live req" case → 80-100 band).
|
|
95
|
+
* - **fit**: `scoreProspectAgainstIcp(bestContact, icp)` (0..1). No ICP / no
|
|
96
|
+
* contact → a neutral 0.5 so a strong, well-grounded timing signal can still
|
|
97
|
+
* surface (fit then doesn't sink an otherwise-hot account).
|
|
98
|
+
* - **memory** (`recentlyTouched`): touched within `TOUCHED_WINDOW_DAYS` → never
|
|
99
|
+
* pile on: cap the decision at `nurture` (or `skip` if already lukewarm).
|
|
100
|
+
*
|
|
101
|
+
* `decision` from the band: strong→send, good→nurture, weak/noise→skip — then
|
|
102
|
+
* the memory cap applies. `skip` is a first-class output: a lone low-intent
|
|
103
|
+
* signal (e.g. a single `social` blip) lands in `weak`/`noise` and is skipped
|
|
104
|
+
* with a reason, not mailed.
|
|
105
|
+
*/
|
|
106
|
+
export declare function scoreAccount(opts: {
|
|
107
|
+
accountDomain: string;
|
|
108
|
+
signals: Signal[];
|
|
109
|
+
weights: Record<SignalBucket, number>;
|
|
110
|
+
icp?: Icp;
|
|
111
|
+
bestContact?: {
|
|
112
|
+
jobTitle?: string;
|
|
113
|
+
jobLevel?: string;
|
|
114
|
+
jobDepartment?: string;
|
|
115
|
+
headline?: string;
|
|
116
|
+
};
|
|
117
|
+
recentlyTouched?: boolean;
|
|
118
|
+
now?: Date;
|
|
119
|
+
}): AccountScore;
|
|
120
|
+
/**
|
|
121
|
+
* Has this account been touched within `TOUCHED_WINDOW_DAYS`? Reads the snapshot:
|
|
122
|
+
* the account record's `lastActivityAt`, plus any `activities` whose `occurredAt`
|
|
123
|
+
* is recent and that link to the account (directly or via a contact at it). Pure;
|
|
124
|
+
* with no snapshot, returns false (the no-history path — caller passes `false`).
|
|
125
|
+
*/
|
|
126
|
+
export declare function accountRecentlyTouched(accountDomain: string, snapshot: CanonicalGtmSnapshot, now?: Date, windowDays?: number): boolean;
|
|
127
|
+
/**
|
|
128
|
+
* Find the best-matching contact for an account from the snapshot, for fit
|
|
129
|
+
* scoring: the account's contacts, preferring one with a title (a title is what
|
|
130
|
+
* fit scores on). Returns undefined when the account/contact isn't in snapshot.
|
|
131
|
+
*/
|
|
132
|
+
export declare function bestContactForAccount(accountDomain: string, snapshot: CanonicalGtmSnapshot): {
|
|
133
|
+
jobTitle?: string;
|
|
134
|
+
jobLevel?: string;
|
|
135
|
+
jobDepartment?: string;
|
|
136
|
+
headline?: string;
|
|
137
|
+
} | undefined;
|
|
138
|
+
/**
|
|
139
|
+
* Build a `JudgeDecision` from an `AccountScore` with the deterministic baseline:
|
|
140
|
+
* whyNow taken VERBATIM from the top credited signal's quote (grounded by
|
|
141
|
+
* construction), no `play`, producedBy "deterministic".
|
|
142
|
+
*/
|
|
143
|
+
export declare function deterministicDecision(score: AccountScore): JudgeDecision;
|
|
144
|
+
/**
|
|
145
|
+
* A grounded why-now drawn verbatim from the signal's quote — capped to a
|
|
146
|
+
* sentence-ish span so it reads as a why-now, not the whole JD. We take the
|
|
147
|
+
* leading clause up to the first sentence boundary (or the whole quote if
|
|
148
|
+
* short), guaranteeing `isGroundedSpan(whyNow, quote)` holds.
|
|
149
|
+
*/
|
|
150
|
+
export declare function deterministicWhyNow(signal: Signal): string;
|
|
151
|
+
export declare const JUDGE_SCHEMA: {
|
|
152
|
+
readonly type: "object";
|
|
153
|
+
readonly required: readonly ["decision", "whyNow", "play"];
|
|
154
|
+
readonly properties: {
|
|
155
|
+
readonly decision: {
|
|
156
|
+
readonly type: "string";
|
|
157
|
+
readonly enum: readonly ["send", "nurture", "skip"];
|
|
158
|
+
};
|
|
159
|
+
readonly whyNow: {
|
|
160
|
+
readonly type: "string";
|
|
161
|
+
readonly description: string;
|
|
162
|
+
};
|
|
163
|
+
readonly play: {
|
|
164
|
+
readonly type: "string";
|
|
165
|
+
readonly description: "One line a rep could say out loud, grounded in the trigger. Max 25 words. No em dashes.";
|
|
166
|
+
};
|
|
167
|
+
readonly skippedReason: {
|
|
168
|
+
readonly type: "string";
|
|
169
|
+
readonly description: "skip only: one phrase why this is not a send.";
|
|
170
|
+
};
|
|
171
|
+
};
|
|
172
|
+
};
|
|
173
|
+
export declare const DEFAULT_JUDGE_PROMPT = "You are a GTM judge deciding whether to reach out to an account based on fresh signals.\nScore bands (the operator owns these \u2014 edit this prompt to change them):\n- 80-100 (send): strong fit AND high intent \u2014 a live demand search, fresh funding, or >=2 clustered signals.\n- 50-79 (nurture): good fit and one solid signal, but not enough to interrupt cold.\n- 20-49 (skip): a weak or lone low-intent signal.\n- 0-19 (skip): off-ICP or noise.\nRules:\n- 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.\n- play is one line a rep could say out loud, tied to the trigger. No greeting, no \"Hi {{firstName}}\". No em dashes.\n- Saying \"skip\" is a first-class, valuable answer. A judge that skips a lukewarm account is doing its job.";
|
|
174
|
+
/**
|
|
175
|
+
* The LLM judge for one account. Forced tool call with `JUDGE_SCHEMA` + an
|
|
176
|
+
* editable prompt; then the verbatim-quote GATE: `whyNow` must be a normalized
|
|
177
|
+
* ≥12-char substring of one of the credited signal quotes. Retry once with
|
|
178
|
+
* corrective feedback (the market classify idiom), then fall back to the
|
|
179
|
+
* deterministic why-now — NEVER store an ungrounded why-now. The deterministic
|
|
180
|
+
* `score`/`band`/`decision`/`evidence` are authoritative; the model contributes
|
|
181
|
+
* `whyNow` (gated) and `play` only — score is not delegated to the model.
|
|
182
|
+
*/
|
|
183
|
+
export declare function judgeDecisionLlm(score: AccountScore, promptTemplate: string, llm: LlmCallOptions): Promise<JudgeDecision>;
|
|
184
|
+
/**
|
|
185
|
+
* Judge a batch of signals: group by account, score each, and produce one
|
|
186
|
+
* decision per account (deterministic when no LLM options, gated-LLM otherwise).
|
|
187
|
+
* `--min-score` filtering and the snapshot/ICP wiring happen in the caller; this
|
|
188
|
+
* takes resolved inputs. Returns decisions sorted by score desc (the ranked list).
|
|
189
|
+
*/
|
|
190
|
+
export declare function judgeSignals(opts: {
|
|
191
|
+
signals: Signal[];
|
|
192
|
+
outcomes?: SignalOutcome[];
|
|
193
|
+
config: Parameters<typeof computeWeights>[0];
|
|
194
|
+
icp?: Icp;
|
|
195
|
+
snapshot?: CanonicalGtmSnapshot;
|
|
196
|
+
withHistory?: boolean;
|
|
197
|
+
promptTemplate?: string;
|
|
198
|
+
llm?: LlmCallOptions;
|
|
199
|
+
now?: Date;
|
|
200
|
+
}): Promise<JudgeDecision[]>;
|
|
201
|
+
export declare function judgeDir(baseDir?: string): string;
|
|
202
|
+
export interface JudgeStore {
|
|
203
|
+
/** Append a new judge run; refuses an existing label (runs are append-only). */
|
|
204
|
+
appendRun(run: JudgeRun): Promise<JudgeRun>;
|
|
205
|
+
getRun(runLabel: string): Promise<JudgeRun | null>;
|
|
206
|
+
listRuns(): Promise<JudgeRun[]>;
|
|
207
|
+
latestRun(): Promise<JudgeRun | null>;
|
|
208
|
+
}
|
|
209
|
+
export declare function createFileJudgeStore(directory?: string): JudgeStore;
|