fullstackgtm 0.31.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
@@ -158,7 +158,7 @@ function poleText(lines, x, y, anchorMode, fs) {
158
158
  .join("");
159
159
  return `<text class="ax-label" style="font-size:${fs}px" x="${x}" y="${y}" text-anchor="${anchorMode}">${spans}</text>`;
160
160
  }
161
- function svgScatter(points, ax, ay, anchor, colorByVendor, numberByVendor) {
161
+ function svgScatter(points, ax, ay, anchor, colorByVendor, numberByVendor, logoByVendor) {
162
162
  const W = 640;
163
163
  const H = 480;
164
164
  const PAD_X = 56;
@@ -180,22 +180,42 @@ function svgScatter(points, ax, ay, anchor, colorByVendor, numberByVendor) {
180
180
  // raises a bubble to the front (JS re-appends its <g>), so even a bubble
181
181
  // born fully underneath a bigger one is one mouse-over from visible.
182
182
  const ordered = [...points].sort((a, b) => b.size - a.size);
183
+ const clipDefs = [];
183
184
  const dots = ordered
184
- .map((p) => {
185
+ .map((p, i) => {
185
186
  const r = p.noScale ? 7 : 7 + 24 * Math.sqrt(p.size);
186
187
  const color = colorByVendor.get(p.vendorId) ?? "#717171";
187
188
  const number = numberByVendor.get(p.vendorId) ?? 0;
188
- const cx = sx(p.x).toFixed(1);
189
- 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);
190
193
  const ring = p.vendorId === anchor ? ` stroke="#1c1c1c" stroke-width="2.5"` : ` stroke="#ffffff" stroke-width="1.5"`;
191
194
  // No measurable scale: minimal dashed outline — visibly "no data", never a guess.
192
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
+ }
193
213
  // Numbers go inside when they fit, above the bubble when they don't.
194
214
  const fs = Math.max(10, Math.min(14, r * 0.9));
195
215
  const numberSvg = r >= 10 && !p.noScale
196
- ? `<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>`
197
- : `<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>`;
198
- 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>`;
199
219
  })
200
220
  .join("");
201
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}"/>` : "";
@@ -216,6 +236,7 @@ function svgScatter(points, ax, ay, anchor, colorByVendor, numberByVendor) {
216
236
  const yPos = yLabel(wrapPole(ay.positivePole, 26), PAD_TOP, "end");
217
237
  const yNeg = ay.signed ? yLabel(wrapPole(ay.negativePole, 26), H - PAD_BOTTOM, "start") : "";
218
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>` : ""}
219
240
  <rect x="${PAD_X}" y="${PAD_TOP}" width="${W - 2 * PAD_X}" height="${H - PAD_TOP - PAD_BOTTOM}" class="plot"/>
220
241
  ${midX}${midY}
221
242
  ${xNeg}${xPos}
@@ -344,7 +365,7 @@ function axisSectionsHtml(config, set) {
344
365
  <h2>Strategic map: ${e(axInfo.label)} &#215; ${e(ayInfo.label)}</h2>
345
366
  <figure class="map">
346
367
  <div class="map-row">
347
- ${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor)}
368
+ ${svgScatter(points, axInfo, ayInfo, config.anchorVendor, colorByVendor, numberByVendor, logoByVendor)}
348
369
  <table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
349
370
  </div>
350
371
  <div class="map-tip" id="map-tip" hidden></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullstackgtm",
3
- "version": "0.31.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)",