@gotcos/glasses-server 6.21.13 → 6.21.17
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 +105 -0
- package/package.json +1 -1
- package/server/lib/chunk-embedding-store.ts +260 -0
- package/server/lib/cos-operations-meetings.ts +99 -1
- package/server/lib/embedding-eviction.ts +133 -0
- package/server/lib/meeting-corrections.ts +218 -0
- package/server/lib/meeting-relabel.ts +267 -0
- package/server/lib/speaker-embeddings.ts +63 -9
- package/server/lib/voice-profile-store.ts +36 -1
- package/server/routes/health.ts +16 -1
- package/server/routes/meeting.ts +205 -10
- package/server/routes/transcribe-stream.ts +28 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// The record of every speaker correction a human has made, per meeting.
|
|
2
|
+
//
|
|
3
|
+
// WHY A LEDGER AND NOT JUST A REWRITE. A relabel mutates files in place: the
|
|
4
|
+
// chunk sidecar, the attendee list, the transcript turn labels. If that is all
|
|
5
|
+
// that happens, three things become impossible:
|
|
6
|
+
//
|
|
7
|
+
// 1. UNDO. Once "Luke H" has become "Luke Henry" in the sidecar, nothing
|
|
8
|
+
// remembers it was ever anything else. A mistaken correction is permanent.
|
|
9
|
+
// 2. CRASH RECOVERY. A rewrite touches several files. Die between them and the
|
|
10
|
+
// meeting is half-corrected with no trace of what was intended.
|
|
11
|
+
// 3. TRAINING. Piece 3 turns a correction into an enrollment. It needs to know
|
|
12
|
+
// WHICH chunks a human vouched for, and to be able to retract that vouching
|
|
13
|
+
// later if the correction is undone.
|
|
14
|
+
//
|
|
15
|
+
// So the ledger is written FIRST, as intent, and the rewrite follows. A row with
|
|
16
|
+
// an intent and no outcome is a correction that did not finish — visible rather
|
|
17
|
+
// than silent.
|
|
18
|
+
//
|
|
19
|
+
// WHAT IS DELIBERATELY NOT CORRECTED. The meeting markdown's Summary, Topics,
|
|
20
|
+
// Decisions and Action Items are LLM prose that refers to people by BARE FIRST
|
|
21
|
+
// NAME ("Jeremy pushed back", "Chris raised Beamer sentiment"). Verified on a
|
|
22
|
+
// real scribe: 6 of 12 speakers appear that way. This org has two Kyles, two
|
|
23
|
+
// Jacobuses and two Chrises, so a find/replace on a first name in narrative text
|
|
24
|
+
// would silently rewrite a sentence about a different person. Prose is left
|
|
25
|
+
// alone and flagged stale instead — `proseStale` on the applied row.
|
|
26
|
+
|
|
27
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
28
|
+
import { join, resolve } from 'node:path'
|
|
29
|
+
import { dataPath } from './data-dir.js'
|
|
30
|
+
|
|
31
|
+
export const CORRECTIONS_DIR = 'meeting-corrections'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A correction is recorded in two phases sharing one id.
|
|
35
|
+
* `intent` goes down before any file is touched; `applied` or `failed` closes it.
|
|
36
|
+
* An unclosed intent is an incomplete correction, not a successful one.
|
|
37
|
+
*/
|
|
38
|
+
export type CorrectionPhase = 'intent' | 'applied' | 'failed'
|
|
39
|
+
|
|
40
|
+
export interface CorrectionSurfaces {
|
|
41
|
+
/** Chunks whose `speaker` changed in the sidecar. */
|
|
42
|
+
sidecar: number
|
|
43
|
+
/** Lines changed in the markdown attendee list. */
|
|
44
|
+
attendees: number
|
|
45
|
+
/** `[Name]:` turn labels changed in the markdown transcript. */
|
|
46
|
+
transcript: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface CorrectionRow {
|
|
50
|
+
id: string
|
|
51
|
+
phase: CorrectionPhase
|
|
52
|
+
/** ISO timestamp, supplied by the caller so this module stays deterministic. */
|
|
53
|
+
at: string
|
|
54
|
+
from: string
|
|
55
|
+
to: string
|
|
56
|
+
/**
|
|
57
|
+
* The exact chunk indices this correction covers. Explicit rather than derived,
|
|
58
|
+
* because piece 3 enrolls precisely these and must be able to retract
|
|
59
|
+
* precisely these. An empty array means "every chunk carrying `from`", which
|
|
60
|
+
* is resolved at apply time and written back onto the applied row.
|
|
61
|
+
*/
|
|
62
|
+
chunks: number[]
|
|
63
|
+
/**
|
|
64
|
+
* 'meeting' is the only scope. Corrections are per-meeting BY DESIGN: the
|
|
65
|
+
* identifier mishearing one voice in one room does not mean every past chunk
|
|
66
|
+
* was wrong, and rewriting history on a single correction is how a small
|
|
67
|
+
* mistake becomes an unrecoverable one.
|
|
68
|
+
*/
|
|
69
|
+
scope: 'meeting'
|
|
70
|
+
surfaces?: CorrectionSurfaces
|
|
71
|
+
/** True when narrative prose still carries the old label. See the header. */
|
|
72
|
+
proseStale?: boolean
|
|
73
|
+
/** Why an attempt failed, on a `failed` row. */
|
|
74
|
+
error?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sessionFile(sessionId: string): string | null {
|
|
78
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) return null
|
|
79
|
+
const dir = dataPath(CORRECTIONS_DIR)
|
|
80
|
+
const path = join(dir, `${sessionId.replace(/:/g, '_')}.jsonl`)
|
|
81
|
+
return resolve(path).startsWith(resolve(dir) + '/') ? path : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Append one row. Returns false rather than throwing on a bad session id or an
|
|
86
|
+
* unwritable directory — but note the caller's contract: if the INTENT row
|
|
87
|
+
* cannot be written, the rewrite must not proceed. An unrecorded mutation is
|
|
88
|
+
* exactly what this file exists to prevent.
|
|
89
|
+
*/
|
|
90
|
+
export function appendCorrection(sessionId: string, row: CorrectionRow): boolean {
|
|
91
|
+
const path = sessionFile(sessionId)
|
|
92
|
+
if (!path) return false
|
|
93
|
+
try {
|
|
94
|
+
mkdirSync(dataPath(CORRECTIONS_DIR), { recursive: true, mode: 0o700 })
|
|
95
|
+
appendFileSync(path, JSON.stringify(row) + '\n', { mode: 0o600 })
|
|
96
|
+
return true
|
|
97
|
+
} catch {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface CorrectionReadResult {
|
|
103
|
+
rows: CorrectionRow[]
|
|
104
|
+
/** Lines that could not be parsed. Surfaced, because a correction history read
|
|
105
|
+
* partially is a correction history that lies about what a human decided. */
|
|
106
|
+
unusable: number
|
|
107
|
+
missing: boolean
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function readCorrections(sessionId: string): CorrectionReadResult {
|
|
111
|
+
const path = sessionFile(sessionId)
|
|
112
|
+
if (!path || !existsSync(path)) return { rows: [], unusable: 0, missing: true }
|
|
113
|
+
let raw: string
|
|
114
|
+
try {
|
|
115
|
+
raw = readFileSync(path, 'utf-8')
|
|
116
|
+
} catch {
|
|
117
|
+
return { rows: [], unusable: 0, missing: true }
|
|
118
|
+
}
|
|
119
|
+
const rows: CorrectionRow[] = []
|
|
120
|
+
let unusable = 0
|
|
121
|
+
for (const line of raw.split('\n')) {
|
|
122
|
+
if (line.trim() === '') continue
|
|
123
|
+
try {
|
|
124
|
+
const o = JSON.parse(line) as Record<string, unknown>
|
|
125
|
+
if (typeof o.id !== 'string' || typeof o.from !== 'string' || typeof o.to !== 'string') { unusable++; continue }
|
|
126
|
+
if (o.phase !== 'intent' && o.phase !== 'applied' && o.phase !== 'failed') { unusable++; continue }
|
|
127
|
+
rows.push({
|
|
128
|
+
id: o.id,
|
|
129
|
+
phase: o.phase,
|
|
130
|
+
at: typeof o.at === 'string' ? o.at : '',
|
|
131
|
+
from: o.from,
|
|
132
|
+
to: o.to,
|
|
133
|
+
chunks: Array.isArray(o.chunks) ? o.chunks.filter((n): n is number => typeof n === 'number') : [],
|
|
134
|
+
scope: 'meeting',
|
|
135
|
+
surfaces: isSurfaces(o.surfaces) ? o.surfaces : undefined,
|
|
136
|
+
proseStale: typeof o.proseStale === 'boolean' ? o.proseStale : undefined,
|
|
137
|
+
error: typeof o.error === 'string' ? o.error : undefined,
|
|
138
|
+
})
|
|
139
|
+
} catch {
|
|
140
|
+
unusable++
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { rows, unusable, missing: false }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isSurfaces(v: unknown): v is CorrectionSurfaces {
|
|
147
|
+
if (!v || typeof v !== 'object') return false
|
|
148
|
+
const o = v as Record<string, unknown>
|
|
149
|
+
return typeof o.sidecar === 'number' && typeof o.attendees === 'number' && typeof o.transcript === 'number'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Corrections that recorded an intent and never closed it.
|
|
154
|
+
*
|
|
155
|
+
* This is the crash signal. A process that died mid-rewrite leaves exactly this,
|
|
156
|
+
* and a meeting with a pending correction should be treated as possibly
|
|
157
|
+
* half-written rather than clean.
|
|
158
|
+
*/
|
|
159
|
+
export function pendingCorrections(sessionId: string): CorrectionRow[] {
|
|
160
|
+
const { rows } = readCorrections(sessionId)
|
|
161
|
+
const closed = new Set(rows.filter(r => r.phase !== 'intent').map(r => r.id))
|
|
162
|
+
return rows.filter(r => r.phase === 'intent' && !closed.has(r.id))
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Only the corrections that actually landed — the ones piece 3 may train on. */
|
|
166
|
+
export function appliedCorrections(sessionId: string): CorrectionRow[] {
|
|
167
|
+
return readCorrections(sessionId).rows.filter(r => r.phase === 'applied')
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Follow a chain of applied corrections to the label a voice now carries.
|
|
172
|
+
*
|
|
173
|
+
* Chains happen: a voice labelled 'Ext' is corrected to 'Luke H', then later to
|
|
174
|
+
* 'Luke Henry'. Asking "what is Ext now?" must answer 'Luke Henry', not stop at
|
|
175
|
+
* the first hop. Cycles are possible if a human corrects A→B then B→A, so the
|
|
176
|
+
* walk is bounded by the number of corrections and returns the last label
|
|
177
|
+
* reached rather than looping.
|
|
178
|
+
*/
|
|
179
|
+
export function currentLabelFor(sessionId: string, originalLabel: string): string {
|
|
180
|
+
const applied = appliedCorrections(sessionId)
|
|
181
|
+
let label = originalLabel
|
|
182
|
+
const seen = new Set<string>([label])
|
|
183
|
+
for (let hop = 0; hop < applied.length; hop++) {
|
|
184
|
+
// Latest applied correction FROM the current label wins: a later human
|
|
185
|
+
// decision supersedes an earlier one.
|
|
186
|
+
const next = [...applied].reverse().find(r => r.from === label)
|
|
187
|
+
if (!next || seen.has(next.to)) break
|
|
188
|
+
label = next.to
|
|
189
|
+
seen.add(label)
|
|
190
|
+
}
|
|
191
|
+
return label
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Counts for /api/health — pending is the number that matters. */
|
|
195
|
+
export function correctionStoreStats(): {
|
|
196
|
+
sessions: number
|
|
197
|
+
applied: number
|
|
198
|
+
pending: number
|
|
199
|
+
failed: number
|
|
200
|
+
} {
|
|
201
|
+
const dir = dataPath(CORRECTIONS_DIR)
|
|
202
|
+
if (!existsSync(dir)) return { sessions: 0, applied: 0, pending: 0, failed: 0 }
|
|
203
|
+
let sessions = 0, applied = 0, pending = 0, failed = 0
|
|
204
|
+
try {
|
|
205
|
+
for (const name of readdirSync(dir).filter(n => n.endsWith('.jsonl'))) {
|
|
206
|
+
try {
|
|
207
|
+
statSync(join(dir, name))
|
|
208
|
+
sessions++
|
|
209
|
+
const id = name.replace(/\.jsonl$/, '')
|
|
210
|
+
const { rows } = readCorrections(id)
|
|
211
|
+
applied += rows.filter(r => r.phase === 'applied').length
|
|
212
|
+
failed += rows.filter(r => r.phase === 'failed').length
|
|
213
|
+
pending += pendingCorrections(id).length
|
|
214
|
+
} catch { /* skip unreadable */ }
|
|
215
|
+
}
|
|
216
|
+
} catch { /* report what we have */ }
|
|
217
|
+
return { sessions, applied, pending, failed }
|
|
218
|
+
}
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// Rewriting a speaker label inside one meeting's files.
|
|
2
|
+
//
|
|
3
|
+
// Pure over strings: every function takes file content and returns file content,
|
|
4
|
+
// so each rule below is testable by execution rather than by reading it.
|
|
5
|
+
//
|
|
6
|
+
// THE CONSTRAINT THAT SHAPES THIS FILE. The markdown transcript is NOT an
|
|
7
|
+
// index-parallel rendering of the chunk sidecar. Measured on a real meeting
|
|
8
|
+
// (2026-08-06 Health Score V2): 135 sidecar chunks collapse to 46 speaker runs,
|
|
9
|
+
// while the markdown carries 70 turns — and they disagree on who spoke, with the
|
|
10
|
+
// markdown's 4th turn attributed to Joe Karbowski where the sidecar's 4th run
|
|
11
|
+
// says Richard Jenkins. The transcript was rendered from a different
|
|
12
|
+
// segmentation pass. So:
|
|
13
|
+
//
|
|
14
|
+
// * There is NO mapping from a chunk index to a transcript turn.
|
|
15
|
+
// * A relabel of EVERY chunk carrying a label can still be applied to the
|
|
16
|
+
// markdown, because "all X becomes Y" needs no index alignment.
|
|
17
|
+
// * A relabel of a SUBSET of chunks cannot touch the markdown transcript at
|
|
18
|
+
// all. Rewriting by label would relabel turns the human never selected.
|
|
19
|
+
//
|
|
20
|
+
// `coveredAllWithLabel` on the sidecar result is how the caller knows which case
|
|
21
|
+
// it is. It is returned as DATA rather than left as a rule to remember.
|
|
22
|
+
//
|
|
23
|
+
// AND WHAT IS NEVER REWRITTEN: the Summary / Topics / Decisions / Action Items
|
|
24
|
+
// prose. It refers to people by bare first name — 6 of 12 speakers did so on the
|
|
25
|
+
// meeting above — and this org has two Kyles, two Jacobuses and two Chrises, so
|
|
26
|
+
// a first-name substitution in narrative text would rewrite sentences about
|
|
27
|
+
// somebody else. Reported as `proseStale` instead.
|
|
28
|
+
|
|
29
|
+
/** A label that would corrupt the file formats it gets written into. */
|
|
30
|
+
export function invalidLabelReason(label: string): string | null {
|
|
31
|
+
if (label.trim() === '') return 'label is empty'
|
|
32
|
+
if (label !== label.trim()) return 'label has leading or trailing whitespace'
|
|
33
|
+
if (label.length > 120) return 'label is longer than 120 characters'
|
|
34
|
+
// `[Name]:` delimits transcript turns and `- Name` delimits attendees; a
|
|
35
|
+
// label containing these would make the file unparseable by its own readers.
|
|
36
|
+
if (/[\[\]\n\r]/.test(label)) return 'label contains a bracket or newline'
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function escapeRegExp(s: string): string {
|
|
41
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SidecarRelabelResult {
|
|
45
|
+
/** Serialized in the same shape the pipeline writes: 2-space indent, trailing newline. */
|
|
46
|
+
json: string
|
|
47
|
+
/** Chunk indices whose speaker actually changed. */
|
|
48
|
+
changed: number[]
|
|
49
|
+
/**
|
|
50
|
+
* True when no chunk still carries `from` after this relabel. ONLY then may the
|
|
51
|
+
* caller rewrite the markdown transcript by label. See the file header.
|
|
52
|
+
*/
|
|
53
|
+
coveredAllWithLabel: boolean
|
|
54
|
+
/** The top-level `speakers` array after the relabel. */
|
|
55
|
+
speakers: string[]
|
|
56
|
+
/** Chunks still carrying `from` — non-zero for a deliberate partial relabel. */
|
|
57
|
+
remainingWithFrom: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type RelabelOutcome<T> = { ok: true; value: T } | { ok: false; error: string }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Relabel chunks in a `.g2-chunks.json` sidecar.
|
|
64
|
+
*
|
|
65
|
+
* `chunks` empty means every chunk carrying `from`. An explicit list restricts
|
|
66
|
+
* the change to those indices — the partial case, which exists because the
|
|
67
|
+
* identifier can mishear one stretch of a meeting without being wrong about the
|
|
68
|
+
* rest.
|
|
69
|
+
*/
|
|
70
|
+
export function relabelSidecarJson(
|
|
71
|
+
raw: string,
|
|
72
|
+
from: string,
|
|
73
|
+
to: string,
|
|
74
|
+
chunks: number[] = [],
|
|
75
|
+
): RelabelOutcome<SidecarRelabelResult> {
|
|
76
|
+
if (from === to) return { ok: false, error: 'from and to are the same label' }
|
|
77
|
+
for (const [which, label] of [['from', from], ['to', to]] as const) {
|
|
78
|
+
const bad = invalidLabelReason(label)
|
|
79
|
+
if (bad) return { ok: false, error: `${which}: ${bad}` }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let parsed: unknown
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw)
|
|
85
|
+
} catch {
|
|
86
|
+
return { ok: false, error: 'sidecar is not valid JSON' }
|
|
87
|
+
}
|
|
88
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
89
|
+
return { ok: false, error: 'sidecar is not an object' }
|
|
90
|
+
}
|
|
91
|
+
const doc = parsed as Record<string, unknown>
|
|
92
|
+
const rows = doc.chunks
|
|
93
|
+
if (!Array.isArray(rows)) return { ok: false, error: 'sidecar has no chunks array' }
|
|
94
|
+
|
|
95
|
+
const restrict = chunks.length > 0 ? new Set(chunks) : null
|
|
96
|
+
// A caller naming indices that do not carry `from` is confused about what it
|
|
97
|
+
// is correcting; failing loudly beats silently relabelling nothing.
|
|
98
|
+
if (restrict) {
|
|
99
|
+
const mismatched = chunks.filter(i => {
|
|
100
|
+
const row = rows[i]
|
|
101
|
+
return !row || typeof row !== 'object' || (row as Record<string, unknown>).speaker !== from
|
|
102
|
+
})
|
|
103
|
+
if (mismatched.length > 0) {
|
|
104
|
+
return { ok: false, error: `chunks do not carry "${from}": ${mismatched.slice(0, 8).join(', ')}` }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const changed: number[] = []
|
|
109
|
+
let remainingWithFrom = 0
|
|
110
|
+
rows.forEach((row, i) => {
|
|
111
|
+
// The `typeof` half guards the cast below rather than any behaviour — a
|
|
112
|
+
// truthy non-object can never satisfy `.speaker === from`, so it is
|
|
113
|
+
// deliberately not claimed as tested.
|
|
114
|
+
if (!row || typeof row !== 'object') return
|
|
115
|
+
const r = row as Record<string, unknown>
|
|
116
|
+
if (r.speaker !== from) return
|
|
117
|
+
if (restrict && !restrict.has(i)) { remainingWithFrom++; return }
|
|
118
|
+
r.speaker = to
|
|
119
|
+
changed.push(i)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
if (changed.length === 0) return { ok: false, error: `no chunk carries "${from}"` }
|
|
123
|
+
|
|
124
|
+
// Keep the top-level speaker list truthful: it is what the review panel and
|
|
125
|
+
// the attendee renderer read, so a stale entry shows a person who is no longer
|
|
126
|
+
// attributed anything.
|
|
127
|
+
const existing = Array.isArray(doc.speakers) ? doc.speakers.filter((s): s is string => typeof s === 'string') : []
|
|
128
|
+
const speakers = [...existing]
|
|
129
|
+
if (!speakers.includes(to)) {
|
|
130
|
+
// Replace in place when `from` is being fully retired, so the list keeps its
|
|
131
|
+
// original ordering rather than moving the person to the end.
|
|
132
|
+
const at = speakers.indexOf(from)
|
|
133
|
+
if (at >= 0 && remainingWithFrom === 0) speakers[at] = to
|
|
134
|
+
else speakers.push(to)
|
|
135
|
+
} else if (remainingWithFrom === 0) {
|
|
136
|
+
// `to` already listed and `from` fully retired — merging a split identity.
|
|
137
|
+
// Drop the old entry rather than leaving a name nothing is attributed to.
|
|
138
|
+
const at = speakers.indexOf(from)
|
|
139
|
+
if (at >= 0) speakers.splice(at, 1)
|
|
140
|
+
}
|
|
141
|
+
doc.speakers = speakers
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
value: {
|
|
146
|
+
json: `${JSON.stringify(doc, null, 2)}\n`,
|
|
147
|
+
changed,
|
|
148
|
+
coveredAllWithLabel: remainingWithFrom === 0,
|
|
149
|
+
speakers,
|
|
150
|
+
remainingWithFrom,
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface MarkdownRelabelResult {
|
|
156
|
+
markdown: string
|
|
157
|
+
/** Attendee bullet lines changed or removed. */
|
|
158
|
+
attendees: number
|
|
159
|
+
/** `[Name]:` turn labels rewritten. */
|
|
160
|
+
transcript: number
|
|
161
|
+
/**
|
|
162
|
+
* True when the old label still appears in narrative prose, which is left
|
|
163
|
+
* untouched on purpose. The caller records this so a human can be told the
|
|
164
|
+
* summary predates their correction.
|
|
165
|
+
*/
|
|
166
|
+
proseStale: boolean
|
|
167
|
+
/** The prose forms found, for an honest message rather than a bare boolean. */
|
|
168
|
+
proseHits: string[]
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Byte range of a `## Heading` section, or null when absent. */
|
|
172
|
+
function sectionRange(md: string, heading: string): { start: number; end: number } | null {
|
|
173
|
+
const re = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, 'm')
|
|
174
|
+
const m = re.exec(md)
|
|
175
|
+
if (!m) return null
|
|
176
|
+
const start = m.index + m[0].length
|
|
177
|
+
const next = /^##\s+/m.exec(md.slice(start))
|
|
178
|
+
return { start, end: next ? start + next.index : md.length }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Rewrite the structured label positions in a meeting markdown file.
|
|
183
|
+
*
|
|
184
|
+
* ONLY sound when every chunk carrying `from` is being relabelled — this works
|
|
185
|
+
* by label, not by index, and the markdown's turn segmentation does not match
|
|
186
|
+
* the sidecar's. See the file header.
|
|
187
|
+
*/
|
|
188
|
+
export function relabelMeetingMarkdown(md: string, from: string, to: string): RelabelOutcome<MarkdownRelabelResult> {
|
|
189
|
+
if (from === to) return { ok: false, error: 'from and to are the same label' }
|
|
190
|
+
for (const [which, label] of [['from', from], ['to', to]] as const) {
|
|
191
|
+
const bad = invalidLabelReason(label)
|
|
192
|
+
if (bad) return { ok: false, error: `${which}: ${bad}` }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let out = md
|
|
196
|
+
let attendees = 0
|
|
197
|
+
let transcript = 0
|
|
198
|
+
|
|
199
|
+
// --- Attendees: exact bullet lines only, inside the Attendees section only.
|
|
200
|
+
const att = sectionRange(out, 'Attendees')
|
|
201
|
+
if (att) {
|
|
202
|
+
const body = out.slice(att.start, att.end)
|
|
203
|
+
// Anchored at BOTH ends, and `[ \t]` rather than `\s` so a pattern can never
|
|
204
|
+
// run past its own line. Without the end anchor, `- Luke H` matches the
|
|
205
|
+
// PREFIX of `- Luke Henry` and deleting it leaves the fragment `enry`.
|
|
206
|
+
const line = (label: string) => `^-[ \t]+${escapeRegExp(label)}[ \t]*$`
|
|
207
|
+
const alreadyListed = new RegExp(line(to), 'm').test(body)
|
|
208
|
+
let rewritten: string
|
|
209
|
+
if (alreadyListed) {
|
|
210
|
+
// Both names present: drop the old bullet instead of creating a duplicate
|
|
211
|
+
// attendee. This is the normal case when merging a split identity.
|
|
212
|
+
rewritten = body.replace(new RegExp(`${line(from)}\\n?`, 'gm'), () => { attendees++; return '' })
|
|
213
|
+
} else {
|
|
214
|
+
rewritten = body.replace(new RegExp(line(from), 'gm'), () => { attendees++; return `- ${to}` })
|
|
215
|
+
}
|
|
216
|
+
out = out.slice(0, att.start) + rewritten + out.slice(att.end)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// --- Transcript: `[Name]:` at line start only. A name appearing inside spoken
|
|
220
|
+
// text is a quote, not a label, and must not be touched.
|
|
221
|
+
const tr = sectionRange(out, 'Transcript')
|
|
222
|
+
if (tr) {
|
|
223
|
+
const body = out.slice(tr.start, tr.end)
|
|
224
|
+
const rewritten = body.replace(
|
|
225
|
+
new RegExp(`^\\[${escapeRegExp(from)}\\]:`, 'gm'),
|
|
226
|
+
() => { transcript++; return `[${to}]:` },
|
|
227
|
+
)
|
|
228
|
+
out = out.slice(0, tr.start) + rewritten + out.slice(tr.end)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- Prose: detect, never rewrite.
|
|
232
|
+
const proseHits = detectProseReferences(out, from)
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
ok: true,
|
|
236
|
+
value: { markdown: out, attendees, transcript, proseStale: proseHits.length > 0, proseHits },
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Forms of `label` still present in narrative prose.
|
|
242
|
+
*
|
|
243
|
+
* Searches everything except the attendee list and the transcript — the
|
|
244
|
+
* transcript's body is spoken words, and a name said aloud is a quote rather
|
|
245
|
+
* than a stale attribution.
|
|
246
|
+
*/
|
|
247
|
+
export function detectProseReferences(md: string, label: string): string[] {
|
|
248
|
+
const ranges = [sectionRange(md, 'Attendees'), sectionRange(md, 'Transcript')].filter(
|
|
249
|
+
(r): r is { start: number; end: number } => r !== null,
|
|
250
|
+
)
|
|
251
|
+
let prose = ''
|
|
252
|
+
let cursor = 0
|
|
253
|
+
for (const r of ranges.sort((a, b) => a.start - b.start)) {
|
|
254
|
+
prose += md.slice(cursor, r.start)
|
|
255
|
+
cursor = Math.max(cursor, r.end)
|
|
256
|
+
}
|
|
257
|
+
prose += md.slice(cursor)
|
|
258
|
+
|
|
259
|
+
const hits: string[] = []
|
|
260
|
+
const full = new RegExp(`\\b${escapeRegExp(label)}\\b`)
|
|
261
|
+
if (full.test(prose)) hits.push(label)
|
|
262
|
+
const first = label.split(/\s+/)[0]
|
|
263
|
+
// Only a multi-word label has a distinct bare-first-name form. 'MU' or 'Ext'
|
|
264
|
+
// would otherwise be reported twice for the same match.
|
|
265
|
+
if (first && first !== label && new RegExp(`\\b${escapeRegExp(first)}\\b`).test(prose)) hits.push(first)
|
|
266
|
+
return hits
|
|
267
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Speaker embedding extraction and verification using sherpa-onnx
|
|
2
|
+
import { chooseEviction, tierBreakdown } from './embedding-eviction.js'
|
|
2
3
|
// Wraps ECAPA-TDNN model for voiceprint-based speaker classification.
|
|
3
4
|
// Falls back gracefully if model is missing — amplitude classification continues.
|
|
4
5
|
//
|
|
@@ -15,6 +16,8 @@ import {
|
|
|
15
16
|
mergeProfilesInStore,
|
|
16
17
|
profileSimilarity,
|
|
17
18
|
describeRepairs,
|
|
19
|
+
alignedSources,
|
|
20
|
+
dropEmbeddingAt,
|
|
18
21
|
dropOldestEmbedding,
|
|
19
22
|
hasRepairs,
|
|
20
23
|
loadVoiceProfileStore,
|
|
@@ -87,7 +90,11 @@ const VERIFY_THRESHOLD = 0.65
|
|
|
87
90
|
const SEARCH_THRESHOLD = 0.55
|
|
88
91
|
const AUTO_ENROLL_THRESHOLD = 0.88 // High bar — must be very confident before auto-enrolling
|
|
89
92
|
const AUTO_ENROLL_CONSENSUS = 2 // Must match N times in same session before enrolling
|
|
90
|
-
|
|
93
|
+
// Raised 20 -> 40 on 2026-08-06. Measured, not guessed: search latency is 1 us
|
|
94
|
+
// at 20, 40 AND 80 samples per speaker (77 speakers, sherpa SpeakerEmbeddingManager),
|
|
95
|
+
// so the old cap defended nothing — while 61 of 77 profiles sat AT it, meaning
|
|
96
|
+
// every correction cost a sample. 20 extra slots per speaker is ~1.2 MB.
|
|
97
|
+
const MAX_EMBEDDINGS_PER_SPEAKER = 40
|
|
91
98
|
const SAMPLE_RATE = 16000
|
|
92
99
|
|
|
93
100
|
// Module-level state — sherpa-onnx-node is CJS with no TS types (SDK v0.0.7 interop)
|
|
@@ -308,8 +315,24 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
|
|
|
308
315
|
// `sources?.shift()` no-opped whenever sources was undefined or short,
|
|
309
316
|
// permanently offsetting provenance from the samples it described.
|
|
310
317
|
if (profile.embeddings.length >= MAX_EMBEDDINGS_PER_SPEAKER) {
|
|
311
|
-
|
|
312
|
-
|
|
318
|
+
// Weakest PROVENANCE goes, not the oldest sample. Age is the wrong axis:
|
|
319
|
+
// four profiles at cap would lose their only human-supplied sample to
|
|
320
|
+
// FIFO while unverified attendee-metadata samples sat untouched.
|
|
321
|
+
// alignedSources is defence-in-depth here, not load-bearing: loadProfileStore
|
|
322
|
+
// already pads sources[] to match embeddings[], so a ragged array cannot
|
|
323
|
+
// reach this line (mutation-verified — swapping it for profile.sources is
|
|
324
|
+
// unobservable through this path). It is kept for any future caller that
|
|
325
|
+
// builds a profile without going through the loader, and is unit-tested
|
|
326
|
+
// directly in voice-profile-store.test.ts.
|
|
327
|
+
const choice = chooseEviction(alignedSources(profile), source, MAX_EMBEDDINGS_PER_SPEAKER)
|
|
328
|
+
const { droppedSource } = choice
|
|
329
|
+
? dropEmbeddingAt(profile, choice.index)
|
|
330
|
+
: dropOldestEmbedding(profile)
|
|
331
|
+
console.log(
|
|
332
|
+
`[speaker] Profile full for "${name}" (${MAX_EMBEDDINGS_PER_SPEAKER}) — `
|
|
333
|
+
+ `${choice ? choice.reason : 'no provenance available, dropping the oldest'} `
|
|
334
|
+
+ `[dropped ${droppedSource ?? 'unknown'}, incoming ${source}]`,
|
|
335
|
+
)
|
|
313
336
|
rebuildSpeakerInManager(name, profile.embeddings)
|
|
314
337
|
}
|
|
315
338
|
}
|
|
@@ -342,7 +365,7 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
|
|
|
342
365
|
export function identifySpeaker(
|
|
343
366
|
wavBuffer: Buffer,
|
|
344
367
|
expectedSpeakers?: string[],
|
|
345
|
-
): { speaker: string; similarity: number } | null {
|
|
368
|
+
): { speaker: string; similarity: number; embedding?: Float32Array } | null {
|
|
346
369
|
if (!extractor || !manager) return null
|
|
347
370
|
|
|
348
371
|
try {
|
|
@@ -356,7 +379,7 @@ export function identifySpeaker(
|
|
|
356
379
|
if (isOwner) {
|
|
357
380
|
const similarity = computeCosineSimilarity(embedding, owner)
|
|
358
381
|
logCalibration(owner, similarity, true)
|
|
359
|
-
return { speaker: owner, similarity }
|
|
382
|
+
return { speaker: owner, similarity, embedding }
|
|
360
383
|
}
|
|
361
384
|
}
|
|
362
385
|
|
|
@@ -369,7 +392,7 @@ export function identifySpeaker(
|
|
|
369
392
|
if (matches) {
|
|
370
393
|
const similarity = computeCosineSimilarity(embedding, name)
|
|
371
394
|
logCalibration(name, similarity, true)
|
|
372
|
-
return { speaker: name, similarity }
|
|
395
|
+
return { speaker: name, similarity, embedding }
|
|
373
396
|
}
|
|
374
397
|
}
|
|
375
398
|
}
|
|
@@ -379,12 +402,14 @@ export function identifySpeaker(
|
|
|
379
402
|
if (found && found.length > 0) {
|
|
380
403
|
const similarity = computeCosineSimilarity(embedding, found)
|
|
381
404
|
logCalibration(found, similarity, true)
|
|
382
|
-
return { speaker: found, similarity }
|
|
405
|
+
return { speaker: found, similarity, embedding }
|
|
383
406
|
}
|
|
384
407
|
|
|
385
|
-
// No match — external speaker
|
|
408
|
+
// No match — external speaker. The embedding still goes back: an
|
|
409
|
+
// unidentified voice's vector is the most valuable thing to retain, because
|
|
410
|
+
// naming it later is exactly the correction that has no other evidence.
|
|
386
411
|
logCalibration('Ext', 0, false)
|
|
387
|
-
return { speaker: 'Ext', similarity: 0 }
|
|
412
|
+
return { speaker: 'Ext', similarity: 0, embedding }
|
|
388
413
|
} catch (err: unknown) {
|
|
389
414
|
console.error('[speaker] Identification error:', errMsg(err))
|
|
390
415
|
return null
|
|
@@ -665,6 +690,35 @@ export function speakerReadiness(
|
|
|
665
690
|
return state === 'active' ? 'ready' : 'unavailable'
|
|
666
691
|
}
|
|
667
692
|
|
|
693
|
+
/**
|
|
694
|
+
* Provenance composition across every stored profile, plus the profiles with no
|
|
695
|
+
* human-verified sample at all.
|
|
696
|
+
*
|
|
697
|
+
* Surfaced because the number that mattered here was invisible: the owner's own
|
|
698
|
+
* profile — the one driving owner detection — was 10 attendee-metadata samples,
|
|
699
|
+
* 9 identifier-labelled ones and 1 unlabelled, with nothing a human had ever
|
|
700
|
+
* confirmed. Nothing in any status output said so.
|
|
701
|
+
*/
|
|
702
|
+
export function profileProvenanceSummary(): {
|
|
703
|
+
profiles: number
|
|
704
|
+
atCap: number
|
|
705
|
+
cap: number
|
|
706
|
+
tiers: Record<string, number>
|
|
707
|
+
noHumanSample: string[]
|
|
708
|
+
} {
|
|
709
|
+
const store = loadProfileStore()
|
|
710
|
+
const tiers: Record<string, number> = {}
|
|
711
|
+
const noHumanSample: string[] = []
|
|
712
|
+
let atCap = 0
|
|
713
|
+
for (const p of store.profiles) {
|
|
714
|
+
const breakdown = tierBreakdown(alignedSources(p))
|
|
715
|
+
for (const [k, v] of Object.entries(breakdown)) tiers[k] = (tiers[k] ?? 0) + v
|
|
716
|
+
if (p.embeddings.length >= MAX_EMBEDDINGS_PER_SPEAKER) atCap++
|
|
717
|
+
if (breakdown.human === 0) noHumanSample.push(p.name)
|
|
718
|
+
}
|
|
719
|
+
return { profiles: store.profiles.length, atCap, cap: MAX_EMBEDDINGS_PER_SPEAKER, tiers, noHumanSample }
|
|
720
|
+
}
|
|
721
|
+
|
|
668
722
|
/** Compute actual cosine similarity between two raw embedding vectors */
|
|
669
723
|
export function rawCosineSimilarity(a: Float32Array, b: Float32Array): number {
|
|
670
724
|
if (a.length !== b.length) return 0
|
|
@@ -69,7 +69,10 @@ export function describeRepairs(r: StoreRepairs): string {
|
|
|
69
69
|
return parts.join(', ')
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
/** Placeholder provenance for a sample whose source was never recorded.
|
|
73
|
+
* Exported so callers and tests reference the same value the store writes;
|
|
74
|
+
* 3 samples in the live store carry it. */
|
|
75
|
+
export const UNKNOWN_SOURCE = 'unknown'
|
|
73
76
|
|
|
74
77
|
function isUsableEmbedding(row: unknown): row is number[] {
|
|
75
78
|
return Array.isArray(row) && row.length > 0 && row.every(v => typeof v === 'number' && Number.isFinite(v))
|
|
@@ -317,6 +320,38 @@ export function dropOldestEmbedding(profile: VoiceProfile): { droppedSource: str
|
|
|
317
320
|
return { droppedSource }
|
|
318
321
|
}
|
|
319
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Drop the sample at `index`, keeping sources[] in lockstep.
|
|
325
|
+
*
|
|
326
|
+
* Alignment happens BEFORE the splice: a sources[] shorter than embeddings[]
|
|
327
|
+
* would otherwise make index N refer to two different samples in the two
|
|
328
|
+
* arrays, permanently offsetting provenance from the samples it describes —
|
|
329
|
+
* the same class of bug as the old inline `sources?.shift()`.
|
|
330
|
+
*/
|
|
331
|
+
export function dropEmbeddingAt(profile: VoiceProfile, index: number): { droppedSource: string | null } {
|
|
332
|
+
if (!Number.isInteger(index) || index < 0 || index >= profile.embeddings.length) {
|
|
333
|
+
return { droppedSource: null }
|
|
334
|
+
}
|
|
335
|
+
if (!Array.isArray(profile.sources)) profile.sources = []
|
|
336
|
+
while (profile.sources.length < profile.embeddings.length) profile.sources.push(UNKNOWN_SOURCE)
|
|
337
|
+
profile.embeddings.splice(index, 1)
|
|
338
|
+
const droppedSource = profile.sources.splice(index, 1)[0] ?? null
|
|
339
|
+
profile.sources.length = profile.embeddings.length
|
|
340
|
+
return { droppedSource }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Provenance of each stored sample, index-aligned with embeddings[].
|
|
345
|
+
*
|
|
346
|
+
* Holes and a short array both read as undefined, which the eviction policy
|
|
347
|
+
* classifies as `unknown` — the weakest tier. That is the safe direction: a
|
|
348
|
+
* sample whose provenance was lost must never inherit the protection given to
|
|
349
|
+
* one a human supplied.
|
|
350
|
+
*/
|
|
351
|
+
export function alignedSources(profile: VoiceProfile): Array<string | undefined> {
|
|
352
|
+
return Array.from({ length: profile.embeddings.length }, (_, i) => profile.sources?.[i])
|
|
353
|
+
}
|
|
354
|
+
|
|
320
355
|
/** Append a sample to both arrays together. */
|
|
321
356
|
export function appendEmbedding(profile: VoiceProfile, embedding: number[], source: string): void {
|
|
322
357
|
if (!Array.isArray(profile.sources)) profile.sources = []
|