fullstackgtm 0.11.1 → 0.13.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 +56 -0
- package/README.md +20 -1
- package/dist/calls.d.ts +72 -0
- package/dist/calls.js +345 -0
- package/dist/cli.js +179 -0
- package/dist/connectors/hubspot.js +70 -0
- package/dist/connectors/salesforce.js +9 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp.js +20 -0
- package/dist/rules.js +21 -18
- package/dist/suggest.js +77 -0
- package/dist/types.d.ts +1 -1
- package/docs/api.md +1 -1
- package/docs/crm-health-lifecycle.md +1 -1
- package/llms.txt +7 -0
- package/package.json +1 -1
- package/src/calls.ts +434 -0
- package/src/cli.ts +193 -0
- package/src/connectors/hubspot.ts +71 -0
- package/src/connectors/salesforce.ts +10 -0
- package/src/index.ts +13 -0
- package/src/mcp.ts +26 -0
- package/src/rules.ts +24 -18
- package/src/suggest.ts +88 -0
- package/src/types.ts +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,62 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
|
5
5
|
and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
The path to 1.0 is planned in [docs/roadmap-to-1.0.md](./docs/roadmap-to-1.0.md).
|
|
7
7
|
|
|
8
|
+
## [0.13.0] — 2026-06-11
|
|
9
|
+
|
|
10
|
+
Calls become evidence: the deterministic skeleton of every call workflow —
|
|
11
|
+
normalize, link, govern the writeback — as chainable primitives. Born from
|
|
12
|
+
two real client pipelines that each hand-rolled all three.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **`fullstackgtm call parse`**: normalizes any transcript dialect
|
|
17
|
+
(`Speaker: text`, `[Speaker]:` labels, or raw Granola utterance JSON) into
|
|
18
|
+
canonical segments, nine types of deterministic keyword-derived insights,
|
|
19
|
+
and `GtmEvidence` records with stable ids. `--json`, `--out`, and
|
|
20
|
+
`--ndjson` (one flat row per insight — warehouse-ready). LLM-free.
|
|
21
|
+
- **`fullstackgtm call link`**: attendee emails/domain → account (by domain
|
|
22
|
+
or contact email) → open deals ranked by recent activity, with
|
|
23
|
+
confidence + written reason — the heuristic every call pipeline
|
|
24
|
+
hand-rolls, as one command.
|
|
25
|
+
- **`fullstackgtm call plan`**: next-step insights become a saved patch
|
|
26
|
+
plan — `deal.next_step` (compare-and-set protected) plus follow-up tasks
|
|
27
|
+
(idempotent), with the call evidence attached — flowing into the standard
|
|
28
|
+
suggest/approve/apply lifecycle. Implements the
|
|
29
|
+
`call_next_step_not_reflected_in_crm` finding declared since the MVP.
|
|
30
|
+
- **MCP `fullstackgtm_call_parse`** tool; `parseCall`, `normalizeTranscript`,
|
|
31
|
+
`suggestCallDeal`, `parseTranscript`, `extractCallInsights`,
|
|
32
|
+
`summarizeInsights` exported from the package (the extraction library
|
|
33
|
+
moved out of the proprietary app — the boundary moves outward).
|
|
34
|
+
|
|
35
|
+
## [0.12.0] — 2026-06-11
|
|
36
|
+
|
|
37
|
+
Governed merges: the Remediate layer of the
|
|
38
|
+
[CRM-health lifecycle](./docs/crm-health-lifecycle.md). Duplicate detection
|
|
39
|
+
now ends in an approvable merge, not a chore.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **`merge_records` operation type**: merges a duplicate group
|
|
44
|
+
(`beforeValue` = group ids) into an approved survivor (`afterValue`).
|
|
45
|
+
HubSpot connector implements it via the v3 merge API for companies,
|
|
46
|
+
contacts, and deals — pairwise, survivor's values win, losers archived by
|
|
47
|
+
HubSpot, **irreversible** (called out in every operation's rollback text).
|
|
48
|
+
Refuses survivors outside the group; treats 404 losers as already merged,
|
|
49
|
+
so replayed plans are safe. Salesforce skips honestly (merge is SOAP/Apex
|
|
50
|
+
only on that platform).
|
|
51
|
+
- **Survivor suggestions in `suggest`**: deterministic ranking — most
|
|
52
|
+
complete record, then most recent activity — with the evidence written
|
|
53
|
+
into the reason. **Capped at low confidence by design**: an irreversible
|
|
54
|
+
merge can never clear the default bulk-approval bar; accepting one takes
|
|
55
|
+
`--min-confidence low` or an explicit `--value`.
|
|
56
|
+
|
|
57
|
+
### Changed
|
|
58
|
+
|
|
59
|
+
- **The three duplicate rules now emit `merge_records`** (high risk,
|
|
60
|
+
approval required, irreversibility in the rollback text) instead of
|
|
61
|
+
`create_task` review chores — detection now connects to remediation
|
|
62
|
+
inside the governed loop.
|
|
63
|
+
|
|
8
64
|
## [0.11.1] — 2026-06-11
|
|
9
65
|
|
|
10
66
|
Write-path integrity: fixes our own dupe faucets, found auditing the apply
|
package/README.md
CHANGED
|
@@ -47,6 +47,25 @@ HUBSPOT_ACCESS_TOKEN=pat-... npx fullstackgtm apply \
|
|
|
47
47
|
|
|
48
48
|
Nothing is ever written without an explicit `--approve`. Operations whose value is a human decision (`requires_human_*` placeholders, e.g. which owner to assign) are refused unless you supply a concrete `--value` override.
|
|
49
49
|
|
|
50
|
+
## Call workflows: calls become governed evidence
|
|
51
|
+
|
|
52
|
+
Calls are where pipeline truth lives. `call parse` normalizes any transcript dialect — `Speaker: text` lines (Fathom, Gong exports), `[Speaker]:` labels, or raw Granola utterance JSON — into canonical segments, deterministic keyword-derived insights (next steps, objections, pricing, risks, competitor mentions…), and `GtmEvidence` records, all LLM-free and byte-stable per transcript. `call link` answers "which deal was this call about" from attendee domains (account domain or contact emails → open deals, most recent activity first, with confidence + reason). `call plan` turns next-step insights into the same governed plan lifecycle as everything else.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Coaching/score pipeline (Slack + CRM): parse → link → govern the writeback
|
|
56
|
+
fullstackgtm call parse --transcript call.txt --title "Acme disco" --out parsed.json
|
|
57
|
+
fullstackgtm call link --attendees jane@acme.com --provider hubspot # → deal id + reason
|
|
58
|
+
fullstackgtm call plan --call parsed.json --deal 123 --provider hubspot --save
|
|
59
|
+
# review → plans approve → apply: deal.next_step + follow-up tasks, compare-and-set protected
|
|
60
|
+
# (LLM scoring/extraction stays in your own pipeline; pipe parsed.json into it and into Slack)
|
|
61
|
+
|
|
62
|
+
# Analytics pipeline (warehouse): one flat NDJSON row per insight
|
|
63
|
+
for t in transcripts/*; do fullstackgtm call parse --transcript "$t" --ndjson; done > insights.ndjson
|
|
64
|
+
# COPY into your warehouse (stable call/evidence ids make reloads idempotent)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The deliberate boundary: parsing, linking, and governed writeback are deterministic CLI primitives; LLM scoring, rubric extraction, and Slack/Notion/warehouse sinks are *your* pipeline, composed around the JSON.
|
|
68
|
+
|
|
50
69
|
## From findings to fixes: the suggest chain
|
|
51
70
|
|
|
52
71
|
Most placeholder answers are already derivable from your own CRM data. `suggest` computes them deterministically — account-name matching cross-checked against contact associations — with a confidence level and a written reason per operation, so you (or an agent) approve evidence, not guesses:
|
|
@@ -59,7 +78,7 @@ fullstackgtm plans approve patch_plan_abc123 --values-from suggestions.json #
|
|
|
59
78
|
fullstackgtm apply --plan-id patch_plan_abc123 --provider hubspot
|
|
60
79
|
```
|
|
61
80
|
|
|
62
|
-
Widen the bar deliberately: `--min-confidence low` accepts single-signal matches; `--include-creates` accepts `create:<Name>` values — approving one **creates the missing company/account record and links to it** in a single audited operation, so even record creation stays inside the typed, human-approved model. Conflicting or ambiguous evidence always yields *no* suggestion with an explanation, never a guess.
|
|
81
|
+
Widen the bar deliberately: `--min-confidence low` accepts single-signal matches and **merge survivor suggestions** (irreversible merges are capped at low confidence by design, so the default bar never bulk-approves one); `--include-creates` accepts `create:<Name>` values — approving one **creates the missing company/account record and links to it** in a single audited operation, so even record creation stays inside the typed, human-approved model. Conflicting or ambiguous evidence always yields *no* suggestion with an explanation, never a guess.
|
|
63
82
|
|
|
64
83
|
```bash
|
|
65
84
|
# 3. Hand the findings to whoever owns the CRM: a client-ready report
|
package/dist/calls.d.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export type CallInsightType = "pain_point" | "objection" | "competitor_mention" | "next_step" | "feature_request" | "pricing" | "decision_criteria" | "risk" | "coaching_moment";
|
|
2
|
+
export type ParsedTranscriptSegment = {
|
|
3
|
+
index: number;
|
|
4
|
+
speaker?: string;
|
|
5
|
+
speakerRole: "rep" | "customer" | "unknown";
|
|
6
|
+
text: string;
|
|
7
|
+
startChar: number;
|
|
8
|
+
endChar: number;
|
|
9
|
+
};
|
|
10
|
+
export type ExtractedCallInsight = {
|
|
11
|
+
type: CallInsightType;
|
|
12
|
+
title: string;
|
|
13
|
+
text: string;
|
|
14
|
+
evidence: string;
|
|
15
|
+
speaker?: string;
|
|
16
|
+
confidence: number;
|
|
17
|
+
importance: number;
|
|
18
|
+
segmentIndex?: number;
|
|
19
|
+
startChar?: number;
|
|
20
|
+
endChar?: number;
|
|
21
|
+
};
|
|
22
|
+
export declare function parseTranscript(transcript: string): ParsedTranscriptSegment[];
|
|
23
|
+
export declare function extractCallInsights(transcript: string, segments?: ParsedTranscriptSegment[]): ExtractedCallInsight[];
|
|
24
|
+
export declare function summarizeInsights(insights: ExtractedCallInsight[]): {
|
|
25
|
+
total: number;
|
|
26
|
+
highImportance: number;
|
|
27
|
+
byType: Record<string, number>;
|
|
28
|
+
topTypes: {
|
|
29
|
+
type: string;
|
|
30
|
+
count: number;
|
|
31
|
+
}[];
|
|
32
|
+
};
|
|
33
|
+
import type { CanonicalGtmSnapshot, GtmEvidence, GtmEvidenceSourceSystem } from "./types.ts";
|
|
34
|
+
export type ParsedCall = {
|
|
35
|
+
/** Stable hash of the normalized transcript — same input, same id. */
|
|
36
|
+
id: string;
|
|
37
|
+
title?: string;
|
|
38
|
+
sourceSystem: GtmEvidenceSourceSystem;
|
|
39
|
+
segments: ParsedTranscriptSegment[];
|
|
40
|
+
insights: ExtractedCallInsight[];
|
|
41
|
+
evidence: GtmEvidence[];
|
|
42
|
+
summary: ReturnType<typeof summarizeInsights>;
|
|
43
|
+
};
|
|
44
|
+
/** Detect and normalize a raw transcript payload into "Speaker: text" lines. */
|
|
45
|
+
export declare function normalizeTranscript(raw: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Parse any supported transcript dialect into a canonical call: segments,
|
|
48
|
+
* deterministic insights, and GtmEvidence records ready for findings and
|
|
49
|
+
* patch plans. Pure and deterministic — same transcript, same ids.
|
|
50
|
+
*/
|
|
51
|
+
export declare function parseCall(raw: string, options?: {
|
|
52
|
+
title?: string;
|
|
53
|
+
sourceSystem?: GtmEvidenceSourceSystem;
|
|
54
|
+
capturedAt?: string;
|
|
55
|
+
}): ParsedCall;
|
|
56
|
+
export type CallDealSuggestion = {
|
|
57
|
+
dealId: string | null;
|
|
58
|
+
dealName?: string;
|
|
59
|
+
accountId?: string;
|
|
60
|
+
accountName?: string;
|
|
61
|
+
confidence: "high" | "low" | "none";
|
|
62
|
+
reason: string;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Suggest which deal a call belongs to: attendee email domains → account
|
|
66
|
+
* (contact emails or account domain) → open deals, most recent activity
|
|
67
|
+
* first. Deterministic; mirrors the heuristic every call pipeline hand-rolls.
|
|
68
|
+
*/
|
|
69
|
+
export declare function suggestCallDeal(snapshot: CanonicalGtmSnapshot, options: {
|
|
70
|
+
attendeeEmails?: string[];
|
|
71
|
+
domain?: string;
|
|
72
|
+
}): CallDealSuggestion;
|
package/dist/calls.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
const DEFAULT_REP_SPEAKERS = [
|
|
2
|
+
"rep",
|
|
3
|
+
"sales",
|
|
4
|
+
"seller",
|
|
5
|
+
"ae",
|
|
6
|
+
"sdr",
|
|
7
|
+
"bdr",
|
|
8
|
+
"account executive",
|
|
9
|
+
"host",
|
|
10
|
+
];
|
|
11
|
+
const CUSTOMER_HINTS = [
|
|
12
|
+
"customer",
|
|
13
|
+
"prospect",
|
|
14
|
+
"buyer",
|
|
15
|
+
"client",
|
|
16
|
+
"champion",
|
|
17
|
+
"participant",
|
|
18
|
+
];
|
|
19
|
+
const insightPatterns = [
|
|
20
|
+
{
|
|
21
|
+
type: "pain_point",
|
|
22
|
+
title: "Customer pain point",
|
|
23
|
+
confidence: 0.78,
|
|
24
|
+
importance: 4,
|
|
25
|
+
patterns: [
|
|
26
|
+
/\b(struggling|struggle|pain|problem|challenge|frustrat(?:ed|ing)|manual|messy|broken|hard to|difficult|too much time|takes too long)\b/i,
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
type: "objection",
|
|
31
|
+
title: "Sales objection",
|
|
32
|
+
confidence: 0.76,
|
|
33
|
+
importance: 4,
|
|
34
|
+
patterns: [
|
|
35
|
+
/\b(concern|worried|not sure|too expensive|budget|price|pricing|timing|procurement|security review|legal review|need to think|another vendor)\b/i,
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: "competitor_mention",
|
|
40
|
+
title: "Competitor or alternative mentioned",
|
|
41
|
+
confidence: 0.72,
|
|
42
|
+
importance: 3,
|
|
43
|
+
patterns: [
|
|
44
|
+
/\b(competitor|alternative|evaluating|looked at|using|salesforce|hubspot|gong|chorus|clari|outreach|salesloft|6sense|demandbase|clay|zapier|workato)\b/i,
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
type: "next_step",
|
|
49
|
+
title: "Next step",
|
|
50
|
+
confidence: 0.82,
|
|
51
|
+
importance: 5,
|
|
52
|
+
patterns: [
|
|
53
|
+
/\b(next step|follow up|send over|circle back|schedule|book|meet again|by (monday|tuesday|wednesday|thursday|friday)|action item|I'll send|we'll send)\b/i,
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
type: "feature_request",
|
|
58
|
+
title: "Feature request",
|
|
59
|
+
confidence: 0.74,
|
|
60
|
+
importance: 3,
|
|
61
|
+
patterns: [
|
|
62
|
+
/\b(wish|would love|need it to|feature|capability|does it support|can it|integration|custom field|dashboard|report|export|import)\b/i,
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
type: "pricing",
|
|
67
|
+
title: "Pricing or budget signal",
|
|
68
|
+
confidence: 0.76,
|
|
69
|
+
importance: 4,
|
|
70
|
+
patterns: [
|
|
71
|
+
/\b(price|pricing|budget|cost|discount|contract|renewal|procurement|purchase order|PO|invoice|annual|monthly|ARR|MRR|\$\d+)\b/i,
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
type: "decision_criteria",
|
|
76
|
+
title: "Decision criteria",
|
|
77
|
+
confidence: 0.73,
|
|
78
|
+
importance: 4,
|
|
79
|
+
patterns: [
|
|
80
|
+
/\b(criteria|decision|evaluate|evaluation|success looks like|requirements|must have|need to see|stakeholder|committee|approve|approval)\b/i,
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
type: "risk",
|
|
85
|
+
title: "Deal risk",
|
|
86
|
+
confidence: 0.75,
|
|
87
|
+
importance: 5,
|
|
88
|
+
patterns: [
|
|
89
|
+
/\b(delay|blocked|risk|concern|not a priority|no budget|push|slip|stalled|ghost|unresponsive|champion left|reorg)\b/i,
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
type: "coaching_moment",
|
|
94
|
+
title: "Coaching moment",
|
|
95
|
+
confidence: 0.68,
|
|
96
|
+
importance: 2,
|
|
97
|
+
patterns: [
|
|
98
|
+
/\b(let me tell you|obviously|basically|trust me|to be honest|just checking in|does that make sense\?)\b/i,
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
export function parseTranscript(transcript) {
|
|
103
|
+
const normalized = transcript.replace(/\r\n/g, "\n").trim();
|
|
104
|
+
if (!normalized)
|
|
105
|
+
return [];
|
|
106
|
+
const lines = normalized.split(/\n+/);
|
|
107
|
+
const segments = [];
|
|
108
|
+
let cursor = 0;
|
|
109
|
+
let current = null;
|
|
110
|
+
for (const rawLine of lines) {
|
|
111
|
+
const line = rawLine.trim();
|
|
112
|
+
if (!line) {
|
|
113
|
+
cursor += rawLine.length + 1;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const startChar = normalized.indexOf(line, cursor);
|
|
117
|
+
const lineStart = startChar >= 0 ? startChar : cursor;
|
|
118
|
+
const lineEnd = lineStart + line.length;
|
|
119
|
+
const match = line.match(/^([^:\[\]]{1,60}|\[[^\]]{1,60}\])\s*:\s*(.+)$/);
|
|
120
|
+
if (match) {
|
|
121
|
+
const speaker = match[1].replace(/^\[|\]$/g, "").trim();
|
|
122
|
+
const text = match[2].trim();
|
|
123
|
+
current = {
|
|
124
|
+
index: segments.length,
|
|
125
|
+
speaker,
|
|
126
|
+
speakerRole: inferSpeakerRole(speaker),
|
|
127
|
+
text,
|
|
128
|
+
startChar: lineStart + line.indexOf(text),
|
|
129
|
+
endChar: lineEnd,
|
|
130
|
+
};
|
|
131
|
+
segments.push(current);
|
|
132
|
+
}
|
|
133
|
+
else if (current) {
|
|
134
|
+
current.text = `${current.text}\n${line}`;
|
|
135
|
+
current.endChar = lineEnd;
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
current = {
|
|
139
|
+
index: segments.length,
|
|
140
|
+
speakerRole: "unknown",
|
|
141
|
+
text: line,
|
|
142
|
+
startChar: lineStart,
|
|
143
|
+
endChar: lineEnd,
|
|
144
|
+
};
|
|
145
|
+
segments.push(current);
|
|
146
|
+
}
|
|
147
|
+
cursor = lineEnd;
|
|
148
|
+
}
|
|
149
|
+
return segments;
|
|
150
|
+
}
|
|
151
|
+
export function extractCallInsights(transcript, segments = parseTranscript(transcript)) {
|
|
152
|
+
const seen = new Set();
|
|
153
|
+
const insights = [];
|
|
154
|
+
for (const segment of segments) {
|
|
155
|
+
const text = segment.text.trim();
|
|
156
|
+
if (!text || text.length < 12)
|
|
157
|
+
continue;
|
|
158
|
+
for (const pattern of insightPatterns) {
|
|
159
|
+
if (!pattern.patterns.some((candidate) => candidate.test(text)))
|
|
160
|
+
continue;
|
|
161
|
+
const key = `${pattern.type}:${text.slice(0, 180).toLowerCase()}`;
|
|
162
|
+
if (seen.has(key))
|
|
163
|
+
continue;
|
|
164
|
+
seen.add(key);
|
|
165
|
+
insights.push({
|
|
166
|
+
type: pattern.type,
|
|
167
|
+
title: pattern.title,
|
|
168
|
+
text: text.length > 500 ? `${text.slice(0, 497)}...` : text,
|
|
169
|
+
evidence: text,
|
|
170
|
+
speaker: segment.speaker,
|
|
171
|
+
confidence: pattern.confidence,
|
|
172
|
+
importance: pattern.importance,
|
|
173
|
+
segmentIndex: segment.index,
|
|
174
|
+
startChar: segment.startChar,
|
|
175
|
+
endChar: segment.endChar,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return insights.sort((a, b) => b.importance - a.importance || b.confidence - a.confidence);
|
|
180
|
+
}
|
|
181
|
+
export function summarizeInsights(insights) {
|
|
182
|
+
const byType = {};
|
|
183
|
+
let highImportance = 0;
|
|
184
|
+
for (const insight of insights) {
|
|
185
|
+
byType[insight.type] = (byType[insight.type] ?? 0) + 1;
|
|
186
|
+
if (insight.importance >= 4)
|
|
187
|
+
highImportance += 1;
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
total: insights.length,
|
|
191
|
+
highImportance,
|
|
192
|
+
byType,
|
|
193
|
+
topTypes: Object.entries(byType)
|
|
194
|
+
.sort((a, b) => b[1] - a[1])
|
|
195
|
+
.slice(0, 5)
|
|
196
|
+
.map(([type, count]) => ({ type, count })),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function inferSpeakerRole(speaker) {
|
|
200
|
+
const normalized = speaker.toLowerCase();
|
|
201
|
+
if (DEFAULT_REP_SPEAKERS.some((hint) => normalized.includes(hint)))
|
|
202
|
+
return "rep";
|
|
203
|
+
if (CUSTOMER_HINTS.some((hint) => normalized.includes(hint)))
|
|
204
|
+
return "customer";
|
|
205
|
+
return "unknown";
|
|
206
|
+
}
|
|
207
|
+
/** Detect and normalize a raw transcript payload into "Speaker: text" lines. */
|
|
208
|
+
export function normalizeTranscript(raw) {
|
|
209
|
+
const trimmed = raw.trim();
|
|
210
|
+
if (trimmed.startsWith("[") || trimmed.startsWith("{")) {
|
|
211
|
+
try {
|
|
212
|
+
const parsed = JSON.parse(trimmed);
|
|
213
|
+
const utterances = Array.isArray(parsed)
|
|
214
|
+
? parsed
|
|
215
|
+
: Array.isArray(parsed.transcript)
|
|
216
|
+
? (parsed.transcript)
|
|
217
|
+
: null;
|
|
218
|
+
if (utterances && utterances.every((u) => typeof u === "object" && u !== null && "text" in u)) {
|
|
219
|
+
return utterances
|
|
220
|
+
.filter((u) => typeof u.text === "string" && u.text.trim())
|
|
221
|
+
.map((u) => `${u.source === "microphone" ? "[Me]" : "[Them]"}: ${String(u.text).trim()}`)
|
|
222
|
+
.join("\n");
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// not JSON — fall through and treat as plain text
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return raw;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Parse any supported transcript dialect into a canonical call: segments,
|
|
233
|
+
* deterministic insights, and GtmEvidence records ready for findings and
|
|
234
|
+
* patch plans. Pure and deterministic — same transcript, same ids.
|
|
235
|
+
*/
|
|
236
|
+
export function parseCall(raw, options = {}) {
|
|
237
|
+
const normalized = normalizeTranscript(raw);
|
|
238
|
+
const segments = parseTranscript(normalized);
|
|
239
|
+
const insights = extractCallInsights(normalized, segments);
|
|
240
|
+
const sourceSystem = options.sourceSystem ?? "manual";
|
|
241
|
+
const id = `call_${callHash(normalized)}`;
|
|
242
|
+
const evidence = insights.map((insight, index) => ({
|
|
243
|
+
id: `ev_${callHash(`${id}:${insight.type}:${index}:${insight.text.slice(0, 80)}`)}`,
|
|
244
|
+
sourceSystem,
|
|
245
|
+
sourceObjectType: "call",
|
|
246
|
+
sourceObjectId: id,
|
|
247
|
+
title: insight.title,
|
|
248
|
+
text: insight.evidence,
|
|
249
|
+
capturedAt: options.capturedAt,
|
|
250
|
+
metadata: {
|
|
251
|
+
insightType: insight.type,
|
|
252
|
+
speaker: insight.speaker,
|
|
253
|
+
confidence: insight.confidence,
|
|
254
|
+
importance: insight.importance,
|
|
255
|
+
segmentIndex: insight.segmentIndex,
|
|
256
|
+
},
|
|
257
|
+
}));
|
|
258
|
+
return {
|
|
259
|
+
id,
|
|
260
|
+
title: options.title,
|
|
261
|
+
sourceSystem,
|
|
262
|
+
segments,
|
|
263
|
+
insights,
|
|
264
|
+
evidence,
|
|
265
|
+
summary: summarizeInsights(insights),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Suggest which deal a call belongs to: attendee email domains → account
|
|
270
|
+
* (contact emails or account domain) → open deals, most recent activity
|
|
271
|
+
* first. Deterministic; mirrors the heuristic every call pipeline hand-rolls.
|
|
272
|
+
*/
|
|
273
|
+
export function suggestCallDeal(snapshot, options) {
|
|
274
|
+
const domains = new Set();
|
|
275
|
+
if (options.domain)
|
|
276
|
+
domains.add(options.domain.trim().toLowerCase());
|
|
277
|
+
for (const email of options.attendeeEmails ?? []) {
|
|
278
|
+
const at = email.indexOf("@");
|
|
279
|
+
if (at > 0)
|
|
280
|
+
domains.add(email.slice(at + 1).trim().toLowerCase());
|
|
281
|
+
}
|
|
282
|
+
if (domains.size === 0) {
|
|
283
|
+
return { dealId: null, confidence: "none", reason: "No attendee emails or domain supplied to match on." };
|
|
284
|
+
}
|
|
285
|
+
const accountIds = new Set();
|
|
286
|
+
for (const account of snapshot.accounts) {
|
|
287
|
+
const domain = account.domain?.trim().toLowerCase().replace(/^www\./, "");
|
|
288
|
+
if (domain && domains.has(domain))
|
|
289
|
+
accountIds.add(account.id);
|
|
290
|
+
}
|
|
291
|
+
for (const contact of snapshot.contacts) {
|
|
292
|
+
const email = contact.email?.trim().toLowerCase();
|
|
293
|
+
const at = email ? email.indexOf("@") : -1;
|
|
294
|
+
if (email && at > 0 && domains.has(email.slice(at + 1)) && contact.accountId) {
|
|
295
|
+
accountIds.add(contact.accountId);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (accountIds.size === 0) {
|
|
299
|
+
return {
|
|
300
|
+
dealId: null,
|
|
301
|
+
confidence: "none",
|
|
302
|
+
reason: `No account matches the attendee domain(s) ${[...domains].join(", ")} by account domain or contact email.`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const accountsById = new Map(snapshot.accounts.map((a) => [a.id, a]));
|
|
306
|
+
const openDeals = snapshot.deals
|
|
307
|
+
.filter((deal) => deal.accountId && accountIds.has(deal.accountId))
|
|
308
|
+
.filter((deal) => deal.isClosed !== true && deal.isWon !== true)
|
|
309
|
+
.sort((a, b) => Date.parse(b.lastActivityAt ?? "1970") - Date.parse(a.lastActivityAt ?? "1970"));
|
|
310
|
+
if (openDeals.length === 0) {
|
|
311
|
+
const names = [...accountIds].map((id) => accountsById.get(id)?.name ?? id).join(", ");
|
|
312
|
+
return {
|
|
313
|
+
dealId: null,
|
|
314
|
+
confidence: "none",
|
|
315
|
+
reason: `Matched account(s) ${names} but found no open deals on them.`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
const top = openDeals[0];
|
|
319
|
+
const account = top.accountId ? accountsById.get(top.accountId) : undefined;
|
|
320
|
+
if (openDeals.length === 1) {
|
|
321
|
+
return {
|
|
322
|
+
dealId: top.id,
|
|
323
|
+
dealName: top.name,
|
|
324
|
+
accountId: top.accountId,
|
|
325
|
+
accountName: account?.name,
|
|
326
|
+
confidence: "high",
|
|
327
|
+
reason: `"${top.name}" is the only open deal on matched account "${account?.name ?? top.accountId}".`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
return {
|
|
331
|
+
dealId: top.id,
|
|
332
|
+
dealName: top.name,
|
|
333
|
+
accountId: top.accountId,
|
|
334
|
+
accountName: account?.name,
|
|
335
|
+
confidence: "low",
|
|
336
|
+
reason: `${openDeals.length} open deals on matched account(s); "${top.name}" has the most recent activity. Confirm before writing.`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
function callHash(value) {
|
|
340
|
+
let hash = 0;
|
|
341
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
342
|
+
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
|
343
|
+
}
|
|
344
|
+
return hash.toString(36);
|
|
345
|
+
}
|