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/judgeEval.ts
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import {
|
|
2
|
+
deterministicDecision,
|
|
3
|
+
scoreAccount,
|
|
4
|
+
type JudgeDecision,
|
|
5
|
+
type JudgeDecisionKind,
|
|
6
|
+
} from "./judge.ts";
|
|
7
|
+
import {
|
|
8
|
+
DEFAULT_SIGNALS_CONFIG,
|
|
9
|
+
computeWeights,
|
|
10
|
+
normalizeAccountDomain,
|
|
11
|
+
type Signal,
|
|
12
|
+
type SignalOutcome,
|
|
13
|
+
type SignalsConfig,
|
|
14
|
+
} from "./signals.ts";
|
|
15
|
+
import type { Icp } from "./icp.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The self-grading gate (`icp eval`). The judge (`icp judge`) makes a
|
|
19
|
+
* PROBABILISTIC claim — "this account is hot." Every other layer's correctness
|
|
20
|
+
* is a code review (audit rules are deterministic); the judge's is not. This
|
|
21
|
+
* module is the part every "AI SDR" tool skips: proving the judge is calibrated
|
|
22
|
+
* BEFORE it sends.
|
|
23
|
+
*
|
|
24
|
+
* Two gates, both read-only and free to run offline:
|
|
25
|
+
*
|
|
26
|
+
* 1. `gradeJudge(rows, judgeFn)` — run the judge over a labeled golden set
|
|
27
|
+
* ({signals, history, expectedDecision: send|skip}) and score agreement
|
|
28
|
+
* (accuracy + precision/recall on the binary send-vs-skip call, with a
|
|
29
|
+
* confusion matrix). `nurture` collapses to the non-send class: the live
|
|
30
|
+
* question the gate guards is "do we interrupt this account cold?" — and a
|
|
31
|
+
* nurture is a not-now, i.e. not a send. Ships a `DEFAULT_GOLDEN_SET` inline
|
|
32
|
+
* so `icp eval --golden default` runs with no file and no key.
|
|
33
|
+
*
|
|
34
|
+
* 2. `gradeAgainstOutcomes(decisions, outcomes)` — the EMPIRICAL gate: once real
|
|
35
|
+
* `signals outcome` results exist, do accounts the judge scored hot (>=80)
|
|
36
|
+
* book more than the ones it scored cold? `calibrated` is true iff
|
|
37
|
+
* hotBookRate > coldBookRate. "If it cannot prove hot beats cold, it does not
|
|
38
|
+
* send."
|
|
39
|
+
*
|
|
40
|
+
* The CLI wires both as exit-2 gates (the audit/`diff` convention) so they
|
|
41
|
+
* compose in front of any `apply`. This file is pure grading — no store, no
|
|
42
|
+
* network, no `process.exitCode` (the CLI owns the exit code).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Types
|
|
47
|
+
|
|
48
|
+
/** Account CRM memory for a golden row — `touched` caps a hot account to not-now. */
|
|
49
|
+
export type GoldenHistory = {
|
|
50
|
+
/** True if the account was touched within the judge's memory window. */
|
|
51
|
+
recentlyTouched?: boolean;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One labeled example: the signals that fired on an account and the decision a
|
|
56
|
+
* human says the judge SHOULD reach (the binary send-vs-not the gate guards).
|
|
57
|
+
*/
|
|
58
|
+
export type GoldenRow = {
|
|
59
|
+
/** Optional human label, for readable failure output. */
|
|
60
|
+
label?: string;
|
|
61
|
+
signals: Signal[];
|
|
62
|
+
history?: GoldenHistory;
|
|
63
|
+
/** The ground-truth call: do we interrupt this account cold, or not? */
|
|
64
|
+
expectedDecision: "send" | "skip";
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A judge under test: given a row's signals + history, return the judge's call.
|
|
69
|
+
* Accepts a `JudgeDecision`, a bare `JudgeDecisionKind`, or a `Promise` of
|
|
70
|
+
* either — so the real (async, LLM-capable) judge AND a one-line deterministic
|
|
71
|
+
* stub both fit. `nurture` is treated as not-send when graded.
|
|
72
|
+
*/
|
|
73
|
+
export type EvalJudgeFn = (
|
|
74
|
+
row: GoldenRow,
|
|
75
|
+
) =>
|
|
76
|
+
| JudgeDecision
|
|
77
|
+
| JudgeDecisionKind
|
|
78
|
+
| Promise<JudgeDecision | JudgeDecisionKind>;
|
|
79
|
+
|
|
80
|
+
/** 2x2 confusion matrix on the binary send-vs-skip call. */
|
|
81
|
+
export type Confusion = {
|
|
82
|
+
/** Predicted send, expected send. */
|
|
83
|
+
truePositive: number;
|
|
84
|
+
/** Predicted send, expected skip (a cold mail off a bad target). */
|
|
85
|
+
falsePositive: number;
|
|
86
|
+
/** Predicted skip, expected send (a missed hot account). */
|
|
87
|
+
falseNegative: number;
|
|
88
|
+
/** Predicted skip, expected skip (correctly held). */
|
|
89
|
+
trueNegative: number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type GradeResult = {
|
|
93
|
+
/** (tp + tn) / total — overall agreement. */
|
|
94
|
+
accuracy: number;
|
|
95
|
+
/** tp / (tp + fp) — of the accounts we'd mail, how many should we? 1 when none predicted send. */
|
|
96
|
+
precision: number;
|
|
97
|
+
/** tp / (tp + fn) — of the accounts we should mail, how many did we catch? 1 when none expected send. */
|
|
98
|
+
recall: number;
|
|
99
|
+
confusion: Confusion;
|
|
100
|
+
/** Rows graded (== rows.length). */
|
|
101
|
+
total: number;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type OutcomeCalibration = {
|
|
105
|
+
/** Booked-meeting rate among decisions scored hot (>=80). */
|
|
106
|
+
hotBookRate: number;
|
|
107
|
+
/** Booked-meeting rate among decisions scored cold (<80). */
|
|
108
|
+
coldBookRate: number;
|
|
109
|
+
/** True iff hotBookRate > coldBookRate — the "hot beats cold" claim holds. */
|
|
110
|
+
calibrated: boolean;
|
|
111
|
+
/** Decisions in each bucket that had a recorded outcome (the denominators). */
|
|
112
|
+
hotCount: number;
|
|
113
|
+
coldCount: number;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/** Hot threshold for the empirical gate — mirrors the judge's strong band (>=80). */
|
|
117
|
+
export const HOT_SCORE = 80;
|
|
118
|
+
|
|
119
|
+
/** Default golden-set pass bar (`--min-accuracy`). */
|
|
120
|
+
export const DEFAULT_MIN_ACCURACY = 0.8;
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Grading a judge against a golden set
|
|
124
|
+
|
|
125
|
+
/** Collapse the three-way decision to the binary send-vs-not the gate guards. */
|
|
126
|
+
function isSend(decision: JudgeDecisionKind): boolean {
|
|
127
|
+
return decision === "send";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function decisionKindOf(result: JudgeDecision | JudgeDecisionKind): JudgeDecisionKind {
|
|
131
|
+
return typeof result === "string" ? result : result.decision;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Run `judgeFn` over each golden row and score agreement on the binary
|
|
136
|
+
* send-vs-skip call (nurture counts as not-send). Deterministic given a
|
|
137
|
+
* deterministic `judgeFn`; the only async is the judge itself (so an LLM judge
|
|
138
|
+
* can be graded with the same harness). Empty `rows` → perfect-but-vacuous
|
|
139
|
+
* scores (accuracy 1) — the CLI guards against an empty set separately.
|
|
140
|
+
*/
|
|
141
|
+
export async function gradeJudge(
|
|
142
|
+
rows: GoldenRow[],
|
|
143
|
+
judgeFn: EvalJudgeFn,
|
|
144
|
+
): Promise<GradeResult> {
|
|
145
|
+
const confusion: Confusion = {
|
|
146
|
+
truePositive: 0,
|
|
147
|
+
falsePositive: 0,
|
|
148
|
+
falseNegative: 0,
|
|
149
|
+
trueNegative: 0,
|
|
150
|
+
};
|
|
151
|
+
for (const row of rows) {
|
|
152
|
+
const predicted = isSend(decisionKindOf(await judgeFn(row)));
|
|
153
|
+
const expected = row.expectedDecision === "send";
|
|
154
|
+
if (predicted && expected) confusion.truePositive += 1;
|
|
155
|
+
else if (predicted && !expected) confusion.falsePositive += 1;
|
|
156
|
+
else if (!predicted && expected) confusion.falseNegative += 1;
|
|
157
|
+
else confusion.trueNegative += 1;
|
|
158
|
+
}
|
|
159
|
+
const total = rows.length;
|
|
160
|
+
const correct = confusion.truePositive + confusion.trueNegative;
|
|
161
|
+
const predictedSend = confusion.truePositive + confusion.falsePositive;
|
|
162
|
+
const actualSend = confusion.truePositive + confusion.falseNegative;
|
|
163
|
+
return {
|
|
164
|
+
accuracy: total === 0 ? 1 : round4(correct / total),
|
|
165
|
+
// A judge that never says send is vacuously precise; one with no positives
|
|
166
|
+
// to find has perfect recall — the standard 0-denominator conventions.
|
|
167
|
+
precision: predictedSend === 0 ? 1 : round4(confusion.truePositive / predictedSend),
|
|
168
|
+
recall: actualSend === 0 ? 1 : round4(confusion.truePositive / actualSend),
|
|
169
|
+
confusion,
|
|
170
|
+
total,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// The default deterministic judge fn (offline, free, no LLM)
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The deterministic judge wrapped as an `EvalJudgeFn`: group a row's signals by
|
|
179
|
+
* account, score the (single, by construction) account on timing × fit × memory
|
|
180
|
+
* via `scoreAccount`, and return the deterministic decision. This is the SAME
|
|
181
|
+
* scorer `icp judge` runs key-free, so `gradeJudge(rows, defaultJudgeFn())`
|
|
182
|
+
* grades the real baseline — not a re-implementation. A row with no signals is
|
|
183
|
+
* a skip (nothing changed → nothing to reach out about).
|
|
184
|
+
*/
|
|
185
|
+
export function defaultJudgeFn(opts: { config?: SignalsConfig; icp?: Icp; now?: Date } = {}): EvalJudgeFn {
|
|
186
|
+
const config = opts.config ?? DEFAULT_SIGNALS_CONFIG;
|
|
187
|
+
return (row: GoldenRow): JudgeDecisionKind => {
|
|
188
|
+
if (row.signals.length === 0) return "skip";
|
|
189
|
+
// A golden row is one account; take the first signal's domain as the anchor.
|
|
190
|
+
const domain = normalizeAccountDomain(row.signals[0].accountDomain);
|
|
191
|
+
const signals = row.signals.filter((s) => normalizeAccountDomain(s.accountDomain) === domain);
|
|
192
|
+
const weights = computeWeights(config, [], new Map(signals.map((s) => [s.id, s])));
|
|
193
|
+
const score = scoreAccount({
|
|
194
|
+
accountDomain: domain,
|
|
195
|
+
signals,
|
|
196
|
+
weights,
|
|
197
|
+
icp: opts.icp,
|
|
198
|
+
recentlyTouched: row.history?.recentlyTouched ?? false,
|
|
199
|
+
now: opts.now,
|
|
200
|
+
});
|
|
201
|
+
return deterministicDecision(score).decision;
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// The default golden set (ships inline so `icp eval --golden default` is free)
|
|
207
|
+
|
|
208
|
+
const GOLD_NOW_ISO = "2026-06-23T12:00:00.000Z";
|
|
209
|
+
|
|
210
|
+
function goldSignal(over: {
|
|
211
|
+
accountDomain: string;
|
|
212
|
+
bucket: Signal["bucket"];
|
|
213
|
+
trigger: string;
|
|
214
|
+
quote: string;
|
|
215
|
+
reposted?: boolean;
|
|
216
|
+
firstSeen?: string;
|
|
217
|
+
}): Signal {
|
|
218
|
+
const domain = normalizeAccountDomain(over.accountDomain);
|
|
219
|
+
return {
|
|
220
|
+
id: `gold_${domain}_${over.bucket}_${over.trigger}`.replace(/[^\w]+/g, "_").toLowerCase(),
|
|
221
|
+
accountDomain: domain,
|
|
222
|
+
bucket: over.bucket,
|
|
223
|
+
trigger: over.trigger,
|
|
224
|
+
quote: over.quote,
|
|
225
|
+
sourceUrl: "https://example.com/golden",
|
|
226
|
+
firstSeen: over.firstSeen ?? GOLD_NOW_ISO,
|
|
227
|
+
weight: 1,
|
|
228
|
+
source: "ingest",
|
|
229
|
+
judgedBy: null,
|
|
230
|
+
...(over.reposted ? { reposted: true } : {}),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A starter labeled set covering the four rubric corners, so `icp eval --golden
|
|
236
|
+
* default` runs offline and the deterministic baseline passes it (>= the default
|
|
237
|
+
* min-accuracy). Each row is a single account.
|
|
238
|
+
*
|
|
239
|
+
* - SEND: a fresh clustered account (funding + reposted live req) — strong band.
|
|
240
|
+
* - SEND: a single high-weight demand/funding signal that still clears the bar.
|
|
241
|
+
* - SKIP: a lone low-intent social blip — weak band, first-class no.
|
|
242
|
+
* - SKIP: a single company-news mention with no in-persona trigger — weak.
|
|
243
|
+
* - SKIP: a hot account TOUCHED within the memory window — don't pile on.
|
|
244
|
+
*/
|
|
245
|
+
export const DEFAULT_GOLDEN_SET: GoldenRow[] = [
|
|
246
|
+
{
|
|
247
|
+
label: "clustered funding + reposted role -> send",
|
|
248
|
+
expectedDecision: "send",
|
|
249
|
+
signals: [
|
|
250
|
+
goldSignal({
|
|
251
|
+
accountDomain: "apexnorth.agency",
|
|
252
|
+
bucket: "funding",
|
|
253
|
+
trigger: "raised Series B",
|
|
254
|
+
quote: "ApexNorth raised a $40M Series B led by Acme Ventures this week.",
|
|
255
|
+
}),
|
|
256
|
+
goldSignal({
|
|
257
|
+
accountDomain: "apexnorth.agency",
|
|
258
|
+
bucket: "job",
|
|
259
|
+
trigger: "reposted: Head of Growth",
|
|
260
|
+
quote: "Head of Growth reopened — own demand gen and marketing ops for the next stage of growth.",
|
|
261
|
+
reposted: true,
|
|
262
|
+
}),
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
label: "fresh funding + live req -> send",
|
|
267
|
+
expectedDecision: "send",
|
|
268
|
+
signals: [
|
|
269
|
+
goldSignal({
|
|
270
|
+
accountDomain: "brightloop.io",
|
|
271
|
+
bucket: "funding",
|
|
272
|
+
trigger: "raised Series A",
|
|
273
|
+
quote: "Brightloop closed a $12M Series A to expand its go-to-market team this quarter.",
|
|
274
|
+
}),
|
|
275
|
+
goldSignal({
|
|
276
|
+
accountDomain: "brightloop.io",
|
|
277
|
+
bucket: "job",
|
|
278
|
+
trigger: "hiring: VP Marketing",
|
|
279
|
+
quote: "VP Marketing — build the demand engine and own pipeline targets from scratch.",
|
|
280
|
+
}),
|
|
281
|
+
],
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
label: "lone social blip -> skip",
|
|
285
|
+
expectedDecision: "skip",
|
|
286
|
+
signals: [
|
|
287
|
+
goldSignal({
|
|
288
|
+
accountDomain: "quietco.io",
|
|
289
|
+
bucket: "social",
|
|
290
|
+
trigger: "liked a post",
|
|
291
|
+
quote: "Their VP of Sales liked a post about pipeline hygiene last Tuesday.",
|
|
292
|
+
}),
|
|
293
|
+
],
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
label: "single low-intent company mention -> skip",
|
|
297
|
+
expectedDecision: "skip",
|
|
298
|
+
signals: [
|
|
299
|
+
goldSignal({
|
|
300
|
+
accountDomain: "stillwater.co",
|
|
301
|
+
bucket: "social",
|
|
302
|
+
trigger: "shared an article",
|
|
303
|
+
quote: "Stillwater's account exec shared an industry article on LinkedIn over the weekend.",
|
|
304
|
+
}),
|
|
305
|
+
],
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
label: "hot but touched within 7 days -> skip (don't pile on)",
|
|
309
|
+
expectedDecision: "skip",
|
|
310
|
+
history: { recentlyTouched: true },
|
|
311
|
+
signals: [
|
|
312
|
+
goldSignal({
|
|
313
|
+
accountDomain: "veridian.com",
|
|
314
|
+
bucket: "social",
|
|
315
|
+
trigger: "liked a post",
|
|
316
|
+
quote: "A Veridian director liked a post about outbound sequencing yesterday afternoon.",
|
|
317
|
+
}),
|
|
318
|
+
],
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Parse a golden set from JSON or JSONL (one row per line). Validates each row
|
|
324
|
+
* up front and names the offending entry (the enrich/signals parse idiom). The
|
|
325
|
+
* literal "default" resolves to `DEFAULT_GOLDEN_SET` (handled by the CLI before
|
|
326
|
+
* reading a file; this parses an actual file's contents).
|
|
327
|
+
*/
|
|
328
|
+
export function parseGoldenSet(raw: string): GoldenRow[] {
|
|
329
|
+
const trimmed = raw.trim();
|
|
330
|
+
if (!trimmed) throw new Error("golden set: empty file (expected a JSON array or JSONL of rows)");
|
|
331
|
+
let candidates: unknown[];
|
|
332
|
+
if (trimmed.startsWith("[")) {
|
|
333
|
+
let parsed: unknown;
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(trimmed);
|
|
336
|
+
} catch (error) {
|
|
337
|
+
throw new Error(`golden set: not valid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
338
|
+
}
|
|
339
|
+
if (!Array.isArray(parsed)) throw new Error("golden set: expected a JSON array of rows");
|
|
340
|
+
candidates = parsed;
|
|
341
|
+
} else {
|
|
342
|
+
// JSONL: one row object per non-empty line.
|
|
343
|
+
candidates = [];
|
|
344
|
+
const lines = trimmed.split("\n");
|
|
345
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
346
|
+
const line = lines[i].trim();
|
|
347
|
+
if (!line) continue;
|
|
348
|
+
try {
|
|
349
|
+
candidates.push(JSON.parse(line));
|
|
350
|
+
} catch (error) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`golden set: line ${i + 1} is not valid JSON (${error instanceof Error ? error.message : String(error)})`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return candidates.map((row, i) => validateGoldenRow(row, i));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function validateGoldenRow(row: unknown, index: number): GoldenRow {
|
|
361
|
+
const where = `golden set: row ${index}`;
|
|
362
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) {
|
|
363
|
+
throw new Error(`${where} must be an object with signals + expectedDecision`);
|
|
364
|
+
}
|
|
365
|
+
const r = row as Partial<GoldenRow> & { signals?: unknown; expectedDecision?: unknown };
|
|
366
|
+
if (r.expectedDecision !== "send" && r.expectedDecision !== "skip") {
|
|
367
|
+
throw new Error(`${where}: expectedDecision must be "send" or "skip"`);
|
|
368
|
+
}
|
|
369
|
+
if (!Array.isArray(r.signals)) {
|
|
370
|
+
throw new Error(`${where}: signals must be an array of Signal objects`);
|
|
371
|
+
}
|
|
372
|
+
for (let s = 0; s < r.signals.length; s += 1) {
|
|
373
|
+
const sig = r.signals[s] as Partial<Signal>;
|
|
374
|
+
if (!sig || typeof sig !== "object") throw new Error(`${where} signal ${s} must be an object`);
|
|
375
|
+
if (typeof sig.accountDomain !== "string" || !sig.accountDomain) {
|
|
376
|
+
throw new Error(`${where} signal ${s}: accountDomain must be a non-empty string`);
|
|
377
|
+
}
|
|
378
|
+
if (typeof sig.bucket !== "string") throw new Error(`${where} signal ${s}: bucket must be a string`);
|
|
379
|
+
if (typeof sig.quote !== "string") throw new Error(`${where} signal ${s}: quote must be a string`);
|
|
380
|
+
}
|
|
381
|
+
const history =
|
|
382
|
+
r.history && typeof r.history === "object" && !Array.isArray(r.history)
|
|
383
|
+
? { recentlyTouched: Boolean((r.history as GoldenHistory).recentlyTouched) }
|
|
384
|
+
: undefined;
|
|
385
|
+
return {
|
|
386
|
+
...(typeof r.label === "string" ? { label: r.label } : {}),
|
|
387
|
+
signals: r.signals as Signal[],
|
|
388
|
+
...(history ? { history } : {}),
|
|
389
|
+
expectedDecision: r.expectedDecision,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
// The empirical gate: do hot scores book more than cold scores?
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Grade the judge's decisions against accumulated `signals outcome` results: of
|
|
398
|
+
* the decisions scored hot (>=80) vs cold (<80), which books meetings more
|
|
399
|
+
* often? A decision is matched to outcomes by account domain (and an outcome
|
|
400
|
+
* "books" when its result is "meeting"). Accounts with no recorded outcome are
|
|
401
|
+
* excluded from the denominators. `calibrated` is true iff hot strictly beats
|
|
402
|
+
* cold — absent or inverted correlation is NOT calibrated (the CLI exits
|
|
403
|
+
* nonzero on it). Deterministic.
|
|
404
|
+
*/
|
|
405
|
+
export function gradeAgainstOutcomes(
|
|
406
|
+
decisions: JudgeDecision[],
|
|
407
|
+
outcomes: SignalOutcome[],
|
|
408
|
+
): OutcomeCalibration {
|
|
409
|
+
// Per-account: did any credited touch book a meeting? (Domain-level booking.)
|
|
410
|
+
const booked = new Map<string, boolean>();
|
|
411
|
+
const sawOutcome = new Map<string, boolean>();
|
|
412
|
+
for (const outcome of outcomes) {
|
|
413
|
+
const domain = normalizeAccountDomain(outcome.accountDomain);
|
|
414
|
+
if (!domain) continue;
|
|
415
|
+
sawOutcome.set(domain, true);
|
|
416
|
+
if (outcome.result === "meeting") booked.set(domain, true);
|
|
417
|
+
else if (!booked.has(domain)) booked.set(domain, false);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
let hotBooked = 0;
|
|
421
|
+
let hotCount = 0;
|
|
422
|
+
let coldBooked = 0;
|
|
423
|
+
let coldCount = 0;
|
|
424
|
+
// One decision per account; if a domain repeats, the highest score wins the
|
|
425
|
+
// bucket assignment so a single domain isn't double-counted across buckets.
|
|
426
|
+
const scoreByDomain = new Map<string, number>();
|
|
427
|
+
for (const decision of decisions) {
|
|
428
|
+
const domain = normalizeAccountDomain(decision.accountDomain);
|
|
429
|
+
if (!domain || !sawOutcome.get(domain)) continue;
|
|
430
|
+
const prior = scoreByDomain.get(domain);
|
|
431
|
+
if (prior === undefined || decision.score > prior) scoreByDomain.set(domain, decision.score);
|
|
432
|
+
}
|
|
433
|
+
for (const [domain, score] of scoreByDomain) {
|
|
434
|
+
const didBook = booked.get(domain) === true ? 1 : 0;
|
|
435
|
+
if (score >= HOT_SCORE) {
|
|
436
|
+
hotCount += 1;
|
|
437
|
+
hotBooked += didBook;
|
|
438
|
+
} else {
|
|
439
|
+
coldCount += 1;
|
|
440
|
+
coldBooked += didBook;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const hotBookRate = hotCount === 0 ? 0 : round4(hotBooked / hotCount);
|
|
445
|
+
const coldBookRate = coldCount === 0 ? 0 : round4(coldBooked / coldCount);
|
|
446
|
+
return {
|
|
447
|
+
hotBookRate,
|
|
448
|
+
coldBookRate,
|
|
449
|
+
calibrated: hotBookRate > coldBookRate,
|
|
450
|
+
hotCount,
|
|
451
|
+
coldCount,
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
function round4(value: number): number {
|
|
458
|
+
return Math.round(value * 10000) / 10000;
|
|
459
|
+
}
|
package/src/llm.ts
CHANGED
|
@@ -22,6 +22,20 @@ export const DEFAULT_MODELS: Record<LlmProvider, string> = {
|
|
|
22
22
|
|
|
23
23
|
const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
|
|
24
24
|
const OPENAI_URL = "https://api.openai.com/v1/chat/completions";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the effective endpoint, honoring an optional base-URL override.
|
|
28
|
+
* Mirrors the `prospectSources.ts` idiom (`(base ?? default).replace(/\/$/, "")`):
|
|
29
|
+
* trailing-slash-stripped; if the configured base already ends in the known
|
|
30
|
+
* path suffix it is used as-is, otherwise the suffix is appended so callers can
|
|
31
|
+
* pass either a bare origin (`https://glm.example`) or a full endpoint URL.
|
|
32
|
+
* Unset override → the upstream default, so behavior is unchanged.
|
|
33
|
+
*/
|
|
34
|
+
function resolveLlmUrl(override: string | undefined, defaultUrl: string, pathSuffix: string): string {
|
|
35
|
+
if (!override) return defaultUrl;
|
|
36
|
+
const base = override.replace(/\/$/, "");
|
|
37
|
+
return base.endsWith(pathSuffix) ? base : `${base}${pathSuffix}`;
|
|
38
|
+
}
|
|
25
39
|
// Bound cost and context: long calls keep the head and tail.
|
|
26
40
|
const MAX_TRANSCRIPT_CHARS = 28_000;
|
|
27
41
|
|
|
@@ -92,6 +106,19 @@ export type LlmCallOptions = {
|
|
|
92
106
|
apiKey: string;
|
|
93
107
|
model?: string;
|
|
94
108
|
fetchImpl?: typeof fetch;
|
|
109
|
+
/**
|
|
110
|
+
* Override the Anthropic Messages endpoint — point the package at an
|
|
111
|
+
* OpenAI/Anthropic-compatible gateway (GLM-5.2, z.ai, a local proxy) without
|
|
112
|
+
* code changes. An origin (`https://...`) gets `/v1/messages` appended; a base
|
|
113
|
+
* already ending in `/messages` is used verbatim. Threaded from
|
|
114
|
+
* `ANTHROPIC_API_BASE_URL` at the LlmCallOptions-construction layer (cli.ts),
|
|
115
|
+
* never read inside `forcedToolCall`. Unset → default api.anthropic.com.
|
|
116
|
+
*/
|
|
117
|
+
anthropicBaseUrl?: string;
|
|
118
|
+
/** OpenAI equivalent of `anthropicBaseUrl` (e.g. an Ollama OpenAI-compatible
|
|
119
|
+
* endpoint). Origin → `/v1/chat/completions` appended; a base ending in
|
|
120
|
+
* `/chat/completions` is used verbatim. Threaded from `OPENAI_API_BASE_URL`. */
|
|
121
|
+
openaiBaseUrl?: string;
|
|
95
122
|
};
|
|
96
123
|
|
|
97
124
|
export type LlmExtractedInsight = ExtractedCallInsight & {
|
|
@@ -398,7 +425,8 @@ export async function forcedToolCall(
|
|
|
398
425
|
): Promise<unknown> {
|
|
399
426
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
400
427
|
if (options.provider === "anthropic") {
|
|
401
|
-
const
|
|
428
|
+
const anthropicUrl = resolveLlmUrl(options.anthropicBaseUrl, ANTHROPIC_URL, "/v1/messages");
|
|
429
|
+
const response = await llmFetch(fetchImpl, anthropicUrl, {
|
|
402
430
|
method: "POST",
|
|
403
431
|
headers: {
|
|
404
432
|
"x-api-key": options.apiKey,
|
|
@@ -419,7 +447,8 @@ export async function forcedToolCall(
|
|
|
419
447
|
if (!block?.input) throw new Error("Anthropic returned no tool call — try again or a different --model.");
|
|
420
448
|
return block.input;
|
|
421
449
|
}
|
|
422
|
-
const
|
|
450
|
+
const openaiUrl = resolveLlmUrl(options.openaiBaseUrl, OPENAI_URL, "/v1/chat/completions");
|
|
451
|
+
const response = await llmFetch(fetchImpl, openaiUrl, {
|
|
423
452
|
method: "POST",
|
|
424
453
|
headers: { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json" },
|
|
425
454
|
body: JSON.stringify({
|
package/src/schedule.ts
CHANGED
|
@@ -86,10 +86,20 @@ const SCHEDULABLE: Record<string, string[] | null> = {
|
|
|
86
86
|
doctor: null,
|
|
87
87
|
enrich: ["append", "refresh"],
|
|
88
88
|
market: ["capture", "refresh"],
|
|
89
|
+
// The GTM brain. `signals fetch` is read-only re: CRM (--save persists only
|
|
90
|
+
// the local signal ledger). `icp judge`/`icp eval` are read-only/grade-only
|
|
91
|
+
// (--save writes only the local judge store, never a plan). `draft` is
|
|
92
|
+
// plan-side — it only stages a needs_approval plan, never applies — so the
|
|
93
|
+
// whole verb is safely schedulable (apply stays `apply --plan-id` only and
|
|
94
|
+
// re-checks `approved` at run time, so a scheduled draft still cannot send).
|
|
95
|
+
signals: ["fetch"],
|
|
96
|
+
icp: ["judge", "eval"],
|
|
97
|
+
draft: null,
|
|
89
98
|
};
|
|
90
99
|
|
|
91
100
|
const ALLOWLIST_SUMMARY =
|
|
92
|
-
"audit, snapshot, enrich append|refresh, market capture|refresh,
|
|
101
|
+
"audit, snapshot, enrich append|refresh, market capture|refresh, signals fetch, " +
|
|
102
|
+
"icp judge|eval, draft (stages a plan), suggest, report, doctor — " +
|
|
93
103
|
"plus apply --plan-id <id> (re-checked approved at every firing)";
|
|
94
104
|
|
|
95
105
|
/**
|