@gotcos/glasses-server 6.95.0 → 6.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +157 -0
- package/README.md +4 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +12 -0
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +44 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +453 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +570 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +46 -27
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-speaker-review.ts +187 -21
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- package/server/lib/session-find-ask.ts +288 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-shadow-decide.ts +59 -22
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +2 -2
- package/server/lib/speaker-shadow-worker.ts +50 -15
- package/server/lib/speaker-shadow.ts +80 -19
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +10 -5
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +515 -61
- package/server/routes/session-find.ts +73 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +28 -18
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
|
@@ -0,0 +1,453 @@
|
|
|
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
|
+
// - domain (dataArmGate): a Quilt meeting runs it. An unreadable orientation is off. Any other confirmed domain (personal,
|
|
17
|
+
// Sprocket Rocket, Hermit Crabs...) is off. A meeting with no confirmed domain (most G2 meetings) needs a Quilt signal from
|
|
18
|
+
// the same evaluation: a Quilt brand or SBU/XBU named, a Quilt-domain memory or note among the candidates, or a Quilt term in
|
|
19
|
+
// the meeting's title. A plain industry word alone (grocery, liquor, pet...) is not a signal.
|
|
20
|
+
|
|
21
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
22
|
+
import type { GroundedNote } from './live-cues-v2-grounded.js'
|
|
23
|
+
|
|
24
|
+
export const DATA_FACTS_FILE = 'data-facts.json'
|
|
25
|
+
|
|
26
|
+
export const DATA_FACTS_LIMITS = {
|
|
27
|
+
/** 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
|
|
28
|
+
* 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. */
|
|
29
|
+
maxAgeHours: 120,
|
|
30
|
+
/** 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
|
|
31
|
+
* 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
|
|
32
|
+
* stopped is still caught. Without this the first Monday after a month start, when the report window is still under 3 days, went dark. */
|
|
33
|
+
maxClosedAgeDays: 35,
|
|
34
|
+
/** A clock that reads this far behind the file's own time is wrong or the file is: off. */
|
|
35
|
+
maxFutureMs: 60 * 60_000,
|
|
36
|
+
maxBytes: 2_000_000,
|
|
37
|
+
maxGroups: 60,
|
|
38
|
+
maxSources: 400,
|
|
39
|
+
/** The speech the trigger reads: the last this many characters of the window (about 100 words). */
|
|
40
|
+
recentChars: 600,
|
|
41
|
+
/** A candidate is at most this long, like a file passage. */
|
|
42
|
+
candidateChars: 300,
|
|
43
|
+
maxNamedGroups: 2,
|
|
44
|
+
/** What the meeting assistant is given: at most this many characters of Tableau text. */
|
|
45
|
+
askChars: 3_500,
|
|
46
|
+
askSourceRowsPerIndustry: 6,
|
|
47
|
+
/** A typed question longer than this is probably pasted text (see askUserWords). */
|
|
48
|
+
askQuestionChars: 300,
|
|
49
|
+
} as const
|
|
50
|
+
|
|
51
|
+
export const DATA_STAGES = ['leads', 'opps', 'sqls', 'demos', 'customers'] as const
|
|
52
|
+
export type DataStageKey = typeof DATA_STAGES[number]
|
|
53
|
+
|
|
54
|
+
export interface DataStage {
|
|
55
|
+
actual: number
|
|
56
|
+
budget: number | null
|
|
57
|
+
budgetMonth: number | null
|
|
58
|
+
pctToGoal: number | null
|
|
59
|
+
priorMonthSamePeriod: number | null
|
|
60
|
+
pySamePeriod: number | null
|
|
61
|
+
exEventsPct?: number
|
|
62
|
+
coreActual?: number
|
|
63
|
+
}
|
|
64
|
+
export interface DataGroup {
|
|
65
|
+
name: string
|
|
66
|
+
/** How text names the group where the plain name would be mistaken for the company ("Quilt industry (Like Sew)"). */
|
|
67
|
+
display?: string
|
|
68
|
+
kind: 'bu' | 'industry'
|
|
69
|
+
aliases: string[]
|
|
70
|
+
weakAliases: string[]
|
|
71
|
+
/** The aliases that are Quilt brand names (and sbu/xbu): naming one is a Quilt signal. A plain industry word is not. */
|
|
72
|
+
brandAliases: string[]
|
|
73
|
+
members: string[]
|
|
74
|
+
coverage?: { have: number; of: number }
|
|
75
|
+
stages: Partial<Record<DataStageKey, DataStage>>
|
|
76
|
+
}
|
|
77
|
+
export interface DataSourceRow { industry: string; source: string; bucket: string; leads: number; opps: number; customers: number }
|
|
78
|
+
export interface DataFacts {
|
|
79
|
+
label: string
|
|
80
|
+
scope: string
|
|
81
|
+
period: { label: string; start: string; end: string; closed: boolean }
|
|
82
|
+
/** The Tableau extract refresh (or the report time), as given, and in ms. */
|
|
83
|
+
asOfIso: string
|
|
84
|
+
asOf: number
|
|
85
|
+
completeThrough?: string
|
|
86
|
+
groups: DataGroup[]
|
|
87
|
+
sources: DataSourceRow[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type DataFactsOff = 'missing' | 'unreadable' | 'invalid' | 'stale' | 'future'
|
|
91
|
+
export type DataFactsRead = { ok: true; facts: DataFacts } | { ok: false; reason: DataFactsOff }
|
|
92
|
+
|
|
93
|
+
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
94
|
+
const str = (v: unknown, max: number): string | null => (typeof v === 'string' && v.trim() !== '' && v.length <= max ? v : null)
|
|
95
|
+
const fin = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
|
|
96
|
+
const wordList = (v: unknown, maxItems: number): string[] | null => {
|
|
97
|
+
if (v === undefined) return []
|
|
98
|
+
if (!Array.isArray(v) || v.length > maxItems) return null
|
|
99
|
+
const out: string[] = []
|
|
100
|
+
for (const item of v) {
|
|
101
|
+
const s = str(item, 40)
|
|
102
|
+
if (s === null || s.trim().length < 2) return null
|
|
103
|
+
out.push(s.trim().toLowerCase())
|
|
104
|
+
}
|
|
105
|
+
return out
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseStage(raw: unknown): DataStage | null {
|
|
109
|
+
if (!isObj(raw)) return null
|
|
110
|
+
const actual = fin(raw.actual)
|
|
111
|
+
if (actual === null || actual < 0) return null
|
|
112
|
+
const opt = (v: unknown) => fin(v)
|
|
113
|
+
const stage: DataStage = { actual, budget: opt(raw.budget), budgetMonth: opt(raw.budgetMonth), pctToGoal: opt(raw.pctToGoal),
|
|
114
|
+
priorMonthSamePeriod: opt(raw.priorMonthSamePeriod), pySamePeriod: opt(raw.pySamePeriod) }
|
|
115
|
+
const ex = fin(raw.exEventsPct), core = fin(raw.coreActual)
|
|
116
|
+
if (ex !== null) stage.exEventsPct = ex
|
|
117
|
+
if (core !== null) stage.coreActual = core
|
|
118
|
+
return stage
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseGroup(raw: unknown): DataGroup | null {
|
|
122
|
+
if (!isObj(raw)) return null
|
|
123
|
+
const name = str(raw.name, 40)
|
|
124
|
+
const aliases = wordList(raw.aliases, 40), weak = wordList(raw.weakAliases, 10), brand = wordList(raw.brandAliases, 20)
|
|
125
|
+
if (name === null || aliases === null || weak === null || brand === null || !isObj(raw.stages)) return null
|
|
126
|
+
const stages: DataGroup['stages'] = {}
|
|
127
|
+
for (const key of DATA_STAGES) {
|
|
128
|
+
const s = parseStage((raw.stages as Record<string, unknown>)[key])
|
|
129
|
+
if (s) stages[key] = s
|
|
130
|
+
}
|
|
131
|
+
if (!Object.keys(stages).length || (!aliases.length && !weak.length)) return null
|
|
132
|
+
const members = Array.isArray(raw.members) ? raw.members.map(m => str(m, 40)).filter((m): m is string => m !== null).slice(0, 20) : []
|
|
133
|
+
const cov = isObj(raw.coverage) && fin(raw.coverage.have) !== null && fin(raw.coverage.of) !== null && (raw.coverage.of as number) > 0
|
|
134
|
+
? { have: raw.coverage.have as number, of: raw.coverage.of as number } : undefined
|
|
135
|
+
const display = str(raw.display, 60)
|
|
136
|
+
return { name, ...(display ? { display } : {}), kind: raw.kind === 'bu' ? 'bu' : 'industry', aliases, weakAliases: weak, brandAliases: brand, members, ...(cov ? { coverage: cov } : {}), stages }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 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
|
|
140
|
+
* invalid. The clock is passed in, so staleness is testable. */
|
|
141
|
+
export function parseDataFacts(raw: unknown, nowMs: number): DataFactsRead {
|
|
142
|
+
if (!isObj(raw) || raw.v !== 1 || raw.source !== 'Tableau') return { ok: false, reason: 'invalid' }
|
|
143
|
+
const label = str(raw.label, 120), scope = str(raw.scope, 160)
|
|
144
|
+
const period = isObj(raw.period) ? raw.period : null
|
|
145
|
+
const pLabel = str(period?.label, 40), pStart = str(period?.start, 10), pEnd = str(period?.end, 10)
|
|
146
|
+
const asOfIso = str(raw.asOf, 40)
|
|
147
|
+
const asOf = asOfIso === null ? NaN : Date.parse(asOfIso)
|
|
148
|
+
if (!label || !scope || !period || !pLabel || !pStart || !pEnd || typeof period.closed !== 'boolean' || Number.isNaN(asOf)) return { ok: false, reason: 'invalid' }
|
|
149
|
+
if (!Array.isArray(raw.groups) || raw.groups.length > DATA_FACTS_LIMITS.maxGroups) return { ok: false, reason: 'invalid' }
|
|
150
|
+
const groups = raw.groups.map(parseGroup).filter((g): g is DataGroup => g !== null)
|
|
151
|
+
if (!groups.length) return { ok: false, reason: 'invalid' }
|
|
152
|
+
const sources: DataSourceRow[] = []
|
|
153
|
+
for (const r of Array.isArray(raw.sources) ? raw.sources.slice(0, DATA_FACTS_LIMITS.maxSources) : []) {
|
|
154
|
+
if (!isObj(r)) continue
|
|
155
|
+
const industry = str(r.industry, 40), source = str(r.source, 80)
|
|
156
|
+
const leads = fin(r.leads), opps = fin(r.opps), customers = fin(r.customers)
|
|
157
|
+
if (industry && source && leads !== null && opps !== null && customers !== null) sources.push({ industry, source, bucket: typeof r.bucket === 'string' ? r.bucket : '', leads, opps, customers })
|
|
158
|
+
}
|
|
159
|
+
if (asOf - nowMs > DATA_FACTS_LIMITS.maxFutureMs) return { ok: false, reason: 'future' }
|
|
160
|
+
const maxAgeMs = period.closed === true ? DATA_FACTS_LIMITS.maxClosedAgeDays * 24 * 3_600_000 : DATA_FACTS_LIMITS.maxAgeHours * 3_600_000
|
|
161
|
+
if (nowMs - asOf > maxAgeMs) return { ok: false, reason: 'stale' }
|
|
162
|
+
const through = typeof raw.completeThrough === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.completeThrough) ? raw.completeThrough : undefined
|
|
163
|
+
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 } }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Reads and parses the file. Missing, too big, unreadable or not JSON are three different reasons; never throws. */
|
|
168
|
+
export function readDataFactsFile(path: string, nowMs: number): DataFactsRead {
|
|
169
|
+
try {
|
|
170
|
+
if (!existsSync(path)) return { ok: false, reason: 'missing' }
|
|
171
|
+
const st = statSync(path)
|
|
172
|
+
if (!st.isFile() || st.size > DATA_FACTS_LIMITS.maxBytes) return { ok: false, reason: 'invalid' }
|
|
173
|
+
return parseDataFacts(JSON.parse(readFileSync(path, 'utf8')), nowMs)
|
|
174
|
+
} catch (e) {
|
|
175
|
+
return { ok: false, reason: e instanceof SyntaxError ? 'invalid' : 'unreadable' }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A reader that parses the file again only when its size or modified time changed, and checks staleness on every call. A cue
|
|
180
|
+
* evaluation runs up to three times a minute; this is one stat each. */
|
|
181
|
+
export function dataFactsReader(path: string, now: () => number = Date.now): () => DataFactsRead {
|
|
182
|
+
let sig = '', cached: { raw: unknown } | { reason: DataFactsOff } | null = null
|
|
183
|
+
return () => {
|
|
184
|
+
const nowMs = now()
|
|
185
|
+
try {
|
|
186
|
+
if (!existsSync(path)) { sig = ''; cached = null; return { ok: false, reason: 'missing' } }
|
|
187
|
+
const st = statSync(path)
|
|
188
|
+
const next = `${st.size}:${st.mtimeMs}`
|
|
189
|
+
if (next !== sig || !cached) {
|
|
190
|
+
sig = next
|
|
191
|
+
if (!st.isFile() || st.size > DATA_FACTS_LIMITS.maxBytes) cached = { reason: 'invalid' }
|
|
192
|
+
else { try { cached = { raw: JSON.parse(readFileSync(path, 'utf8')) } } catch (e) { cached = { reason: e instanceof SyntaxError ? 'invalid' : 'unreadable' } } }
|
|
193
|
+
}
|
|
194
|
+
} catch { sig = ''; cached = null; return { ok: false, reason: 'unreadable' } }
|
|
195
|
+
return 'reason' in cached ? { ok: false, reason: cached.reason } : parseDataFacts(cached.raw, nowMs)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ── The trigger ──────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
const FUNNEL_TOKENS = new Set(['lead', 'leads', 'opp', 'opps', 'opportunity', 'opportunities', 'sql', 'sqls', 'demo', 'demos', 'logo', 'logos',
|
|
202
|
+
'customer', 'customers', 'budget', 'budgets', 'budgeted', 'pacing', 'pace', 'percent', 'plan', 'goal', 'goals', 'forecast', 'attainment', 'quota'])
|
|
203
|
+
const STAGE_OF: Record<string, DataStageKey> = { lead: 'leads', leads: 'leads', opp: 'opps', opps: 'opps', opportunity: 'opps', opportunities: 'opps',
|
|
204
|
+
sql: 'sqls', sqls: 'sqls', demo: 'demos', demos: 'demos', logo: 'customers', logos: 'customers', customer: 'customers', customers: 'customers' }
|
|
205
|
+
/** Words that, beside a weak alias, say the industry is meant: "pet vertical", "dive shops". */
|
|
206
|
+
const INDUSTRY_TOKENS = new Set(['industry', 'vertical', 'segment', 'stores', 'shops', 'retailers'])
|
|
207
|
+
/** A word between a weak alias and a funnel word: "leads for pet", "customers in dive". */
|
|
208
|
+
const BRIDGE_TOKENS = new Set(['for', 'in', 'on', 'of', 'the', 'our', 'with', 'by'])
|
|
209
|
+
/** 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. */
|
|
210
|
+
function hasFigure(speech: string): boolean {
|
|
211
|
+
if (/%|\bpercent\b/i.test(speech)) return true
|
|
212
|
+
for (const m of speech.matchAll(/(?<![A-Za-z0-9:.,])(\d[\d,]*)(?:\.\d+)?(?![A-Za-z0-9:])/g)) {
|
|
213
|
+
const digits = m[1].replace(/,/g, '')
|
|
214
|
+
if (digits.length >= 2 && !/^(?:19|20)\d{2}$/.test(digits)) return true
|
|
215
|
+
}
|
|
216
|
+
return false
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export const wordsOfSpeech = (text: string): string[] => text.toLowerCase().match(/[a-z0-9]+/g) ?? []
|
|
220
|
+
|
|
221
|
+
/** The speech the trigger reads: the last recentChars of the window, cut at a word. */
|
|
222
|
+
export function recentSpeech(window: string, chars: number = DATA_FACTS_LIMITS.recentChars): string {
|
|
223
|
+
if (window.length <= chars) return window
|
|
224
|
+
const cut = window.slice(-chars)
|
|
225
|
+
const space = cut.indexOf(' ')
|
|
226
|
+
return space > 0 && space < 24 ? cut.slice(space + 1) : cut
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function occurrences(tokens: string[], phrase: string[]): number[] {
|
|
230
|
+
const at: number[] = []
|
|
231
|
+
if (!phrase.length) return at
|
|
232
|
+
for (let i = 0; i + phrase.length <= tokens.length; i += 1) {
|
|
233
|
+
let all = true
|
|
234
|
+
for (let j = 0; j < phrase.length; j += 1) if (tokens[i + j] !== phrase[j]) { all = false; break }
|
|
235
|
+
if (all) at.push(i)
|
|
236
|
+
}
|
|
237
|
+
return at
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function weakAt(tokens: string[], i: number): boolean {
|
|
241
|
+
const near = (k: number) => (k >= 0 && k < tokens.length ? tokens[k] : '')
|
|
242
|
+
const funnelOrIndustry = (t: string) => FUNNEL_TOKENS.has(t) || INDUSTRY_TOKENS.has(t)
|
|
243
|
+
if (funnelOrIndustry(near(i - 1)) || funnelOrIndustry(near(i + 1))) return true
|
|
244
|
+
return (BRIDGE_TOKENS.has(near(i - 1)) && FUNNEL_TOKENS.has(near(i - 2))) || (BRIDGE_TOKENS.has(near(i + 1)) && FUNNEL_TOKENS.has(near(i + 2)))
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
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
|
+
/** The groups the speech names, the one named last first. A group named twice counts at its last mention. */
|
|
249
|
+
export function namedGroups(facts: DataFacts, speech: string): NamedGroup[] {
|
|
250
|
+
const tokens = wordsOfSpeech(speech)
|
|
251
|
+
const out: NamedGroup[] = []
|
|
252
|
+
for (const group of facts.groups) {
|
|
253
|
+
let last = -1, brand = false
|
|
254
|
+
for (const alias of group.aliases) for (const i of occurrences(tokens, wordsOfSpeech(alias))) last = Math.max(last, i)
|
|
255
|
+
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)
|
|
257
|
+
if (last >= 0) out.push({ group, at: last, brand })
|
|
258
|
+
}
|
|
259
|
+
return out.sort((a, b) => b.at - a.at)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export type DataTrigger = { fired: true; groups: DataGroup[]; stages: DataStageKey[]; brand: boolean } | { fired: false; why: 'no_group' | 'no_funnel' }
|
|
263
|
+
|
|
264
|
+
/** Fires when the speech names a group AND carries a funnel word or a figure (two digits or more, or a percent). */
|
|
265
|
+
export function dataTrigger(facts: DataFacts, window: string): DataTrigger {
|
|
266
|
+
const speech = recentSpeech(window)
|
|
267
|
+
const named = namedGroups(facts, speech)
|
|
268
|
+
if (!named.length) return { fired: false, why: 'no_group' }
|
|
269
|
+
const tokens = wordsOfSpeech(speech)
|
|
270
|
+
if (!tokens.some(t => FUNNEL_TOKENS.has(t)) && !hasFigure(speech)) return { fired: false, why: 'no_funnel' }
|
|
271
|
+
const stages = [...new Set(tokens.map(t => STAGE_OF[t]).filter((s): s is DataStageKey => !!s))]
|
|
272
|
+
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) }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export interface DataGateInput {
|
|
277
|
+
/** The confirmed meeting domain (Ask orientation), or null/empty when none is confirmed. */
|
|
278
|
+
domain: string | null | undefined
|
|
279
|
+
/** The orientation could not be read: it may belong to a personal meeting, so the arm is off. */
|
|
280
|
+
orientationFailed?: boolean
|
|
281
|
+
/** The speech names a Quilt brand or sbu/xbu (not only an industry word). */
|
|
282
|
+
brand: boolean
|
|
283
|
+
/** A Quilt-domain cue_memory or notes candidate survived the exclusions in this evaluation. */
|
|
284
|
+
memoryQuilt?: boolean
|
|
285
|
+
/** The meeting's title carries a Quilt term. */
|
|
286
|
+
titleQuilt?: boolean
|
|
287
|
+
}
|
|
288
|
+
export type DataGate = { ok: true; signal: 'quilt' | 'brand' | 'memory' | 'title' } | { ok: false; why: 'orientation' | 'domain' | 'unconfirmed' }
|
|
289
|
+
|
|
290
|
+
/** Whether this meeting may be given Quilt's numbers. A Quilt meeting: yes. An unreadable orientation: no. Another confirmed domain: no.
|
|
291
|
+
* No confirmed domain: only with a Quilt signal from this evaluation, never from an industry word alone. */
|
|
292
|
+
export function dataArmGate(i: DataGateInput): DataGate {
|
|
293
|
+
if (i.orientationFailed) return { ok: false, why: 'orientation' }
|
|
294
|
+
const d = typeof i.domain === 'string' ? i.domain.trim().toLowerCase() : ''
|
|
295
|
+
if (d === 'quilt') return { ok: true, signal: 'quilt' }
|
|
296
|
+
if (d !== '') return { ok: false, why: 'domain' }
|
|
297
|
+
if (i.brand) return { ok: true, signal: 'brand' }
|
|
298
|
+
if (i.memoryQuilt) return { ok: true, signal: 'memory' }
|
|
299
|
+
if (i.titleQuilt) return { ok: true, signal: 'title' }
|
|
300
|
+
return { ok: false, why: 'unconfirmed' }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Does a Quilt-domain note (cue_memory.py or the notes arm says the domain) stand among the candidates. */
|
|
304
|
+
export const isQuiltDomain = (domain: unknown): boolean => typeof domain === 'string' && domain.trim().toLowerCase() === 'quilt'
|
|
305
|
+
|
|
306
|
+
/** A Quilt term in a meeting title: the company, SBU or XBU, Tableau, or the name of a Quilt brand in the facts. */
|
|
307
|
+
export function quiltTitleSignal(facts: DataFacts, titles: ReadonlyArray<string | null | undefined>): boolean {
|
|
308
|
+
const brands = facts.groups.flatMap(g => g.brandAliases)
|
|
309
|
+
for (const title of titles) {
|
|
310
|
+
if (typeof title !== 'string' || !title.trim()) continue
|
|
311
|
+
const tokens = wordsOfSpeech(title)
|
|
312
|
+
if (tokens.some(t => t === 'quilt' || t === 'sbu' || t === 'xbu' || t === 'tableau')) return true
|
|
313
|
+
if (brands.some(b => occurrences(tokens, wordsOfSpeech(b)).length > 0)) return true
|
|
314
|
+
}
|
|
315
|
+
return false
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── The candidate ────────────────────────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
321
|
+
const fmt = (n: number) => (Number.isInteger(n) ? String(n) : String(Math.round(n * 10) / 10))
|
|
322
|
+
const pctText = (s: DataStage) => (s.pctToGoal === null ? '' : ` (${Math.round(s.pctToGoal)}%)`)
|
|
323
|
+
|
|
324
|
+
/** "Nov 2 05:41 CT": the refresh as the file states it, with CT only for a Central offset. */
|
|
325
|
+
export function refreshText(iso: string): string {
|
|
326
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/.exec(iso)
|
|
327
|
+
if (!m) return iso
|
|
328
|
+
const zone = m[6] === '-06:00' || m[6] === '-0600' || m[6] === '-05:00' || m[6] === '-0500' ? ' CT' : m[6] ? ` ${m[6]}` : ''
|
|
329
|
+
return `${MONTHS[Number(m[2]) - 1] ?? m[2]} ${Number(m[3])} ${m[4]}:${m[5]}${zone}`
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function periodText(f: DataFacts): string {
|
|
333
|
+
if (f.period.closed) return `${f.period.label} final`
|
|
334
|
+
const end = /^(\d{4})-(\d{2})-(\d{2})$/.exec(f.period.end)
|
|
335
|
+
return end ? `${f.period.label} to ${MONTHS[Number(end[2]) - 1]} ${Number(end[3])}, month to date` : `${f.period.label}, month to date`
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function groupLine(g: DataGroup, stages: DataStageKey[], withContext: boolean): string {
|
|
339
|
+
const cov = g.coverage && g.coverage.have < g.coverage.of ? ` (${g.coverage.have} of ${g.coverage.of} industries)` : ''
|
|
340
|
+
const parts = stages.filter(k => g.stages[k]).map(k => {
|
|
341
|
+
const s = g.stages[k] as DataStage
|
|
342
|
+
const vs = s.budget === null ? ' (no budget)' : ` vs ${fmt(s.budget)}${pctText(s)}`
|
|
343
|
+
return `${k} ${fmt(s.actual)}${vs}`
|
|
344
|
+
})
|
|
345
|
+
let line = `${g.display ?? g.name}${cov}: ${parts.join(', ')}`
|
|
346
|
+
if (withContext && stages.length === 1) {
|
|
347
|
+
const s = g.stages[stages[0]]
|
|
348
|
+
if (s?.priorMonthSamePeriod != null) line += `, prior month same period ${fmt(s.priorMonthSamePeriod)}`
|
|
349
|
+
if (s?.pySamePeriod != null) line += `, last year ${fmt(s.pySamePeriod)}`
|
|
350
|
+
}
|
|
351
|
+
return line
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The stages a candidate shows: the ones the speech named, else leads, opps and customers. */
|
|
355
|
+
function shownStages(g: DataGroup, named: DataStageKey[]): DataStageKey[] {
|
|
356
|
+
const wanted = (named.length ? named : (['leads', 'opps', 'customers'] as DataStageKey[])).filter(k => g.stages[k])
|
|
357
|
+
return wanted.length ? wanted : DATA_STAGES.filter(k => g.stages[k]).slice(0, 3)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The candidate text: period, scope and refresh first, then actual against budget to date and % to goal for the named group(s).
|
|
361
|
+
* At most candidateChars: a second group, then stages, are dropped before it is cut. Empty only when no named group has a stage. */
|
|
362
|
+
export function dataCandidateText(facts: DataFacts, groups: DataGroup[], named: DataStageKey[]): string {
|
|
363
|
+
const head = `Tableau ${periodText(facts)}, marketing-sourced funnel only, actual vs budget to date, refreshed ${refreshText(facts.asOfIso)}.`
|
|
364
|
+
const max = DATA_FACTS_LIMITS.candidateChars
|
|
365
|
+
const build = (gs: DataGroup[], cut: number): string => {
|
|
366
|
+
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(': '))
|
|
367
|
+
return lines.length ? `${head} ${lines.join('; ')}.` : ''
|
|
368
|
+
}
|
|
369
|
+
for (const gs of [groups, groups.slice(0, 1)]) {
|
|
370
|
+
for (let cut = 0; cut < 3; cut += 1) {
|
|
371
|
+
const text = build(gs, cut)
|
|
372
|
+
if (text && text.length <= max) return text
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return build(groups.slice(0, 1), 2).slice(0, max)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 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' } {
|
|
380
|
+
const t = dataTrigger(facts, window)
|
|
381
|
+
if (!t.fired) return { none: t.why }
|
|
382
|
+
const notes = dataCandidateText(facts, t.groups, t.stages)
|
|
383
|
+
if (!notes) return { none: 'no_stage' }
|
|
384
|
+
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 }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Appended to the writer's rules only when a Tableau note was kept. The v3/v4 rules themselves stay byte-identical. */
|
|
389
|
+
export const DATA_WRITER_ADDENDUM = '\nTableau note. A memory note whose title starts with Tableau is a live extract of the marketing-sourced funnel '
|
|
390
|
+
+ '(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. '
|
|
391
|
+
+ 'If you use it, say Tableau, the period, and marketing sources. Quote its numbers exactly; do not sum, project or round them. '
|
|
392
|
+
+ 'Set a figure the room said beside the Tableau figure only when both measure the same thing (same stage, same group). '
|
|
393
|
+
+ '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. '
|
|
394
|
+
+ 'If the note does not cover what the room said, ignore it.'
|
|
395
|
+
|
|
396
|
+
// ── The meeting assistant ────────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
const ASK_TABLEAU = /\btableau\b/i
|
|
399
|
+
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
|
|
400
|
+
|
|
401
|
+
/** 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:
|
|
402
|
+
* past askQuestionChars only its last line counts, and only when that line is short; a long text with no short last line counts
|
|
403
|
+
* for nothing. */
|
|
404
|
+
export function askUserWords(question: string): string {
|
|
405
|
+
if (question.length <= DATA_FACTS_LIMITS.askQuestionChars) return question
|
|
406
|
+
const last = question.trimEnd().split(/\n+/).pop() ?? ''
|
|
407
|
+
return last.length <= DATA_FACTS_LIMITS.askQuestionChars && last.length < question.trim().length ? last : ''
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export interface AskDataIntent { intent: 'tableau' | 'named' | null; /** A Quilt brand or sbu/xbu is named. */ brand: boolean }
|
|
411
|
+
/** 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
|
|
413
|
+
* 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
|
+
export function askDataIntent(question: string, facts: DataFacts): AskDataIntent {
|
|
415
|
+
const own = askUserWords(question)
|
|
416
|
+
const named = namedGroups(facts, own)
|
|
417
|
+
const brand = named.some(n => n.brand)
|
|
418
|
+
if (ASK_TABLEAU.test(own)) return { intent: 'tableau', brand }
|
|
419
|
+
return { intent: ASK_FUNNEL.test(own) && named.length > 0 ? 'named' : null, brand }
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** 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. */
|
|
424
|
+
export function dataFactsForAsk(facts: DataFacts, rawQuestion: string): { title: string; text: string } | null {
|
|
425
|
+
const named = namedGroups(facts, askUserWords(rawQuestion)).map(n => n.group)
|
|
426
|
+
const chosen = named.length ? named : facts.groups
|
|
427
|
+
const lines: string[] = [
|
|
428
|
+
`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
|
+
`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).`,
|
|
430
|
+
]
|
|
431
|
+
for (const g of chosen) {
|
|
432
|
+
const cov = g.coverage && g.coverage.have < g.coverage.of ? ` (${g.coverage.have} of ${g.coverage.of} industries: ${g.members.join(', ')})` : ''
|
|
433
|
+
const stages = DATA_STAGES.filter(k => g.stages[k]).map(k => {
|
|
434
|
+
const s = g.stages[k] as DataStage
|
|
435
|
+
const extra = [s.priorMonthSamePeriod != null ? `PM ${fmt(s.priorMonthSamePeriod)}` : '', s.pySamePeriod != null ? `PY ${fmt(s.pySamePeriod)}` : '',
|
|
436
|
+
s.exEventsPct != null ? `ex-events ${Math.round(s.exEventsPct)}%` : ''].filter(Boolean).join(', ')
|
|
437
|
+
return `${k} ${fmt(s.actual)} vs ${s.budget === null ? 'no budget' : fmt(s.budget)}${pctText(s)}${extra ? ` [${extra}]` : ''}`
|
|
438
|
+
})
|
|
439
|
+
lines.push(`${g.display ?? g.name}${cov}: ${stages.join('; ')}`)
|
|
440
|
+
}
|
|
441
|
+
if (named.length) {
|
|
442
|
+
for (const g of named) {
|
|
443
|
+
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
|
+
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
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const text = lines.join('\n').slice(0, DATA_FACTS_LIMITS.askChars)
|
|
448
|
+
return chosen.length ? { title: facts.label, text } : null
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Added to the meeting assistant's rules only when its Tableau source is in the context. */
|
|
452
|
+
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.'
|