@gotcos/glasses-server 6.95.0 → 6.98.1

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.
Files changed (123) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/README.md +4 -0
  3. package/managed-runtime-contract.json +13 -0
  4. package/package.json +3 -1
  5. package/server/index.ts +12 -0
  6. package/server/lib/attached-provider-adapter.ts +35 -1
  7. package/server/lib/claude-bridge.ts +47 -0
  8. package/server/lib/codex-bridge.ts +11 -1
  9. package/server/lib/codex-rollout-watermark.ts +42 -0
  10. package/server/lib/cursor-bridge.ts +2 -1
  11. package/server/lib/decision-provider.ts +4 -2
  12. package/server/lib/dictation-clean-models.ts +2 -0
  13. package/server/lib/dictation-clean.ts +2 -2
  14. package/server/lib/dictation-polish-pipeline.ts +22 -2
  15. package/server/lib/dictation-prewarm.ts +16 -0
  16. package/server/lib/direct-bridge.ts +14 -2
  17. package/server/lib/direct-sse.ts +6 -1
  18. package/server/lib/display-bus.ts +1 -1
  19. package/server/lib/fork-thread.ts +32 -1
  20. package/server/lib/held-naming-batches.ts +44 -7
  21. package/server/lib/held-voice-groups.ts +5 -4
  22. package/server/lib/job-audit-caller.ts +20 -0
  23. package/server/lib/keyed-sessions.ts +21 -1
  24. package/server/lib/lens-gist-engines.ts +17 -5
  25. package/server/lib/live-cos-ask.ts +29 -6
  26. package/server/lib/live-cue-verdicts.ts +43 -8
  27. package/server/lib/live-cues-engine.ts +4 -1
  28. package/server/lib/live-cues-filter.ts +50 -0
  29. package/server/lib/live-cues-general-prompts.ts +137 -0
  30. package/server/lib/live-cues-general-topic.ts +245 -0
  31. package/server/lib/live-cues-general.ts +715 -0
  32. package/server/lib/live-cues-v2-data.ts +763 -0
  33. package/server/lib/live-cues-v2-grounded.ts +262 -0
  34. package/server/lib/live-cues-v2-judge-b.ts +59 -0
  35. package/server/lib/live-cues-v2-names.ts +49 -0
  36. package/server/lib/live-cues-v2-notes.ts +8 -4
  37. package/server/lib/live-cues-v2-open-items.ts +132 -0
  38. package/server/lib/live-cues-v2-pacing.ts +45 -0
  39. package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
  40. package/server/lib/live-cues-v2-settings.ts +89 -10
  41. package/server/lib/live-cues-v2.ts +572 -47
  42. package/server/lib/mac-recording-asr.ts +6 -3
  43. package/server/lib/mac-speaker-call-human.ts +371 -0
  44. package/server/lib/mac-speaker-call-interval-human.ts +499 -0
  45. package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
  46. package/server/lib/mac-speaker-call-original-view.ts +328 -0
  47. package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
  48. package/server/lib/mac-speaker-call-producer.ts +539 -0
  49. package/server/lib/mac-speaker-call-store.ts +264 -0
  50. package/server/lib/mac-speaker-call-view.ts +373 -0
  51. package/server/lib/mac-speaker-live-compute.ts +72 -0
  52. package/server/lib/mac-speaker-live-memory.ts +414 -0
  53. package/server/lib/mac-speaker-live-projection.ts +126 -0
  54. package/server/lib/mac-speaker-live-save.ts +90 -0
  55. package/server/lib/mac-speaker-live-store.ts +71 -0
  56. package/server/lib/mac-speaker-live-worker.ts +58 -0
  57. package/server/lib/mac-speaker-live.ts +175 -0
  58. package/server/lib/mac-speaker-profile-extractor.ts +15 -10
  59. package/server/lib/mac-speaker-shadow-repair.ts +307 -0
  60. package/server/lib/mac-voice-assignment.ts +46 -27
  61. package/server/lib/mac-voice-display.ts +195 -0
  62. package/server/lib/mac-voice-naming-batch.ts +1067 -0
  63. package/server/lib/mac-voice-suggestions.ts +548 -0
  64. package/server/lib/maintenance-lifecycle.ts +2 -0
  65. package/server/lib/media-store.ts +2 -1
  66. package/server/lib/meeting-ask-pasted.ts +135 -0
  67. package/server/lib/meeting-ask-review.ts +8 -1
  68. package/server/lib/meeting-ask-runner.ts +4 -1
  69. package/server/lib/meeting-ask-runtime.ts +189 -17
  70. package/server/lib/meeting-ask-store.ts +2 -0
  71. package/server/lib/meeting-ask-web.ts +284 -13
  72. package/server/lib/meeting-batch-progress.ts +51 -5
  73. package/server/lib/meeting-batch-transcribe.ts +35 -2
  74. package/server/lib/meeting-corrections.ts +2 -1
  75. package/server/lib/meeting-fact-check.ts +3 -0
  76. package/server/lib/meeting-finalization-jobs.ts +69 -2
  77. package/server/lib/meeting-public-topic.ts +6 -1
  78. package/server/lib/meeting-relabel-enrolment.ts +2 -1
  79. package/server/lib/meeting-relabel.ts +17 -10
  80. package/server/lib/meeting-speaker-review.ts +187 -21
  81. package/server/lib/meeting-store.ts +6 -1
  82. package/server/lib/parakeet.ts +51 -5
  83. package/server/lib/pending-batch-retention.ts +58 -0
  84. package/server/lib/private-reference.ts +38 -0
  85. package/server/lib/provider-usage.ts +561 -0
  86. package/server/lib/public-https-url.ts +32 -0
  87. package/server/lib/query-job-runtime.ts +3 -0
  88. package/server/lib/rich-media-safety.ts +23 -2
  89. package/server/lib/session-find-ask.ts +288 -0
  90. package/server/lib/session-find-collect.ts +715 -0
  91. package/server/lib/session-find-redact.ts +254 -0
  92. package/server/lib/session-find.ts +345 -0
  93. package/server/lib/speaker-calibration-log.ts +3 -2
  94. package/server/lib/speaker-embeddings.ts +46 -35
  95. package/server/lib/speaker-name-nfc.ts +37 -0
  96. package/server/lib/speaker-rename-fanout.ts +7 -6
  97. package/server/lib/speaker-shadow-decide.ts +59 -22
  98. package/server/lib/speaker-shadow-pair-source.ts +91 -0
  99. package/server/lib/speaker-shadow-paired.ts +268 -0
  100. package/server/lib/speaker-shadow-report.ts +2 -2
  101. package/server/lib/speaker-shadow-worker.ts +50 -15
  102. package/server/lib/speaker-shadow.ts +80 -19
  103. package/server/lib/speaker-trainer.ts +2 -1
  104. package/server/lib/token-audit.ts +36 -6
  105. package/server/lib/transcript-cleanup.ts +3 -2
  106. package/server/lib/voice-directory.ts +10 -5
  107. package/server/lib/voice-profile-store.ts +24 -14
  108. package/server/lib/web-lookup-ledger.ts +151 -0
  109. package/server/lib/work-review-runtime.ts +2 -0
  110. package/server/lib/work-search.ts +3 -1
  111. package/server/lib/xlsx-text.ts +277 -0
  112. package/server/routes/decision-provider.ts +4 -1
  113. package/server/routes/jev.ts +5 -1
  114. package/server/routes/live-cues.ts +36 -14
  115. package/server/routes/meeting.ts +515 -61
  116. package/server/routes/session-find.ts +73 -0
  117. package/server/routes/transcribe-stream.ts +114 -33
  118. package/server/routes/voice.ts +28 -18
  119. package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
  120. package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
  121. package/shared/live-cues-v2.ts +38 -5
  122. package/shared/media-attachment.ts +2 -0
  123. package/shared/meeting-ask.ts +11 -0
