fullstackgtm 0.30.0 → 0.32.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/dist/cli.js CHANGED
@@ -20,6 +20,7 @@ import { auditReportToHtml, auditReportToMarkdown } from "./report.js";
20
20
  import { builtinAuditRules } from "./rules.js";
21
21
  import { sampleSnapshot } from "./sampleData.js";
22
22
  import { normalizeTranscript, parseCall, suggestCallDeal } from "./calls.js";
23
+ import { classifyCall, rubricForCallType, rubricPresets, CALL_TYPES, CALL_TYPE_IDS, } from "./callTypes.js";
23
24
  import { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, loadCaptureTexts, loadMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
24
25
  import { assessAxes, axesReportToText } from "./marketAxes.js";
25
26
  import { computeDirectives, computeOverlayStats, directivesToPlan, overlayToMarkdown, } from "./marketOverlay.js";
@@ -27,7 +28,7 @@ import { computeScaleIndex, scaleReportToText } from "./marketScale.js";
27
28
  import { suggestMarketConfig } from "./marketTaxonomy.js";
28
29
  import { buildWorksheet, classifyMarket } from "./marketClassify.js";
29
30
  import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.js";
30
- import { DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
31
+ import { DEFAULT_RUBRIC, classifyCallLlm, detectProviderFromKey, extractInsightsLlm, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
31
32
  import { buildEnrichPlan, createFileEnrichRunStore, DEFAULT_STALE_DAYS, ENRICH_CONFIG_FILE_NAME, enrichRunId, inferIngestObjectType, latestStamps, loadEnrichConfig, parseCsv, resolveCrmField, selectStaleWork, stagedSourceRecords, staleDaysFor, } from "./enrich.js";
32
33
  import { apolloPullKeysForAppend, apolloPullKeysForRefresh, createApolloClient, pullApolloRecords, } from "./enrichApollo.js";
33
34
  import { computeMissedFirings, createFileScheduleRunStore, createFileScheduleStore, nextCronFiring, parseCron, renderManagedBlock, replaceManagedBlock, assertSingleLineLabel, hasControlChar, scheduleId, systemCrontabIo, tokenizeCommand, validateSchedulableArgv, } from "./schedule.js";
@@ -60,7 +61,8 @@ Usage:
60
61
  fullstackgtm diff --before <a.json> --after <b.json> [--json] [--fail-on-new-findings]
61
62
  fullstackgtm merge --input <a.json> --input <b.json> [...] --out <merged.json> [--json]
62
63
  fullstackgtm call parse --transcript <file> [--title t] [--source fathom|granola|...] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
63
- fullstackgtm call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
64
+ fullstackgtm call classify --transcript <file>|--call <parsed.json> [--llm] [--deterministic] [--json]
65
+ fullstackgtm call score --transcript <file>|--call <parsed.json> [--call-type <t>] [--rubric <rubric.json>] [--model m] [--json|--out <path>]
64
66
  fullstackgtm call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
65
67
  fullstackgtm call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
66
68
  calls become evidence: LLM extraction by default (bring your own
@@ -517,14 +519,20 @@ function parseValueOverrides(args) {
517
519
  async function callCommand(args) {
518
520
  const [subcommand, ...rest] = args;
519
521
  if (args.includes("--help") || args.includes("-h")) {
520
- console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
521
- call score --transcript <file>|--call <parsed.json> [--rubric <rubric.json>] [--model m] [--json|--out <path>]
522
- call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
523
- call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
522
+ console.log(`call parse --transcript <file> [--title t] [--source s] [--model m] [--deterministic] [--json|--ndjson] [--out <path>]
523
+ call classify --transcript <file>|--call <parsed.json> [--llm] [--deterministic] [--json] [--list]
524
+ call score --transcript <file>|--call <parsed.json> [--call-type <t>] [--rubric <rubric.json>] [--model m] [--json|--out <path>] [--list-rubrics]
525
+ call link --attendees <a@x.com,...> | --domain <x.com> [source options] [--json]
526
+ call plan --transcript <file>|--call <parsed.json> --deal <id> [source options] [--save|--json]
527
+
528
+ classify picks the call type (deterministic signals; --llm for a model tiebreak).
529
+ score auto-selects the type-specific rubric from that classification unless you
530
+ pass --call-type or --rubric. Call types: ${CALL_TYPE_IDS.join(", ")}.
524
531
 
525
532
  parse/score default to LLM extraction (Anthropic or OpenAI key via env,
526
- \`login anthropic|openai\`, or a one-time prompt). parse --deterministic is
527
- the free keyword baseline; score always needs a key (scoring is LLM work).`);
533
+ \`login anthropic|openai\`, or a one-time prompt). parse --deterministic is the
534
+ free keyword baseline and classify --deterministic needs no key.
535
+ score always needs a key (scoring is LLM work).`);
528
536
  return;
529
537
  }
530
538
  const loadParsedCall = async () => {
@@ -559,6 +567,56 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
559
567
  extractor: `llm:${credential.provider}:${model}`,
560
568
  });
561
569
  };
570
+ // Reconstruct plain transcript text from either a --transcript file (any
571
+ // dialect, normalized) or a parsed --call JSON. Shared by classify + score.
572
+ const loadTranscriptText = () => {
573
+ const transcriptPath = option(rest, "--transcript");
574
+ if (transcriptPath) {
575
+ return normalizeTranscript(readFileSync(resolve(process.cwd(), transcriptPath), "utf8"));
576
+ }
577
+ const callPath = option(rest, "--call");
578
+ if (!callPath)
579
+ throw new Error(`call ${subcommand} requires --transcript <file> or --call <parsed.json>`);
580
+ const parsed = JSON.parse(readFileSync(resolve(process.cwd(), callPath), "utf8"));
581
+ return parsed.segments.map((s) => (s.speaker ? `${s.speaker}: ${s.text}` : s.text)).join("\n");
582
+ };
583
+ if (subcommand === "classify") {
584
+ if (rest.includes("--list")) {
585
+ const lines = CALL_TYPES.map((d) => `${d.id.padEnd(22)} ${d.name} — ${d.definition}`);
586
+ console.log(rest.includes("--json") ? JSON.stringify(CALL_TYPES, null, 2) : lines.join("\n"));
587
+ return;
588
+ }
589
+ const transcriptText = loadTranscriptText();
590
+ const title = option(rest, "--title") ?? undefined;
591
+ const deterministic = classifyCall(transcriptText);
592
+ // LLM tiebreak: explicit --llm, or auto when the deterministic pass is unsure
593
+ // and a key is available (never required — deterministic always answers).
594
+ const wantLlm = rest.includes("--llm") || (!rest.includes("--deterministic") && deterministic.confidence !== "high" && Boolean(resolveLlmCredential()));
595
+ let result = deterministic;
596
+ if (wantLlm) {
597
+ const credential = await requireLlmCredential("score");
598
+ const llm = await classifyCallLlm(transcriptText, CALL_TYPES, {
599
+ ...credential,
600
+ model: option(rest, "--model") ?? undefined,
601
+ title,
602
+ });
603
+ result = { type: llm.type, confidence: "high", reason: llm.reason, method: "llm", model: llm.model };
604
+ }
605
+ if (rest.includes("--json")) {
606
+ console.log(JSON.stringify(result, null, 2));
607
+ return;
608
+ }
609
+ const def = CALL_TYPES.find((d) => d.id === result.type);
610
+ console.log(`Call type: ${def?.name ?? result.type} (${result.type})`);
611
+ console.log(`Confidence: ${result.confidence} · via ${result.method}${result.model ? ` (${result.model})` : ""}`);
612
+ console.log(`Why: ${result.reason}`);
613
+ if (result.method === "deterministic" && deterministic.candidates.length > 1) {
614
+ const others = deterministic.candidates.slice(1, 4).map((c) => `${c.type} (${c.score})`).join(", ");
615
+ console.log(`Other matches: ${others}`);
616
+ }
617
+ 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}`);
618
+ return;
619
+ }
562
620
  if (subcommand === "parse") {
563
621
  const parsed = await loadParsedCall();
564
622
  const outPath = option(rest, "--out");
@@ -644,9 +702,13 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
644
702
  return;
645
703
  }
646
704
  if (subcommand === "score") {
647
- // Rubric problems surface before any credential or API work.
705
+ if (rest.includes("--list-rubrics")) {
706
+ console.log(JSON.stringify(rubricPresets(), null, 2));
707
+ return;
708
+ }
709
+ // Explicit-rubric problems surface before any credential or API work.
648
710
  const rubricPath = option(rest, "--rubric");
649
- let rubric = DEFAULT_RUBRIC;
711
+ let rubric;
650
712
  if (rubricPath) {
651
713
  const rubricRaw = readFileSync(resolve(process.cwd(), rubricPath), "utf8");
652
714
  try {
@@ -656,6 +718,10 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
656
718
  throw new Error(`${rubricPath} is not a valid rubric: ${error instanceof Error ? error.message : String(error)} Expected JSON like { "scale": 5, "dimensions": [{ "name": "...", "weight": 1, "rubric": "..." }] }.`);
657
719
  }
658
720
  }
721
+ const callTypeOpt = option(rest, "--call-type");
722
+ if (callTypeOpt && !CALL_TYPE_IDS.includes(callTypeOpt)) {
723
+ throw new Error(`Unknown --call-type "${callTypeOpt}". One of: ${CALL_TYPE_IDS.join(", ")}.`);
724
+ }
659
725
  const credential = await requireLlmCredential("score");
660
726
  const transcriptPath = option(rest, "--transcript");
661
727
  let transcriptText;
@@ -673,6 +739,17 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
673
739
  .join("\n");
674
740
  title = title ?? parsed.title;
675
741
  }
742
+ // Rubric selection: explicit --rubric wins, then --call-type, else the
743
+ // deterministic classifier picks the type-specific preset. No generic
744
+ // discovery rubric silently applied to a renewal anymore.
745
+ if (!rubric) {
746
+ const type = callTypeOpt ?? classifyCall(transcriptText).type;
747
+ rubric = rubricForCallType(type, DEFAULT_RUBRIC);
748
+ if (!rest.includes("--json")) {
749
+ const how = callTypeOpt ? `--call-type ${callTypeOpt}` : `auto-classified as ${type}`;
750
+ console.error(`Scoring with the "${rubric.name ?? "Generic"}" rubric (${how}). Override with --rubric <file> or --call-type <type>.`);
751
+ }
752
+ }
676
753
  const scorecard = await scoreCallLlm(transcriptText, rubric, {
677
754
  ...credential,
678
755
  model: option(rest, "--model") ?? undefined,
@@ -688,7 +765,7 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
688
765
  console.log(renderScorecard(scorecard, title));
689
766
  return;
690
767
  }
691
- throw new Error(`call supports: parse, link, plan, score (got ${subcommand ?? "nothing"})`);
768
+ throw new Error(`call supports: parse, classify, link, plan, score (got ${subcommand ?? "nothing"})`);
692
769
  }
693
770
  /**
694
771
  * First-touch key onboarding: env vars win, then the credential store; on a
@@ -723,10 +800,14 @@ async function requireLlmCredential(command = "parse") {
723
800
  return { provider, apiKey };
724
801
  }
725
802
  function renderScorecard(scorecard, title) {
803
+ const bandText = scorecard.band ? ` — ${scorecard.band.label}` : "";
804
+ const rubricLine = scorecard.rubricName ? `Rubric: ${scorecard.rubricName}${scorecard.callType ? ` (${scorecard.callType})` : ""}` : "";
726
805
  const lines = [
727
806
  `# Coaching Scorecard${title ? ` — ${title}` : ""}`,
728
807
  "",
729
- `**Overall: ${scorecard.overallScore}/${scorecard.scale}** (model: ${scorecard.model})`,
808
+ `**Overall: ${scorecard.overallScore}/${scorecard.scale}${bandText}** (model: ${scorecard.model})`,
809
+ ...(scorecard.band?.meaning ? [`> ${scorecard.band.meaning}`] : []),
810
+ ...(rubricLine ? ["", `_${rubricLine}_`] : []),
730
811
  "",
731
812
  "| Dimension | Score | | Coaching note |",
732
813
  "| --- | --- | --- | --- |",
package/dist/index.d.ts CHANGED
@@ -24,7 +24,8 @@ export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mapp
24
24
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.ts";
25
25
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, type CallDealSuggestion, type CallInsightType, type ExtractedCallInsight, type ParsedCall, type ParsedTranscriptSegment, } from "./calls.ts";
26
26
  export { sampleSnapshot } from "./sampleData.ts";
27
- export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type ScoredDimension, } from "./llm.ts";
27
+ export { DEFAULT_MODELS, DEFAULT_RUBRIC, classifyCallLlm, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, type CallScorecard, type LlmCallClassification, type LlmCredential, type LlmExtractedInsight, type LlmProvider, type Rubric, type RubricDimension, type ScoreBand, type ScoredDimension, } from "./llm.ts";
28
+ export { classifyCall, rubricForCallType, rubricPresets, bandForScore, CALL_TYPES, CALL_TYPE_IDS, BANDS_5, type CallType, type CallTypeDef, type CallClassification, } from "./callTypes.ts";
28
29
  export { resolveRecord, type ResolveCandidate, type ResolveMatch, type ResolveResult } from "./resolve.ts";
29
30
  export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, type CaptureEntry, type CaptureOptions, type ClaimFront, type ClaimIntensity, type FrontDrift, type FrontState, type MarketAxis, type MarketClaim, type MarketConfig, type MarketObservation, type MarketVendor, type ObservationConfidence, type ObservationSet, type ObservationStore, type ScaleSignal, type SpanVerificationFailure, } from "./market.ts";
