@gotcos/glasses-server 6.98.0 → 6.99.12
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 +158 -0
- package/package.json +2 -1
- package/server/index.ts +147 -9
- package/server/lib/api-auth.ts +31 -14
- package/server/lib/claude-bridge.ts +21 -2
- package/server/lib/codex-bridge.ts +21 -2
- package/server/lib/display-bus.ts +3 -0
- package/server/lib/drop-destinations.ts +134 -0
- package/server/lib/drop-history-search.ts +97 -0
- package/server/lib/drop-p2-boot.ts +60 -0
- package/server/lib/drop-p2-runtime.ts +237 -0
- package/server/lib/drop-p2-settings.ts +174 -0
- package/server/lib/drop-resolution.ts +168 -0
- package/server/lib/drop-router.ts +325 -0
- package/server/lib/drop-service.ts +739 -0
- package/server/lib/drop-settings.ts +45 -0
- package/server/lib/drop-store.ts +428 -0
- package/server/lib/drop-when.ts +265 -0
- package/server/lib/glasses-pairing.ts +15 -0
- package/server/lib/learning-needs-you.ts +111 -0
- package/server/lib/live-cues-v2-data.ts +336 -26
- package/server/lib/live-cues-v2-settings.ts +2 -1
- package/server/lib/live-cues-v2.ts +9 -5
- package/server/lib/meeting-consolidation-run.ts +169 -0
- package/server/lib/meeting-store.ts +61 -0
- package/server/lib/meeting-title-steer.ts +200 -0
- package/server/lib/network-policy.ts +84 -6
- package/server/lib/permission-broker.ts +11 -0
- package/server/lib/ping-budget.ts +149 -0
- package/server/lib/ping-engine.ts +245 -0
- package/server/lib/ping-feed-sink.ts +33 -0
- package/server/lib/ping-sources.ts +192 -0
- package/server/lib/query-job-coordinator.ts +5 -2
- package/server/lib/query-job-runtime.ts +45 -4
- package/server/lib/query-job-types.ts +10 -1
- package/server/lib/reminder-scheduler.ts +153 -0
- package/server/lib/tailnet-self-name.ts +103 -0
- package/server/lib/tailscale-nudge.ts +40 -2
- package/server/lib/task-store.ts +32 -0
- package/server/lib/web-ask-admission.ts +56 -0
- package/server/lib/web-live-chunks.ts +273 -0
- package/server/lib/web-memories-bridge.ts +323 -0
- package/server/lib/web-session-auth.ts +492 -0
- package/server/lib/web-usage-bridge.ts +154 -0
- package/server/lib/work-search.ts +14 -1
- package/server/lib/your-move-count.ts +58 -0
- package/server/routes/display.ts +11 -1
- package/server/routes/drops.ts +260 -0
- package/server/routes/health.ts +16 -0
- package/server/routes/live-cues.ts +5 -4
- package/server/routes/meeting.ts +61 -5
- package/server/routes/permission-broker.ts +23 -3
- package/server/routes/transcribe-stream.ts +34 -3
- package/server/routes/web-control.ts +569 -0
- package/server/routes/welcome-context.ts +2 -1
- package/server/web-control/app.css +519 -0
- package/server/web-control/app.js +3643 -0
- package/server/web-control/control.css +312 -0
- package/server/web-control/fonts/DMSans.ttf +0 -0
- package/server/web-control/fonts/Fraunces.ttf +0 -0
- package/server/web-control/fonts/JetBrainsMono.ttf +0 -0
- package/server/web-control/index.html +275 -0
- package/server/web-control/mark.svg +1 -0
- package/server/web-control/memories/d3.min.js +2 -0
- package/server/web-control/memories/graph-explorer.css +200 -0
- package/server/web-control/memories/graph-explorer.js +748 -0
- package/server/web-control/memories/memories-app.js +1561 -0
- package/server/web-control/memories/memories-theme.css +73 -0
- package/server/web-control/memories/memories.html +72 -0
- package/server/web-control/memories/memory-stewardship.js +63 -0
- package/server/web-control/memories/memory-workspace.js +216 -0
- package/server/web-control/panes/cos-chat.css +157 -0
- package/server/web-control/panes/cos-chat.js +973 -0
- package/server/web-control/panes/dictation.js +173 -0
- package/server/web-control/panes/meetings.css +257 -0
- package/server/web-control/panes/meetings.js +2264 -0
- package/server/web-control/panes/messages.js +711 -0
- package/server/web-control/panes/questions.css +32 -0
- package/server/web-control/panes/questions.js +275 -0
- package/server/web-control/panes/sessions.css +452 -0
- package/server/web-control/panes/settings.js +292 -0
- package/server/web-control/panes/speakers.css +269 -0
- package/server/web-control/panes/speakers.js +2755 -0
- package/server/web-control/panes/threads.js +200 -0
- package/server/web-control/panes/work.css +261 -0
- package/server/web-control/panes/work.js +1667 -0
- package/server/web-control/shell.js +294 -0
- package/server/web-control/theme.js +25 -0
- package/server/web-control/vendor/qrcode-encoder.js +10 -0
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
// carry a funnel word or a figure of two digits or a percent;
|
|
14
14
|
// - a one-word alias that is also plain English (pet, dive, music...) is "weak": it counts only beside a funnel word or an
|
|
15
15
|
// industry word, so "let's dive into the numbers" and "pet project" stay quiet. The bare word "quilt" is never an alias;
|
|
16
|
+
// - a lead source named in speech ("paid search", "PPC", "events") narrows the note to that source: its rows for the named group,
|
|
17
|
+
// or the largest industries when no group is named. A source alone fires only for a jargon or multi-word name with a funnel word
|
|
18
|
+
// or figure; a plain word that is also English (direct, events, email, organic...) counts only beside a named group. A source with
|
|
19
|
+
// a group fires without a funnel word ("PPC landing pages for thrift"), the way a funnel word alone never could;
|
|
16
20
|
// - domain (dataArmGate): a Quilt meeting runs it. An unreadable orientation is off. Any other confirmed domain (personal,
|
|
17
21
|
// Sprocket Rocket, Hermit Crabs...) is off. A meeting with no confirmed domain (most G2 meetings) needs a Quilt signal from
|
|
18
22
|
// the same evaluation: a Quilt brand or SBU/XBU named, a Quilt-domain memory or note among the candidates, or a Quilt term in
|
|
@@ -36,6 +40,7 @@ export const DATA_FACTS_LIMITS = {
|
|
|
36
40
|
maxBytes: 2_000_000,
|
|
37
41
|
maxGroups: 60,
|
|
38
42
|
maxSources: 400,
|
|
43
|
+
maxBudgetLines: 400,
|
|
39
44
|
/** The speech the trigger reads: the last this many characters of the window (about 100 words). */
|
|
40
45
|
recentChars: 600,
|
|
41
46
|
/** A candidate is at most this long, like a file passage. */
|
|
@@ -44,6 +49,8 @@ export const DATA_FACTS_LIMITS = {
|
|
|
44
49
|
/** What the meeting assistant is given: at most this many characters of Tableau text. */
|
|
45
50
|
askChars: 3_500,
|
|
46
51
|
askSourceRowsPerIndustry: 6,
|
|
52
|
+
/** A source-alone cue (no group named) lists this many industries, largest by leads. */
|
|
53
|
+
cueSourceRows: 2,
|
|
47
54
|
/** A typed question longer than this is probably pasted text (see askUserWords). */
|
|
48
55
|
askQuestionChars: 300,
|
|
49
56
|
} as const
|
|
@@ -74,7 +81,19 @@ export interface DataGroup {
|
|
|
74
81
|
coverage?: { have: number; of: number }
|
|
75
82
|
stages: Partial<Record<DataStageKey, DataStage>>
|
|
76
83
|
}
|
|
77
|
-
|
|
84
|
+
/** The stages a lead source has an actual for (Tableau's source budget file carries no per-source SQLs or demos). */
|
|
85
|
+
export const SOURCE_STAGES = ['leads', 'opps', 'customers'] as const
|
|
86
|
+
export type SourceStageKey = typeof SOURCE_STAGES[number]
|
|
87
|
+
export interface DataSourceStage { actual: number; budget: number | null; pctToGoal: number | null }
|
|
88
|
+
/** Tableau budgets per BUDGET LINE, not per lead source, and one line can count several sources (Paid Social sits inside "Paid Search -
|
|
89
|
+
* Non-Brand"). So a line's actual and budget are the whole line's, never one source's. Optional: older files have none. */
|
|
90
|
+
export interface DataBudgetLine { industry: string; line: string; sources: string[]; stages: Partial<Record<SourceStageKey, DataSourceStage>> }
|
|
91
|
+
export interface DataSourceRow {
|
|
92
|
+
industry: string; source: string; bucket: string; leads: number; opps: number; customers: number
|
|
93
|
+
/** The budget line the writer verified this source counts under; absent in older files. Informational: the readers join a source to
|
|
94
|
+
* its line by the line's own member list (budgetLinesFor), for the sources that have rows. */
|
|
95
|
+
budgetLine?: string
|
|
96
|
+
}
|
|
78
97
|
export interface DataFacts {
|
|
79
98
|
label: string
|
|
80
99
|
scope: string
|
|
@@ -85,6 +104,8 @@ export interface DataFacts {
|
|
|
85
104
|
completeThrough?: string
|
|
86
105
|
groups: DataGroup[]
|
|
87
106
|
sources: DataSourceRow[]
|
|
107
|
+
/** Empty for a file written before budget lines. */
|
|
108
|
+
budgetLines: DataBudgetLine[]
|
|
88
109
|
}
|
|
89
110
|
|
|
90
111
|
export type DataFactsOff = 'missing' | 'unreadable' | 'invalid' | 'stale' | 'future'
|
|
@@ -118,6 +139,26 @@ function parseStage(raw: unknown): DataStage | null {
|
|
|
118
139
|
return stage
|
|
119
140
|
}
|
|
120
141
|
|
|
142
|
+
function parseSourceStage(raw: unknown): DataSourceStage | null {
|
|
143
|
+
if (!isObj(raw)) return null
|
|
144
|
+
const actual = fin(raw.actual)
|
|
145
|
+
return actual === null || actual < 0 ? null : { actual, budget: fin(raw.budget), pctToGoal: fin(raw.pctToGoal) }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function parseBudgetLine(raw: unknown): DataBudgetLine | null {
|
|
149
|
+
if (!isObj(raw) || !isObj(raw.stages)) return null
|
|
150
|
+
const industry = str(raw.industry, 40), line = str(raw.line, 80)
|
|
151
|
+
if (!industry || !line) return null
|
|
152
|
+
const stages: DataBudgetLine['stages'] = {}
|
|
153
|
+
for (const key of SOURCE_STAGES) {
|
|
154
|
+
const st = parseSourceStage((raw.stages as Record<string, unknown>)[key])
|
|
155
|
+
if (st) stages[key] = st
|
|
156
|
+
}
|
|
157
|
+
if (!Object.keys(stages).length) return null
|
|
158
|
+
const sources = Array.isArray(raw.sources) ? raw.sources.map(x => str(x, 80)).filter((x): x is string => x !== null).slice(0, 20) : []
|
|
159
|
+
return { industry, line, sources, stages }
|
|
160
|
+
}
|
|
161
|
+
|
|
121
162
|
function parseGroup(raw: unknown): DataGroup | null {
|
|
122
163
|
if (!isObj(raw)) return null
|
|
123
164
|
const name = str(raw.name, 40)
|
|
@@ -154,14 +195,16 @@ export function parseDataFacts(raw: unknown, nowMs: number): DataFactsRead {
|
|
|
154
195
|
if (!isObj(r)) continue
|
|
155
196
|
const industry = str(r.industry, 40), source = str(r.source, 80)
|
|
156
197
|
const leads = fin(r.leads), opps = fin(r.opps), customers = fin(r.customers)
|
|
157
|
-
|
|
198
|
+
const budgetLine = str(r.budgetLine, 80)
|
|
199
|
+
if (industry && source && leads !== null && opps !== null && customers !== null) sources.push({ industry, source, bucket: typeof r.bucket === 'string' ? r.bucket : '', leads, opps, customers, ...(budgetLine ? { budgetLine } : {}) })
|
|
158
200
|
}
|
|
201
|
+
const budgetLines = (Array.isArray(raw.budgetLines) ? raw.budgetLines.slice(0, DATA_FACTS_LIMITS.maxBudgetLines) : []).map(parseBudgetLine).filter((l): l is DataBudgetLine => l !== null)
|
|
159
202
|
if (asOf - nowMs > DATA_FACTS_LIMITS.maxFutureMs) return { ok: false, reason: 'future' }
|
|
160
203
|
const maxAgeMs = period.closed === true ? DATA_FACTS_LIMITS.maxClosedAgeDays * 24 * 3_600_000 : DATA_FACTS_LIMITS.maxAgeHours * 3_600_000
|
|
161
204
|
if (nowMs - asOf > maxAgeMs) return { ok: false, reason: 'stale' }
|
|
162
205
|
const through = typeof raw.completeThrough === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.completeThrough) ? raw.completeThrough : undefined
|
|
163
206
|
return { ok: true, facts: { label, scope, period: { label: pLabel, start: pStart, end: pEnd, closed: period.closed }, asOfIso: asOfIso as string, asOf,
|
|
164
|
-
...(through ? { completeThrough: through } : {}), groups, sources } }
|
|
207
|
+
...(through ? { completeThrough: through } : {}), groups, sources, budgetLines } }
|
|
165
208
|
}
|
|
166
209
|
|
|
167
210
|
/** Reads and parses the file. Missing, too big, unreadable or not JSON are three different reasons; never throws. */
|
|
@@ -237,40 +280,169 @@ function occurrences(tokens: string[], phrase: string[]): number[] {
|
|
|
237
280
|
return at
|
|
238
281
|
}
|
|
239
282
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
283
|
+
/** `anchors` are the token positions of a lead-source name that is specific enough to stand alone (ppc, paid search): directly beside one,
|
|
284
|
+
* a weak alias counts the way it does beside a funnel word ("paid search pet", "pet ppc"). Not across a bridge word: "let's dive in,
|
|
285
|
+
* google ads are the topic" is not about the dive industry. */
|
|
286
|
+
function weakAt(tokens: string[], i: number, anchors: ReadonlySet<number>): boolean {
|
|
287
|
+
const inside = (k: number) => k >= 0 && k < tokens.length
|
|
288
|
+
const funnel = (k: number) => inside(k) && FUNNEL_TOKENS.has(tokens[k])
|
|
289
|
+
const funnelOrIndustry = (k: number) => funnel(k) || anchors.has(k) || (inside(k) && INDUSTRY_TOKENS.has(tokens[k]))
|
|
290
|
+
const bridge = (k: number) => inside(k) && BRIDGE_TOKENS.has(tokens[k])
|
|
291
|
+
if (funnelOrIndustry(i - 1) || funnelOrIndustry(i + 1)) return true
|
|
292
|
+
return (bridge(i - 1) && funnel(i - 2)) || (bridge(i + 1) && funnel(i + 2))
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Lead sources ─────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/** How a lead source is said aloud. `strong` aliases are jargon or multi-word names that stand alone; `weak` ones are plain English
|
|
298
|
+
* (direct, events, email, organic, social...) and count only beside a named group or a stage word. `match` picks the source names IN THE
|
|
299
|
+
* FILE the concept covers, so a file without Webinar rows has nothing for "webinar". Names in the file are aliases too (see sourceHits); where
|
|
300
|
+
* a concept lists the same phrase as weak, the concept wins the tie, so a plain-English name stays weak. */
|
|
301
|
+
interface SourceConcept { label: string; strong: string[]; weak: string[]; match: (key: string) => boolean; /** "non-brand" or "brand" right beside it narrows it. */ narrow?: boolean }
|
|
302
|
+
const SOURCE_CONCEPTS: SourceConcept[] = [
|
|
303
|
+
{ label: 'Paid Search', strong: ['paid search', 'ppc', 'pay per click', 'sem', 'search engine marketing', 'google ads', 'google adwords', 'adwords', 'search ads'], weak: [], match: k => k.startsWith('paid search'), narrow: true },
|
|
304
|
+
{ label: 'Paid Search Brand', strong: [], weak: ['brand search', 'branded search'], match: k => k === 'paid search brand' },
|
|
305
|
+
{ label: 'Paid Search Non-Brand', strong: [], weak: ['non brand search', 'nonbrand search'], match: k => k === 'paid search non brand' },
|
|
306
|
+
{ label: 'Paid Social', strong: ['paid social', 'social ads', 'facebook ads', 'meta ads', 'instagram ads', 'linkedin ads', 'paid facebook', 'paid linkedin'], weak: ['facebook', 'linkedin', 'instagram'], match: k => k === 'paid social' },
|
|
307
|
+
{ label: 'Social Media', strong: [], weak: ['social media'], match: k => k === 'social media' },
|
|
308
|
+
{ label: 'Social', strong: [], weak: ['social'], match: k => k.includes('social') },
|
|
309
|
+
{ label: 'Events', strong: ['trade show', 'trade shows', 'tradeshow', 'tradeshows'], weak: ['events', 'event', 'conference', 'conferences'], match: k => /(^| )events?( |$)/.test(k) },
|
|
310
|
+
{ label: 'Organic', strong: [], weak: ['organic', 'organic search', 'seo'], match: k => k.startsWith('organic') },
|
|
311
|
+
{ label: 'Direct', strong: [], weak: ['direct', 'direct traffic'], match: k => k === 'direct' },
|
|
312
|
+
{ label: 'Referral Traffic', strong: [], weak: ['referral traffic'], match: k => k === 'referral traffic' },
|
|
313
|
+
{ label: 'Customer Referral', strong: [], weak: ['customer referral', 'customer referrals'], match: k => k === 'customer referral' },
|
|
314
|
+
{ label: 'Referral', strong: [], weak: ['referral', 'referrals'], match: k => k === 'referral traffic' || k === 'customer referral' },
|
|
315
|
+
{ label: 'AI Referrals', strong: ['ai referrals', 'ai referral', 'ai traffic'], weak: ['chatgpt', 'chat gpt', 'perplexity'], match: k => k === 'ai referrals' },
|
|
316
|
+
{ label: 'Aggregators', strong: ['paid aggregator', 'paid aggregators', 'lead aggregator', 'lead aggregators', 'capterra', 'software advice', 'getapp', 'g2 crowd'], weak: ['aggregator', 'aggregators'], match: k => k === 'paid aggregator' },
|
|
317
|
+
{ label: 'Email', strong: ['email database', 'email marketing', 'newsletter', 'newsletters'], weak: ['email', 'emails'], match: k => k === 'email database' },
|
|
318
|
+
{ label: 'Webinar', strong: [], weak: ['webinar', 'webinars'], match: k => k === 'webinar' },
|
|
319
|
+
{ label: 'Partnership', strong: ['paid partnership', 'paid partnerships'], weak: ['partnership', 'partnerships'], match: k => k === 'paid partnership' },
|
|
320
|
+
]
|
|
321
|
+
|
|
322
|
+
const sourceKey = (name: string): string => wordsOfSpeech(name).join(' ')
|
|
323
|
+
/** "Paid Search - Non-Brand" as people say it: "Paid Search Non-Brand". */
|
|
324
|
+
export const sourceLabel = (name: string): string => name.replace(/\s+-\s+/g, ' ')
|
|
325
|
+
|
|
326
|
+
/** Every lead source the file knows: the rows' and the budget lines' members. */
|
|
327
|
+
function sourceNamesOf(facts: DataFacts): string[] {
|
|
328
|
+
const out = new Set<string>()
|
|
329
|
+
for (const r of facts.sources) out.add(r.source)
|
|
330
|
+
for (const l of facts.budgetLines) for (const x of l.sources) out.add(x)
|
|
331
|
+
return [...out]
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Plain words that are a lead source only some of the time. A hit is dropped where the next or previous word says it is something else:
|
|
335
|
+
* "direct reports", "the conference call", "let me email Gina". Keyed by the alias as spoken. */
|
|
336
|
+
const PLAIN_WORD_GUARDS: Record<string, (tokens: string[], start: number, end: number) => boolean> = {
|
|
337
|
+
direct: (t, _s, e) => ['report', 'reports', 'mail', 'message', 'messages', 'line', 'lines'].includes(t[e] ?? ''),
|
|
338
|
+
conference: (t, _s, e) => ['call', 'calls', 'room', 'rooms'].includes(t[e] ?? ''),
|
|
339
|
+
conferences: (t, _s, e) => ['call', 'calls', 'room', 'rooms'].includes(t[e] ?? ''),
|
|
340
|
+
// "email" the verb: after a helper or a pronoun ("let me email Gina", "I will email them") or before "about" or a pronoun.
|
|
341
|
+
email: (t, s, e) => emailAsVerb(t, s, e),
|
|
342
|
+
emails: (t, s, e) => emailAsVerb(t, s, e),
|
|
343
|
+
}
|
|
344
|
+
const EMAIL_VERB_BEFORE = new Set(['to', 'will', 'll', 'should', 'please', 'can', 'could', 'would', 'just', 'gonna', 'may', 'might', 'must', 'me', 'us', 'we', 'i', 'you', 'they', 'he', 'she'])
|
|
345
|
+
const EMAIL_VERB_AFTER = new Set(['about', 'him', 'her', 'them', 'me', 'us', 'you', 'everyone', 'everybody'])
|
|
346
|
+
function emailAsVerb(t: string[], start: number, end: number): boolean {
|
|
347
|
+
return EMAIL_VERB_BEFORE.has(t[start - 1] ?? '') || EMAIL_VERB_AFTER.has(t[end] ?? '')
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
interface SourceHit { start: number; end: number; names: string[]; strong: boolean; label: string }
|
|
351
|
+
|
|
352
|
+
/** "ppc non-brand", "brand ppc", "paid search brand": the word right beside a broad paid-search name picks Non-Brand or Brand. */
|
|
353
|
+
function narrowedNames(tokens: string[], start: number, end: number, names: string[]): string[] {
|
|
354
|
+
const at = (k: number) => tokens[k] ?? ''
|
|
355
|
+
const nonBrand = (at(start - 2) === 'non' && at(start - 1) === 'brand') || at(start - 1) === 'nonbrand' || (at(end) === 'non' && at(end + 1) === 'brand') || at(end) === 'nonbrand'
|
|
356
|
+
if (nonBrand) return names.filter(n => sourceKey(n).includes('non brand'))
|
|
357
|
+
const brand = at(start - 1) === 'brand' || at(start - 1) === 'branded' || at(end) === 'brand' || at(end) === 'branded'
|
|
358
|
+
return brand ? names.filter(n => sourceKey(n).endsWith('brand') && !sourceKey(n).includes('non brand')) : names
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Every place the speech names a lead source, the longer name winning where two overlap ("paid search non-brand" beats "paid search"). */
|
|
362
|
+
function sourceHits(facts: DataFacts, tokens: string[]): SourceHit[] {
|
|
363
|
+
const all = sourceNamesOf(facts)
|
|
364
|
+
const hits: SourceHit[] = []
|
|
365
|
+
const add = (alias: string, strong: boolean, label: string, names: string[], narrow: boolean) => {
|
|
366
|
+
const phrase = wordsOfSpeech(alias)
|
|
367
|
+
const guard = phrase.length === 1 ? PLAIN_WORD_GUARDS[phrase[0]] : undefined
|
|
368
|
+
for (const start of occurrences(tokens, phrase)) {
|
|
369
|
+
const end = start + phrase.length
|
|
370
|
+
if (guard?.(tokens, start, end)) continue
|
|
371
|
+
hits.push({ start, end, names: narrow ? narrowedNames(tokens, start, end, names) : names, strong, label })
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
for (const c of SOURCE_CONCEPTS) {
|
|
375
|
+
const names = all.filter(n => c.match(sourceKey(n)))
|
|
376
|
+
for (const a of c.strong) add(a, true, c.label, names, !!c.narrow)
|
|
377
|
+
for (const a of c.weak) add(a, false, c.label, names, !!c.narrow)
|
|
378
|
+
}
|
|
379
|
+
for (const n of all) add(n, wordsOfSpeech(n).length > 1, sourceLabel(n), [n], false)
|
|
380
|
+
const span = (h: SourceHit) => h.end - h.start
|
|
381
|
+
return hits.filter((h, i) => !hits.some((o, j) => j !== i && o.start <= h.start && o.end >= h.end
|
|
382
|
+
&& (span(o) > span(h) || (span(o) === span(h) && (o.names.length > h.names.length || (o.names.length === h.names.length && j < i))))))
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export interface NamedSource {
|
|
386
|
+
/** What the speech called it ("Paid Search", "Events"), for a sentence. */
|
|
387
|
+
label: string
|
|
388
|
+
/** The source names in the file it covers. Empty when this extract has nothing for it. */
|
|
389
|
+
names: string[]
|
|
390
|
+
/** Specific enough to stand alone; a weak one is plain English and counts only beside a group or a stage word. */
|
|
391
|
+
strong: boolean
|
|
392
|
+
at: number
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** The lead sources the speech names, the one named last first. Two phrases that cover the same source names are one source. */
|
|
396
|
+
export function namedSources(facts: DataFacts, speech: string): NamedSource[] {
|
|
397
|
+
const merged = new Map<string, NamedSource>()
|
|
398
|
+
for (const h of sourceHits(facts, wordsOfSpeech(speech))) {
|
|
399
|
+
const key = [...h.names].sort().join('|') || `none:${h.label}`
|
|
400
|
+
const cur = merged.get(key)
|
|
401
|
+
if (!cur) merged.set(key, { label: h.label, names: h.names, strong: h.strong, at: h.start })
|
|
402
|
+
else { cur.strong = cur.strong || h.strong; cur.at = Math.max(cur.at, h.start) }
|
|
403
|
+
}
|
|
404
|
+
return [...merged.values()].sort((a, b) => b.at - a.at)
|
|
245
405
|
}
|
|
246
406
|
|
|
247
407
|
export interface NamedGroup { group: DataGroup; at: number; /** Named by a Quilt brand name or sbu/xbu, not only by an industry word. */ brand: boolean }
|
|
248
408
|
/** The groups the speech names, the one named last first. A group named twice counts at its last mention. */
|
|
249
409
|
export function namedGroups(facts: DataFacts, speech: string): NamedGroup[] {
|
|
250
410
|
const tokens = wordsOfSpeech(speech)
|
|
411
|
+
const anchors = new Set<number>()
|
|
412
|
+
for (const h of sourceHits(facts, tokens)) if (h.strong) for (let k = h.start; k < h.end; k += 1) anchors.add(k)
|
|
251
413
|
const out: NamedGroup[] = []
|
|
252
414
|
for (const group of facts.groups) {
|
|
253
415
|
let last = -1, brand = false
|
|
254
416
|
for (const alias of group.aliases) for (const i of occurrences(tokens, wordsOfSpeech(alias))) last = Math.max(last, i)
|
|
255
417
|
for (const alias of group.brandAliases) for (const i of occurrences(tokens, wordsOfSpeech(alias))) { last = Math.max(last, i); brand = true }
|
|
256
|
-
for (const alias of group.weakAliases) for (const i of occurrences(tokens, wordsOfSpeech(alias))) if (weakAt(tokens, i)) last = Math.max(last, i)
|
|
418
|
+
for (const alias of group.weakAliases) for (const i of occurrences(tokens, wordsOfSpeech(alias))) if (weakAt(tokens, i, anchors)) last = Math.max(last, i)
|
|
257
419
|
if (last >= 0) out.push({ group, at: last, brand })
|
|
258
420
|
}
|
|
259
421
|
return out.sort((a, b) => b.at - a.at)
|
|
260
422
|
}
|
|
261
423
|
|
|
262
|
-
export type DataTrigger = { fired: true; groups: DataGroup[]; stages: DataStageKey[]; brand: boolean
|
|
424
|
+
export type DataTrigger = { fired: true; groups: DataGroup[]; stages: DataStageKey[]; brand: boolean
|
|
425
|
+
/** The lead source the speech named last, when the note should be about that source. Null: the group totals. */
|
|
426
|
+
source: NamedSource | null
|
|
427
|
+
/** The speech carries a funnel word or a figure. False only when a source beside a group fired it. */
|
|
428
|
+
funnel: boolean } | { fired: false; why: 'no_group' | 'no_funnel' }
|
|
263
429
|
|
|
264
|
-
/** Fires when the speech names a group AND carries a funnel word or a figure (two digits or more, or a percent).
|
|
430
|
+
/** Fires when the speech names a group AND carries a funnel word or a figure (two digits or more, or a percent). A lead source narrows
|
|
431
|
+
* the note: a specific one fires beside a group with no funnel word ("PPC landing pages for thrift") and, with a funnel word or a
|
|
432
|
+
* figure, alone; a plain-English one ("events", "direct") counts only beside a group. */
|
|
265
433
|
export function dataTrigger(facts: DataFacts, window: string): DataTrigger {
|
|
266
434
|
const speech = recentSpeech(window)
|
|
267
435
|
const named = namedGroups(facts, speech)
|
|
268
|
-
if (!named.length) return { fired: false, why: 'no_group' }
|
|
269
436
|
const tokens = wordsOfSpeech(speech)
|
|
270
|
-
|
|
437
|
+
// The last specific source named; a plain-English one only when no specific one was, and only beside a group.
|
|
438
|
+
const usable = namedSources(facts, speech).filter(s => s.names.length > 0 && (s.strong || named.length > 0))
|
|
439
|
+
const source = usable.find(s => s.strong) ?? usable[0] ?? null
|
|
440
|
+
if (!named.length && !source) return { fired: false, why: 'no_group' }
|
|
441
|
+
const funnel = tokens.some(t => FUNNEL_TOKENS.has(t)) || hasFigure(speech)
|
|
442
|
+
if (!funnel && !(source?.strong && named.length)) return { fired: false, why: 'no_funnel' }
|
|
271
443
|
const stages = [...new Set(tokens.map(t => STAGE_OF[t]).filter((s): s is DataStageKey => !!s))]
|
|
272
444
|
const picked = named.slice(0, DATA_FACTS_LIMITS.maxNamedGroups)
|
|
273
|
-
return { fired: true, groups: picked.map(n => n.group), stages: DATA_STAGES.filter(s => stages.includes(s)), brand: picked.some(n => n.brand) }
|
|
445
|
+
return { fired: true, groups: picked.map(n => n.group), stages: DATA_STAGES.filter(s => stages.includes(s)), brand: picked.some(n => n.brand), source, funnel }
|
|
274
446
|
}
|
|
275
447
|
|
|
276
448
|
export interface DataGateInput {
|
|
@@ -319,7 +491,7 @@ export function quiltTitleSignal(facts: DataFacts, titles: ReadonlyArray<string
|
|
|
319
491
|
|
|
320
492
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
321
493
|
const fmt = (n: number) => (Number.isInteger(n) ? String(n) : String(Math.round(n * 10) / 10))
|
|
322
|
-
const pctText = (s:
|
|
494
|
+
const pctText = (s: { pctToGoal: number | null }) => (s.pctToGoal === null ? '' : ` (${Math.round(s.pctToGoal)}%)`)
|
|
323
495
|
|
|
324
496
|
/** "Nov 2 05:41 CT": the refresh as the file states it, with CT only for a Central offset. */
|
|
325
497
|
export function refreshText(iso: string): string {
|
|
@@ -375,14 +547,91 @@ export function dataCandidateText(facts: DataFacts, groups: DataGroup[], named:
|
|
|
375
547
|
return build(groups.slice(0, 1), 2).slice(0, max)
|
|
376
548
|
}
|
|
377
549
|
|
|
550
|
+
const bySourceLeads = (a: DataSourceRow, b: DataSourceRow) => b.leads - a.leads
|
|
551
|
+
const rowStages = (r: DataSourceRow, shown: SourceStageKey[]) => shown.map(k => `${k} ${fmt(r[k])}`).join(', ')
|
|
552
|
+
|
|
553
|
+
/** The rows of the named source names, for these industries (every industry when null), the largest by leads first. */
|
|
554
|
+
function sourceRowsFor(facts: DataFacts, names: string[], industries: string[] | null): DataSourceRow[] {
|
|
555
|
+
return facts.sources.filter(r => names.includes(r.source) && (!industries || industries.includes(r.industry))).sort(bySourceLeads)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** The budget lines of an industry that count any of these sources. A line is the whole line's actual against its budget. */
|
|
559
|
+
function budgetLinesFor(facts: DataFacts, industry: string, names: string[]): DataBudgetLine[] {
|
|
560
|
+
return facts.budgetLines.filter(l => l.industry === industry && l.sources.some(x => names.includes(x)))
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/** One budget line as a sentence. `rowSources` are the sources shown above it (the ones that have rows). A line named for one of them
|
|
564
|
+
* says what else it counts ("Budget line Paid Search Non-Brand (also counts Paid Social): ..."); a line named for something else says
|
|
565
|
+
* the source sits inside it ("Paid Social is budgeted inside the Paid Search Non-Brand line, which also counts Paid Search Non-Brand: ...").
|
|
566
|
+
* "Also counts" lists only other sources that have volume in this industry. Empty when the line has none of the shown stages. */
|
|
567
|
+
function budgetClause(facts: DataFacts, l: DataBudgetLine, rowSources: string[], shown: SourceStageKey[]): string {
|
|
568
|
+
const text = lineStagesText(l, shown)
|
|
569
|
+
if (!text) return ''
|
|
570
|
+
const others = l.sources.filter(x => !rowSources.includes(x) && facts.sources.some(r => r.industry === l.industry && r.source === x && r.leads + r.opps + r.customers > 0))
|
|
571
|
+
.slice(0, 3).map(sourceLabel).join(', ')
|
|
572
|
+
const line = sourceLabel(l.line)
|
|
573
|
+
if (rowSources.some(x => sourceKey(x) === sourceKey(l.line))) return `Budget line ${line}${others ? ` (also counts ${others})` : ''}: ${text}.`
|
|
574
|
+
const mine = rowSources.filter(x => l.sources.includes(x)).map(sourceLabel)
|
|
575
|
+
return `${mine.join(' and ')} ${mine.length > 1 ? 'are' : 'is'} budgeted inside the ${line} line${others ? `, which also counts ${others}` : ''}: ${text}.`
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function lineStagesText(l: DataBudgetLine, shown: SourceStageKey[]): string {
|
|
579
|
+
const parts = shown.filter(k => l.stages[k]).map(k => { const st = l.stages[k] as DataSourceStage; return `${k} ${fmt(st.actual)} vs ${st.budget === null ? 'no budget' : fmt(st.budget)}${pctText(st)}` })
|
|
580
|
+
return parts.join(', ')
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** The candidate when the room named a lead source. Leads with that source's own rows ("Thrift, Paid Search Non-Brand alone: leads 45,
|
|
584
|
+
* opps 11, customers 3") for the named industry, or for the largest industries when no group is named; with the budget line it is counted
|
|
585
|
+
* under, when the file has one, because Tableau budgets per line and a line can count more than one source. At most candidateChars:
|
|
586
|
+
* budget lines, then a second group, then stages are dropped before it is cut. Empty when the source has no row for the named group. */
|
|
587
|
+
export function dataSourceCandidateText(facts: DataFacts, source: NamedSource, groups: DataGroup[], named: DataStageKey[]): string {
|
|
588
|
+
const head = `Tableau ${periodText(facts)}, marketing-sourced, lead-source detail, refreshed ${refreshText(facts.asOfIso)}.`
|
|
589
|
+
const max = DATA_FACTS_LIMITS.candidateChars
|
|
590
|
+
const wanted = SOURCE_STAGES.filter(k => named.includes(k))
|
|
591
|
+
const all: SourceStageKey[] = wanted.length ? wanted : [...SOURCE_STAGES]
|
|
592
|
+
const several = source.names.length > 1
|
|
593
|
+
// `budget`: every row's line, only the largest row's line, or none.
|
|
594
|
+
type Budget = 'all' | 'top' | 'none'
|
|
595
|
+
const segment = (g: DataGroup | null, shown: SourceStageKey[], budget: Budget): string => {
|
|
596
|
+
if (g && g.kind === 'industry' && g.members.length === 1) {
|
|
597
|
+
const industry = g.members[0]
|
|
598
|
+
const rows = sourceRowsFor(facts, source.names, [industry])
|
|
599
|
+
if (!rows.length) return ''
|
|
600
|
+
let out = `${g.display ?? g.name}, ${rows.map(r => `${sourceLabel(r.source)} alone: ${rowStages(r, shown)}`).join('; ')}.`
|
|
601
|
+
if (budget !== 'none') for (const l of budgetLinesFor(facts, industry, (budget === 'top' ? rows.slice(0, 1) : rows).map(r => r.source))) {
|
|
602
|
+
const clause = budgetClause(facts, l, rows.map(r => r.source), shown)
|
|
603
|
+
if (clause) out += ` ${clause}`
|
|
604
|
+
}
|
|
605
|
+
return out
|
|
606
|
+
}
|
|
607
|
+
const found = sourceRowsFor(facts, source.names, g ? g.members : null)
|
|
608
|
+
// A list of the largest industries is only worth a cue when the largest has something to say.
|
|
609
|
+
if (!found.length || found[0].leads === 0) return ''
|
|
610
|
+
const rows = found.slice(0, DATA_FACTS_LIMITS.cueSourceRows)
|
|
611
|
+
return `${g ? `${g.display ?? g.name} industries, ` : ''}${several ? source.label : sourceLabel(source.names[0])} alone, largest by leads: ${rows.map(r => `${r.industry}${several ? ` ${sourceLabel(r.source)}` : ''} ${rowStages(r, shown)}`).join('; ')}.`
|
|
612
|
+
}
|
|
613
|
+
const tries = groups.length ? [groups, groups.slice(0, 1)] : [[] as DataGroup[]]
|
|
614
|
+
for (const gs of tries) for (const budget of ['all', 'top', 'none'] as Budget[]) for (let cut = 0; cut < 3; cut += 1) {
|
|
615
|
+
const shown = all.slice(0, Math.max(1, all.length - cut))
|
|
616
|
+
const parts = (gs.length ? gs : [null]).map(g => segment(g, shown, budget)).filter(Boolean)
|
|
617
|
+
const text = parts.length ? `${head} ${parts.join(' ')}` : ''
|
|
618
|
+
if (text && text.length <= max) return text
|
|
619
|
+
}
|
|
620
|
+
const parts = (tries[tries.length - 1].length ? tries[tries.length - 1] : [null]).map(g => segment(g, all.slice(0, 1), 'none')).filter(Boolean)
|
|
621
|
+
return parts.length ? `${head} ${parts.join(' ')}`.slice(0, max) : ''
|
|
622
|
+
}
|
|
623
|
+
|
|
378
624
|
/** The grounded candidate for a window, or why there is none. */
|
|
379
|
-
export function dataCandidate(facts: DataFacts, window: string, nowDate: string): { candidate: GroundedNote; brand: boolean } | { none: 'no_group' | 'no_funnel' | 'no_stage' } {
|
|
625
|
+
export function dataCandidate(facts: DataFacts, window: string, nowDate: string): { candidate: GroundedNote; brand: boolean; /** Fired by a lead source with no group named. */ sourceOnly: boolean } | { none: 'no_group' | 'no_funnel' | 'no_stage' } {
|
|
380
626
|
const t = dataTrigger(facts, window)
|
|
381
627
|
if (!t.fired) return { none: t.why }
|
|
382
|
-
const
|
|
628
|
+
const fromSource = t.source ? dataSourceCandidateText(facts, t.source, t.groups, t.stages) : ''
|
|
629
|
+
// A source with no row for the named group must not turn a window with no funnel word into a totals cue.
|
|
630
|
+
if (!fromSource && t.source && !t.funnel) return { none: 'no_funnel' }
|
|
631
|
+
const notes = fromSource || (t.groups.length ? dataCandidateText(facts, t.groups, t.stages) : '')
|
|
383
632
|
if (!notes) return { none: 'no_stage' }
|
|
384
633
|
const date = /^\d{4}-\d{2}-\d{2}/.exec(facts.asOfIso)?.[0] ?? nowDate
|
|
385
|
-
return { candidate: { id: 'D1', date, title: facts.label, notes, key: `data:tableau|${facts.label}|${facts.asOfIso}`, arm: 'data', kind: 'data' }, brand: t.brand }
|
|
634
|
+
return { candidate: { id: 'D1', date, title: facts.label, notes, key: `data:tableau|${facts.label}|${facts.asOfIso}`, arm: 'data', kind: 'data' }, brand: t.brand, sourceOnly: t.groups.length === 0 }
|
|
386
635
|
}
|
|
387
636
|
|
|
388
637
|
/** Appended to the writer's rules only when a Tableau note was kept. The v3/v4 rules themselves stay byte-identical. */
|
|
@@ -391,6 +640,8 @@ export const DATA_WRITER_ADDENDUM = '\nTableau note. A memory note whose title s
|
|
|
391
640
|
+ 'If you use it, say Tableau, the period, and marketing sources. Quote its numbers exactly; do not sum, project or round them. '
|
|
392
641
|
+ 'Set a figure the room said beside the Tableau figure only when both measure the same thing (same stage, same group). '
|
|
393
642
|
+ 'When they differ in scope, such as MRR or locations against marketing-sourced customers, say they are different measures and give the Tableau figure as context, not as proof that the room is wrong. '
|
|
643
|
+
+ 'A note that names a lead source (Paid Search Non-Brand, Events...) covers only that source: say which source, never present it as the whole group, '
|
|
644
|
+
+ 'and when it gives a budget line, say the budget is for that line, which can count other sources too. '
|
|
394
645
|
+ 'If the note does not cover what the room said, ignore it.'
|
|
395
646
|
|
|
396
647
|
// ── The meeting assistant ────────────────────────────────────────────────────
|
|
@@ -407,23 +658,47 @@ export function askUserWords(question: string): string {
|
|
|
407
658
|
return last.length <= DATA_FACTS_LIMITS.askQuestionChars && last.length < question.trim().length ? last : ''
|
|
408
659
|
}
|
|
409
660
|
|
|
661
|
+
/** A funnel stage or pacing word: enough, beside a plain-English source word, to say the question is about the data ("leads from events"). */
|
|
662
|
+
const ASK_STAGE = /\b(?:leads?|opps?|opportunit(?:y|ies)|sqls?|demos?|logos?|customers?|pac(?:e|ing)|attainment)\b/i
|
|
663
|
+
const ASK_SOURCE_WORD = /\b(?:sources?|channels?|campaigns?)\b/i
|
|
664
|
+
/** "how did events do for thrift": a question about how something performed, which a group and a plain source word then make a data question. */
|
|
665
|
+
const ASK_PERFORMANCE = /\b(?:do|doing|perform(?:ed|ing|ance)?|results?|numbers?|convert(?:ed|ing)?|conversions?|roi)\b/i
|
|
666
|
+
|
|
667
|
+
/** The lead sources a typed question names that it may be answered from. A specific name ("paid search", "PPC") always counts; a
|
|
668
|
+
* plain-English one ("direct", "events", "email") only with a stage or source word, or with a group beside "Tableau" or a
|
|
669
|
+
* performance word, so "the direct reports on the thrift team" stays quiet. */
|
|
670
|
+
export function askSources(facts: DataFacts, own: string, hasGroup: boolean): NamedSource[] {
|
|
671
|
+
const weakOk = ASK_STAGE.test(own) || ASK_SOURCE_WORD.test(own) || (hasGroup && (ASK_PERFORMANCE.test(own) || ASK_TABLEAU.test(own)))
|
|
672
|
+
return namedSources(facts, own).filter(x => x.strong || weakOk)
|
|
673
|
+
}
|
|
674
|
+
|
|
410
675
|
export interface AskDataIntent { intent: 'tableau' | 'named' | null; /** A Quilt brand or sbu/xbu is named. */ brand: boolean }
|
|
411
676
|
/** What a typed question asks of the data: `tableau` when it says Tableau, `named` when it names a group and asks about funnel,
|
|
412
|
-
* budget or pacing, else null. `tableau` may be answered in any meeting but a personal one. `named` goes through dataArmGate like
|
|
677
|
+
* budget or pacing, or names a lead source (see askSources) beside a group or a funnel word, else null. `tableau` may be answered in any meeting but a personal one. `named` goes through dataArmGate like
|
|
413
678
|
* the cue arm: a Quilt meeting, or an unconfirmed one with a Quilt brand named or a Quilt term in the meeting's title. */
|
|
414
679
|
export function askDataIntent(question: string, facts: DataFacts): AskDataIntent {
|
|
415
680
|
const own = askUserWords(question)
|
|
416
681
|
const named = namedGroups(facts, own)
|
|
417
682
|
const brand = named.some(n => n.brand)
|
|
418
683
|
if (ASK_TABLEAU.test(own)) return { intent: 'tableau', brand }
|
|
419
|
-
|
|
684
|
+
if (ASK_FUNNEL.test(own) && named.length > 0) return { intent: 'named', brand }
|
|
685
|
+
// A lead source: a specific name needs a group or a funnel word beside it ("how did paid search do for thrift"); a plain-English one
|
|
686
|
+
// already passed askSources' own test.
|
|
687
|
+
const sources = askSources(facts, own, named.length > 0)
|
|
688
|
+
if (sources.length && (!sources.some(x => x.strong) || named.length > 0 || ASK_FUNNEL.test(own))) return { intent: 'named', brand }
|
|
689
|
+
return { intent: null, brand }
|
|
420
690
|
}
|
|
421
691
|
|
|
422
692
|
/** The Tableau text the meeting assistant reads as a local source, or null. Stage rows for the groups the question names (or the
|
|
423
|
-
* business units and every industry when it names none), then the lead-source rows of the named industries.
|
|
693
|
+
* business units and every industry when it names none), then the lead-source rows of the named industries. When the question names a
|
|
694
|
+
* lead source ("paid search", "PPC"), the answer is about that source instead: its rows for the named industries, or for every industry
|
|
695
|
+
* when none is named, each with the budget line Tableau counts it under. The rows are listed, never added up: the writer's rules say
|
|
696
|
+
* not to sum, and a sum of rows is not a figure Tableau shows. */
|
|
424
697
|
export function dataFactsForAsk(facts: DataFacts, rawQuestion: string): { title: string; text: string } | null {
|
|
425
|
-
const
|
|
426
|
-
const
|
|
698
|
+
const own = askUserWords(rawQuestion)
|
|
699
|
+
const named = namedGroups(facts, own).map(n => n.group)
|
|
700
|
+
const sources = askSources(facts, own, named.length > 0)
|
|
701
|
+
const chosen = named.length || sources.length ? named : facts.groups
|
|
427
702
|
const lines: string[] = [
|
|
428
703
|
`Tableau marketing funnel, ${periodText(facts)}. Scope: ${facts.scope}; marketing-sourced leads, opportunities, SQLs, demos and customers only. Not company revenue, MRR, ARR or location counts.`,
|
|
429
704
|
`Extract refreshed ${refreshText(facts.asOfIso)}.${facts.completeThrough ? ` Complete through ${facts.completeThrough}.` : ''} Each stage: actual vs budget to date (% to goal; PM = prior month same period; PY = last year same period).`,
|
|
@@ -438,16 +713,51 @@ export function dataFactsForAsk(facts: DataFacts, rawQuestion: string): { title:
|
|
|
438
713
|
})
|
|
439
714
|
lines.push(`${g.display ?? g.name}${cov}: ${stages.join('; ')}`)
|
|
440
715
|
}
|
|
441
|
-
if (
|
|
716
|
+
if (sources.length) {
|
|
717
|
+
lines.push(...sourceLinesForAsk(facts, sources, named, lines.join('\n').length))
|
|
718
|
+
} else if (named.length) {
|
|
442
719
|
for (const g of named) {
|
|
443
720
|
const rows = facts.sources.filter(r => g.members.includes(r.industry)).sort((a, b) => b.leads - a.leads).slice(0, DATA_FACTS_LIMITS.askSourceRowsPerIndustry * Math.max(1, g.members.length))
|
|
444
721
|
if (rows.length) lines.push(`${g.display ?? g.name} lead sources (leads/opps/customers): ${rows.map(r => `${g.kind === 'bu' ? `${r.industry} ` : ''}${r.source} ${fmt(r.leads)}/${fmt(r.opps)}/${fmt(r.customers)}`).join('; ')}`)
|
|
445
722
|
}
|
|
446
723
|
}
|
|
447
724
|
const text = lines.join('\n').slice(0, DATA_FACTS_LIMITS.askChars)
|
|
448
|
-
return chosen.length ? { title: facts.label, text } : null
|
|
725
|
+
return chosen.length || sources.length ? { title: facts.label, text } : null
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** The lines for a question about a lead source: what was asked, then one line per industry (largest leads first) with that source's
|
|
729
|
+
* rows and the budget line(s) it is counted under. Industries that do not fit in askChars are named as left out, never half shown. */
|
|
730
|
+
function sourceLinesForAsk(facts: DataFacts, sources: NamedSource[], groups: DataGroup[], used: number): string[] {
|
|
731
|
+
const names = [...new Set(sources.flatMap(x => x.names))]
|
|
732
|
+
const asked = sources.map(x => `${x.label}${x.names.length ? ` (${x.names.map(sourceLabel).join(', ')})` : ' (no rows in this Tableau extract)'}`).join('; ')
|
|
733
|
+
const out = [`Lead source asked about: ${asked}. Each row below is that source alone as leads/opps/customers. Tableau sets budgets per budget line, and one line can count several sources, `
|
|
734
|
+
+ 'so a budget appears only against its whole line, never against one source. Rows are listed, not added up.']
|
|
735
|
+
if (!names.length) return out
|
|
736
|
+
const wanted = groups.length ? new Set(groups.flatMap(g => g.members)) : null
|
|
737
|
+
// The industries that have rows for the source; plus a named single industry with none, so the answer says so.
|
|
738
|
+
const industries = [...new Set([...facts.sources.filter(r => names.includes(r.source)).map(r => r.industry).filter(i => !wanted || wanted.has(i)),
|
|
739
|
+
...groups.filter(g => g.kind === 'industry').flatMap(g => g.members)])]
|
|
740
|
+
const leadsOf = (i: string) => sourceRowsFor(facts, names, [i]).reduce((n, r) => n + r.leads, 0)
|
|
741
|
+
const blocks = industries.sort((a, b) => leadsOf(b) - leadsOf(a)).map(industry => {
|
|
742
|
+
const rows = sourceRowsFor(facts, names, [industry])
|
|
743
|
+
const display = facts.groups.find(g => g.kind === 'industry' && g.name === industry)?.display ?? industry
|
|
744
|
+
const rowText = rows.length ? rows.map(r => `${sourceLabel(r.source)} ${fmt(r.leads)}/${fmt(r.opps)}/${fmt(r.customers)}`).join('; ') : 'no rows for this source'
|
|
745
|
+
// Only the sources that have rows here choose the lines: "social" must not pull in a line whose only link is a source with no rows.
|
|
746
|
+
const rowSources = rows.map(r => r.source)
|
|
747
|
+
const budget = budgetLinesFor(facts, industry, rowSources).map(l => budgetClause(facts, l, rowSources, [...SOURCE_STAGES])).filter(Boolean)
|
|
748
|
+
return `${display} (leads/opps/customers): ${rowText}.${budget.length ? ` ${budget.join(' ')}` : ''}`
|
|
749
|
+
})
|
|
750
|
+
const room = DATA_FACTS_LIMITS.askChars - used - out[0].length - 80
|
|
751
|
+
let spent = 0, shown = 0
|
|
752
|
+
for (const b of blocks) {
|
|
753
|
+
if (spent + b.length + 1 > room) break
|
|
754
|
+
out.push(b); spent += b.length + 1; shown += 1
|
|
755
|
+
}
|
|
756
|
+
if (shown < blocks.length) out.push(`${blocks.length - shown} more industries are not shown (the smallest by leads).`)
|
|
757
|
+
return out
|
|
449
758
|
}
|
|
450
759
|
|
|
451
760
|
/** Added to the meeting assistant's rules only when its Tableau source is in the context. */
|
|
452
761
|
export const DATA_ASK_ADDENDUM = ' A Tableau source covers the marketing-sourced funnel only: when you use it, say Tableau, its period and marketing sources, '
|
|
453
|
-
+ 'and never present it as company revenue, MRR, ARR or location counts. Set a figure from the meeting beside it only when both measure the same thing; otherwise name the difference.'
|
|
762
|
+
+ 'and never present it as company revenue, MRR, ARR or location counts. Set a figure from the meeting beside it only when both measure the same thing; otherwise name the difference. '
|
|
763
|
+
+ 'A lead-source row covers only that source: say which source it is, and when you quote a budget, say it is the budget line, which can count other sources too.'
|
|
@@ -28,7 +28,8 @@ export type LiveCuesV2SettingSource = 'env' | 'setting' | 'default'
|
|
|
28
28
|
/** COS Control's live-cue spend limits (6.79.2). Bounds are inclusive; integer fields take whole numbers only. */
|
|
29
29
|
export const LIVE_CUES_V2_LIMIT_BOUNDS = {
|
|
30
30
|
gate: { min: 0.5, max: 0.95, default: 0.8, integer: false, env: 'COS_LIVE_CUES_V2_GATE' },
|
|
31
|
-
|
|
31
|
+
// 6.99.0: the ceiling is 50 (Miles 2026-11-03, longer meetings); the default stays 25. Everything that clamps or validates this reads `.max`.
|
|
32
|
+
maxCuesPerMeeting: { min: 1, max: 50, default: 25, integer: true, env: 'COS_LIVE_CUES_V2_MAX_PER_MEETING' },
|
|
32
33
|
cooldownSeconds: { min: 20, max: 900, default: 45, integer: true, env: 'COS_LIVE_CUES_V2_COOLDOWN_S' },
|
|
33
34
|
dailyWriterCalls: { min: 1, max: 500, default: 150, integer: true, env: 'COS_LIVE_CUES_V2_DAILY_WRITER_CALLS' },
|
|
34
35
|
} as const
|
|
@@ -90,8 +90,10 @@ export const LIVE_CUES_V2_LIMITS = {
|
|
|
90
90
|
staleMs: 30_000,
|
|
91
91
|
windowWords: 400,
|
|
92
92
|
windowChars: 2_200,
|
|
93
|
-
/**
|
|
94
|
-
*
|
|
93
|
+
/** FALLBACK DEFAULTS ONLY (nothing reads these at run time): the live cap is `resolveLiveCuesV2Limits().maxCuesPerMeeting`, which
|
|
94
|
+
* Settings can raise to 50 (6.99.0) and env can lock. 25 a meeting (Miles 2026-10-19: 10 ran out 10 minutes into an HC call).
|
|
95
|
+
* Writer calls stay at twice the cue cap so discarded drafts cannot end a meeting's cues early. At 25 cues the weekly default
|
|
96
|
+
* of 750 writer calls covers ~15 capped meetings (50 calls each); at 50 cues it covers ~7 (100 calls each). */
|
|
95
97
|
maxCuesPerMeeting: 25,
|
|
96
98
|
maxWriterCallsPerMeeting: 50,
|
|
97
99
|
defaultWeeklyWriterCalls: 750,
|
|
@@ -171,9 +173,9 @@ export function resolveLiveCuesV2Limits(env: NodeJS.ProcessEnv = process.env, se
|
|
|
171
173
|
try { saved = settings?.limits?.() ?? {} } catch { saved = {} }
|
|
172
174
|
const fromEnv: Record<LiveCuesV2LimitName, number | null> = {
|
|
173
175
|
gate: envGate(env),
|
|
174
|
-
maxCuesPerMeeting: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.env,
|
|
176
|
+
maxCuesPerMeeting: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.env, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.min, LIVE_CUES_V2_LIMIT_BOUNDS.maxCuesPerMeeting.max),
|
|
175
177
|
cooldownSeconds: envCooldownSeconds(env),
|
|
176
|
-
dailyWriterCalls: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.env,
|
|
178
|
+
dailyWriterCalls: envWhole(env, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.env, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.min, LIVE_CUES_V2_LIMIT_BOUNDS.dailyWriterCalls.max),
|
|
177
179
|
}
|
|
178
180
|
const out = {} as LiveCuesV2Limits
|
|
179
181
|
for (const name of LIVE_CUES_V2_LIMIT_NAMES) {
|
|
@@ -1323,7 +1325,9 @@ export class LiveCuesV2Engine {
|
|
|
1323
1325
|
if (!early.ok) { trace.dataArm = early.why; return null }
|
|
1324
1326
|
const found = dataCandidate(read.facts, window, today)
|
|
1325
1327
|
if ('none' in found) { trace.dataArm = found.none; return null }
|
|
1326
|
-
|
|
1328
|
+
// A lead source alone (no group named) is everyday speech in a marketing agency's meeting ("the trade show is in 30 days"): a Quilt
|
|
1329
|
+
// memory among the candidates does not vouch for it. Only a Quilt-confirmed meeting or a Quilt term in the title does.
|
|
1330
|
+
const gate = dataArmGate({ domain: ctx.domain, orientationFailed: ctx.orientationFailed, brand: found.brand, memoryQuilt: found.sourceOnly ? false : ctx.memoryQuilt, titleQuilt: quiltTitleSignal(read.facts, ctx.titles) })
|
|
1327
1331
|
if (!gate.ok) { trace.dataArm = gate.why; return null }
|
|
1328
1332
|
trace.dataArm = 'offered'
|
|
1329
1333
|
trace.dataSignal = gate.signal
|