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/CHANGELOG.md +31 -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 +2 -1
- package/dist/index.js +2 -1
- package/dist/llm.d.ts +50 -5
- package/dist/llm.js +60 -2
- package/dist/marketReport.js +29 -8
- package/package.json +1 -1
- package/src/callTypes.ts +752 -0
- package/src/cli.ts +109 -11
- package/src/index.ts +16 -0
- package/src/llm.ts +100 -3
- package/src/marketReport.ts +31 -7
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
|
|
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
|
|
521
|
-
call
|
|
522
|
-
call
|
|
523
|
-
call
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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) =>
|
|
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
|
|
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/marketReport.js
CHANGED
|
@@ -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
|
|
189
|
-
const
|
|
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="${(
|
|
197
|
-
: `<text x="${cx}" y="${(
|
|
198
|
-
return `<g class="bubble${p.noScale ? " no-scale" : ""}" data-v="${e(p.vendorId)}"
|
|
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)} × ${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.
|
|
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)",
|