fullstackgtm 0.13.1 → 0.14.1
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 +55 -0
- package/INSTALL_FOR_AGENTS.md +6 -0
- package/README.md +15 -7
- package/dist/calls.d.ts +5 -0
- package/dist/calls.js +3 -1
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +163 -11
- package/dist/credentials.d.ts +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/llm.d.ts +71 -0
- package/dist/llm.js +241 -0
- package/dist/mcp.js +24 -6
- package/llms.txt +4 -2
- package/package.json +1 -1
- package/src/calls.ts +13 -2
- package/src/cli.ts +171 -11
- package/src/credentials.ts +1 -1
- package/src/index.ts +16 -0
- package/src/llm.ts +334 -0
- package/src/mcp.ts +28 -6
package/src/index.ts
CHANGED
|
@@ -112,6 +112,22 @@ export {
|
|
|
112
112
|
type ParsedTranscriptSegment,
|
|
113
113
|
} from "./calls.ts";
|
|
114
114
|
export { sampleSnapshot } from "./sampleData.ts";
|
|
115
|
+
export {
|
|
116
|
+
DEFAULT_MODELS,
|
|
117
|
+
DEFAULT_RUBRIC,
|
|
118
|
+
detectProviderFromKey,
|
|
119
|
+
extractInsightsLlm,
|
|
120
|
+
parseRubric,
|
|
121
|
+
resolveLlmCredential,
|
|
122
|
+
scoreCallLlm,
|
|
123
|
+
validateLlmKey,
|
|
124
|
+
type CallScorecard,
|
|
125
|
+
type LlmCredential,
|
|
126
|
+
type LlmExtractedInsight,
|
|
127
|
+
type LlmProvider,
|
|
128
|
+
type Rubric,
|
|
129
|
+
type ScoredDimension,
|
|
130
|
+
} from "./llm.ts";
|
|
115
131
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
116
132
|
export type {
|
|
117
133
|
ApprovalStatus,
|
package/src/llm.ts
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { getCredential } from "./credentials.ts";
|
|
2
|
+
import type { CallInsightType, ExtractedCallInsight } from "./calls.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* LLM-powered call extraction and scoring. Bring-your-own-key, two providers
|
|
6
|
+
* (Anthropic, OpenAI), raw fetch — no SDK dependency, mirroring how the CRM
|
|
7
|
+
* connectors talk to their APIs. Constrained tool calls keep the output in
|
|
8
|
+
* the same canonical insight shape as the deterministic engine, with
|
|
9
|
+
* mandatory verbatim-quote evidence; every insight is provenance-marked with
|
|
10
|
+
* the extractor that produced it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type LlmProvider = "anthropic" | "openai";
|
|
14
|
+
|
|
15
|
+
export type LlmCredential = { provider: LlmProvider; apiKey: string; source: "env" | "stored" };
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_MODELS: Record<LlmProvider, string> = {
|
|
18
|
+
anthropic: "claude-haiku-4-5",
|
|
19
|
+
openai: "gpt-4o-mini",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
|
|
23
|
+
const OPENAI_URL = "https://api.openai.com/v1/chat/completions";
|
|
24
|
+
// Bound cost and context: long calls keep the head and tail.
|
|
25
|
+
const MAX_TRANSCRIPT_CHARS = 28_000;
|
|
26
|
+
|
|
27
|
+
export function detectProviderFromKey(apiKey: string): LlmProvider {
|
|
28
|
+
return apiKey.startsWith("sk-ant-") ? "anthropic" : "openai";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Env first (ANTHROPIC_API_KEY, then OPENAI_API_KEY), then the credential store. */
|
|
32
|
+
export function resolveLlmCredential(
|
|
33
|
+
env: Record<string, string | undefined> = process.env,
|
|
34
|
+
): LlmCredential | null {
|
|
35
|
+
if (env.ANTHROPIC_API_KEY) return { provider: "anthropic", apiKey: env.ANTHROPIC_API_KEY, source: "env" };
|
|
36
|
+
if (env.OPENAI_API_KEY) return { provider: "openai", apiKey: env.OPENAI_API_KEY, source: "env" };
|
|
37
|
+
for (const provider of ["anthropic", "openai"] as const) {
|
|
38
|
+
const stored = getCredential(provider);
|
|
39
|
+
if (stored?.accessToken) return { provider, apiKey: stored.accessToken, source: "stored" };
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const INSIGHT_TYPES: CallInsightType[] = [
|
|
45
|
+
"pain_point",
|
|
46
|
+
"objection",
|
|
47
|
+
"competitor_mention",
|
|
48
|
+
"next_step",
|
|
49
|
+
"feature_request",
|
|
50
|
+
"pricing",
|
|
51
|
+
"decision_criteria",
|
|
52
|
+
"risk",
|
|
53
|
+
"coaching_moment",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const EXTRACT_SCHEMA = {
|
|
57
|
+
type: "object",
|
|
58
|
+
required: ["insights"],
|
|
59
|
+
properties: {
|
|
60
|
+
insights: {
|
|
61
|
+
type: "array",
|
|
62
|
+
items: {
|
|
63
|
+
type: "object",
|
|
64
|
+
required: ["type", "text", "evidence", "importance", "confidence"],
|
|
65
|
+
properties: {
|
|
66
|
+
type: { type: "string", enum: INSIGHT_TYPES },
|
|
67
|
+
text: { type: "string", description: "The insight, concise and specific (one sentence)." },
|
|
68
|
+
evidence: { type: "string", description: "VERBATIM quote from the transcript that grounds this insight. Never paraphrase." },
|
|
69
|
+
speaker: { type: "string", description: "Who said the evidence, exactly as named in the transcript." },
|
|
70
|
+
importance: { type: "integer", minimum: 1, maximum: 5 },
|
|
71
|
+
confidence: { type: "number", minimum: 0, maximum: 1 },
|
|
72
|
+
owner: { type: "string", description: "next_step only: who committed to the action." },
|
|
73
|
+
deadline: { type: "string", description: "next_step only: when, as stated (e.g. 'Thursday 2 PM')." },
|
|
74
|
+
commitment: { type: "string", enum: ["firm", "tentative", "exploratory"], description: "next_step only." },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
} as const;
|
|
80
|
+
|
|
81
|
+
const EXTRACT_INSTRUCTIONS = `Extract GTM insights from this sales call transcript.
|
|
82
|
+
Rules:
|
|
83
|
+
- evidence MUST be a verbatim quote from the transcript. If you cannot quote it, do not emit the insight.
|
|
84
|
+
- text is your concise restatement; one sentence, specific (names, numbers, dates).
|
|
85
|
+
- next_step insights are concrete commitments: include owner, deadline (as stated), and commitment level.
|
|
86
|
+
- importance: 5 = affects the deal outcome directly, 1 = color.
|
|
87
|
+
- Emit nothing for small talk. Quality over quantity.`;
|
|
88
|
+
|
|
89
|
+
export type LlmCallOptions = {
|
|
90
|
+
provider: LlmProvider;
|
|
91
|
+
apiKey: string;
|
|
92
|
+
model?: string;
|
|
93
|
+
fetchImpl?: typeof fetch;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type LlmExtractedInsight = ExtractedCallInsight & {
|
|
97
|
+
owner?: string;
|
|
98
|
+
deadline?: string;
|
|
99
|
+
commitment?: "firm" | "tentative" | "exploratory";
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export async function extractInsightsLlm(
|
|
103
|
+
transcript: string,
|
|
104
|
+
options: LlmCallOptions & { title?: string },
|
|
105
|
+
): Promise<{ insights: LlmExtractedInsight[]; model: string }> {
|
|
106
|
+
const model = options.model ?? DEFAULT_MODELS[options.provider];
|
|
107
|
+
const text = truncateTranscript(transcript);
|
|
108
|
+
const prompt = `${EXTRACT_INSTRUCTIONS}\n\n${options.title ? `Call: ${options.title}\n` : ""}Transcript:\n${text}`;
|
|
109
|
+
const result = (await forcedToolCall(prompt, "extract_call_insights", EXTRACT_SCHEMA, model, options)) as {
|
|
110
|
+
insights?: LlmExtractedInsight[];
|
|
111
|
+
};
|
|
112
|
+
const insights = (result.insights ?? [])
|
|
113
|
+
.filter((insight) => INSIGHT_TYPES.includes(insight.type))
|
|
114
|
+
.map((insight) => ({
|
|
115
|
+
...insight,
|
|
116
|
+
title: insight.type.replace(/_/g, " "),
|
|
117
|
+
importance: clamp(Math.round(insight.importance ?? 3), 1, 5),
|
|
118
|
+
confidence: clamp(insight.confidence ?? 0.7, 0, 1),
|
|
119
|
+
}))
|
|
120
|
+
.sort((a, b) => b.importance - a.importance || b.confidence - a.confidence);
|
|
121
|
+
return { insights, model };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Rubric scoring ─────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
export type Rubric = {
|
|
127
|
+
scale: number;
|
|
128
|
+
dimensions: Array<{ name: string; weight: number; rubric: string }>;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const DEFAULT_RUBRIC: Rubric = {
|
|
132
|
+
scale: 5,
|
|
133
|
+
dimensions: [
|
|
134
|
+
{ name: "Depth of Discovery", weight: 1.2, rubric: "Did the rep uncover concrete pain, current process, and cost of inaction with specifics — 5 — or stay at surface level — 1?" },
|
|
135
|
+
{ name: "Next Steps & Commitment", weight: 1.2, rubric: "Did the call end with a specific, time-bound, mutually agreed next step (5) or vague intentions (1)?" },
|
|
136
|
+
{ name: "Stakeholder Engagement", weight: 1.0, rubric: "Were decision makers and influencers identified and engaged (5) or is the rep single-threaded with an unknown buying group (1)?" },
|
|
137
|
+
{ name: "Value Articulation", weight: 1.0, rubric: "Was value tied to the prospect's own stated problems and numbers (5) or generic feature talk (1)?" },
|
|
138
|
+
{ name: "Objection Handling", weight: 1.0, rubric: "Were concerns surfaced, acknowledged, and resolved with evidence (5), or dismissed/avoided (1)?" },
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
export type ScoredDimension = {
|
|
143
|
+
name: string;
|
|
144
|
+
score: number;
|
|
145
|
+
maxScore: number;
|
|
146
|
+
weight: number;
|
|
147
|
+
evidence: string[];
|
|
148
|
+
coachingNote: string;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export type CallScorecard = {
|
|
152
|
+
dimensions: ScoredDimension[];
|
|
153
|
+
/** Weighted average, computed deterministically client-side. */
|
|
154
|
+
overallScore: number;
|
|
155
|
+
scale: number;
|
|
156
|
+
highlights: string[];
|
|
157
|
+
missedItems: string[];
|
|
158
|
+
model: string;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const SCORE_SCHEMA = (scale: number, dimensions: Rubric["dimensions"]) =>
|
|
162
|
+
({
|
|
163
|
+
type: "object",
|
|
164
|
+
required: ["dimensions", "highlights", "missed_items"],
|
|
165
|
+
properties: {
|
|
166
|
+
dimensions: {
|
|
167
|
+
type: "array",
|
|
168
|
+
items: {
|
|
169
|
+
type: "object",
|
|
170
|
+
required: ["name", "score", "evidence", "coaching_note"],
|
|
171
|
+
properties: {
|
|
172
|
+
name: { type: "string", enum: dimensions.map((d) => d.name) },
|
|
173
|
+
score: { type: "integer", minimum: 1, maximum: scale },
|
|
174
|
+
evidence: { type: "array", items: { type: "string" }, description: "Verbatim quotes supporting the score." },
|
|
175
|
+
coaching_note: { type: "string", description: "One actionable sentence, max 25 words." },
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
highlights: { type: "array", items: { type: "string" } },
|
|
180
|
+
missed_items: { type: "array", items: { type: "string" } },
|
|
181
|
+
},
|
|
182
|
+
}) as const;
|
|
183
|
+
|
|
184
|
+
export async function scoreCallLlm(
|
|
185
|
+
transcript: string,
|
|
186
|
+
rubric: Rubric,
|
|
187
|
+
options: LlmCallOptions & { title?: string },
|
|
188
|
+
): Promise<CallScorecard> {
|
|
189
|
+
const model = options.model ?? DEFAULT_MODELS[options.provider];
|
|
190
|
+
const text = truncateTranscript(transcript);
|
|
191
|
+
const rubricText = rubric.dimensions
|
|
192
|
+
.map((d) => `- ${d.name} (weight ${d.weight}): ${d.rubric}`)
|
|
193
|
+
.join("\n");
|
|
194
|
+
const prompt = `Score this sales call against the rubric. Score every dimension 1-${rubric.scale}. Ground every score in verbatim quotes; if the transcript gives no signal for a dimension, score it low and say why in the coaching note.\n\nRubric:\n${rubricText}\n\n${options.title ? `Call: ${options.title}\n` : ""}Transcript:\n${text}`;
|
|
195
|
+
const result = (await forcedToolCall(prompt, "score_call", SCORE_SCHEMA(rubric.scale, rubric.dimensions), model, options)) as {
|
|
196
|
+
dimensions?: Array<{ name: string; score: number; evidence?: string[]; coaching_note?: string }>;
|
|
197
|
+
highlights?: string[];
|
|
198
|
+
missed_items?: string[];
|
|
199
|
+
};
|
|
200
|
+
const byName = new Map((result.dimensions ?? []).map((d) => [d.name, d]));
|
|
201
|
+
const dimensions: ScoredDimension[] = rubric.dimensions.map((dim) => {
|
|
202
|
+
const scored = byName.get(dim.name);
|
|
203
|
+
return {
|
|
204
|
+
name: dim.name,
|
|
205
|
+
score: clamp(Math.round(scored?.score ?? 1), 1, rubric.scale),
|
|
206
|
+
maxScore: rubric.scale,
|
|
207
|
+
weight: dim.weight,
|
|
208
|
+
evidence: scored?.evidence ?? [],
|
|
209
|
+
coachingNote: scored?.coaching_note ?? "No signal for this dimension in the transcript.",
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
const totalWeight = dimensions.reduce((sum, d) => sum + d.weight, 0);
|
|
213
|
+
const overallScore =
|
|
214
|
+
Math.round((dimensions.reduce((sum, d) => sum + d.score * d.weight, 0) / totalWeight) * 100) / 100;
|
|
215
|
+
return {
|
|
216
|
+
dimensions,
|
|
217
|
+
overallScore,
|
|
218
|
+
scale: rubric.scale,
|
|
219
|
+
highlights: result.highlights ?? [],
|
|
220
|
+
missedItems: result.missed_items ?? [],
|
|
221
|
+
model,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function parseRubric(json: string): Rubric {
|
|
226
|
+
const parsed = JSON.parse(json) as Partial<Rubric>;
|
|
227
|
+
if (!Array.isArray(parsed.dimensions) || parsed.dimensions.length === 0) {
|
|
228
|
+
throw new Error("Rubric needs a dimensions array: { scale, dimensions: [{ name, weight, rubric }] }");
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
scale: parsed.scale ?? 5,
|
|
232
|
+
dimensions: parsed.dimensions.map((d) => ({
|
|
233
|
+
name: String(d.name),
|
|
234
|
+
weight: typeof d.weight === "number" ? d.weight : 1,
|
|
235
|
+
rubric: String(d.rubric ?? ""),
|
|
236
|
+
})),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── Provider plumbing (raw fetch, forced tool calls) ───────────────────────
|
|
241
|
+
|
|
242
|
+
async function forcedToolCall(
|
|
243
|
+
prompt: string,
|
|
244
|
+
toolName: string,
|
|
245
|
+
schema: object,
|
|
246
|
+
model: string,
|
|
247
|
+
options: LlmCallOptions,
|
|
248
|
+
): Promise<unknown> {
|
|
249
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
250
|
+
if (options.provider === "anthropic") {
|
|
251
|
+
const response = await llmFetch(fetchImpl, ANTHROPIC_URL, {
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: {
|
|
254
|
+
"x-api-key": options.apiKey,
|
|
255
|
+
"anthropic-version": "2023-06-01",
|
|
256
|
+
"Content-Type": "application/json",
|
|
257
|
+
},
|
|
258
|
+
body: JSON.stringify({
|
|
259
|
+
model,
|
|
260
|
+
max_tokens: 4096,
|
|
261
|
+
tools: [{ name: toolName, description: `Return the ${toolName} result.`, input_schema: schema }],
|
|
262
|
+
tool_choice: { type: "tool", name: toolName },
|
|
263
|
+
messages: [{ role: "user", content: prompt }],
|
|
264
|
+
}),
|
|
265
|
+
});
|
|
266
|
+
const block = (response as { content?: Array<{ type: string; input?: unknown }> }).content?.find(
|
|
267
|
+
(item) => item.type === "tool_use",
|
|
268
|
+
);
|
|
269
|
+
if (!block?.input) throw new Error("Anthropic returned no tool call — try again or a different --model.");
|
|
270
|
+
return block.input;
|
|
271
|
+
}
|
|
272
|
+
const response = await llmFetch(fetchImpl, OPENAI_URL, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: { Authorization: `Bearer ${options.apiKey}`, "Content-Type": "application/json" },
|
|
275
|
+
body: JSON.stringify({
|
|
276
|
+
model,
|
|
277
|
+
messages: [{ role: "user", content: prompt }],
|
|
278
|
+
tools: [{ type: "function", function: { name: toolName, parameters: schema } }],
|
|
279
|
+
tool_choice: { type: "function", function: { name: toolName } },
|
|
280
|
+
}),
|
|
281
|
+
});
|
|
282
|
+
const call = (response as { choices?: Array<{ message?: { tool_calls?: Array<{ function?: { arguments?: string } }> } }> })
|
|
283
|
+
.choices?.[0]?.message?.tool_calls?.[0];
|
|
284
|
+
if (!call?.function?.arguments) throw new Error("OpenAI returned no tool call — try again or a different --model.");
|
|
285
|
+
return JSON.parse(call.function.arguments);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function llmFetch(fetchImpl: typeof fetch, url: string, init: RequestInit): Promise<unknown> {
|
|
289
|
+
let response: Response;
|
|
290
|
+
try {
|
|
291
|
+
response = await fetchImpl(url, init);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
294
|
+
throw new Error(`Cannot reach ${new URL(url).hostname}${cause}. Check network access.`);
|
|
295
|
+
}
|
|
296
|
+
if (!response.ok) {
|
|
297
|
+
// Status line only — provider error bodies can reflect request content.
|
|
298
|
+
throw new Error(`LLM API error ${response.status} ${response.statusText} from ${new URL(url).hostname}. Check the API key (\`fullstackgtm login anthropic|openai\`) and model name.`);
|
|
299
|
+
}
|
|
300
|
+
return response.json();
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function truncateTranscript(transcript: string): string {
|
|
304
|
+
if (transcript.length <= MAX_TRANSCRIPT_CHARS) return transcript;
|
|
305
|
+
const half = MAX_TRANSCRIPT_CHARS / 2;
|
|
306
|
+
return `${transcript.slice(0, half)}\n[... middle of transcript truncated ...]\n${transcript.slice(-half)}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function clamp(value: number, min: number, max: number) {
|
|
310
|
+
return Math.min(max, Math.max(min, value));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Cheap key validation against the provider's model-list endpoint. Status line only. */
|
|
314
|
+
export async function validateLlmKey(
|
|
315
|
+
provider: LlmProvider,
|
|
316
|
+
apiKey: string,
|
|
317
|
+
fetchImpl: typeof fetch = fetch,
|
|
318
|
+
): Promise<{ ok: boolean; detail: string }> {
|
|
319
|
+
const url = provider === "anthropic" ? "https://api.anthropic.com/v1/models" : "https://api.openai.com/v1/models";
|
|
320
|
+
const headers: Record<string, string> =
|
|
321
|
+
provider === "anthropic"
|
|
322
|
+
? { "x-api-key": apiKey, "anthropic-version": "2023-06-01" }
|
|
323
|
+
: { Authorization: `Bearer ${apiKey}` };
|
|
324
|
+
let response: Response;
|
|
325
|
+
try {
|
|
326
|
+
response = await fetchImpl(url, { headers });
|
|
327
|
+
} catch (error) {
|
|
328
|
+
const cause = error instanceof Error && error.cause instanceof Error ? `: ${error.cause.message}` : "";
|
|
329
|
+
return { ok: false, detail: `Cannot reach ${new URL(url).hostname}${cause}.` };
|
|
330
|
+
}
|
|
331
|
+
return response.ok
|
|
332
|
+
? { ok: true, detail: `Key accepted by the ${provider} API.` }
|
|
333
|
+
: { ok: false, detail: `HTTP ${response.status} ${response.statusText}`.trim() };
|
|
334
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -46,7 +46,8 @@ import type { FieldMappings } from "./mappings.ts";
|
|
|
46
46
|
import { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
47
47
|
import { builtinAuditRules } from "./rules.ts";
|
|
48
48
|
import { sampleSnapshot } from "./sampleData.ts";
|
|
49
|
-
import { parseCall } from "./calls.ts";
|
|
49
|
+
import { normalizeTranscript, parseCall } from "./calls.ts";
|
|
50
|
+
import { extractInsightsLlm, resolveLlmCredential } from "./llm.ts";
|
|
50
51
|
import { suggestValues } from "./suggest.ts";
|
|
51
52
|
import type { CanonicalGtmSnapshot, GtmConnector, PatchPlan } from "./types.ts";
|
|
52
53
|
|
|
@@ -197,22 +198,43 @@ export async function startMcpServer() {
|
|
|
197
198
|
{
|
|
198
199
|
title: "Parse Call Transcript",
|
|
199
200
|
description:
|
|
200
|
-
"
|
|
201
|
-
"
|
|
202
|
-
"
|
|
203
|
-
"
|
|
201
|
+
"Parse a call transcript (Speaker:/[Speaker]: lines or Granola utterance JSON) into " +
|
|
202
|
+
"canonical segments, insights, and GtmEvidence records. extractor: 'auto' (default) " +
|
|
203
|
+
"uses LLM extraction when an Anthropic/OpenAI key is configured in the server " +
|
|
204
|
+
"environment or credential store, else the free deterministic keyword baseline; " +
|
|
205
|
+
"'llm' and 'deterministic' force either. Read-only; every insight is provenance-marked.",
|
|
204
206
|
inputSchema: {
|
|
205
207
|
transcript: z.string().optional(),
|
|
206
208
|
transcriptPath: z.string().optional(),
|
|
207
209
|
title: z.string().optional(),
|
|
208
210
|
source: z.enum(["gong", "chorus", "fathom", "manual", "csv", "unknown"]).optional(),
|
|
211
|
+
extractor: z.enum(["auto", "llm", "deterministic"]).optional(),
|
|
212
|
+
model: z.string().optional(),
|
|
209
213
|
},
|
|
210
214
|
},
|
|
211
|
-
async ({ transcript, transcriptPath, title, source }) => {
|
|
215
|
+
async ({ transcript, transcriptPath, title, source, extractor, model }) => {
|
|
212
216
|
const raw =
|
|
213
217
|
transcript ??
|
|
214
218
|
(transcriptPath ? readFileSync(resolve(process.cwd(), transcriptPath), "utf8") : null);
|
|
215
219
|
if (!raw) throw new Error("Provide transcript (text) or transcriptPath (file).");
|
|
220
|
+
const mode = extractor ?? "auto";
|
|
221
|
+
const credential = mode === "deterministic" ? null : resolveLlmCredential();
|
|
222
|
+
if (mode === "llm" && !credential) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
"extractor 'llm' needs an API key: set ANTHROPIC_API_KEY or OPENAI_API_KEY in the MCP server environment, or store one with `fullstackgtm login anthropic|openai`.",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (credential) {
|
|
228
|
+
const normalized = normalizeTranscript(raw);
|
|
229
|
+
const { insights, model: used } = await extractInsightsLlm(normalized, {
|
|
230
|
+
...credential,
|
|
231
|
+
model,
|
|
232
|
+
title,
|
|
233
|
+
});
|
|
234
|
+
return content(
|
|
235
|
+
parseCall(raw, { title, sourceSystem: source, insights, extractor: `llm:${credential.provider}:${used}` }),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
216
238
|
return content(parseCall(raw, { title, sourceSystem: source }));
|
|
217
239
|
},
|
|
218
240
|
);
|