@gotcos/glasses-server 6.94.1 → 6.94.3
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 +26 -0
- package/README.md +27 -0
- package/package.json +2 -1
- package/server/index.ts +6 -0
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/held-naming-batches.ts +8 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/mac-speaker-machine-evidence.ts +111 -0
- package/server/lib/mac-speaker-profile-extractor.ts +41 -0
- package/server/lib/mac-speaker-profile-finalizer.ts +257 -0
- package/server/lib/mac-speaker-protection.ts +22 -0
- package/server/lib/mac-speaker-reconcile.ts +300 -0
- package/server/lib/mac-speaker-repair-projection.ts +301 -0
- package/server/lib/mac-speaker-repair-render.ts +105 -0
- package/server/lib/mac-speaker-windows.ts +239 -0
- package/server/lib/mac-voice-assignment.ts +2 -1
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-relabel-enrolment.ts +2 -1
- package/server/lib/meeting-relabel.ts +17 -10
- package/server/lib/meeting-scribe-content.ts +8 -1
- package/server/lib/meeting-speaker-review.ts +46 -7
- package/server/lib/nemotron-final.ts +64 -14
- package/server/lib/session-find-ask.ts +281 -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-trainer.ts +2 -1
- package/server/lib/voice-directory.ts +7 -2
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/meeting.ts +43 -15
- package/server/routes/session-find.ts +73 -0
- package/server/routes/voice.ts +25 -18
- package/server/scripts/mac-speaker-repair.ts +324 -0
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
greedyDiversitySelect,
|
|
78
78
|
} from './voice-enrolment-selection.js'
|
|
79
79
|
import { VOICEPRINT_MIN_AUDIO_SEC } from './voiceprint-floor.js'
|
|
80
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
80
81
|
|
|
81
82
|
/** A turn shorter than this is not sliced out of a mixed chunk. */
|
|
82
83
|
export const MAC_ASSIGN_MIN_SLICE_SEC = 3.0
|
|
@@ -239,7 +240,7 @@ export async function planMacVoiceAssignment(input: MacAssignInput): Promise<Mac
|
|
|
239
240
|
if (isPlaceholderLabel(to)) return none('unknown_profile')
|
|
240
241
|
if (input.ownerLabel && sameName(to, input.ownerLabel)) return none('owner_profile')
|
|
241
242
|
let profile: { name: string; embeddings: number[][]; sources?: string[] } | undefined
|
|
242
|
-
try { profile = readVoiceProfiles().profiles.find(p => p.name
|
|
243
|
+
try { profile = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, to)) } catch { return none('store_unavailable') }
|
|
243
244
|
if (!profile) return none('unknown_profile')
|
|
244
245
|
if (!chunkEmbeddingsEnabled()) return none('disabled')
|
|
245
246
|
if (!isEmbeddingAvailable()) return none('store_unavailable')
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs'
|
|
28
28
|
import { join, resolve } from 'node:path'
|
|
29
29
|
import { dataPath } from './data-dir.js'
|
|
30
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
30
31
|
|
|
31
32
|
export const CORRECTIONS_DIR = 'meeting-corrections'
|
|
32
33
|
|
|
@@ -268,7 +269,7 @@ export function currentLabelFor(sessionId: string, originalLabel: string): strin
|
|
|
268
269
|
for (let hop = 0; hop < applied.length; hop++) {
|
|
269
270
|
// Latest applied correction FROM the current label wins: a later human
|
|
270
271
|
// decision supersedes an earlier one.
|
|
271
|
-
const next = [...applied].reverse().find(r => r.from
|
|
272
|
+
const next = [...applied].reverse().find(r => sameSpeakerName(r.from, label))
|
|
272
273
|
if (!next || seen.has(next.to)) break
|
|
273
274
|
label = next.to
|
|
274
275
|
seen.add(label)
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
// every future meeting. A wrong write is permanent and silent. So every step below
|
|
38
38
|
// fails CLOSED: no mapping means no enrolment, never a fall back to positions.
|
|
39
39
|
|
|
40
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
40
41
|
import {
|
|
41
42
|
chunkEmbeddingTtlMs,
|
|
42
43
|
chunkEmbeddingsEnabled,
|
|
@@ -163,7 +164,7 @@ function missingReason(sidecar: Record<string, unknown>): EnrolmentSkipReason {
|
|
|
163
164
|
* append as a creation. */
|
|
164
165
|
function profileExists(name: string): boolean {
|
|
165
166
|
try {
|
|
166
|
-
return readVoiceProfiles().profiles.some(p => p.name
|
|
167
|
+
return readVoiceProfiles().profiles.some(p => sameSpeakerName(p.name, name))
|
|
167
168
|
} catch {
|
|
168
169
|
// Unknowable is not "new". Claiming creation is the lie that matters here.
|
|
169
170
|
return true
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
// somebody else. Reported as `proseStale` instead.
|
|
28
28
|
|
|
29
29
|
import { isVoiceLabel } from './mac-voice-labels.js'
|
|
30
|
+
import { nameForms, nfc, sameSpeakerName } from './speaker-name-nfc.js'
|
|
30
31
|
|
|
31
32
|
/** A label that would corrupt the file formats it gets written into. */
|
|
32
33
|
export function invalidLabelReason(label: string): string | null {
|
|
@@ -75,6 +76,10 @@ export function relabelSidecarJson(
|
|
|
75
76
|
to: string,
|
|
76
77
|
chunks: number[] = [],
|
|
77
78
|
): RelabelOutcome<SidecarRelabelResult> {
|
|
79
|
+
// Names are NFC here whatever arrived (a decomposed spelling must never be written), and matched NFC on both sides, so a chunk
|
|
80
|
+
// already stored decomposed still counts as carrying `from`.
|
|
81
|
+
from = nfc(from)
|
|
82
|
+
to = nfc(to)
|
|
78
83
|
if (from === to) return { ok: false, error: 'from and to are the same label' }
|
|
79
84
|
for (const [which, label] of [['from', from], ['to', to]] as const) {
|
|
80
85
|
const bad = invalidLabelReason(label)
|
|
@@ -100,7 +105,7 @@ export function relabelSidecarJson(
|
|
|
100
105
|
if (restrict) {
|
|
101
106
|
const mismatched = chunks.filter(i => {
|
|
102
107
|
const row = rows[i]
|
|
103
|
-
return !row || typeof row !== 'object' || (row as Record<string, unknown>).speaker
|
|
108
|
+
return !row || typeof row !== 'object' || !sameSpeakerName((row as Record<string, unknown>).speaker, from)
|
|
104
109
|
})
|
|
105
110
|
if (mismatched.length > 0) {
|
|
106
111
|
return { ok: false, error: `chunks do not carry "${from}": ${mismatched.slice(0, 8).join(', ')}` }
|
|
@@ -111,11 +116,11 @@ export function relabelSidecarJson(
|
|
|
111
116
|
let remainingWithFrom = 0
|
|
112
117
|
rows.forEach((row, i) => {
|
|
113
118
|
// The `typeof` half guards the cast below rather than any behaviour — a
|
|
114
|
-
// truthy non-object can never satisfy
|
|
119
|
+
// truthy non-object can never satisfy the NFC name match below, so it is
|
|
115
120
|
// deliberately not claimed as tested.
|
|
116
121
|
if (!row || typeof row !== 'object') return
|
|
117
122
|
const r = row as Record<string, unknown>
|
|
118
|
-
if (r.speaker
|
|
123
|
+
if (!sameSpeakerName(r.speaker, from)) return
|
|
119
124
|
if (restrict && !restrict.has(i)) { remainingWithFrom++; return }
|
|
120
125
|
r.speaker = to
|
|
121
126
|
changed.push(i)
|
|
@@ -131,18 +136,18 @@ export function relabelSidecarJson(
|
|
|
131
136
|
if (isVoiceLabel(to)) {
|
|
132
137
|
// 6.90.0: a Mac `Voice N` placeholder never enters `speakers` (the store
|
|
133
138
|
// keeps it out too), so merging into one adds nothing. Retire `from` only.
|
|
134
|
-
const at = speakers.
|
|
139
|
+
const at = speakers.findIndex(x => sameSpeakerName(x, from))
|
|
135
140
|
if (at >= 0 && remainingWithFrom === 0) speakers.splice(at, 1)
|
|
136
|
-
} else if (!speakers.
|
|
141
|
+
} else if (!speakers.some(x => sameSpeakerName(x, to))) {
|
|
137
142
|
// Replace in place when `from` is being fully retired, so the list keeps its
|
|
138
143
|
// original ordering rather than moving the person to the end.
|
|
139
|
-
const at = speakers.
|
|
144
|
+
const at = speakers.findIndex(x => sameSpeakerName(x, from))
|
|
140
145
|
if (at >= 0 && remainingWithFrom === 0) speakers[at] = to
|
|
141
146
|
else speakers.push(to)
|
|
142
147
|
} else if (remainingWithFrom === 0) {
|
|
143
148
|
// `to` already listed and `from` fully retired — merging a split identity.
|
|
144
149
|
// Drop the old entry rather than leaving a name nothing is attributed to.
|
|
145
|
-
const at = speakers.
|
|
150
|
+
const at = speakers.findIndex(x => sameSpeakerName(x, from))
|
|
146
151
|
if (at >= 0) speakers.splice(at, 1)
|
|
147
152
|
}
|
|
148
153
|
doc.speakers = speakers
|
|
@@ -198,6 +203,8 @@ export function relabelMeetingMarkdown(
|
|
|
198
203
|
to: string,
|
|
199
204
|
options: { removeAttendee?: boolean } = {},
|
|
200
205
|
): RelabelOutcome<MarkdownRelabelResult> {
|
|
206
|
+
from = nfc(from)
|
|
207
|
+
to = nfc(to)
|
|
201
208
|
if (from === to) return { ok: false, error: 'from and to are the same label' }
|
|
202
209
|
for (const [which, label] of [['from', from], ['to', to]] as const) {
|
|
203
210
|
const bad = invalidLabelReason(label)
|
|
@@ -215,7 +222,7 @@ export function relabelMeetingMarkdown(
|
|
|
215
222
|
// Anchored at BOTH ends, and `[ \t]` rather than `\s` so a pattern can never
|
|
216
223
|
// run past its own line. Without the end anchor, `- Theo M` matches the
|
|
217
224
|
// PREFIX of `- Theo Marsh` and deleting it leaves the fragment `enry`.
|
|
218
|
-
const line = (label: string) => `^-[ \t]+${
|
|
225
|
+
const line = (label: string) => `^-[ \t]+${nameForms(label, escapeRegExp)}[ \t]*$`
|
|
219
226
|
// A de-attribution must DELETE the attendee bullet, never rename it. Renaming
|
|
220
227
|
// writes `- Unidentified 2` into the attendee list as though it were a
|
|
221
228
|
// person, and the COS pipeline that BUILDS attendees deliberately excludes
|
|
@@ -241,7 +248,7 @@ export function relabelMeetingMarkdown(
|
|
|
241
248
|
if (tr) {
|
|
242
249
|
const body = out.slice(tr.start, tr.end)
|
|
243
250
|
const rewritten = body.replace(
|
|
244
|
-
new RegExp(`^\\[${
|
|
251
|
+
new RegExp(`^\\[${nameForms(from, escapeRegExp)}\\]:`, 'gm'),
|
|
245
252
|
() => { transcript++; return `[${to}]:` },
|
|
246
253
|
)
|
|
247
254
|
out = out.slice(0, tr.start) + rewritten + out.slice(tr.end)
|
|
@@ -276,7 +283,7 @@ export function detectProseReferences(md: string, label: string): string[] {
|
|
|
276
283
|
prose += md.slice(cursor)
|
|
277
284
|
|
|
278
285
|
const hits: string[] = []
|
|
279
|
-
const full = new RegExp(`\\b${
|
|
286
|
+
const full = new RegExp(`\\b${nameForms(label, escapeRegExp)}\\b`)
|
|
280
287
|
if (full.test(prose)) hits.push(label)
|
|
281
288
|
const first = label.split(/\s+/)[0]
|
|
282
289
|
// Only a multi-word label has a distinct bare-first-name form. 'MU' or 'Ext'
|
|
@@ -276,6 +276,9 @@ export interface AttendeeLine {
|
|
|
276
276
|
*/
|
|
277
277
|
isOwner: boolean
|
|
278
278
|
confirmedByHuman: boolean
|
|
279
|
+
/** Qualified exact-span machine evidence; raw capture scores stay unchanged. */
|
|
280
|
+
machineQualified?: boolean
|
|
281
|
+
machineQualifiedSegments?: number
|
|
279
282
|
}
|
|
280
283
|
|
|
281
284
|
/**
|
|
@@ -406,8 +409,12 @@ export function renderProvenance(
|
|
|
406
409
|
const clauses: string[] = []
|
|
407
410
|
const owner = named.filter(v => v.isOwner).map(q)
|
|
408
411
|
const human = named.filter(v => !v.isOwner && v.confirmedByHuman).map(q)
|
|
409
|
-
const
|
|
412
|
+
const machine = named.filter(v => !v.isOwner && !v.confirmedByHuman && v.machineQualified).map(q)
|
|
413
|
+
const combined = named.filter(v => !v.isOwner && !v.confirmedByHuman && !v.machineQualified && (v.machineQualifiedSegments ?? 0) > 0).map(q)
|
|
414
|
+
const matched = named.filter(v => !v.isOwner && !v.confirmedByHuman && !v.machineQualified && !(v.machineQualifiedSegments ?? 0)).map(q)
|
|
410
415
|
if (owner.length) clauses.push(`${owner.join(', ')} by wearing the device`)
|
|
416
|
+
if (machine.length) clauses.push(`${machine.join(', ')} by qualified independent long-window profile evidence (not human confirmation)`)
|
|
417
|
+
if (combined.length) clauses.push(`${combined.join(', ')} by capture matches plus qualified independent long-window profile evidence (not human confirmation)`)
|
|
411
418
|
if (matched.length) clauses.push(`${matched.join(', ')} by voice match`)
|
|
412
419
|
if (human.length) clauses.push(`${human.join(', ')} because a human named that voice`)
|
|
413
420
|
const lines: string[] = [
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
//
|
|
23
23
|
// Everything here is pure over a chunk array so it can be tested by execution.
|
|
24
24
|
|
|
25
|
+
import type { QualifiedMachinePosition } from './mac-speaker-machine-evidence.js'
|
|
25
26
|
import { isVoiceLabel } from './mac-voice-labels.js'
|
|
27
|
+
import { nfc, sameSpeakerName } from './speaker-name-nfc.js'
|
|
26
28
|
|
|
27
29
|
/** One transcript chunk as stored in the `.g2-chunks.json` sidecar. */
|
|
28
30
|
export interface ReviewChunk {
|
|
@@ -152,6 +154,10 @@ export interface VoiceReview {
|
|
|
152
154
|
isOwner: boolean
|
|
153
155
|
/** A human confirmed this label for this meeting; the floor is waived. */
|
|
154
156
|
confirmedByHuman: boolean
|
|
157
|
+
/** Independent long-window profile evidence, never a human confirmation. */
|
|
158
|
+
machineQualified: boolean
|
|
159
|
+
machineQualifiedSegments: number
|
|
160
|
+
assertionSource: 'owner' | 'human' | 'qualified-profile' | 'capture-and-qualified-profile' | 'capture-match' | null
|
|
155
161
|
reliability: Reliability
|
|
156
162
|
/**
|
|
157
163
|
* Whether `label` may be shown to a human AS A NAME.
|
|
@@ -588,6 +594,9 @@ export function reviewMeetingSpeakers(
|
|
|
588
594
|
*/
|
|
589
595
|
confirmed?: Set<string>
|
|
590
596
|
confirmedChunks?: Map<number, string>
|
|
597
|
+
/** Reader-validated exact compacted positions from current bound raw audio. */
|
|
598
|
+
machineEvidenceStatus?: 'absent' | 'qualified' | 'invalid'
|
|
599
|
+
machineQualifiedPositions?: ReadonlyMap<number, QualifiedMachinePosition>
|
|
591
600
|
/**
|
|
592
601
|
* The sidecar's `batchSegments`, when the HQ pass has run. Their word
|
|
593
602
|
* timings give real voiced time; without them speaking time falls back to
|
|
@@ -597,10 +606,10 @@ export function reviewMeetingSpeakers(
|
|
|
597
606
|
} = {},
|
|
598
607
|
): MeetingSpeakerReview {
|
|
599
608
|
const owner = options.owner ?? 'Me'
|
|
600
|
-
const confirmed = new Set(options.confirmed ?? [])
|
|
609
|
+
const confirmed = new Set([...(options.confirmed ?? [])].map(nfc))
|
|
601
610
|
for (const label of new Set(chunks.map(c => c.speaker ?? ''))) {
|
|
602
611
|
const positions = chunks.flatMap((c, i) => c.speaker === label ? [i] : [])
|
|
603
|
-
if (positions.length > 0 && positions.every(i => options.confirmedChunks?.get(i)
|
|
612
|
+
if (positions.length > 0 && positions.every(i => sameSpeakerName(options.confirmedChunks?.get(i), label))) confirmed.add(nfc(label))
|
|
604
613
|
}
|
|
605
614
|
const limit = options.phrasesPerVoice ?? 3
|
|
606
615
|
const sequence = chunks.map(c => c.speaker ?? '')
|
|
@@ -646,10 +655,28 @@ export function reviewMeetingSpeakers(
|
|
|
646
655
|
const voices: VoiceReview[] = labels.map(label => {
|
|
647
656
|
const own = chunks.filter(c => (c.speaker ?? '') === label)
|
|
648
657
|
const sims = own.map(c => c.similarity).filter((s): s is number => typeof s === 'number' && s > 0)
|
|
658
|
+
const machineQualifiedSegments = chunks.reduce((n, c, i) => {
|
|
659
|
+
const proof = options.machineQualifiedPositions?.get(i)
|
|
660
|
+
return n + Number(c.speaker === label && !!proof && proof.label === label
|
|
661
|
+
&& (c.chunkIndex === undefined || c.chunkIndex === proof.rawChunkIndex)
|
|
662
|
+
&& proof.independentWindows >= 2 && proof.minimumSimilarity >= .88 && proof.minimumMargin >= .05)
|
|
663
|
+
}, 0)
|
|
664
|
+
const machineQualified = own.length > 0 && machineQualifiedSegments === own.length
|
|
665
|
+
// Repaired records require support for every occurrence. A legacy mean
|
|
666
|
+
// from one positive row cannot vouch for repaired zero-score positions.
|
|
667
|
+
const scopedMachineRecord = (options.machineEvidenceStatus !== undefined && options.machineEvidenceStatus !== 'absent') || (options.machineQualifiedPositions?.size ?? 0) > 0
|
|
668
|
+
const unsupportedScopedSegments = scopedMachineRecord ? chunks.reduce((n, c, i) => {
|
|
669
|
+
if (c.speaker !== label) return n
|
|
670
|
+
const proof = options.machineQualifiedPositions?.get(i)
|
|
671
|
+
const machine = proof?.label === label && (c.chunkIndex === undefined || c.chunkIndex === proof.rawChunkIndex) && proof.independentWindows >= 2 && proof.minimumSimilarity >= .88 && proof.minimumMargin >= .05
|
|
672
|
+
const human = confirmed.has(nfc(label)) || sameSpeakerName(options.confirmedChunks?.get(i), label)
|
|
673
|
+
const capture = typeof c.similarity === 'number' && Number.isFinite(c.similarity) && c.similarity >= ASSERT_MIN_SIMILARITY
|
|
674
|
+
return n + Number(!machine && !human && !capture)
|
|
675
|
+
}, 0) : 0
|
|
649
676
|
const runs = speakerRuns(sequence, label)
|
|
650
677
|
const unattributed = isUnattributed(label)
|
|
651
|
-
const confirmedSegments = unattributed ? 0 : confirmed.has(label) ? own.length
|
|
652
|
-
: chunks.reduce((n, c, i) => n + Number(c.speaker === label && options.confirmedChunks?.get(i)
|
|
678
|
+
const confirmedSegments = unattributed ? 0 : confirmed.has(nfc(label)) ? own.length
|
|
679
|
+
: chunks.reduce((n, c, i) => n + Number(c.speaker === label && sameSpeakerName(options.confirmedChunks?.get(i), label)), 0)
|
|
653
680
|
|
|
654
681
|
const thrashesWith: ThrashPair[] = []
|
|
655
682
|
if (!unattributed) {
|
|
@@ -674,7 +701,7 @@ export function reviewMeetingSpeakers(
|
|
|
674
701
|
? 'unattributed'
|
|
675
702
|
: thrashesWith.length > 0
|
|
676
703
|
? 'unreliable'
|
|
677
|
-
: (meanSim ?? 0) >= CONFIDENT_SIMILARITY ? 'confident' : 'weak'
|
|
704
|
+
: machineQualified || (meanSim ?? 0) >= CONFIDENT_SIMILARITY ? 'confident' : 'weak'
|
|
678
705
|
|
|
679
706
|
// What stops this label being presented as a name. Collected as reasons
|
|
680
707
|
// rather than a bare boolean: "2 segments" and "similarity 0.58" are
|
|
@@ -682,12 +709,17 @@ export function reviewMeetingSpeakers(
|
|
|
682
709
|
const assertionBlockers: string[] = []
|
|
683
710
|
if (unattributed) {
|
|
684
711
|
assertionBlockers.push('no name was ever assigned to this voice')
|
|
685
|
-
} else if (confirmed.has(label)) {
|
|
712
|
+
} else if (confirmed.has(nfc(label))) {
|
|
686
713
|
// A human said this is them. Blockers stay OFF the list rather than being
|
|
687
714
|
// listed-then-overridden, because a reviewer reading "similarity 0.56
|
|
688
715
|
// below 0.65" under a name they personally confirmed would reasonably
|
|
689
716
|
// conclude the confirmation had not taken. `thrashesWith` still renders,
|
|
690
717
|
// so a genuinely mixed row is still visibly mixed.
|
|
718
|
+
} else if (unsupportedScopedSegments) {
|
|
719
|
+
// System-audio repairs do not identify the wearer by device ownership.
|
|
720
|
+
// This gate also precedes the legacy owner exemption.
|
|
721
|
+
assertionBlockers.push(`${unsupportedScopedSegments} segment${unsupportedScopedSegments === 1 ? '' : 's'} lack current scoped speaker evidence`)
|
|
722
|
+
if (thrashesWith.length > 0) assertionBlockers.push(`swaps with ${thrashesWith[0].speaker} every ${Math.round(thrashesWith[0].meanRun)} segments`)
|
|
691
723
|
} else if (label === owner) {
|
|
692
724
|
// The wearer is exempt. Their identity is established by wearing the
|
|
693
725
|
// device, not by cosine — and the owner is verified at exactly this floor
|
|
@@ -696,6 +728,10 @@ export function reviewMeetingSpeakers(
|
|
|
696
728
|
// row read "Unidentified voice" in 4 of 9 meetings, including one with 285
|
|
697
729
|
// of their own segments. `thrashesWith` still renders, so a mixed row is
|
|
698
730
|
// still visible — the name is asserted, the caveat is not hidden.
|
|
731
|
+
} else if (machineQualified) {
|
|
732
|
+
// Exact positions passed the independent .88/margin/long-window gate.
|
|
733
|
+
// Keep raw similarities unchanged and retain the mixed-row warning.
|
|
734
|
+
if (thrashesWith.length > 0) assertionBlockers.push(`swaps with ${thrashesWith[0].speaker} every ${Math.round(thrashesWith[0].meanRun)} segments`)
|
|
699
735
|
} else {
|
|
700
736
|
if (own.length < ASSERT_MIN_SEGMENTS) {
|
|
701
737
|
assertionBlockers.push(`only ${own.length} segment${own.length === 1 ? '' : 's'} (needs ${ASSERT_MIN_SEGMENTS})`)
|
|
@@ -718,7 +754,10 @@ export function reviewMeetingSpeakers(
|
|
|
718
754
|
longestRun: runs.length ? Math.max(...runs) : 0,
|
|
719
755
|
isOwner: label === owner,
|
|
720
756
|
/** True when a human vouched for this label in this meeting. */
|
|
721
|
-
confirmedByHuman: confirmed.has(label),
|
|
757
|
+
confirmedByHuman: confirmed.has(nfc(label)),
|
|
758
|
+
machineQualified: !unattributed && machineQualified,
|
|
759
|
+
machineQualifiedSegments: unattributed ? 0 : machineQualifiedSegments,
|
|
760
|
+
assertionSource: assertionBlockers.length ? null : confirmed.has(nfc(label)) ? 'human' : label === owner ? 'owner' : machineQualified ? 'qualified-profile' : machineQualifiedSegments > 0 ? 'capture-and-qualified-profile' : 'capture-match',
|
|
722
761
|
reliability,
|
|
723
762
|
nameAsserted: assertionBlockers.length === 0,
|
|
724
763
|
assertionBlockers,
|
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
nemotronCliPath,
|
|
41
41
|
prepareNemotronModelsDir,
|
|
42
42
|
} from './diarizer-backend.js'
|
|
43
|
+
import { readCorrections } from './meeting-corrections.js'
|
|
43
44
|
import { CONFIDENT_SIMILARITY, isUnattributed } from './meeting-speaker-review.js'
|
|
44
45
|
import { getOwnerSpeakerLabel } from './profile.js'
|
|
45
46
|
import { OWNER_VERIFY_SIMILARITY } from './voiceprint-floor.js'
|
|
@@ -370,6 +371,24 @@ function liveSpeakerAt(chunk: SidecarChunk, t: number, owner?: string | null): s
|
|
|
370
371
|
return best && typeof best.speaker === 'string' ? guardOwnerName(best.speaker, best.similarity, chunk, owner) : null
|
|
371
372
|
}
|
|
372
373
|
|
|
374
|
+
/** Existing strict Mac names are local immutable claims, never a name source
|
|
375
|
+
* for a global anonymous channel. Exact token/word labels win over chunk names.
|
|
376
|
+
* An unplaced named segment withholds anonymous refinement for the whole chunk. */
|
|
377
|
+
function protectedMacName(chunk: SidecarChunk, t: number, exact?: string): { name: string | null; hold: boolean } {
|
|
378
|
+
if (chunk.speakerPolicy !== 'high-confidence') return { name: null, hold: false }
|
|
379
|
+
if (typeof exact === 'string' && isName(exact)) return { name: exact, hold: true }
|
|
380
|
+
const named = (chunk.segments ?? []).filter(s => isName(s.speaker))
|
|
381
|
+
for (const segment of named) {
|
|
382
|
+
if (!Number.isFinite(segment.startSec) || !Number.isFinite(segment.endSec)
|
|
383
|
+
|| Number(segment.startSec) < 0 || Number(segment.endSec) <= Number(segment.startSec)) return { name: null, hold: true }
|
|
384
|
+
if (t >= Number(segment.startSec) && t < Number(segment.endSec)) return { name: segment.speaker, hold: true }
|
|
385
|
+
}
|
|
386
|
+
// A segment-less legacy chunk has only its head claim. Explicit anonymous
|
|
387
|
+
// turns, however, must never inherit a real name from the majority/head label.
|
|
388
|
+
if (isName(chunk.speaker) && !chunk.segments?.length) return { name: chunk.speaker, hold: true }
|
|
389
|
+
return { name: null, hold: false }
|
|
390
|
+
}
|
|
391
|
+
|
|
373
392
|
export interface RelabelStats {
|
|
374
393
|
total: number
|
|
375
394
|
/** Words whose label changed. */
|
|
@@ -418,13 +437,15 @@ export function relabelBatchSegments(
|
|
|
418
437
|
const end = batchWordToTimeline(segment, Number(word.end), timeline)
|
|
419
438
|
// Existing live anonymous turns are established capture evidence. A
|
|
420
439
|
// meeting-wide channel may refine unknown speech, never overwrite them.
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
440
|
+
const liveCandidate = start ? liveSpeakerAt(start.window.chunk, start.inChunk, owner) : null
|
|
441
|
+
const protectedName = start ? protectedMacName(start.window.chunk, start.inChunk, word.speaker) : { name: null, hold: false }
|
|
442
|
+
const live = start?.window.chunk.speakerPolicy === 'high-confidence' && isName(liveCandidate) ? protectedName.name : liveCandidate
|
|
443
|
+
let name: string | null = protectedName.name ?? (isVoiceLabel(live) ? live : null)
|
|
444
|
+
if (name === null && !protectedName.hold && start) {
|
|
424
445
|
const channel = channelForSpan(a, start.at, Math.max(start.at, end?.window === start.window ? end.at : start.at + 0.01))
|
|
425
446
|
if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
|
|
426
447
|
}
|
|
427
|
-
if (isVoiceLabel(live)) stats.kept++
|
|
448
|
+
if (isVoiceLabel(live) || protectedName.name !== null) stats.kept++
|
|
428
449
|
if (name === null) {
|
|
429
450
|
stats.kept++
|
|
430
451
|
name = live
|
|
@@ -479,20 +500,34 @@ export function relabelStreamingChunks(
|
|
|
479
500
|
if (typeof chunk.text !== 'string' || !chunk.text.trim()) continue
|
|
480
501
|
const tokens = splitTokens(chunk.text)
|
|
481
502
|
const times = streamingTokenTimes(chunk, tokens, window, a)
|
|
482
|
-
const
|
|
503
|
+
const originalBase = guardOwnerName(typeof chunk.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk.speaker, chunk.similarity, chunk, owner)
|
|
504
|
+
const base = chunk.speakerPolicy === 'high-confidence' && chunk.segments?.length && isName(originalBase) ? 'Ext' : originalBase
|
|
483
505
|
const labels: string[] = []
|
|
484
506
|
const liveTokens = (chunk.segments ?? []).flatMap(segment => splitTokens(String(segment.text ?? '')).map(token => ({ token, speaker: segment.speaker })))
|
|
485
507
|
const exactLive = liveTokens.length === tokens.length && liveTokens.every((part, i) => part.token === tokens[i]) ? liveTokens : null
|
|
508
|
+
// Without token alignment or a clock, preserve the original named chunk
|
|
509
|
+
// rather than spreading a nearby named turn across its unmatched text.
|
|
510
|
+
if (chunk.speakerPolicy === 'high-confidence' && chunk.segments?.some(s => isName(s.speaker)) && !exactLive && !times) continue
|
|
486
511
|
let carry: string | null = null
|
|
487
512
|
for (let i = 0; i < tokens.length; i++) {
|
|
488
513
|
const established = exactLive?.[i].speaker
|
|
489
|
-
if (!isWordToken(tokens[i]) || !times) {
|
|
514
|
+
if (!isWordToken(tokens[i]) || !times) {
|
|
515
|
+
const protectedName = !times && established === undefined && chunk.segments?.length ? { name: null, hold: true } : protectedMacName(chunk, times?.[i]?.start ?? 0, established)
|
|
516
|
+
const local = liveSpeakerAt(chunk, times?.[i]?.start ?? 0, owner)
|
|
517
|
+
const safeLocal = chunk.speakerPolicy === 'high-confidence' && isName(local) ? protectedName.name : local
|
|
518
|
+
labels.push(protectedName.name ?? (chunk.speakerPolicy === 'high-confidence' && established !== undefined ? established : isVoiceLabel(established) ? established! : carry ?? safeLocal ?? base))
|
|
519
|
+
continue
|
|
520
|
+
}
|
|
490
521
|
stats.total++
|
|
491
522
|
const span = times[i]
|
|
492
523
|
const channel = channelForSpan(a, window.start + span.start, window.start + Math.max(span.end, span.start + 0.01))
|
|
493
524
|
let name: string
|
|
494
|
-
const
|
|
495
|
-
|
|
525
|
+
const liveCandidate = isVoiceLabel(established) ? established! : liveSpeakerAt(chunk, span.start, owner)
|
|
526
|
+
const protectedName = protectedMacName(chunk, span.start, established)
|
|
527
|
+
const live = chunk.speakerPolicy === 'high-confidence' && isName(liveCandidate) ? protectedName.name : liveCandidate
|
|
528
|
+
if (protectedName.name !== null) { name = protectedName.name; stats.kept++ }
|
|
529
|
+
else if (protectedName.hold) { name = live ?? base; stats.kept++ }
|
|
530
|
+
else if (isVoiceLabel(live)) { name = live!; stats.kept++ }
|
|
496
531
|
else if (channel !== null && map.has(channel)) { name = map.get(channel)!; stats.viaChannel++ }
|
|
497
532
|
else { name = live ?? base; stats.kept++ }
|
|
498
533
|
labels.push(name)
|
|
@@ -787,6 +822,14 @@ function distinctSpeakers(transcript: string): number {
|
|
|
787
822
|
return names.size
|
|
788
823
|
}
|
|
789
824
|
|
|
825
|
+
/** Legacy corrections may have a ledger without a sidecar revision. Unknown
|
|
826
|
+
* or incomplete recorded history is held, never treated as automatic evidence. */
|
|
827
|
+
function hasHumanCorrectionHistory(sidecar: Record<string, unknown>): boolean {
|
|
828
|
+
if (typeof sidecar.sessionId !== 'string') return false
|
|
829
|
+
const corrections = readCorrections(sidecar.sessionId)
|
|
830
|
+
return corrections.rows.length > 0 || corrections.unusable > 0
|
|
831
|
+
}
|
|
832
|
+
|
|
790
833
|
async function finalPassNow(options: FinalPassOptions): Promise<FinalPassOutcome> {
|
|
791
834
|
const started = (options.now ?? Date.now)()
|
|
792
835
|
const env = options.env ?? process.env
|
|
@@ -801,6 +844,7 @@ async function finalPassNow(options: FinalPassOptions): Promise<FinalPassOutcome
|
|
|
801
844
|
const sidecar = JSON.parse(sidecarText) as Record<string, any>
|
|
802
845
|
const owner = options.owner !== undefined ? options.owner : ownerLabelOrNull()
|
|
803
846
|
if (Number(sidecar.correctionRevision ?? 0) > 0) return { status: 'skipped', reason: 'human_corrected' }
|
|
847
|
+
if (hasHumanCorrectionHistory(sidecar)) return { status: 'skipped', reason: 'human_corrected' }
|
|
804
848
|
const markdown = readFileSync(options.meetingPath, 'utf8')
|
|
805
849
|
const transcript = readTranscriptSection(markdown)
|
|
806
850
|
if (transcript === null) return { status: 'skipped', reason: 'no_transcript' }
|
|
@@ -1025,6 +1069,9 @@ function commitFinalPass(
|
|
|
1025
1069
|
return { status: 'skipped', reason: 'changed_during_pass', detail: sidecarNow !== sidecarText ? 'sidecar changed' : 'markdown changed' }
|
|
1026
1070
|
}
|
|
1027
1071
|
|
|
1072
|
+
// A legacy confirmation can land only in its ledger while native work runs.
|
|
1073
|
+
if (hasHumanCorrectionHistory(nextSidecar)) return { status: 'skipped', reason: 'changed_during_pass', detail: 'correction history changed' }
|
|
1074
|
+
|
|
1028
1075
|
// Sidecar first, markdown second, as the store does: the markdown is the
|
|
1029
1076
|
// visible commit. If the markdown write fails, put the sidecar back, and say
|
|
1030
1077
|
// so loudly if even that fails (the two files then disagree).
|
|
@@ -1056,8 +1103,8 @@ function commitFinalPass(
|
|
|
1056
1103
|
// ── 6.90.0: unnamed, meeting-stable voices for Mac recordings ───────────────────
|
|
1057
1104
|
//
|
|
1058
1105
|
// The named pass above maps a channel to a name from chunks the voiceprint was
|
|
1059
|
-
// sure of.
|
|
1060
|
-
//
|
|
1106
|
+
// sure of. Strict Mac recordings never borrow names across a global channel;
|
|
1107
|
+
// rare existing names are preserved locally while other words stay anonymous.
|
|
1061
1108
|
// This pass keeps the channels and drops the names: each channel that holds the
|
|
1062
1109
|
// floor in enough chunks becomes `Voice 1`, `Voice 2`, ... in order of first
|
|
1063
1110
|
// speech. No profile is read or written anywhere below; `owner` is never passed,
|
|
@@ -1070,9 +1117,9 @@ export const VOICE_MIN_SUPPORT = FINAL_MIN_SUPPORT
|
|
|
1070
1117
|
|
|
1071
1118
|
/**
|
|
1072
1119
|
* Unnamed mode applies to a Mac recording, with the switch on, when every chunk
|
|
1073
|
-
* that carries words is the Mac strict policy
|
|
1074
|
-
*
|
|
1075
|
-
*
|
|
1120
|
+
* that carries words is the Mac strict policy. Rare existing names are protected
|
|
1121
|
+
* per span by the relabellers; one match must not disable anonymous refinement
|
|
1122
|
+
* for the remainder of an otherwise strict recording.
|
|
1076
1123
|
*/
|
|
1077
1124
|
export function unnamedVoicesEligible(
|
|
1078
1125
|
sidecar: Record<string, unknown>,
|
|
@@ -1090,7 +1137,7 @@ export function unnamedVoicesEligible(
|
|
|
1090
1137
|
// strict; anything else (anonymous, a malformed marker) still refuses.
|
|
1091
1138
|
const silentOk = entries.filter(entry => !hasText(entry.chunk))
|
|
1092
1139
|
.every(entry => entry.chunk?.speakerPolicy === undefined || entry.chunk.speakerPolicy === 'high-confidence')
|
|
1093
|
-
return silentOk && spoken.every(chunk => chunk.speakerPolicy === 'high-confidence'
|
|
1140
|
+
return silentOk && spoken.every(chunk => chunk.speakerPolicy === 'high-confidence')
|
|
1094
1141
|
}
|
|
1095
1142
|
|
|
1096
1143
|
export interface VoiceChannels {
|
|
@@ -1360,6 +1407,9 @@ async function unnamedVoicePass(input: UnnamedInput): Promise<FinalPassOutcome>
|
|
|
1360
1407
|
for (const chunk of [entry.chunk, chunks[i]]) {
|
|
1361
1408
|
const original = typeof chunk.originalSpeaker === 'string' ? chunk.originalSpeaker : chunk.speaker
|
|
1362
1409
|
if (final) chunk.finalSegments = final.segments
|
|
1410
|
+
// A pre-existing strict name is immutable even when a legacy chunk's
|
|
1411
|
+
// segments disagree. The anonymous pass never asserts a different name.
|
|
1412
|
+
if (chunk.speakerPolicy === 'high-confidence' && isName(chunk.speaker)) continue
|
|
1363
1413
|
chunk.speaker = label
|
|
1364
1414
|
if (label !== original) chunk.originalSpeaker = original
|
|
1365
1415
|
else delete chunk.originalSpeaker
|