30
31
  export { suggestMarketConfig, type SeedVendor, type SuggestTaxonomyOptions, type SuggestTaxonomyResult, } from "./marketTaxonomy.ts";
package/dist/index.js CHANGED
@@ -24,7 +24,8 @@ export { HUBSPOT_DEFAULT_FIELD_MAPPINGS, SALESFORCE_DEFAULT_FIELD_MAPPINGS, mapp
24
24
  export { accountSingleSourceRule, activeDealAccountWithoutContactsRule, auditFindingId, buildSnapshotIndex, builtinAuditRules, closingSoonInactiveRule, duplicateAccountDomainRule, duplicateContactEmailRule, duplicateOpenDealRule, missingDealAccountRule, missingDealAmountRule, missingDealOwnerRule, orphanAccountRule, pastCloseDateRule, patchOperationId, provenanceSummary, requiresHumanInput, staleDealRule, } from "./rules.js";
25
25
  export { extractCallInsights, normalizeTranscript, parseCall, parseTranscript, suggestCallDeal, summarizeInsights, } from "./calls.js";
26
26
  export { sampleSnapshot } from "./sampleData.js";
27
- export { DEFAULT_MODELS, DEFAULT_RUBRIC, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
27
+ export { DEFAULT_MODELS, DEFAULT_RUBRIC, classifyCallLlm, detectProviderFromKey, extractInsightsLlm, forcedToolCall, parseRubric, resolveLlmCredential, scoreCallLlm, validateLlmKey, } from "./llm.js";
28
+ export { classifyCall, rubricForCallType, rubricPresets, bandForScore, CALL_TYPES, CALL_TYPE_IDS, BANDS_5, } from "./callTypes.js";
28
29
  export { resolveRecord } from "./resolve.js";
29
30
  export { captureMarket, computeFrontStates, createFileObservationStore, diffFrontStates, extractReadableText, loadCaptureTexts, loadMarketConfig, marketHome, normalizeForMatch, observationId, parseMarketConfig, starterMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
30
31
  export { suggestMarketConfig, } from "./marketTaxonomy.js";
package/dist/llm.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtractedCallInsight } from "./calls.ts";
2
+ import { type CallType } from "./callTypes.ts";
2
3
  /**
3
4
  * LLM-powered call extraction and scoring. Bring-your-own-key, two providers
4
5
  * (Anthropic, OpenAI), raw fetch — no SDK dependency, mirroring how the CRM
@@ -34,13 +35,34 @@ export declare function extractInsightsLlm(transcript: string, options: LlmCallO
34
35
  insights: LlmExtractedInsight[];
35
36
  model: string;
36
37
  }>;
38
+ /** A qualitative band over the weighted overall (e.g. "developing" at >=2). */
39
+ export type ScoreBand = {
40
+ label: string;
41
+ min: number;
42
+ meaning?: string;
43
+ };
44
+ export type RubricDimension = {
45
+ name: string;
46
+ weight: number;
47
+ rubric: string;
48
+ /** Anchored behavioral examples of a top score — sharpens the model and cuts variance. */
49
+ anchorsHigh?: string[];
50
+ /** Anchored behavioral examples of a bottom score. */
51
+ anchorsLow?: string[];
52
+ /** Verbatim phrases to listen for — tightens evidence grounding. */
53
+ evidenceCues?: string[];
54
+ /** Reflective questions surfaced to the rep alongside the score. */
55
+ coachingPrompts?: string[];
56
+ };
37
57
  export type Rubric = {
38
58
  scale: number;
39
- dimensions: Array<{
40
- name: string;
41
- weight: number;
42
- rubric: string;
43
- }>;
59
+ /** Display name (e.g. the call type this rubric is built for). */
60
+ name?: string;
61
+ /** The call type this rubric scores, when type-specific. */
62
+ callType?: CallType;
63
+ dimensions: RubricDimension[];
64
+ /** Optional qualitative bands over the weighted overall. */
65
+ bands?: ScoreBand[];
44
66
  };
45
67
  export declare const DEFAULT_RUBRIC: Rubric;
46
68
  export type ScoredDimension = {
@@ -56,6 +78,11 @@ export type CallScorecard = {
56
78
  /** Weighted average, computed deterministically client-side. */
57
79
  overallScore: number;
58
80
  scale: number;
81
+ /** Qualitative band for the overall, computed client-side from the rubric's bands. */
82
+ band?: ScoreBand;
83
+ /** The rubric used, for provenance in reports. */
84
+ rubricName?: string;
85
+ callType?: CallType;
59
86
  highlights: string[];
60
87
  missedItems: string[];
61
88
  model: string;
@@ -64,6 +91,24 @@ export declare function scoreCallLlm(transcript: string, rubric: Rubric, options
64
91
  title?: string;
65
92
  }): Promise<CallScorecard>;
66
93
  export declare function parseRubric(json: string): Rubric;
94
+ export type LlmCallClassification = {
95
+ type: CallType;
96
+ reason: string;
97
+ model: string;
98
+ method: "llm";
99
+ };
100
+ /**
101
+ * Model tiebreak for call-type classification — the opt-in counterpart to the
102
+ * deterministic `classifyCall`. Same forced-tool-call seam as every other LLM
103
+ * feature; returns the canonical CallType plus a one-line reason.
104
+ */
105
+ export declare function classifyCallLlm(transcript: string, defs: Array<{
106
+ id: string;
107
+ name: string;
108
+ definition: string;
109
+ }>, options: LlmCallOptions & {
110
+ title?: string;
111
+ }): Promise<LlmCallClassification>;
67
112
  /**
68
113
  * Shared constrained-tool-call plumbing: force the model to answer through a
69
114
  * single tool whose input_schema is the output contract. Exported for other
package/dist/llm.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { getCredential } from "./credentials.js";
2
+ import { CALL_TYPE_IDS } from "./callTypes.js";
2
3
  export const DEFAULT_MODELS = {
3
4
  anthropic: "claude-haiku-4-5",
4
5
  openai: "gpt-4o-mini",
@@ -131,6 +132,7 @@ function actionGroundedInEvidence(text, evidence) {
131
132
  }
132
133
  export const DEFAULT_RUBRIC = {
133
134
  scale: 5,
135
+ name: "Generic",
134
136
  dimensions: [
135
137
  { 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?" },
136
138
  { 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)?" },
@@ -164,9 +166,19 @@ export async function scoreCallLlm(transcript, rubric, options) {
164
166
  const model = options.model ?? DEFAULT_MODELS[options.provider];
165
167
  const text = truncateTranscript(transcript);
166
168
  const rubricText = rubric.dimensions
167
- .map((d) => `- ${d.name} (weight ${d.weight}): ${d.rubric}`)
169
+ .map((d) => {
170
+ const lines = [`- ${d.name} (weight ${d.weight}): ${d.rubric}`];
171
+ if (d.anchorsHigh?.length)
172
+ lines.push(` a ${rubric.scale} looks like: ${d.anchorsHigh.join("; ")}`);
173
+ if (d.anchorsLow?.length)
174
+ lines.push(` a 1 looks like: ${d.anchorsLow.join("; ")}`);
175
+ if (d.evidenceCues?.length)
176
+ lines.push(` listen for: ${d.evidenceCues.join(", ")}`);
177
+ return lines.join("\n");
178
+ })
168
179
  .join("\n");
169
- 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}`;
180
+ const heading = rubric.name ? `${rubric.name} call` : "sales call";
181
+ 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}`;
170
182
  const result = (await forcedToolCall(prompt, "score_call", SCORE_SCHEMA(rubric.scale, rubric.dimensions), model, options));
171
183
  const byName = new Map((result.dimensions ?? []).map((d) => [d.name, d]));
172
184
  const dimensions = rubric.dimensions.map((dim) => {
@@ -182,29 +194,75 @@ export async function scoreCallLlm(transcript, rubric, options) {
182
194
  });
183
195
  const totalWeight = dimensions.reduce((sum, d) => sum + d.weight, 0);
184
196
  const overallScore = Math.round((dimensions.reduce((sum, d) => sum + d.score * d.weight, 0) / totalWeight) * 100) / 100;
197
+ const band = rubric.bands?.length
198
+ ? [...rubric.bands].sort((a, b) => b.min - a.min).find((b) => overallScore >= b.min)
199
+ : undefined;
185
200
  return {
186
201
  dimensions,
187
202
  overallScore,
188
203
  scale: rubric.scale,
204
+ band,
205
+ rubricName: rubric.name,
206
+ callType: rubric.callType,
189
207
  highlights: result.highlights ?? [],
190
208
  missedItems: result.missed_items ?? [],
191
209
  model,
192
210
  };
193
211
  }
212
+ const strArray = (value) => Array.isArray(value) && value.length ? value.map((v) => String(v)) : undefined;
194
213
  export function parseRubric(json) {
195
214
  const parsed = JSON.parse(json);
196
215
  if (!Array.isArray(parsed.dimensions) || parsed.dimensions.length === 0) {
197
216
  throw new Error("Rubric needs a dimensions array: { scale, dimensions: [{ name, weight, rubric }] }");
198
217
  }
218
+ const bands = Array.isArray(parsed.bands)
219
+ ? parsed.bands
220
+ .filter((b) => b && typeof b.min === "number" && b.label)
221
+ .map((b) => ({ label: String(b.label), min: Number(b.min), meaning: b.meaning ? String(b.meaning) : undefined }))
222
+ : undefined;
199
223
  return {
200
224
  scale: parsed.scale ?? 5,
225
+ name: parsed.name ? String(parsed.name) : undefined,
226
+ callType: parsed.callType,
227
+ bands: bands?.length ? bands : undefined,
201
228
  dimensions: parsed.dimensions.map((d) => ({
202
229
  name: String(d.name),
203
230
  weight: typeof d.weight === "number" ? d.weight : 1,
204
231
  rubric: String(d.rubric ?? ""),
232
+ anchorsHigh: strArray(d.anchorsHigh),
233
+ anchorsLow: strArray(d.anchorsLow),
234
+ evidenceCues: strArray(d.evidenceCues),
235
+ coachingPrompts: strArray(d.coachingPrompts),
205
236
  })),
206
237
  };
207
238
  }
239
+ // ── Call-type classification (LLM tiebreak) ────────────────────────────────
240
+ const CLASSIFY_SCHEMA = {
241
+ type: "object",
242
+ required: ["type", "reason"],
243
+ properties: {
244
+ type: { type: "string", enum: CALL_TYPE_IDS },
245
+ reason: { type: "string", description: "One sentence, citing what in the transcript decided it." },
246
+ },
247
+ };
248
+ /**
249
+ * Model tiebreak for call-type classification — the opt-in counterpart to the
250
+ * deterministic `classifyCall`. Same forced-tool-call seam as every other LLM
251
+ * feature; returns the canonical CallType plus a one-line reason.
252
+ */
253
+ export async function classifyCallLlm(transcript, defs, options) {
254
+ const model = options.model ?? DEFAULT_MODELS[options.provider];
255
+ const text = truncateTranscript(transcript);
256
+ const taxonomy = defs.map((d) => `- ${d.id} (${d.name}): ${d.definition}`).join("\n");
257
+ 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}`;
258
+ const result = (await forcedToolCall(prompt, "classify_call", CLASSIFY_SCHEMA, model, options));
259
+ return {
260
+ type: result.type ?? "other",
261
+ reason: result.reason ?? "Model did not give a reason.",
262
+ model,
263
+ method: "llm",
264
+ };
265
+ }
208
266
  // ── Provider plumbing (raw fetch, forced tool calls) ───────────────────────
209
267
  /**
210
268
  * Shared constrained-tool-call plumbing: force the model to answer through a
package/dist/market.d.ts CHANGED
@@ -81,6 +81,14 @@ export type MarketVendor = {
81
81
  * obvious from the vendor's own pricing page (which the map captures).
82
82
  */
83
83
  acvBand?: string;
84
+ /**
85
+ * Optional brand logo for the report (legend + matrix headers). A `data:` URI
86
+ * keeps the rendered report self-contained — no external requests, survives
87
+ * being saved or emailed. The hosted service extracts it from the vendor's
88
+ * homepage; CLI users can set it by hand. Renderers degrade gracefully to the
89
+ * numbered swatch when absent.
90
+ */
91
+ logo?: string;
84
92
  notes?: string;
85
93
  };
86
94
  export type MarketAxis = {
@@ -28,6 +28,17 @@ function escapeHtml(value) {
28
28
  .replace(/>/g, "&gt;")
29
29
  .replace(/"/g, "&quot;");
30
30
  }
31
+ /**
32
+ * A small brand logo for legend rows / matrix headers. Accepts only `data:image/`
33
+ * URIs — self-contained (no external request, survives save/email) and safe under
34
+ * the report's `img-src data:` CSP (an SVG loaded via <img> can't execute script).
35
+ * Returns "" when absent so callers fall back to the numbered swatch / plain name.
36
+ */
37
+ function logoImg(logo, cls) {
38
+ if (typeof logo !== "string" || !logo.startsWith("data:image/"))
39
+ return "";
40
+ return `<img class="${cls}" src="${escapeHtml(logo)}" alt="" loading="lazy">`;
41
+ }
31
42
  /**
32
43
  * Serialize JSON for embedding inside an inline <script> block. JSON.stringify
33
44
  * does not escape `<`, `>`, `&`, or the U+2028/U+2029 line separators, so a
@@ -147,7 +158,7 @@ function poleText(lines, x, y, anchorMode, fs) {
147
158
  .join("");
148
159
  return `<text class="ax-label" style="font-size:${fs}px" x="${x}" y="${y}" text-anchor="${anchorMode}">${spans}</text>`;
149
160
  }
150
- function svgScatter(points, ax, ay, anchor, colorByVendor, numberByVendor) {
161
+ function svgScatter(points, ax, ay, anchor, colorByVendor, numberByVendor, logoByVendor) {
151
162
  const W = 640;
152
163
  const H = 480;
153
164
  const PAD_X = 56;
@@ -169,22 +180,42 @@ function svgScatter(points, ax, ay, anchor, colorByVendor, numberByVendor) {
169
180
  // raises a bubble to the front (JS re-appends its <g>), so even a bubble
170
181
  // born fully underneath a bigger one is one mouse-over from visible.
171
182
  const ordered = [...points].sort((a, b) => b.size - a.size);
183
+ const clipDefs = [];
172
184
  const dots = ordered
173
- .map((p) => {
185
+ .map((p, i) => {
174
186
  const r = p.noScale ? 7 : 7 + 24 * Math.sqrt(p.size);
175
187
  const color = colorByVendor.get(p.vendorId) ?? "#717171";
176
188
  const number = numberByVendor.get(p.vendorId) ?? 0;
177
- const cx = sx(p.x).toFixed(1);
178
- const cy = sy(p.y);
189
+ const cxN = sx(p.x);
190
+ const cyN = sy(p.y);
191
+ const cx = cxN.toFixed(1);
192
+ const cy = cyN.toFixed(1);
179
193
  const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
180
194
  // No measurable scale: minimal dashed outline — visibly "no data", never a guess.
181
195
  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}`;
196
+ const circle = `<circle cx="${cx}" cy="${cy}" r="${r.toFixed(1)}"${fill}/>`;
197
+ // When the vendor has a brand logo and the bubble is big enough to read
198
+ // it, the logo IS the in-bubble label: a white disc clipped to the
199
+ // circle carries it, a colored rim still ties the dot to its legend
200
+ // color, and the legend number moves just above so the cross-reference
201
+ // survives. Small or logo-less dots keep the numbered-bubble treatment.
202
+ const logo = logoByVendor.get(p.vendorId);
203
+ const hasLogo = !p.noScale && r >= 12 && typeof logo === "string" && logo.startsWith("data:image/");
204
+ if (hasLogo) {
205
+ const ri = r - Math.max(3, r * 0.14);
206
+ const clipId = `bclip-${i}`;
207
+ clipDefs.push(`<clipPath id="${clipId}"><circle cx="${cx}" cy="${cy}" r="${ri.toFixed(1)}"/></clipPath>`);
208
+ const disc = `<circle cx="${cx}" cy="${cy}" r="${ri.toFixed(1)}" fill="#ffffff" style="pointer-events:none"/>`;
209
+ const img = `<image href="${e(logo)}" 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"/>`;
210
+ 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>`;
211
+ return `<g class="bubble" data-v="${e(p.vendorId)}">${circle}${disc}${img}${numberAbove}</g>`;
212
+ }
182
213
  // Numbers go inside when they fit, above the bubble when they don't.
183
214
  const fs = Math.max(10, Math.min(14, r * 0.9));
184
215
  const numberSvg = r >= 10 && !p.noScale
185
- ? `<text x="${cx}" y="${(cy + 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>`
186
- : `<text x="${cx}" y="${(cy - r - 3).toFixed(1)}" text-anchor="middle" font-size="10" font-weight="700" fill="${color}" style="pointer-events:none">${number}</text>`;
187
- return `<g class="bubble${p.noScale ? " no-scale" : ""}" data-v="${e(p.vendorId)}"><circle cx="${cx}" cy="${cy.toFixed(1)}" r="${r.toFixed(1)}"${fill}/>${numberSvg}</g>`;
216
+ ? `<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>`
217
+ : `<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>`;
218
+ return `<g class="bubble${p.noScale ? " no-scale" : ""}" data-v="${e(p.vendorId)}">${circle}${numberSvg}</g>`;
188
219
  })
189
220
  .join("");
190
221
  const midX = ax.signed ? `<line class="grid" x1="${sx(0).toFixed(0)}" y1="${PAD_TOP}" x2="${sx(0).toFixed(0)}" y2="${H - PAD_BOTTOM}"/>` : "";
@@ -205,6 +236,7 @@ function svgScatter(points, ax, ay, anchor, colorByVendor, numberByVendor) {
205
236
  const yPos = yLabel(wrapPole(ay.positivePole, 26), PAD_TOP, "end");
206
237
  const yNeg = ay.signed ? yLabel(wrapPole(ay.negativePole, 26), H - PAD_BOTTOM, "start") : "";
207
238
  return `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${e(ax.label)} vs ${e(ay.label)}">
239
+ ${clipDefs.length ? `<defs>${clipDefs.join("")}</defs>` : ""}
208
240
  <rect x="${PAD_X}" y="${PAD_TOP}" width="${W - 2 * PAD_X}" height="${H - PAD_TOP - PAD_BOTTOM}" class="plot"/>
209
241
  ${midX}${midY}
210
242
  ${xNeg}${xPos}
@@ -291,6 +323,7 @@ function axisSectionsHtml(config, set) {
291
323
  // The number inside each bubble resolves dense clusters that name labels
292
324
  // never could; color is Okabe–Ito (colorblind-safe) keyed in the legend.
293
325
  const points = pointsFor(px, py);
326
+ const logoByVendor = new Map(config.vendors.map((vendor) => [vendor.id, vendor.logo]));
294
327
  const legendOrder = [...points].sort((a, b) => b.size - a.size || a.name.localeCompare(b.name));
295
328
  const numberByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, index + 1]));
296
329
  const colorByVendor = new Map(legendOrder.map((point, index) => [point.vendorId, VENDOR_COLORS[index % VENDOR_COLORS.length]]));
@@ -305,7 +338,7 @@ function axisSectionsHtml(config, set) {
305
338
  ? `${(share * 100).toFixed(1)}%`
306
339
  : "—"
307
340
  : `${loudCounts.get(point.vendorId) ?? 0} loud`;
308
- return `<tr data-v="${e(point.vendorId)}"${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${e(point.name)}${isAnchor ? " ·&nbsp;anchor" : ""}</td><td class="num">${measure}</td></tr>`;
341
+ return `<tr data-v="${e(point.vendorId)}"${isAnchor ? ' class="anchor-row"' : ""}><td><span class="swatch" style="background:${color};color:${numeralColor(color)}">${number}</span></td><td>${logoImg(logoByVendor.get(point.vendorId), "v-logo")}${e(point.name)}${isAnchor ? " ·&nbsp;anchor" : ""}</td><td class="num">${measure}</td></tr>`;
309
342
  })
310
343
  .join("");
311
344
  const legendMeasureHead = useScale ? "est. share" : "loud";
@@ -332,7 +365,7 @@ function axisSectionsHtml(config, set) {
332
365
  <h2>Strategic map: ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
333
366
  <figure class="map">
334
367
  <div class="map-row">
335
- ${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
368
+ ${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor, logoByVendor)}
336
369
  <table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
337
370
  </div>
338
371
  <div class="map-tip" id="map-tip" hidden></div>
@@ -426,7 +459,7 @@ export function marketMapToHtml(config, set) {
426
459
  `<td class="front"><span class="chip chip-${state}">${state.toUpperCase()}</span></td></tr>`);
427
460
  };
428
461
  const vendorHeads = config.vendors
429
- .map((vendor) => `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}"><span>${e(vendor.name)}</span></th>`)
462
+ .map((vendor) => `<th class="vh${vendor.id === anchor ? " anchor-col" : ""}">${logoImg(vendor.logo, "vh-logo")}<span>${e(vendor.name)}</span></th>`)
430
463
  .join("");
431
464
  // Claims grouped by front state, each group a collapsed <details> whose
432
465
  // summary carries the stats a skimmer needs; the full matrix is one click
@@ -562,6 +595,8 @@ tr.front-open th .claim-cap { color:var(--accent); font-weight:600; }
562
595
  figure.map { margin-top:16px; border:1px solid var(--line); position:relative; }
563
596
  g.bubble { cursor:pointer; }
564
597
  g.bubble.dim { opacity:0.25; transition:opacity .12s; }
598
+ img.v-logo { width:15px; height:15px; border-radius:3px; object-fit:contain; vertical-align:-3px; margin-right:6px; background:#fff; }
599
+ th.vh img.vh-logo { display:block; width:18px; height:18px; border-radius:3px; object-fit:contain; margin:0 auto 4px; background:#fff; }
565
600
  table.legend tbody tr { cursor:default; }
566
601
  table.legend tbody tr.hl td { background:var(--faint); }
567
602
  .map-tip { position:absolute; z-index:5; background:#1c1c1c; color:#fff; font-size:11.5px; line-height:1.45;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Full Stack GTM LLC <ryan@fullstackgtm.com> (https://fullstackgtm.com)",