@@ -0,0 +1,763 @@
1
+ // Live cues data arm and meeting assistant data context: Tableau actuals against budget for the marketing-sourced funnel,
2
+ // read from ONE local file (`<data dir>/data-facts.json`, written by operations/scripts/tableau/cue_data_facts.py from the
3
+ // weekday Tableau report CSVs). Nothing here calls a model, the network or Tableau, and nothing writes.
4
+ //
5
+ // Why: a funnel number said in a meeting ("SBU closed at 120% of MRR budget") is checked against what Tableau shows for the
6
+ // same group. The two are often different measures, so a candidate always carries its scope and period, and the writer is told
7
+ // never to present it as revenue, MRR or location counts (DATA_WRITER_ADDENDUM).
8
+ //
9
+ // Rules, each on purpose:
10
+ // - the arm is OFF when the file is missing, unreadable, invalid or older than DATA_FACTS_LIMITS.maxAgeHours (asOf, the
11
+ // Tableau extract refresh, not the file's mtime);
12
+ // - it offers at most ONE candidate, and only when the last ~600 characters spoken name a group (or an alias of one) AND
13
+ // carry a funnel word or a figure of two digits or a percent;
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
+ // 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;
20
+ // - domain (dataArmGate): a Quilt meeting runs it. An unreadable orientation is off. Any other confirmed domain (personal,
21
+ // Sprocket Rocket, Hermit Crabs...) is off. A meeting with no confirmed domain (most G2 meetings) needs a Quilt signal from
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
23
+ // the meeting's title. A plain industry word alone (grocery, liquor, pet...) is not a signal.
24
+
25
+ import { existsSync, readFileSync, statSync } from 'node:fs'
26
+ import type { GroundedNote } from './live-cues-v2-grounded.js'
27
+
28
+ export const DATA_FACTS_FILE = 'data-facts.json'
29
+
30
+ export const DATA_FACTS_LIMITS = {
31
+ /** An open month: 120 hours (5 days) from `asOf`. The file is rewritten on weekdays from a 10:30 CT job; a long weekend leaves it
32
+ * 72 hours old on Monday morning and a missed run or a holiday adds a day or two. Past 5 days the numbers could be last week's. */
33
+ maxAgeHours: 120,
34
+ /** A closed month does not go stale the way a month to date does: its figures only move by late-closing deals. It is kept for 35
35
+ * days from `asOf` (a month plus five days of slack): by then the next month's close should have replaced it, and a writer that
36
+ * stopped is still caught. Without this the first Monday after a month start, when the report window is still under 3 days, went dark. */
37
+ maxClosedAgeDays: 35,
38
+ /** A clock that reads this far behind the file's own time is wrong or the file is: off. */
39
+ maxFutureMs: 60 * 60_000,
40
+ maxBytes: 2_000_000,
41
+ maxGroups: 60,
42
+ maxSources: 400,
43
+ maxBudgetLines: 400,
44
+ /** The speech the trigger reads: the last this many characters of the window (about 100 words). */
45
+ recentChars: 600,
46
+ /** A candidate is at most this long, like a file passage. */
47
+ candidateChars: 300,
48
+ maxNamedGroups: 2,
49
+ /** What the meeting assistant is given: at most this many characters of Tableau text. */
50
+ askChars: 3_500,
51
+ askSourceRowsPerIndustry: 6,
52
+ /** A source-alone cue (no group named) lists this many industries, largest by leads. */
53
+ cueSourceRows: 2,
54
+ /** A typed question longer than this is probably pasted text (see askUserWords). */
55
+ askQuestionChars: 300,
56
+ } as const
57
+
58
+ export const DATA_STAGES = ['leads', 'opps', 'sqls', 'demos', 'customers'] as const
59
+ export type DataStageKey = typeof DATA_STAGES[number]
60
+
61
+ export interface DataStage {
62
+ actual: number
63
+ budget: number | null
64
+ budgetMonth: number | null
65
+ pctToGoal: number | null
66
+ priorMonthSamePeriod: number | null
67
+ pySamePeriod: number | null
68
+ exEventsPct?: number
69
+ coreActual?: number
70
+ }
71
+ export interface DataGroup {
72
+ name: string
73
+ /** How text names the group where the plain name would be mistaken for the company ("Quilt industry (Like Sew)"). */
74
+ display?: string
75
+ kind: 'bu' | 'industry'
76
+ aliases: string[]
77
+ weakAliases: string[]
78
+ /** The aliases that are Quilt brand names (and sbu/xbu): naming one is a Quilt signal. A plain industry word is not. */
79
+ brandAliases: string[]
80
+ members: string[]
81
+ coverage?: { have: number; of: number }
82
+ stages: Partial<Record<DataStageKey, DataStage>>
83
+ }
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
+ }
97
+ export interface DataFacts {
98
+ label: string
99
+ scope: string
100
+ period: { label: string; start: string; end: string; closed: boolean }
101
+ /** The Tableau extract refresh (or the report time), as given, and in ms. */
102
+ asOfIso: string
103
+ asOf: number
104
+ completeThrough?: string
105
+ groups: DataGroup[]
106
+ sources: DataSourceRow[]
107
+ /** Empty for a file written before budget lines. */
108
+ budgetLines: DataBudgetLine[]
109
+ }
110
+
111
+ export type DataFactsOff = 'missing' | 'unreadable' | 'invalid' | 'stale' | 'future'
112
+ export type DataFactsRead = { ok: true; facts: DataFacts } | { ok: false; reason: DataFactsOff }
113
+
114
+ const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
115
+ const str = (v: unknown, max: number): string | null => (typeof v === 'string' && v.trim() !== '' && v.length <= max ? v : null)
116
+ const fin = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
117
+ const wordList = (v: unknown, maxItems: number): string[] | null => {
118
+ if (v === undefined) return []
119
+ if (!Array.isArray(v) || v.length > maxItems) return null
120
+ const out: string[] = []
121
+ for (const item of v) {
122
+ const s = str(item, 40)
123
+ if (s === null || s.trim().length < 2) return null
124
+ out.push(s.trim().toLowerCase())
125
+ }
126
+ return out
127
+ }
128
+
129
+ function parseStage(raw: unknown): DataStage | null {
130
+ if (!isObj(raw)) return null
131
+ const actual = fin(raw.actual)
132
+ if (actual === null || actual < 0) return null
133
+ const opt = (v: unknown) => fin(v)
134
+ const stage: DataStage = { actual, budget: opt(raw.budget), budgetMonth: opt(raw.budgetMonth), pctToGoal: opt(raw.pctToGoal),
135
+ priorMonthSamePeriod: opt(raw.priorMonthSamePeriod), pySamePeriod: opt(raw.pySamePeriod) }
136
+ const ex = fin(raw.exEventsPct), core = fin(raw.coreActual)
137
+ if (ex !== null) stage.exEventsPct = ex
138
+ if (core !== null) stage.coreActual = core
139
+ return stage
140
+ }
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
+
162
+ function parseGroup(raw: unknown): DataGroup | null {
163
+ if (!isObj(raw)) return null
164
+ const name = str(raw.name, 40)
165
+ const aliases = wordList(raw.aliases, 40), weak = wordList(raw.weakAliases, 10), brand = wordList(raw.brandAliases, 20)
166
+ if (name === null || aliases === null || weak === null || brand === null || !isObj(raw.stages)) return null
167
+ const stages: DataGroup['stages'] = {}
168
+ for (const key of DATA_STAGES) {
169
+ const s = parseStage((raw.stages as Record<string, unknown>)[key])
170
+ if (s) stages[key] = s
171
+ }
172
+ if (!Object.keys(stages).length || (!aliases.length && !weak.length)) return null
173
+ const members = Array.isArray(raw.members) ? raw.members.map(m => str(m, 40)).filter((m): m is string => m !== null).slice(0, 20) : []
174
+ const cov = isObj(raw.coverage) && fin(raw.coverage.have) !== null && fin(raw.coverage.of) !== null && (raw.coverage.of as number) > 0
175
+ ? { have: raw.coverage.have as number, of: raw.coverage.of as number } : undefined
176
+ const display = str(raw.display, 60)
177
+ return { name, ...(display ? { display } : {}), kind: raw.kind === 'bu' ? 'bu' : 'industry', aliases, weakAliases: weak, brandAliases: brand, members, ...(cov ? { coverage: cov } : {}), stages }
178
+ }
179
+
180
+ /** The facts from a parsed file, or why not. A group that does not parse is dropped (never guessed at); a file with no usable group is
181
+ * invalid. The clock is passed in, so staleness is testable. */
182
+ export function parseDataFacts(raw: unknown, nowMs: number): DataFactsRead {
183
+ if (!isObj(raw) || raw.v !== 1 || raw.source !== 'Tableau') return { ok: false, reason: 'invalid' }
184
+ const label = str(raw.label, 120), scope = str(raw.scope, 160)
185
+ const period = isObj(raw.period) ? raw.period : null
186
+ const pLabel = str(period?.label, 40), pStart = str(period?.start, 10), pEnd = str(period?.end, 10)
187
+ const asOfIso = str(raw.asOf, 40)
188
+ const asOf = asOfIso === null ? NaN : Date.parse(asOfIso)
189
+ if (!label || !scope || !period || !pLabel || !pStart || !pEnd || typeof period.closed !== 'boolean' || Number.isNaN(asOf)) return { ok: false, reason: 'invalid' }
190
+ if (!Array.isArray(raw.groups) || raw.groups.length > DATA_FACTS_LIMITS.maxGroups) return { ok: false, reason: 'invalid' }
191
+ const groups = raw.groups.map(parseGroup).filter((g): g is DataGroup => g !== null)
192
+ if (!groups.length) return { ok: false, reason: 'invalid' }
193
+ const sources: DataSourceRow[] = []
194
+ for (const r of Array.isArray(raw.sources) ? raw.sources.slice(0, DATA_FACTS_LIMITS.maxSources) : []) {
195
+ if (!isObj(r)) continue
196
+ const industry = str(r.industry, 40), source = str(r.source, 80)
197
+ const leads = fin(r.leads), opps = fin(r.opps), customers = fin(r.customers)
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 } : {}) })
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)
202
+ if (asOf - nowMs > DATA_FACTS_LIMITS.maxFutureMs) return { ok: false, reason: 'future' }
203
+ const maxAgeMs = period.closed === true ? DATA_FACTS_LIMITS.maxClosedAgeDays * 24 * 3_600_000 : DATA_FACTS_LIMITS.maxAgeHours * 3_600_000
204
+ if (nowMs - asOf > maxAgeMs) return { ok: false, reason: 'stale' }
205
+ const through = typeof raw.completeThrough === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.completeThrough) ? raw.completeThrough : undefined
206
+ return { ok: true, facts: { label, scope, period: { label: pLabel, start: pStart, end: pEnd, closed: period.closed }, asOfIso: asOfIso as string, asOf,
207
+ ...(through ? { completeThrough: through } : {}), groups, sources, budgetLines } }
208
+ }
209
+
210
+ /** Reads and parses the file. Missing, too big, unreadable or not JSON are three different reasons; never throws. */
211
+ export function readDataFactsFile(path: string, nowMs: number): DataFactsRead {
212
+ try {
213
+ if (!existsSync(path)) return { ok: false, reason: 'missing' }
214
+ const st = statSync(path)
215
+ if (!st.isFile() || st.size > DATA_FACTS_LIMITS.maxBytes) return { ok: false, reason: 'invalid' }
216
+ return parseDataFacts(JSON.parse(readFileSync(path, 'utf8')), nowMs)
217
+ } catch (e) {
218
+ return { ok: false, reason: e instanceof SyntaxError ? 'invalid' : 'unreadable' }
219
+ }
220
+ }
221
+
222
+ /** A reader that parses the file again only when its size or modified time changed, and checks staleness on every call. A cue
223
+ * evaluation runs up to three times a minute; this is one stat each. */
224
+ export function dataFactsReader(path: string, now: () => number = Date.now): () => DataFactsRead {
225
+ let sig = '', cached: { raw: unknown } | { reason: DataFactsOff } | null = null
226
+ return () => {
227
+ const nowMs = now()
228
+ try {
229
+ if (!existsSync(path)) { sig = ''; cached = null; return { ok: false, reason: 'missing' } }
230
+ const st = statSync(path)
231
+ const next = `${st.size}:${st.mtimeMs}`
232
+ if (next !== sig || !cached) {
233
+ sig = next
234
+ if (!st.isFile() || st.size > DATA_FACTS_LIMITS.maxBytes) cached = { reason: 'invalid' }
235
+ else { try { cached = { raw: JSON.parse(readFileSync(path, 'utf8')) } } catch (e) { cached = { reason: e instanceof SyntaxError ? 'invalid' : 'unreadable' } } }
236
+ }
237
+ } catch { sig = ''; cached = null; return { ok: false, reason: 'unreadable' } }
238
+ return 'reason' in cached ? { ok: false, reason: cached.reason } : parseDataFacts(cached.raw, nowMs)
239
+ }
240
+ }
241
+
242
+ // ── The trigger ──────────────────────────────────────────────────────────────
243
+
244
+ const FUNNEL_TOKENS = new Set(['lead', 'leads', 'opp', 'opps', 'opportunity', 'opportunities', 'sql', 'sqls', 'demo', 'demos', 'logo', 'logos',
245
+ 'customer', 'customers', 'budget', 'budgets', 'budgeted', 'pacing', 'pace', 'percent', 'plan', 'goal', 'goals', 'forecast', 'attainment', 'quota'])
246
+ const STAGE_OF: Record<string, DataStageKey> = { lead: 'leads', leads: 'leads', opp: 'opps', opps: 'opps', opportunity: 'opps', opportunities: 'opps',
247
+ sql: 'sqls', sqls: 'sqls', demo: 'demos', demos: 'demos', logo: 'customers', logos: 'customers', customer: 'customers', customers: 'customers' }
248
+ /** Words that, beside a weak alias, say the industry is meant: "pet vertical", "dive shops". */
249
+ const INDUSTRY_TOKENS = new Set(['industry', 'vertical', 'segment', 'stores', 'shops', 'retailers'])
250
+ /** A word between a weak alias and a funnel word: "leads for pet", "customers in dive". */
251
+ const BRIDGE_TOKENS = new Set(['for', 'in', 'on', 'of', 'the', 'our', 'with', 'by'])
252
+ /** A figure of two digits or more standing alone (not inside a word like said10 or a time like 10:30, and not a year), or a percent. */
253
+ function hasFigure(speech: string): boolean {
254
+ if (/%|\bpercent\b/i.test(speech)) return true
255
+ for (const m of speech.matchAll(/(?<![A-Za-z0-9:.,])(\d[\d,]*)(?:\.\d+)?(?![A-Za-z0-9:])/g)) {
256
+ const digits = m[1].replace(/,/g, '')
257
+ if (digits.length >= 2 && !/^(?:19|20)\d{2}$/.test(digits)) return true
258
+ }
259
+ return false
260
+ }
261
+
262
+ export const wordsOfSpeech = (text: string): string[] => text.toLowerCase().match(/[a-z0-9]+/g) ?? []
263
+
264
+ /** The speech the trigger reads: the last recentChars of the window, cut at a word. */
265
+ export function recentSpeech(window: string, chars: number = DATA_FACTS_LIMITS.recentChars): string {
266
+ if (window.length <= chars) return window
267
+ const cut = window.slice(-chars)
268
+ const space = cut.indexOf(' ')
269
+ return space > 0 && space < 24 ? cut.slice(space + 1) : cut
270
+ }
271
+
272
+ function occurrences(tokens: string[], phrase: string[]): number[] {
273
+ const at: number[] = []
274
+ if (!phrase.length) return at
275
+ for (let i = 0; i + phrase.length <= tokens.length; i += 1) {
276
+ let all = true
277
+ for (let j = 0; j < phrase.length; j += 1) if (tokens[i + j] !== phrase[j]) { all = false; break }
278
+ if (all) at.push(i)
279
+ }
280
+ return at
281
+ }
282
+
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)
405
+ }
406
+
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 }
408
+ /** The groups the speech names, the one named last first. A group named twice counts at its last mention. */
409
+ export function namedGroups(facts: DataFacts, speech: string): NamedGroup[] {
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)
413
+ const out: NamedGroup[] = []
414
+ for (const group of facts.groups) {
415
+ let last = -1, brand = false
416
+ for (const alias of group.aliases) for (const i of occurrences(tokens, wordsOfSpeech(alias))) last = Math.max(last, i)
417
+ for (const alias of group.brandAliases) for (const i of occurrences(tokens, wordsOfSpeech(alias))) { last = Math.max(last, i); brand = true }
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)
419
+ if (last >= 0) out.push({ group, at: last, brand })
420
+ }
421
+ return out.sort((a, b) => b.at - a.at)
422
+ }
423
+
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' }
429
+
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. */
433
+ export function dataTrigger(facts: DataFacts, window: string): DataTrigger {
434
+ const speech = recentSpeech(window)
435
+ const named = namedGroups(facts, speech)
436
+ const tokens = wordsOfSpeech(speech)
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' }
443
+ const stages = [...new Set(tokens.map(t => STAGE_OF[t]).filter((s): s is DataStageKey => !!s))]
444
+ const picked = named.slice(0, DATA_FACTS_LIMITS.maxNamedGroups)
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 }
446
+ }
447
+
448
+ export interface DataGateInput {
449
+ /** The confirmed meeting domain (Ask orientation), or null/empty when none is confirmed. */
450
+ domain: string | null | undefined
451
+ /** The orientation could not be read: it may belong to a personal meeting, so the arm is off. */
452
+ orientationFailed?: boolean
453
+ /** The speech names a Quilt brand or sbu/xbu (not only an industry word). */
454
+ brand: boolean
455
+ /** A Quilt-domain cue_memory or notes candidate survived the exclusions in this evaluation. */
456
+ memoryQuilt?: boolean
457
+ /** The meeting's title carries a Quilt term. */
458
+ titleQuilt?: boolean
459
+ }
460
+ export type DataGate = { ok: true; signal: 'quilt' | 'brand' | 'memory' | 'title' } | { ok: false; why: 'orientation' | 'domain' | 'unconfirmed' }
461
+
462
+ /** Whether this meeting may be given Quilt's numbers. A Quilt meeting: yes. An unreadable orientation: no. Another confirmed domain: no.
463
+ * No confirmed domain: only with a Quilt signal from this evaluation, never from an industry word alone. */
464
+ export function dataArmGate(i: DataGateInput): DataGate {
465
+ if (i.orientationFailed) return { ok: false, why: 'orientation' }
466
+ const d = typeof i.domain === 'string' ? i.domain.trim().toLowerCase() : ''
467
+ if (d === 'quilt') return { ok: true, signal: 'quilt' }
468
+ if (d !== '') return { ok: false, why: 'domain' }
469
+ if (i.brand) return { ok: true, signal: 'brand' }
470
+ if (i.memoryQuilt) return { ok: true, signal: 'memory' }
471
+ if (i.titleQuilt) return { ok: true, signal: 'title' }
472
+ return { ok: false, why: 'unconfirmed' }
473
+ }
474
+
475
+ /** Does a Quilt-domain note (cue_memory.py or the notes arm says the domain) stand among the candidates. */
476
+ export const isQuiltDomain = (domain: unknown): boolean => typeof domain === 'string' && domain.trim().toLowerCase() === 'quilt'
477
+
478
+ /** A Quilt term in a meeting title: the company, SBU or XBU, Tableau, or the name of a Quilt brand in the facts. */
479
+ export function quiltTitleSignal(facts: DataFacts, titles: ReadonlyArray<string | null | undefined>): boolean {
480
+ const brands = facts.groups.flatMap(g => g.brandAliases)
481
+ for (const title of titles) {
482
+ if (typeof title !== 'string' || !title.trim()) continue
483
+ const tokens = wordsOfSpeech(title)
484
+ if (tokens.some(t => t === 'quilt' || t === 'sbu' || t === 'xbu' || t === 'tableau')) return true
485
+ if (brands.some(b => occurrences(tokens, wordsOfSpeech(b)).length > 0)) return true
486
+ }
487
+ return false
488
+ }
489
+
490
+ // ── The candidate ────────────────────────────────────────────────────────────
491
+
492
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
493
+ const fmt = (n: number) => (Number.isInteger(n) ? String(n) : String(Math.round(n * 10) / 10))
494
+ const pctText = (s: { pctToGoal: number | null }) => (s.pctToGoal === null ? '' : ` (${Math.round(s.pctToGoal)}%)`)
495
+
496
+ /** "Nov 2 05:41 CT": the refresh as the file states it, with CT only for a Central offset. */
497
+ export function refreshText(iso: string): string {
498
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/.exec(iso)
499
+ if (!m) return iso
500
+ const zone = m[6] === '-06:00' || m[6] === '-0600' || m[6] === '-05:00' || m[6] === '-0500' ? ' CT' : m[6] ? ` ${m[6]}` : ''
501
+ return `${MONTHS[Number(m[2]) - 1] ?? m[2]} ${Number(m[3])} ${m[4]}:${m[5]}${zone}`
502
+ }
503
+
504
+ function periodText(f: DataFacts): string {
505
+ if (f.period.closed) return `${f.period.label} final`
506
+ const end = /^(\d{4})-(\d{2})-(\d{2})$/.exec(f.period.end)
507
+ return end ? `${f.period.label} to ${MONTHS[Number(end[2]) - 1]} ${Number(end[3])}, month to date` : `${f.period.label}, month to date`
508
+ }
509
+
510
+ function groupLine(g: DataGroup, stages: DataStageKey[], withContext: boolean): string {
511
+ const cov = g.coverage && g.coverage.have < g.coverage.of ? ` (${g.coverage.have} of ${g.coverage.of} industries)` : ''
512
+ const parts = stages.filter(k => g.stages[k]).map(k => {
513
+ const s = g.stages[k] as DataStage
514
+ const vs = s.budget === null ? ' (no budget)' : ` vs ${fmt(s.budget)}${pctText(s)}`
515
+ return `${k} ${fmt(s.actual)}${vs}`
516
+ })
517
+ let line = `${g.display ?? g.name}${cov}: ${parts.join(', ')}`
518
+ if (withContext && stages.length === 1) {
519
+ const s = g.stages[stages[0]]
520
+ if (s?.priorMonthSamePeriod != null) line += `, prior month same period ${fmt(s.priorMonthSamePeriod)}`
521
+ if (s?.pySamePeriod != null) line += `, last year ${fmt(s.pySamePeriod)}`
522
+ }
523
+ return line
524
+ }
525
+
526
+ /** The stages a candidate shows: the ones the speech named, else leads, opps and customers. */
527
+ function shownStages(g: DataGroup, named: DataStageKey[]): DataStageKey[] {
528
+ const wanted = (named.length ? named : (['leads', 'opps', 'customers'] as DataStageKey[])).filter(k => g.stages[k])
529
+ return wanted.length ? wanted : DATA_STAGES.filter(k => g.stages[k]).slice(0, 3)
530
+ }
531
+
532
+ /** The candidate text: period, scope and refresh first, then actual against budget to date and % to goal for the named group(s).
533
+ * At most candidateChars: a second group, then stages, are dropped before it is cut. Empty only when no named group has a stage. */
534
+ export function dataCandidateText(facts: DataFacts, groups: DataGroup[], named: DataStageKey[]): string {
535
+ const head = `Tableau ${periodText(facts)}, marketing-sourced funnel only, actual vs budget to date, refreshed ${refreshText(facts.asOfIso)}.`
536
+ const max = DATA_FACTS_LIMITS.candidateChars
537
+ const build = (gs: DataGroup[], cut: number): string => {
538
+ const lines = gs.map(g => groupLine(g, shownStages(g, named).slice(0, Math.max(1, shownStages(g, named).length - cut)), true)).filter(l => !l.endsWith(': '))
539
+ return lines.length ? `${head} ${lines.join('; ')}.` : ''
540
+ }
541
+ for (const gs of [groups, groups.slice(0, 1)]) {
542
+ for (let cut = 0; cut < 3; cut += 1) {
543
+ const text = build(gs, cut)
544
+ if (text && text.length <= max) return text
545
+ }
546
+ }
547
+ return build(groups.slice(0, 1), 2).slice(0, max)
548
+ }
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
+
624
+ /** The grounded candidate for a window, or why there is none. */
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' } {
626
+ const t = dataTrigger(facts, window)
627
+ if (!t.fired) return { none: t.why }
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) : '')
632
+ if (!notes) return { none: 'no_stage' }
633
+ const date = /^\d{4}-\d{2}-\d{2}/.exec(facts.asOfIso)?.[0] ?? nowDate
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 }
635
+ }
636
+
637
+ /** Appended to the writer's rules only when a Tableau note was kept. The v3/v4 rules themselves stay byte-identical. */
638
+ export const DATA_WRITER_ADDENDUM = '\nTableau note. A memory note whose title starts with Tableau is a live extract of the marketing-sourced funnel '
639
+ + '(leads, opportunities, SQLs, demos, customers) for the period in its title. It is not company revenue, MRR, ARR, location counts or customers from every source. '
640
+ + 'If you use it, say Tableau, the period, and marketing sources. Quote its numbers exactly; do not sum, project or round them. '
641
+ + 'Set a figure the room said beside the Tableau figure only when both measure the same thing (same stage, same group). '
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. '
645
+ + 'If the note does not cover what the room said, ignore it.'
646
+
647
+ // ── The meeting assistant ────────────────────────────────────────────────────
648
+
649
+ const ASK_TABLEAU = /\btableau\b/i
650
+ const ASK_FUNNEL = /\b(?:funnel|leads?|opps?|opportunit(?:y|ies)|sqls?|demos?|logos?|customers?|budget(?:s|ed)?|pac(?:e|ing)|forecast|attainment|goals?|quota|percent|plan)\b|%/i
651
+
652
+ /** The words of a typed question that are the user's own. A long text (a pasted article, a transcript) carries other people's words:
653
+ * past askQuestionChars only its last line counts, and only when that line is short; a long text with no short last line counts
654
+ * for nothing. */
655
+ export function askUserWords(question: string): string {
656
+ if (question.length <= DATA_FACTS_LIMITS.askQuestionChars) return question
657
+ const last = question.trimEnd().split(/\n+/).pop() ?? ''
658
+ return last.length <= DATA_FACTS_LIMITS.askQuestionChars && last.length < question.trim().length ? last : ''
659
+ }
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
+
675
+ export interface AskDataIntent { intent: 'tableau' | 'named' | null; /** A Quilt brand or sbu/xbu is named. */ brand: boolean }
676
+ /** What a typed question asks of the data: `tableau` when it says Tableau, `named` when it names a group and asks about funnel,
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
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. */
679
+ export function askDataIntent(question: string, facts: DataFacts): AskDataIntent {
680
+ const own = askUserWords(question)
681
+ const named = namedGroups(facts, own)
682
+ const brand = named.some(n => n.brand)
683
+ if (ASK_TABLEAU.test(own)) return { intent: 'tableau', brand }
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 }
690
+ }
691
+
692
+ /** The Tableau text the meeting assistant reads as a local source, or null. Stage rows for the groups the question names (or the
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. */
697
+ export function dataFactsForAsk(facts: DataFacts, rawQuestion: string): { title: string; text: string } | null {
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
702
+ const lines: string[] = [
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.`,
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).`,
705
+ ]
706
+ for (const g of chosen) {
707
+ const cov = g.coverage && g.coverage.have < g.coverage.of ? ` (${g.coverage.have} of ${g.coverage.of} industries: ${g.members.join(', ')})` : ''
708
+ const stages = DATA_STAGES.filter(k => g.stages[k]).map(k => {
709
+ const s = g.stages[k] as DataStage
710
+ const extra = [s.priorMonthSamePeriod != null ? `PM ${fmt(s.priorMonthSamePeriod)}` : '', s.pySamePeriod != null ? `PY ${fmt(s.pySamePeriod)}` : '',
711
+ s.exEventsPct != null ? `ex-events ${Math.round(s.exEventsPct)}%` : ''].filter(Boolean).join(', ')
712
+ return `${k} ${fmt(s.actual)} vs ${s.budget === null ? 'no budget' : fmt(s.budget)}${pctText(s)}${extra ? ` [${extra}]` : ''}`
713
+ })
714
+ lines.push(`${g.display ?? g.name}${cov}: ${stages.join('; ')}`)
715
+ }
716
+ if (sources.length) {
717
+ lines.push(...sourceLinesForAsk(facts, sources, named, lines.join('\n').length))
718
+ } else if (named.length) {
719
+ for (const g of named) {
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))
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('; ')}`)
722
+ }
723
+ }
724
+ const text = lines.join('\n').slice(0, DATA_FACTS_LIMITS.askChars)
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
758
+ }
759
+
760
+ /** Added to the meeting assistant's rules only when its Tableau source is in the context. */
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, '
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.'