fullstackgtm 0.31.0 → 0.33.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 +54 -0
- package/README.md +7 -3
- package/dist/callTypes.d.ts +72 -0
- package/dist/callTypes.js +690 -0
- package/dist/cli.js +93 -12
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/llm.d.ts +50 -5
- package/dist/llm.js +60 -2
- package/dist/marketReport.js +29 -8
- package/dist/marketSourcing.d.ts +66 -0
- package/dist/marketSourcing.js +405 -0
- package/package.json +1 -1
- package/src/callTypes.ts +752 -0
- package/src/cli.ts +109 -11
- package/src/index.ts +30 -0
- package/src/llm.ts +100 -3
- package/src/marketReport.ts +31 -7
- package/src/marketSourcing.ts +405 -0
package/src/cli.ts
CHANGED
|
@@ -41,6 +41,14 @@ import { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./
|
|
|
41
41
|
import { builtinAuditRules } from "./rules.ts";
|
|
42
42
|
import { sampleSnapshot } from "./sampleData.ts";
|
|
43
43
|
import { normalizeTranscript, parseCall, suggestCallDeal, type ExtractedCallInsight, type ParsedCall } from "./calls.ts";
|
|
44
|
+
import {
|
|
45
|
+
classifyCall,
|
|
46
|
+
rubricForCallType,
|
|
47
|
+
rubricPresets,
|
|
48
|
+
CALL_TYPES,
|
|
49
|
+
CALL_TYPE_IDS,
|
|
50
|
+
type CallType,
|
|
51
|
+
} from "./callTypes.ts";
|
|
44
52
|
import {
|
|
45
53
|
captureMarket,
|
|
46
54
|
computeFrontStates,
|
|
@@ -67,6 +75,7 @@ import { buildWorksheet, classifyMarket } from "./marketClassify.ts";
|
|
|
67
75
|
import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
68
76
|
import {
|
|
69
77
|
DEFAULT_RUBRIC,
|
|
78
|
+
classifyCallLlm,
|
|
70
79
|
detectProviderFromKey,
|
|
71
80
|
extractInsightsLlm,
|
|
72
81
|
parseRubric,
|
|
@@ -75,6 +84,7 @@ import {
|
|
|
75
84
|
validateLlmKey,
|
|
76
85
|
type CallScorecard,
|
|
77
86
|
type LlmProvider,
|
|
87
|
+
type Rubric,
|
|
78
88
|
} from "./llm.ts";
|
|
79
89
|
import {
|
|
80
90
|
buildEnrichPlan,
|
|
@@ -159,7 +169,8 @@ Usage:
|
|
|
159
169
|
fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
|
|
160
170
|
fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
|
|
161
171
|
fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
|
|
162
|
-
fullstackgtm call
|
|
172
|
+
fullstackgtm call classify --transcript <file>|--call <parsed.json> [--llm] [--deterministic] [--json]
|
|
173
|
+
fullstackgtm call score --transcript <file>|--call <parsed.json> [--call-type <t>] [--rubric <rubric.json>] [--model m] [--json|--out <path>]
|
|
163
174
|
fullstackgtm call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
|
|
164
175
|
fullstackgtm call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
|
|
165
176
|
calls become evidence: LLM extraction by default (bring your own
|
|
@@ -649,14 +660,20 @@ function parseValueOverrides(args: string[]) {
|
|
|
649
660
|
async function callCommand(args: string[]) {
|
|
650
661
|
const [subcommand, ...rest] = args;
|
|
651
662
|
if (args.includes("--help") || args.includes("-h")) {
|
|
652
|
-
console.log(`call parse
|
|
653
|
-
call
|
|
654
|
-
call
|
|
655
|
-
call
|
|
663
|
+
console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
|
|
664
|
+
call classify --transcript <file>|--call <parsed.json> [--llm] [--deterministic] [--json] [--list]
|
|
665
|
+
call score --transcript <file>|--call <parsed.json> [--call-type <t>] [--rubric <rubric.json>] [--model m] [--json|--out <path>] [--list-rubrics]
|
|
666
|
+
call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
|
|
667
|
+
call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
|
|
668
|
+
|
|
669
|
+
classify picks the call type (deterministic signals; --llm for a model tiebreak).
|
|
670
|
+
score auto-selects the type-specific rubric from that classification unless you
|
|
671
|
+
pass --call-type or --rubric. Call types: ${CALL_TYPE_IDS.join(", ")}.
|
|
656
672
|
|
|
657
673
|
parse/score default to LLM extraction (Anthropic or OpenAI key via env,
|
|
658
|
-
\`login anthropic|openai\`, or a one-time prompt). parse --deterministic is
|
|
659
|
-
|
|
674
|
+
\`login anthropic|openai\`, or a one-time prompt). parse --deterministic is the
|
|
675
|
+
free keyword baseline and classify --deterministic needs no key.
|
|
676
|
+
score always needs a key (scoring is LLM work).`);
|
|
660
677
|
return;
|
|
661
678
|
}
|
|
662
679
|
|
|
@@ -692,6 +709,64 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
|
|
|
692
709
|
});
|
|
693
710
|
};
|
|
694
711
|
|
|
712
|
+
// Reconstruct plain transcript text from either a --transcript file (any
|
|
713
|
+
// dialect, normalized) or a parsed --call JSON. Shared by classify + score.
|
|
714
|
+
const loadTranscriptText = (): string => {
|
|
715
|
+
const transcriptPath = option(rest, "--transcript");
|
|
716
|
+
if (transcriptPath) {
|
|
717
|
+
return normalizeTranscript(readFileSync(resolve(process.cwd(), transcriptPath), "utf8"));
|
|
718
|
+
}
|
|
719
|
+
const callPath = option(rest, "--call");
|
|
720
|
+
if (!callPath) throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
|
|
721
|
+
const parsed = JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8")) as ParsedCall;
|
|
722
|
+
return parsed.segments.map((s) => (s.speaker ? `${s.speaker}: ${s.text}` : s.text)).join("\n");
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
if (subcommand === "classify") {
|
|
726
|
+
if (rest.includes("--list")) {
|
|
727
|
+
const lines = CALL_TYPES.map((d) => `${d.id.padEnd(22)} ${d.name} — ${d.definition}`);
|
|
728
|
+
console.log(rest.includes("--json") ? JSON.stringify(CALL_TYPES, null, 2) : lines.join("\n"));
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
const transcriptText = loadTranscriptText();
|
|
732
|
+
const title = option(rest, "--title") ?? undefined;
|
|
733
|
+
const deterministic = classifyCall(transcriptText);
|
|
734
|
+
// LLM tiebreak: explicit --llm, or auto when the deterministic pass is unsure
|
|
735
|
+
// and a key is available (never required — deterministic always answers).
|
|
736
|
+
const wantLlm = rest.includes("--llm") || (!rest.includes("--deterministic") && deterministic.confidence !== "high" && Boolean(resolveLlmCredential()));
|
|
737
|
+
let result: {
|
|
738
|
+
type: CallType;
|
|
739
|
+
confidence: string;
|
|
740
|
+
reason: string;
|
|
741
|
+
method: string;
|
|
742
|
+
candidates?: typeof deterministic.candidates;
|
|
743
|
+
model?: string;
|
|
744
|
+
} = deterministic;
|
|
745
|
+
if (wantLlm) {
|
|
746
|
+
const credential = await requireLlmCredential("score");
|
|
747
|
+
const llm = await classifyCallLlm(transcriptText, CALL_TYPES, {
|
|
748
|
+
...credential,
|
|
749
|
+
model: option(rest, "--model") ?? undefined,
|
|
750
|
+
title,
|
|
751
|
+
});
|
|
752
|
+
result = { type: llm.type, confidence: "high", reason: llm.reason, method: "llm", model: llm.model };
|
|
753
|
+
}
|
|
754
|
+
if (rest.includes("--json")) {
|
|
755
|
+
console.log(JSON.stringify(result, null, 2));
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
const def = CALL_TYPES.find((d) => d.id === result.type);
|
|
759
|
+
console.log(`Call type: ${def?.name ?? result.type} (${result.type})`);
|
|
760
|
+
console.log(`Confidence: ${result.confidence} · via ${result.method}${result.model ? ` (${result.model})` : ""}`);
|
|
761
|
+
console.log(`Why: ${result.reason}`);
|
|
762
|
+
if (result.method === "deterministic" && deterministic.candidates.length > 1) {
|
|
763
|
+
const others = deterministic.candidates.slice(1, 4).map((c) => `${c.type} (${c.score})`).join(", ");
|
|
764
|
+
console.log(`Other matches: ${others}`);
|
|
765
|
+
}
|
|
766
|
+
console.log(`\nScore it with this rubric: fullstackgtm call score ${option(rest, "--transcript") ? `--transcript ${option(rest, "--transcript")}` : `--call ${option(rest, "--call")}`} --call-type ${result.type}`);
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
|
|
695
770
|
if (subcommand === "parse") {
|
|
696
771
|
const parsed = await loadParsedCall();
|
|
697
772
|
const outPath = option(rest, "--out");
|
|
@@ -779,9 +854,13 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
|
|
|
779
854
|
}
|
|
780
855
|
|
|
781
856
|
if (subcommand === "score") {
|
|
782
|
-
|
|
857
|
+
if (rest.includes("--list-rubrics")) {
|
|
858
|
+
console.log(JSON.stringify(rubricPresets(), null, 2));
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
// Explicit-rubric problems surface before any credential or API work.
|
|
783
862
|
const rubricPath = option(rest, "--rubric");
|
|
784
|
-
let rubric
|
|
863
|
+
let rubric: Rubric | undefined;
|
|
785
864
|
if (rubricPath) {
|
|
786
865
|
const rubricRaw = readFileSync(resolve(process.cwd(), rubricPath), "utf8");
|
|
787
866
|
try {
|
|
@@ -792,6 +871,10 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
|
|
|
792
871
|
);
|
|
793
872
|
}
|
|
794
873
|
}
|
|
874
|
+
const callTypeOpt = option(rest, "--call-type") as CallType | undefined;
|
|
875
|
+
if (callTypeOpt && !CALL_TYPE_IDS.includes(callTypeOpt)) {
|
|
876
|
+
throw new Error(`Unknown --call-type "${callTypeOpt}". One of: ${CALL_TYPE_IDS.join(", ")}.`);
|
|
877
|
+
}
|
|
795
878
|
const credential = await requireLlmCredential("score");
|
|
796
879
|
const transcriptPath = option(rest, "--transcript");
|
|
797
880
|
let transcriptText: string;
|
|
@@ -807,6 +890,17 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
|
|
|
807
890
|
.join("\n");
|
|
808
891
|
title = title ?? parsed.title;
|
|
809
892
|
}
|
|
893
|
+
// Rubric selection: explicit --rubric wins, then --call-type, else the
|
|
894
|
+
// deterministic classifier picks the type-specific preset. No generic
|
|
895
|
+
// discovery rubric silently applied to a renewal anymore.
|
|
896
|
+
if (!rubric) {
|
|
897
|
+
const type = callTypeOpt ?? classifyCall(transcriptText).type;
|
|
898
|
+
rubric = rubricForCallType(type, DEFAULT_RUBRIC);
|
|
899
|
+
if (!rest.includes("--json")) {
|
|
900
|
+
const how = callTypeOpt ? `--call-type ${callTypeOpt}` : `auto-classified as ${type}`;
|
|
901
|
+
console.error(`Scoring with the "${rubric.name ?? "Generic"}" rubric (${how}). Override with --rubric <file> or --call-type <type>.`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
810
904
|
const scorecard = await scoreCallLlm(transcriptText, rubric, {
|
|
811
905
|
...credential,
|
|
812
906
|
model: option(rest, "--model") ?? undefined,
|
|
@@ -822,7 +916,7 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
|
|
|
822
916
|
return;
|
|
823
917
|
}
|
|
824
918
|
|
|
825
|
-
throw new Error(`call supports: parse, link, plan, score (got ${subcommand ?? "nothing"})`);
|
|
919
|
+
throw new Error(`call supports: parse, classify, link, plan, score (got ${subcommand ?? "nothing"})`);
|
|
826
920
|
}
|
|
827
921
|
|
|
828
922
|
/**
|
|
@@ -862,10 +956,14 @@ async function requireLlmCredential(
|
|
|
862
956
|
}
|
|
863
957
|
|
|
864
958
|
function renderScorecard(scorecard: CallScorecard, title?: string): string {
|
|
959
|
+
const bandText = scorecard.band ? ` — ${scorecard.band.label}` : "";
|
|
960
|
+
const rubricLine = scorecard.rubricName ? `Rubric: ${scorecard.rubricName}${scorecard.callType ? ` (${scorecard.callType})` : ""}` : "";
|
|
865
961
|
const lines = [
|
|
866
962
|
`# Coaching Scorecard${title ? ` — ${title}` : ""}`,
|
|
867
963
|
"",
|
|
868
|
-
`**Overall: ${scorecard.overallScore}/${scorecard.scale}** (model: ${scorecard.model})`,
|
|
964
|
+
`**Overall: ${scorecard.overallScore}/${scorecard.scale}${bandText}** (model: ${scorecard.model})`,
|
|
965
|
+
...(scorecard.band?.meaning ? [`> ${scorecard.band.meaning}`] : []),
|
|
966
|
+
...(rubricLine ? ["", `_${rubricLine}_`] : []),
|
|
869
967
|
"",
|
|
870
968
|
"| Dimension | Score | | Coaching note |",
|
|
871
969
|
"| --- | --- | --- | --- |",
|
package/src/index.ts
CHANGED
|
@@ -179,6 +179,7 @@ export { sampleSnapshot } from "./sampleData.ts";
|
|
|
179
179
|
export {
|
|
180
180
|
DEFAULT_MODELS,
|
|
181
181
|
DEFAULT_RUBRIC,
|
|
182
|
+
classifyCallLlm,
|
|
182
183
|
detectProviderFromKey,
|
|
183
184
|
extractInsightsLlm,
|
|
184
185
|
forcedToolCall,
|
|
@@ -187,12 +188,27 @@ export {
|
|
|
187
188
|
scoreCallLlm,
|
|
188
189
|
validateLlmKey,
|
|
189
190
|
type CallScorecard,
|
|
191
|
+
type LlmCallClassification,
|
|
190
192
|
type LlmCredential,
|
|
191
193
|
type LlmExtractedInsight,
|
|
192
194
|
type LlmProvider,
|
|
193
195
|
type Rubric,
|
|
196
|
+
type RubricDimension,
|
|
197
|
+
type ScoreBand,
|
|
194
198
|
type ScoredDimension,
|
|
195
199
|
} from "./llm.ts";
|
|
200
|
+
export {
|
|
201
|
+
classifyCall,
|
|
202
|
+
rubricForCallType,
|
|
203
|
+
rubricPresets,
|
|
204
|
+
bandForScore,
|
|
205
|
+
CALL_TYPES,
|
|
206
|
+
CALL_TYPE_IDS,
|
|
207
|
+
BANDS_5,
|
|
208
|
+
type CallType,
|
|
209
|
+
type CallTypeDef,
|
|
210
|
+
type CallClassification,
|
|
211
|
+
} from "./callTypes.ts";
|
|
196
212
|
export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
|
|
197
213
|
export {
|
|
198
214
|
captureMarket,
|
|
@@ -275,6 +291,20 @@ export {
|
|
|
275
291
|
type VendorScale,
|
|
276
292
|
} from "./marketScale.ts";
|
|
277
293
|
export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
294
|
+
export {
|
|
295
|
+
registrableDomain,
|
|
296
|
+
categoryKeywords,
|
|
297
|
+
pickCategoryPage,
|
|
298
|
+
extractLogoUrl,
|
|
299
|
+
resolveFinalUrl,
|
|
300
|
+
detectDrift,
|
|
301
|
+
findCategoryPageInSitemap,
|
|
302
|
+
findCategoryPage,
|
|
303
|
+
fetchLogoDataUri,
|
|
304
|
+
type FetchText,
|
|
305
|
+
type FetchBytes,
|
|
306
|
+
type ResolveUrl,
|
|
307
|
+
} from "./marketSourcing.ts";
|
|
278
308
|
export {
|
|
279
309
|
computeMissedFirings,
|
|
280
310
|
createFileScheduleRunStore,
|
package/src/llm.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getCredential } from "./credentials.ts";
|
|
2
2
|
import type { CallInsightType, ExtractedCallInsight } from "./calls.ts";
|
|
3
|
+
import { CALL_TYPE_IDS, type CallType } from "./callTypes.ts";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* LLM-powered call extraction and scoring. Bring-your-own-key, two providers
|
|
@@ -170,13 +171,37 @@ function actionGroundedInEvidence(text: string, evidence: string): boolean {
|
|
|
170
171
|
|
|
171
172
|
// ── Rubric scoring ─────────────────────────────────────────────────────────
|
|
172
173
|
|
|
174
|
+
/** A qualitative band over the weighted overall (e.g. "developing" at >=2). */
|
|
175
|
+
export type ScoreBand = { label: string; min: number; meaning?: string };
|
|
176
|
+
|
|
177
|
+
export type RubricDimension = {
|
|
178
|
+
name: string;
|
|
179
|
+
weight: number;
|
|
180
|
+
rubric: string;
|
|
181
|
+
/** Anchored behavioral examples of a top score — sharpens the model and cuts variance. */
|
|
182
|
+
anchorsHigh?: string[];
|
|
183
|
+
/** Anchored behavioral examples of a bottom score. */
|
|
184
|
+
anchorsLow?: string[];
|
|
185
|
+
/** Verbatim phrases to listen for — tightens evidence grounding. */
|
|
186
|
+
evidenceCues?: string[];
|
|
187
|
+
/** Reflective questions surfaced to the rep alongside the score. */
|
|
188
|
+
coachingPrompts?: string[];
|
|
189
|
+
};
|
|
190
|
+
|
|
173
191
|
export type Rubric = {
|
|
174
192
|
scale: number;
|
|
175
|
-
|
|
193
|
+
/** Display name (e.g. the call type this rubric is built for). */
|
|
194
|
+
name?: string;
|
|
195
|
+
/** The call type this rubric scores, when type-specific. */
|
|
196
|
+
callType?: CallType;
|
|
197
|
+
dimensions: RubricDimension[];
|
|
198
|
+
/** Optional qualitative bands over the weighted overall. */
|
|
199
|
+
bands?: ScoreBand[];
|
|
176
200
|
};
|
|
177
201
|
|
|
178
202
|
export const DEFAULT_RUBRIC: Rubric = {
|
|
179
203
|
scale: 5,
|
|
204
|
+
name: "Generic",
|
|
180
205
|
dimensions: [
|
|
181
206
|
{ 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?" },
|
|
182
207
|
{ 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)?" },
|
|
@@ -200,6 +225,11 @@ export type CallScorecard = {
|
|
|
200
225
|
/** Weighted average, computed deterministically client-side. */
|
|
201
226
|
overallScore: number;
|
|
202
227
|
scale: number;
|
|
228
|
+
/** Qualitative band for the overall, computed client-side from the rubric's bands. */
|
|
229
|
+
band?: ScoreBand;
|
|
230
|
+
/** The rubric used, for provenance in reports. */
|
|
231
|
+
rubricName?: string;
|
|
232
|
+
callType?: CallType;
|
|
203
233
|
highlights: string[];
|
|
204
234
|
missedItems: string[];
|
|
205
235
|
model: string;
|
|
@@ -236,9 +266,16 @@ export async function scoreCallLlm(
|
|
|
236
266
|
const model = options.model ?? DEFAULT_MODELS[options.provider];
|
|
237
267
|
const text = truncateTranscript(transcript);
|
|
238
268
|
const rubricText = rubric.dimensions
|
|
239
|
-
.map((d) =>
|
|
269
|
+
.map((d) => {
|
|
270
|
+
const lines = [`- ${d.name} (weight ${d.weight}): ${d.rubric}`];
|
|
271
|
+
if (d.anchorsHigh?.length) lines.push(` a ${rubric.scale} looks like: ${d.anchorsHigh.join("; ")}`);
|
|
272
|
+
if (d.anchorsLow?.length) lines.push(` a 1 looks like: ${d.anchorsLow.join("; ")}`);
|
|
273
|
+
if (d.evidenceCues?.length) lines.push(` listen for: ${d.evidenceCues.join(", ")}`);
|
|
274
|
+
return lines.join("\n");
|
|
275
|
+
})
|
|
240
276
|
.join("\n");
|
|
241
|
-
const
|
|
277
|
+
const heading = rubric.name ? `${rubric.name} call` : "sales call";
|
|
278
|
+
const prompt = `Score this ${heading} against the rubric. Score every dimension 1-${rubric.scale}, calibrating to the anchored examples where given. 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}`;
|
|
242
279
|
const result = (await forcedToolCall(prompt, "score_call", SCORE_SCHEMA(rubric.scale, rubric.dimensions), model, options)) as {
|
|
243
280
|
dimensions?: Array<{ name: string; score: number; evidence?: string[]; coaching_note?: string }>;
|
|
244
281
|
highlights?: string[];
|
|
@@ -259,31 +296,91 @@ export async function scoreCallLlm(
|
|
|
259
296
|
const totalWeight = dimensions.reduce((sum, d) => sum + d.weight, 0);
|
|
260
297
|
const overallScore =
|
|
261
298
|
Math.round((dimensions.reduce((sum, d) => sum + d.score * d.weight, 0) / totalWeight) * 100) / 100;
|
|
299
|
+
const band = rubric.bands?.length
|
|
300
|
+
? [...rubric.bands].sort((a, b) => b.min - a.min).find((b) => overallScore >= b.min)
|
|
301
|
+
: undefined;
|
|
262
302
|
return {
|
|
263
303
|
dimensions,
|
|
264
304
|
overallScore,
|
|
265
305
|
scale: rubric.scale,
|
|
306
|
+
band,
|
|
307
|
+
rubricName: rubric.name,
|
|
308
|
+
callType: rubric.callType,
|
|
266
309
|
highlights: result.highlights ?? [],
|
|
267
310
|
missedItems: result.missed_items ?? [],
|
|
268
311
|
model,
|
|
269
312
|
};
|
|
270
313
|
}
|
|
271
314
|
|
|
315
|
+
const strArray = (value: unknown): string[] | undefined =>
|
|
316
|
+
Array.isArray(value) && value.length ? value.map((v) => String(v)) : undefined;
|
|
317
|
+
|
|
272
318
|
export function parseRubric(json: string): Rubric {
|
|
273
319
|
const parsed = JSON.parse(json) as Partial<Rubric>;
|
|
274
320
|
if (!Array.isArray(parsed.dimensions) || parsed.dimensions.length === 0) {
|
|
275
321
|
throw new Error("Rubric needs a dimensions array: { scale, dimensions: [{ name, weight, rubric }] }");
|
|
276
322
|
}
|
|
323
|
+
const bands = Array.isArray(parsed.bands)
|
|
324
|
+
? parsed.bands
|
|
325
|
+
.filter((b) => b && typeof b.min === "number" && b.label)
|
|
326
|
+
.map((b) => ({ label: String(b.label), min: Number(b.min), meaning: b.meaning ? String(b.meaning) : undefined }))
|
|
327
|
+
: undefined;
|
|
277
328
|
return {
|
|
278
329
|
scale: parsed.scale ?? 5,
|
|
330
|
+
name: parsed.name ? String(parsed.name) : undefined,
|
|
331
|
+
callType: parsed.callType,
|
|
332
|
+
bands: bands?.length ? bands : undefined,
|
|
279
333
|
dimensions: parsed.dimensions.map((d) => ({
|
|
280
334
|
name: String(d.name),
|
|
281
335
|
weight: typeof d.weight === "number" ? d.weight : 1,
|
|
282
336
|
rubric: String(d.rubric ?? ""),
|
|
337
|
+
anchorsHigh: strArray(d.anchorsHigh),
|
|
338
|
+
anchorsLow: strArray(d.anchorsLow),
|
|
339
|
+
evidenceCues: strArray(d.evidenceCues),
|
|
340
|
+
coachingPrompts: strArray(d.coachingPrompts),
|
|
283
341
|
})),
|
|
284
342
|
};
|
|
285
343
|
}
|
|
286
344
|
|
|
345
|
+
// ── Call-type classification (LLM tiebreak) ────────────────────────────────
|
|
346
|
+
|
|
347
|
+
const CLASSIFY_SCHEMA = {
|
|
348
|
+
type: "object",
|
|
349
|
+
required: ["type", "reason"],
|
|
350
|
+
properties: {
|
|
351
|
+
type: { type: "string", enum: CALL_TYPE_IDS },
|
|
352
|
+
reason: { type: "string", description: "One sentence, citing what in the transcript decided it." },
|
|
353
|
+
},
|
|
354
|
+
} as const;
|
|
355
|
+
|
|
356
|
+
export type LlmCallClassification = { type: CallType; reason: string; model: string; method: "llm" };
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Model tiebreak for call-type classification — the opt-in counterpart to the
|
|
360
|
+
* deterministic `classifyCall`. Same forced-tool-call seam as every other LLM
|
|
361
|
+
* feature; returns the canonical CallType plus a one-line reason.
|
|
362
|
+
*/
|
|
363
|
+
export async function classifyCallLlm(
|
|
364
|
+
transcript: string,
|
|
365
|
+
defs: Array<{ id: string; name: string; definition: string }>,
|
|
366
|
+
options: LlmCallOptions & { title?: string },
|
|
367
|
+
): Promise<LlmCallClassification> {
|
|
368
|
+
const model = options.model ?? DEFAULT_MODELS[options.provider];
|
|
369
|
+
const text = truncateTranscript(transcript);
|
|
370
|
+
const taxonomy = defs.map((d) => `- ${d.id} (${d.name}): ${d.definition}`).join("\n");
|
|
371
|
+
const prompt = `Classify this sales call into exactly one type from the taxonomy. Pick the single best fit; use "other" only if none apply.\n\nTaxonomy:\n${taxonomy}\n\n${options.title ? `Call: ${options.title}\n` : ""}Transcript:\n${text}`;
|
|
372
|
+
const result = (await forcedToolCall(prompt, "classify_call", CLASSIFY_SCHEMA, model, options)) as {
|
|
373
|
+
type?: CallType;
|
|
374
|
+
reason?: string;
|
|
375
|
+
};
|
|
376
|
+
return {
|
|
377
|
+
type: (result.type as CallType) ?? "other",
|
|
378
|
+
reason: result.reason ?? "Model did not give a reason.",
|
|
379
|
+
model,
|
|
380
|
+
method: "llm",
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
287
384
|
// ── Provider plumbing (raw fetch, forced tool calls) ───────────────────────
|
|
288
385
|
|
|
289
386
|
/**
|
package/src/marketReport.ts
CHANGED
|
@@ -196,6 +196,7 @@ function svgScatter(
|
|
|
196
196
|
anchor: string | undefined,
|
|
197
197
|
colorByVendor: Map<string, string>,
|
|
198
198
|
numberByVendor: Map<string, number>,
|
|
199
|
+
logoByVendor: Map<string, string | undefined>,
|
|
199
200
|
): string {
|
|
200
201
|
const W = 640;
|
|
201
202
|
const H = 480;
|
|
@@ -217,23 +218,45 @@ function svgScatter(
|
|
|
217
218
|
// raises a bubble to the front (JS re-appends its <g>), so even a bubble
|
|
218
219
|
// born fully underneath a bigger one is one mouse-over from visible.
|
|
219
220
|
const ordered = [...points].sort((a, b) => b.size - a.size);
|
|
221
|
+
const clipDefs: string[] = [];
|
|
220
222
|
const dots = ordered
|
|
221
|
-
.map((p) => {
|
|
223
|
+
.map((p, i) => {
|
|
222
224
|
const r = p.noScale ? 7 : 7 + 24 * Math.sqrt(p.size);
|
|
223
225
|
const color = colorByVendor.get(p.vendorId) ?? "#717171";
|
|
224
226
|
const number = numberByVendor.get(p.vendorId) ?? 0;
|
|
225
|
-
const
|
|
226
|
-
const
|
|
227
|
+
const cxN = sx(p.x);
|
|
228
|
+
const cyN = sy(p.y);
|
|
229
|
+
const cx = cxN.toFixed(1);
|
|
230
|
+
const cy = cyN.toFixed(1);
|
|
227
231
|
const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
|
|
228
232
|
// No measurable scale: minimal dashed outline — visibly "no data", never a guess.
|
|
229
233
|
const fill = p.noScale ? ` fill="${color}" fill-opacity="0.2" stroke="${color}" stroke-width="1.5" stroke-dasharray="3 2"` : ` fill="${color}" fill-opacity="0.78"${ring}`;
|
|
234
|
+
const circle = `<circle cx="${cx}" cy="${cy}" r="${r.toFixed(1)}"${fill}/>`;
|
|
235
|
+
|
|
236
|
+
// When the vendor has a brand logo and the bubble is big enough to read
|
|
237
|
+
// it, the logo IS the in-bubble label: a white disc clipped to the
|
|
238
|
+
// circle carries it, a colored rim still ties the dot to its legend
|
|
239
|
+
// color, and the legend number moves just above so the cross-reference
|
|
240
|
+
// survives. Small or logo-less dots keep the numbered-bubble treatment.
|
|
241
|
+
const logo = logoByVendor.get(p.vendorId);
|
|
242
|
+
const hasLogo = !p.noScale && r >= 12 && typeof logo === "string" && logo.startsWith("data:image/");
|
|
243
|
+
if (hasLogo) {
|
|
244
|
+
const ri = r - Math.max(3, r * 0.14);
|
|
245
|
+
const clipId = `bclip-${i}`;
|
|
246
|
+
clipDefs.push(`<clipPath id="${clipId}"><circle cx="${cx}" cy="${cy}" r="${ri.toFixed(1)}"/></clipPath>`);
|
|
247
|
+
const disc = `<circle cx="${cx}" cy="${cy}" r="${ri.toFixed(1)}" fill="#ffffff" style="pointer-events:none"/>`;
|
|
248
|
+
const img = `<image href="${e(logo as string)}" x="${(cxN - ri).toFixed(1)}" y="${(cyN - ri).toFixed(1)}" width="${(2 * ri).toFixed(1)}" height="${(2 * ri).toFixed(1)}" preserveAspectRatio="xMidYMid meet" clip-path="url(#${clipId})" style="pointer-events:none"/>`;
|
|
249
|
+
const numberAbove = `<text x="${cx}" y="${(cyN - r - 3).toFixed(1)}" text-anchor="middle" font-size="10" font-weight="700" fill="${color}" style="pointer-events:none">${number}</text>`;
|
|
250
|
+
return `<g class="bubble" data-v="${e(p.vendorId)}">${circle}${disc}${img}${numberAbove}</g>`;
|
|
251
|
+
}
|
|
252
|
+
|
|
230
253
|
// Numbers go inside when they fit, above the bubble when they don't.
|
|
231
254
|
const fs = Math.max(10, Math.min(14, r * 0.9));
|
|
232
255
|
const numberSvg =
|
|
233
256
|
r >= 10 && !p.noScale
|
|
234
|
-
? `<text x="${cx}" y="${(
|
|
235
|
-
: `<text x="${cx}" y="${(
|
|
236
|
-
return `<g class="bubble${p.noScale ? " no-scale" : ""}" data-v="${e(p.vendorId)}"
|
|
257
|
+
? `<text x="${cx}" y="${(cyN + fs * 0.36).toFixed(1)}" text-anchor="middle" font-size="${fs.toFixed(1)}" font-weight="700" fill="${numeralColor(color)}" style="pointer-events:none">${number}</text>`
|
|
258
|
+
: `<text x="${cx}" y="${(cyN - r - 3).toFixed(1)}" text-anchor="middle" font-size="10" font-weight="700" fill="${color}" style="pointer-events:none">${number}</text>`;
|
|
259
|
+
return `<g class="bubble${p.noScale ? " no-scale" : ""}" data-v="${e(p.vendorId)}">${circle}${numberSvg}</g>`;
|
|
237
260
|
})
|
|
238
261
|
.join("");
|
|
239
262
|
|
|
@@ -257,6 +280,7 @@ function svgScatter(
|
|
|
257
280
|
const yNeg = ay.signed ? yLabel(wrapPole(ay.negativePole, 26), H - PAD_BOTTOM, "start") : "";
|
|
258
281
|
|
|
259
282
|
return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${e(ax.label)} vs ${e(ay.label)}">
|
|
283
|
+
${clipDefs.length ? `<defs>${clipDefs.join("")}</defs>` : ""}
|
|
260
284
|
<rect x="${PAD_X}" y="${PAD_TOP}" width="${W - 2 * PAD_X}" height="${H - PAD_TOP - PAD_BOTTOM}" class="plot"/>
|
|
261
285
|
${midX}${midY}
|
|
262
286
|
${xNeg}${xPos}
|
|
@@ -399,7 +423,7 @@ function axisSectionsHtml(
|
|
|
399
423
|
<h2>Strategic map: ${e(axInfo.label)} × ${e(ayInfo.label)}</h2>
|
|
400
424
|
<figure class="map">
|
|
401
425
|
<div class="map-row">
|
|
402
|
-
${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
|
|
426
|
+
${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor, logoByVendor)}
|
|
403
427
|
<table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
|
|
404
428
|
</div>
|
|
405
429
|
<div class="map-tip" id="map-tip" hidden></div>
|