@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,245 @@
|
|
|
1
|
+
// General live cues, privacy side: the sensitive-window skip and the deterministic query admission.
|
|
2
|
+
//
|
|
3
|
+
// General cues are the first automatic path from spoken words to a web search (validation 3, blocker 1). The existing
|
|
4
|
+
// public-search gate cannot be reused: it admits a word only because the USER typed it, and a topic the model picked was
|
|
5
|
+
// typed by nobody. So this module is the whole defence, and every rule in it is deterministic, local and runs without a
|
|
6
|
+
// model: a window with private-life vocabulary never leaves the machine, and a query is admitted only when it has no digit,
|
|
7
|
+
// no amount, no deny-list word, no person's name (speaker, owner), none of the owner's own terms (a whole vocabulary or
|
|
8
|
+
// domain-keyword term, never one word of it) and no repeat. It holds no real names: the names it blocks come from the live
|
|
9
|
+
// transcript, the speakers the ASR path reports, every enrolled voice profile and the owner's profile at run time.
|
|
10
|
+
//
|
|
11
|
+
// RESIDUAL RISK, by design: a private person who is named in the room but never speaks and is not enrolled is caught by no
|
|
12
|
+
// list here. Only the topic prompt's rule 2 (never a private person) stands between that name and the web. The speakers, the
|
|
13
|
+
// enrolled voices, the owner and the capitalized words of the calendar title are what the lists can know.
|
|
14
|
+
import { createHash } from 'node:crypto'
|
|
15
|
+
import { redactSecrets } from './lens-gist-engines.js'
|
|
16
|
+
// The bare word "private" still blocks here (the 6.97.7 rule): these queries are chosen by a model, so nothing the user typed vouches for "private aviation".
|
|
17
|
+
import { PRIVATE_QUERY_BARE as PRIVATE_QUERY } from './meeting-public-topic.js'
|
|
18
|
+
import { speakerNameWords, factWords, stemWord } from './meeting-fact-check.js'
|
|
19
|
+
import { HOME_AREA_LABEL, getDomainKeywords, getOwnerName, getOwnerSpeakerLabel, getVocabulary } from './profile.js'
|
|
20
|
+
|
|
21
|
+
export const GENERAL_QUERY_MAX_CHARS = 80
|
|
22
|
+
export const GENERAL_QUERY_MAX_WORDS = 8
|
|
23
|
+
|
|
24
|
+
// ── Sensitive window ────────────────────────────────────────────────────────
|
|
25
|
+
/** What the lists are matched against: lowercase, compatibility-decomposed (NFKD does what NFKC does to full-width forms, so IRS
|
|
26
|
+
* is irs) with the accents then dropped, dots inside an acronym dropped (I.R.S. becomes irs), hyphens, underscores and slashes
|
|
27
|
+
* read as spaces (C-section becomes c section). Pure. */
|
|
28
|
+
export function normalizeForMatch(text: string): string {
|
|
29
|
+
return text.toLowerCase().normalize('NFKD').replace(/\p{M}/gu, '')
|
|
30
|
+
.replace(/\b(?:\p{L}\.){2,}\p{L}?/gu, m => m.replace(/\./g, ''))
|
|
31
|
+
.replace(/[-_\u2010-\u2015/]+/g, ' ').replace(/\s+/g, ' ').trim()
|
|
32
|
+
}
|
|
33
|
+
/** A trailing * is a stem ("pregnan*" is pregnant, pregnancy); any other regex fragment ("doctors?") is used as written, and must
|
|
34
|
+
* not end in a bare * (write (?:\\w*)). Whole
|
|
35
|
+
* words only, so "son" never matches "reason". Run on normalizeForMatch text. */
|
|
36
|
+
const wordsRe = (words: string[]): RegExp => new RegExp(`\\b(?:${words.map(w => w.endsWith('*') ? `${w.slice(0, -1)}\\w*` : w).join('|')})\\b`, 'i')
|
|
37
|
+
export type SensitiveCategory = 'health' | 'family' | 'finance' | 'legal' | 'school' | 'funeral'
|
|
38
|
+
const CATEGORIES: readonly SensitiveCategory[] = ['health', 'family', 'finance', 'legal', 'school', 'funeral']
|
|
39
|
+
|
|
40
|
+
/** TWO LAYERS, because they have opposite costs. A window that is skipped loses a chance at a cue; a query that is let through
|
|
41
|
+
* can reach the web. So the WINDOW list holds only what is clearly personal (measured on the 974 saved windows: the broad list
|
|
42
|
+
* skipped 37.6% of them, mostly on ordinary work talk: court, sick, baby, finance, taxes, health, school, family, medical),
|
|
43
|
+
* and the QUERY list is the window list plus everything broader.
|
|
44
|
+
*
|
|
45
|
+
* WINDOW_TERMS: family relations, health specifics, personal-finance phrases, legal specifics, school for kids, and death. */
|
|
46
|
+
const WINDOW_TERMS: Record<SensitiveCategory, string[]> = {
|
|
47
|
+
health: ['doctors?', 'hospitals?', 'clinics?', 'diagnos*', 'chemo*', 'cancer', 'surger(?:y|ies)', 'therapists?', 'therapy', 'psychiatr*', 'psycholog*', 'prescription*',
|
|
48
|
+
'pregnan*', 'icu', 'intensive care', 'emergency room', 'er visit', 'urgent care', 'ob gyn', 'obgyn', 'c section', 'csection', 'adhd', 'autis*', 'hospice'],
|
|
49
|
+
family: ['wife', 'wives', 'husbands?', 'spouses?', 'kids?', 'moms?', 'mommy', 'dads?', 'daddy', 'sons?', 'daughters?', 'brothers?', 'sisters?', 'parents',
|
|
50
|
+
'step ?(?:son|daughter|mom|dad|kid\\w*)', 'grandma*', 'grandpa*', 'grandmother*', 'grandfather*', 'grandchild*', 'grandson*', 'granddaughter*',
|
|
51
|
+
'grandkid*', 'nephews?', 'nieces?'],
|
|
52
|
+
finance: ['irs', 'tax bills?', 'tax returns?', 'back taxes', 'income tax(?:es)?', 'property tax(?:es)?', 'debts?', 'loans?', 'mortgages?', 'bankrupt*', 'credit score',
|
|
53
|
+
'installment*', 'escrow', 'pensions?', 'roth', 'ira', 'rollover', 'k 1', 'alimony', 'child support'],
|
|
54
|
+
legal: ['lawsuits?', 'sued', 'attorneys?', 'lawyers?', 'custody', 'divorce*', 'court dates?', 'court hearings?'],
|
|
55
|
+
school: ['daycare*', 'preschool*', 'kindergarten*', 'school pick ?up', 'report cards?', 'tuition', 'parent teacher', 'dnp', 'dissertation*'],
|
|
56
|
+
funeral: ['died', 'deaths?', 'funeral*', 'passed away', 'dying'],
|
|
57
|
+
}
|
|
58
|
+
/** DENY_EXTRA_TERMS: the broader words a QUERY must not carry. Bare words like these are common in work talk, so they never skip a
|
|
59
|
+
* window, but a web query is a different matter. Stems stop where a longer word would be a public topic: no "mom*" (moment),
|
|
60
|
+
* "kid*" (kidney), "tax*" (taxi, taxonomy), "grand*" (Grand Prix) or "mother*" (motherboard). */
|
|
61
|
+
const DENY_EXTRA_TERMS: Record<SensitiveCategory, string[]> = {
|
|
62
|
+
health: ['health*', 'medical', 'medicine*', 'medication*', 'doctor*', 'hospital*', 'clinic*', 'therap*', 'surger*', 'pharmac*', 'oncolog*', 'illness*', 'symptom*',
|
|
63
|
+
'dentist*', 'nurse', 'nurses', 'nursing', 'sick', 'injur*', 'anxiety', 'depress*', 'pediatric*', 'miscarriage*', 'infertil*', 'ivf'],
|
|
64
|
+
family: ['family', 'families', 'husband*', 'spouse*', 'daughter*', 'brother*', 'sister*', 'kiddo', 'kiddos', 'child', 'children', 'childcare', 'childhood', 'baby',
|
|
65
|
+
'babies', 'newborn', 'mum', 'mums', 'mother', 'mothers', 'motherhood', 'father', 'fathers', 'fatherhood', 'parent*', 'sibling*', 'aunt*', 'uncle*', 'cousin*',
|
|
66
|
+
'fiance*', 'girlfriend', 'boyfriend', 'in laws', 'inlaws', 'birthday*', 'wedding*', 'step ?(?:mom|mother|dad|father|son|daughter|kid|child|brother|sister|parent|family)(?:\\w*)'],
|
|
67
|
+
finance: ['financ*', 'tax', 'taxes', 'taxed', 'taxing', 'taxable', 'taxation', 'debt*', 'loan*', 'mortgage*', 'pension*', 'insurance', 'salary', 'salaries', 'payroll',
|
|
68
|
+
'paycheck*', 'retirement', '401k', '403b', 'inheritance', 'net worth', 'k one'],
|
|
69
|
+
legal: ['legal*', 'lawsuit*', 'sue', 'suing', 'lawyer*', 'attorney*', 'court', 'courts', 'courtroom*', 'subpoena*', 'settlement*', 'litigation'],
|
|
70
|
+
school: ['school*', 'nursery', 'pre k', 'prek', 'teacher*', 'homework', 'doctoral'],
|
|
71
|
+
funeral: ['obituar*', 'burial*', 'cemetery', 'die', 'dies', 'death*', 'grief', 'mourning', 'casket', 'condolence*'],
|
|
72
|
+
}
|
|
73
|
+
const WINDOW: ReadonlyArray<readonly [SensitiveCategory, RegExp]> = CATEGORIES.map(c => [c, wordsRe(WINDOW_TERMS[c])] as const)
|
|
74
|
+
const DENY_ALL = wordsRe(CATEGORIES.flatMap(c => [...WINDOW_TERMS[c], ...DENY_EXTRA_TERMS[c]]))
|
|
75
|
+
|
|
76
|
+
/** The clearly personal category a transcript window touches, or null. Pure and exported so the saved windows can be replayed
|
|
77
|
+
* through it. Private-life vocabulary only, and only the high-precision list: the owner's profile terms never skip a window
|
|
78
|
+
* (measured: whole-term matching still skipped a third of the work windows that pass the trigger, word matching all of them),
|
|
79
|
+
* and neither do bare words like court, sick, baby, finance, taxes, health, school, family or medical. The broad list guards the
|
|
80
|
+
* QUERY instead (denyWordHit, ownerTermPhrases). */
|
|
81
|
+
export function sensitiveWindow(text: string): SensitiveCategory | null {
|
|
82
|
+
const t = normalizeForMatch(text)
|
|
83
|
+
for (const [category, re] of WINDOW) if (re.test(t)) return category
|
|
84
|
+
return null
|
|
85
|
+
}
|
|
86
|
+
/** Runs of two or more consecutive tokens of one or two letters, joined ("i r s" is irs, "ta x" is tax): a query spelled out letter
|
|
87
|
+
* by letter is the word it spells. Appended to the text before the deny list reads it, so nothing already in it is lost. */
|
|
88
|
+
function collapseShortRuns(folded: string): string {
|
|
89
|
+
const out: string[] = []
|
|
90
|
+
let run: string[] = []
|
|
91
|
+
const flush = () => { if (run.length >= 2 && run.join('').length >= 3) out.push(run.join('')); run = [] }
|
|
92
|
+
for (const token of folded.split(' ')) { if (/^\p{L}{1,2}$/u.test(token)) run.push(token); else flush() }
|
|
93
|
+
flush()
|
|
94
|
+
return out.join(' ')
|
|
95
|
+
}
|
|
96
|
+
/** Does a QUERY (or any text) carry a word of the broad deny list? The window list plus the broader words, the Ask gate's private
|
|
97
|
+
* words, and the letter-by-letter spellings of them. Pure and exported for probes. */
|
|
98
|
+
export function denyWordHit(text: string): boolean {
|
|
99
|
+
const folded = normalizeForMatch(text)
|
|
100
|
+
const spelled = `${folded} ${collapseShortRuns(folded)}`
|
|
101
|
+
return PRIVATE_QUERY.test(spelled) || DENY_ALL.test(spelled)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── Names from the profile and the live transcript ──────────────────────────
|
|
105
|
+
/** What the profile offers: the owner's name and speaker label, the terms the owner taught the decoder (names of people,
|
|
106
|
+
* companies, products, and plain work words inside longer terms), and the domain keyword lists (the owner's own
|
|
107
|
+
* organizations and brands; empty on the owner's real profile). Injectable for tests. */
|
|
108
|
+
export interface ProfileNameSource {
|
|
109
|
+
ownerName(): string
|
|
110
|
+
speakerLabel(): string
|
|
111
|
+
vocabulary(): string[]
|
|
112
|
+
domainKeywords(): Record<string, string[]>
|
|
113
|
+
/** Every enrolled voice profile's name (the store behind GET /api/voice/profiles). REJECTS or THROWS when the names cannot be
|
|
114
|
+
* read: a caller must then withhold, not guess. Absent means a source with no voice store (a test). */
|
|
115
|
+
voiceProfileNames?(): string[] | Promise<string[]>
|
|
116
|
+
}
|
|
117
|
+
/** Wraps a reader so it is called at most once every `ttlMs`. A failed read propagates and is never papered over with the stale
|
|
118
|
+
* copy: the cache only answers inside its window. */
|
|
119
|
+
export function cachedNames(read: () => string[] | Promise<string[]>, ttlMs = 5 * 60_000, now: () => number = Date.now): () => Promise<string[]> {
|
|
120
|
+
let cache: { at: number; names: string[] } | null = null
|
|
121
|
+
return async () => {
|
|
122
|
+
const t = now()
|
|
123
|
+
if (cache && t - cache.at < ttlMs) return cache.names
|
|
124
|
+
const names = await read()
|
|
125
|
+
cache = { at: t, names }
|
|
126
|
+
return names
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** The real profile. Every accessor is a thunk, and the voice store is loaded on first use: this module is on the import path of
|
|
130
|
+
* every route that feeds live cues, and the speaker store pulls in the data home at load. */
|
|
131
|
+
export const realProfileNames: ProfileNameSource = {
|
|
132
|
+
ownerName: () => getOwnerName(), speakerLabel: () => getOwnerSpeakerLabel(), vocabulary: () => getVocabulary(), domainKeywords: () => getDomainKeywords(),
|
|
133
|
+
voiceProfileNames: cachedNames(async () => (await import('./speaker-embeddings.js')).readVoiceProfiles().profiles.map(p => p.name)),
|
|
134
|
+
}
|
|
135
|
+
/** A label the ASR or the transcript uses for "somebody": never a name to block (blocking "speaker" would reject queries). */
|
|
136
|
+
export function isGenericSpeakerLabel(label: string): boolean {
|
|
137
|
+
return /^(?:you|ext|unidentified|unknown|me|mu|host|note|notes|(?:speaker|guest|voice)\s*\d*)$/i.test(label.trim())
|
|
138
|
+
}
|
|
139
|
+
const fold = (w: string): string => w.normalize('NFKD').replace(/\p{M}/gu, '')
|
|
140
|
+
/** The owner's own terms, each as its stemmed lowercase words: every vocabulary term and every domain-keyword term (and each
|
|
141
|
+
* domain name). A query is rejected only when it carries a WHOLE term as a phrase (admitCueTopic), never one word of it:
|
|
142
|
+
* the real vocabulary holds plain work words ("pos", "retail") inside longer terms, and blocking those one by one would
|
|
143
|
+
* reject every work query. A term under three characters is ignored (a stray letter would match everything). */
|
|
144
|
+
export function ownerTermPhrases(src: ProfileNameSource = realProfileNames): string[][] {
|
|
145
|
+
const raw: string[] = []
|
|
146
|
+
try { raw.push(...src.vocabulary()) } catch { /* no vocabulary */ }
|
|
147
|
+
try { for (const [domain, words] of Object.entries(src.domainKeywords())) raw.push(domain.replace(/_/g, ' '), ...(Array.isArray(words) ? words.filter(w => typeof w === 'string') : [])) } catch { /* none */ }
|
|
148
|
+
const seen = new Set<string>(), out: string[][] = []
|
|
149
|
+
for (const term of raw) {
|
|
150
|
+
const words = factWords(fold(term)).map(stemWord), key = words.join(' ')
|
|
151
|
+
if (key.replace(/ /g, '').length < 3 || seen.has(key)) continue
|
|
152
|
+
seen.add(key)
|
|
153
|
+
out.push(words)
|
|
154
|
+
}
|
|
155
|
+
return out
|
|
156
|
+
}
|
|
157
|
+
/** The same terms with the spaces taken out, unstemmed ("Bottle POS" is bottlepos, "IT Retail" is itretail): a query that writes
|
|
158
|
+
* a term as one word or as a different split of its words still carries it. Compared to the query's words and to its adjacent
|
|
159
|
+
* two-word joins (admitCueTopic): any longer split of a term is already caught as a phrase. */
|
|
160
|
+
export function ownerTermJoins(src: ProfileNameSource = realProfileNames): string[] {
|
|
161
|
+
const raw: string[] = []
|
|
162
|
+
try { raw.push(...src.vocabulary()) } catch { /* no vocabulary */ }
|
|
163
|
+
try { for (const [domain, words] of Object.entries(src.domainKeywords())) raw.push(domain.replace(/_/g, ' '), ...(Array.isArray(words) ? words.filter(w => typeof w === 'string') : [])) } catch { /* none */ }
|
|
164
|
+
return [...new Set(raw.map(t => factWords(fold(t)).join('')).filter(j => j.length >= 3))]
|
|
165
|
+
}
|
|
166
|
+
/** Every name word a query must not carry, one word at a time: the owner and their label, every enrolled voice profile, the
|
|
167
|
+
* speakers the ASR path reported (and the other names the host knows), and the speaker labels that open lines of the window
|
|
168
|
+
* itself. Lowercase, with accents folded as well. REJECTS when the voice profile names cannot be read (the caller withholds).
|
|
169
|
+
* The profile's vocabulary is NOT here: see ownerTermPhrases. */
|
|
170
|
+
export async function blockedNameWords(window: string, speakers: readonly string[] = [], src: ProfileNameSource = realProfileNames): Promise<Set<string>> {
|
|
171
|
+
const names: string[] = [...speakers]
|
|
172
|
+
try { names.push(src.ownerName(), src.speakerLabel()) } catch { /* no profile */ }
|
|
173
|
+
if (src.voiceProfileNames) names.push(...await src.voiceProfileNames())
|
|
174
|
+
for (const m of window.matchAll(/^\s*(?:\[\d+\]\s*)?\*{0,2}(\p{Lu}[\p{L}'’.-]{0,24}(?:\s+\p{Lu}[\p{L}'’.-]{0,24}){0,2})\*{0,2}\s*:/gmu)) names.push(m[1])
|
|
175
|
+
const words = speakerNameWords([], names.filter(n => !isGenericSpeakerLabel(n)))
|
|
176
|
+
return new Set([...words, ...[...words].map(fold)])
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Query admission ─────────────────────────────────────────────────────────
|
|
180
|
+
const NUMBER_WORD = wordsRe(['zero', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen',
|
|
181
|
+
'sixteen', 'seventeen', 'eighteen', 'nineteen', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety', 'hundred', 'hundreds', 'thousand',
|
|
182
|
+
'thousands', 'million', 'millions', 'billion', 'billions', 'trillion', 'trillions', 'dozen', 'dozens', 'half', 'first', 'second', 'third', 'fourth', 'fifth',
|
|
183
|
+
'sixth', 'seventh', 'eighth', 'ninth', 'tenth', 'dollar*', 'bucks', 'percent', 'cent', 'cents'])
|
|
184
|
+
/** Latin script only: letters of another script can spell a word the lists above never see (a Cyrillic "a" inside "tax"). */
|
|
185
|
+
const QUERY_CHARS = /^[\p{Script=Latin}\p{M}][\p{Script=Latin}\p{M}'’.&\- ]*$/u
|
|
186
|
+
const URLISH = /\b(?:https?|www)\b/i
|
|
187
|
+
export type AdmitReason = 'empty' | 'too_long' | 'too_many_words' | 'characters' | 'url' | 'digit' | 'number_word' | 'secret' | 'deny_word' | 'name' | 'owner_term' | 'repeat'
|
|
188
|
+
/** The rejections a trace row may carry a query hash for. Every other reason names a private word, name, number or secret, and a
|
|
189
|
+
* hash of a short query is a dictionary lookup away from the query itself. */
|
|
190
|
+
export const HASHABLE_REJECTIONS: ReadonlySet<AdmitReason> = new Set(['repeat', 'too_long', 'too_many_words'])
|
|
191
|
+
export interface AdmitContext {
|
|
192
|
+
/** Lowercase name words from blockedNameWords(): each blocks on its own. */
|
|
193
|
+
nameWords: ReadonlySet<string>
|
|
194
|
+
/** The owner's own terms from ownerTermPhrases(): each blocks only as a whole phrase. */
|
|
195
|
+
terms: ReadonlyArray<readonly string[]>
|
|
196
|
+
/** The same terms with the spaces taken out (ownerTermJoins()): each blocks when a query word, or two adjacent query
|
|
197
|
+
* words written together, spell it. */
|
|
198
|
+
joins: ReadonlyArray<string>
|
|
199
|
+
/** Normalized queries already used in this meeting or by any meeting in the last 30 minutes. */
|
|
200
|
+
recent: ReadonlySet<string>
|
|
201
|
+
}
|
|
202
|
+
export type AdmitResult = { ok: true; query: string; normalized: string } | { ok: false; reason: AdmitReason }
|
|
203
|
+
|
|
204
|
+
/** The form two queries are compared in: lowercase words only. */
|
|
205
|
+
export function normalizeQuery(query: string): string {
|
|
206
|
+
return query.toLowerCase().normalize('NFKD').replace(/\p{M}/gu, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim()
|
|
207
|
+
}
|
|
208
|
+
/** First 12 hex of sha256 of the normalized query: the only form of a query a trace row may carry. */
|
|
209
|
+
export function queryHash(query: string): string {
|
|
210
|
+
return createHash('sha256').update(normalizeQuery(query)).digest('hex').slice(0, 12)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Rejects (never repairs) a topic query. In order: shape, then the content rules, then repeats. The first that fails decides.
|
|
214
|
+
* The query is read after NFKC, so full-width digits and letters are the plain ones the rules know. */
|
|
215
|
+
export function admitCueTopic(raw: string, ctx: AdmitContext): AdmitResult {
|
|
216
|
+
const query = raw.normalize('NFKC').replace(/\s+/g, ' ').trim()
|
|
217
|
+
if (!query) return { ok: false, reason: 'empty' }
|
|
218
|
+
if (query.length > GENERAL_QUERY_MAX_CHARS) return { ok: false, reason: 'too_long' }
|
|
219
|
+
if (query.split(' ').length > GENERAL_QUERY_MAX_WORDS) return { ok: false, reason: 'too_many_words' }
|
|
220
|
+
if (/\p{Nd}/u.test(query)) return { ok: false, reason: 'digit' }
|
|
221
|
+
const folded = normalizeForMatch(query)
|
|
222
|
+
if (NUMBER_WORD.test(folded)) return { ok: false, reason: 'number_word' }
|
|
223
|
+
if (!QUERY_CHARS.test(query)) return { ok: false, reason: 'characters' }
|
|
224
|
+
if (URLISH.test(folded)) return { ok: false, reason: 'url' }
|
|
225
|
+
if (query !== redactSecrets(query)) return { ok: false, reason: 'secret' }
|
|
226
|
+
if (denyWordHit(folded)) return { ok: false, reason: 'deny_word' }
|
|
227
|
+
const words = factWords(folded)
|
|
228
|
+
if (words.some(w => ctx.nameWords.has(w) || ctx.nameWords.has(stemWord(w)))) return { ok: false, reason: 'name' }
|
|
229
|
+
const padded = ` ${words.map(stemWord).join(' ')} `
|
|
230
|
+
if (ctx.terms.some(t => padded.includes(` ${t.join(' ')} `))) return { ok: false, reason: 'owner_term' }
|
|
231
|
+
const grams = new Set<string>()
|
|
232
|
+
for (let n = 1; n <= 2; n++) for (let i = 0; i + n <= words.length; i++) grams.add(stemWord(words.slice(i, i + n).join('')))
|
|
233
|
+
if (ctx.joins.some(j => grams.has(stemWord(j)))) return { ok: false, reason: 'owner_term' }
|
|
234
|
+
const normalized = normalizeQuery(query)
|
|
235
|
+
if (ctx.recent.has(normalized)) return { ok: false, reason: 'repeat' }
|
|
236
|
+
return { ok: true, query, normalized }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** A local query gets the owner's city-level home area, only when the profile's label passes HOME_AREA_LABEL (letters,
|
|
240
|
+
* spaces and . , ' - only, so never an address). The caller admits the result again: the longer string is a new query. */
|
|
241
|
+
export function withHomeArea(query: string, area: string | null): string | null {
|
|
242
|
+
const label = area?.replace(/\s+/g, ' ').trim() ?? ''
|
|
243
|
+
if (!label || !HOME_AREA_LABEL.test(label)) return null
|
|
244
|
+
return `${query} ${label.replace(/,/g, '')}`
|
|
245
|
+
}
|