@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
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
saveVoiceProfileStore,
|
|
27
27
|
type ProfileStore,
|
|
28
28
|
type VoiceProfile,
|
|
29
|
+
findStoredProfile,
|
|
29
30
|
} from './voice-profile-store.js'
|
|
30
31
|
import { homedir } from 'node:os'
|
|
31
32
|
import { spawnSync } from 'node:child_process'
|
|
@@ -43,6 +44,7 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
|
43
44
|
|
|
44
45
|
import { DATA_DIR } from './data-dir.js'
|
|
45
46
|
import { AUTO_ENROLL_THRESHOLD, OWNER_VERIFY_SIMILARITY } from './voiceprint-floor.js'
|
|
47
|
+
import { nfc, sameSpeakerName } from './speaker-name-nfc.js'
|
|
46
48
|
|
|
47
49
|
export const SPEAKER_MODEL_FILENAME = '3dspeaker_speech_eres2net_sv_en_voxceleb_16k.onnx'
|
|
48
50
|
|
|
@@ -263,10 +265,23 @@ export function initSpeakerEmbeddings(): boolean {
|
|
|
263
265
|
}
|
|
264
266
|
}
|
|
265
267
|
|
|
268
|
+
/**
|
|
269
|
+
* The manager is keyed on the STORED profile name, which may be decomposed (written before names were NFC) or composed. Every lookup
|
|
270
|
+
* therefore tries both forms of the name asked for, and returns the key it is actually held under.
|
|
271
|
+
*/
|
|
272
|
+
function managerKey(name: string): string | null {
|
|
273
|
+
if (!manager) return null
|
|
274
|
+
for (const key of new Set([name, nfc(name), name.normalize('NFD')])) if (manager.contains(key)) return key
|
|
275
|
+
return null
|
|
276
|
+
}
|
|
277
|
+
function removeFromManager(name: string): void {
|
|
278
|
+
if (!manager) return
|
|
279
|
+
for (const key of new Set([name, nfc(name), name.normalize('NFD')])) if (manager.contains(key)) manager.remove(key)
|
|
280
|
+
}
|
|
281
|
+
|
|
266
282
|
/** Check if a speaker is enrolled */
|
|
267
283
|
export function isEnrolled(name: string): boolean {
|
|
268
|
-
|
|
269
|
-
return manager.contains(name)
|
|
284
|
+
return managerKey(name) !== null
|
|
270
285
|
}
|
|
271
286
|
|
|
272
287
|
/** Get all enrolled speaker names */
|
|
@@ -307,7 +322,7 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
|
|
|
307
322
|
|
|
308
323
|
// Check diversity: skip if too similar to an existing embedding for this speaker
|
|
309
324
|
const store = loadProfileStore()
|
|
310
|
-
const profile = store.profiles.find(p => p.name
|
|
325
|
+
const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
|
|
311
326
|
if (profile) {
|
|
312
327
|
// Dedup check: skip if too similar to existing (unless bypassed by trainer)
|
|
313
328
|
if (!skipDedupCheck) {
|
|
@@ -338,7 +353,7 @@ export function enrollEmbedding(name: string, embedding: Float32Array, source: s
|
|
|
338
353
|
return { success: false, dim: extractor.dim, error: `Profile store write failed: ${errMsg(err)}` }
|
|
339
354
|
}
|
|
340
355
|
const updatedStore = loadProfileStore()
|
|
341
|
-
const updatedProfile = updatedStore.profiles.find(p => p.name
|
|
356
|
+
const updatedProfile = updatedStore.profiles.find(p => sameSpeakerName(p.name, name))
|
|
342
357
|
if (updatedProfile) {
|
|
343
358
|
rebuildSpeakerInManager(name, updatedProfile.embeddings)
|
|
344
359
|
console.log(`[speaker] Force-enrolled "${name}" via rebuild (${updatedProfile.embeddings.length} total, source: ${source})`)
|
|
@@ -415,7 +430,7 @@ export interface BatchEnrollResult {
|
|
|
415
430
|
export function enrollEmbeddingsBatch(name: string, items: BatchEnrollItem[]): BatchEnrollResult {
|
|
416
431
|
if (!extractor || !manager) return { enrolled: 0, declined: items.length, persisted: false, error: 'Speaker embeddings not initialized' }
|
|
417
432
|
const store = loadProfileStore()
|
|
418
|
-
const profile = store.profiles.find(p => p.name
|
|
433
|
+
const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
|
|
419
434
|
if (!profile) return { enrolled: 0, declined: items.length, persisted: false, error: 'No such profile' }
|
|
420
435
|
let added = 0
|
|
421
436
|
let declined = 0
|
|
@@ -474,13 +489,14 @@ export function identifySpeaker(
|
|
|
474
489
|
// Phase 4: scoped search — try expected attendees first
|
|
475
490
|
if (expectedSpeakers && expectedSpeakers.length > 0) {
|
|
476
491
|
for (const name of expectedSpeakers) {
|
|
477
|
-
if (name
|
|
478
|
-
|
|
479
|
-
|
|
492
|
+
if (sameSpeakerName(name, owner)) continue // wearer already checked
|
|
493
|
+
const held = managerKey(name)
|
|
494
|
+
if (!held) continue
|
|
495
|
+
const matches = manager.verify({ name: held, v: embedding, threshold: Math.max(SEARCH_THRESHOLD, minimum) })
|
|
480
496
|
if (matches) {
|
|
481
|
-
const similarity = computeCosineSimilarity(embedding,
|
|
482
|
-
logCalibration(
|
|
483
|
-
return { speaker:
|
|
497
|
+
const similarity = computeCosineSimilarity(embedding, held)
|
|
498
|
+
logCalibration(held, similarity, true, options.event)
|
|
499
|
+
return { speaker: held, similarity, embedding }
|
|
484
500
|
}
|
|
485
501
|
}
|
|
486
502
|
}
|
|
@@ -518,7 +534,7 @@ export function autoEnroll(
|
|
|
518
534
|
sessionId: string,
|
|
519
535
|
): { enrolled: boolean; reason: string } {
|
|
520
536
|
if (!extractor || !manager) return { enrolled: false, reason: 'not initialized' }
|
|
521
|
-
if (name
|
|
537
|
+
if (sameSpeakerName(name, getOwnerSpeakerLabel()) || name === 'Ext') return { enrolled: false, reason: 'skip owner/Ext' }
|
|
522
538
|
if (similarity < AUTO_ENROLL_THRESHOLD) return { enrolled: false, reason: `similarity ${similarity.toFixed(3)} < ${AUTO_ENROLL_THRESHOLD}` }
|
|
523
539
|
|
|
524
540
|
// Check if already auto-enrolled this session
|
|
@@ -565,7 +581,7 @@ export function clearAutoEnrollSession(sessionId: string): void {
|
|
|
565
581
|
/** Clear all embeddings for a speaker (used by fresh training mode) */
|
|
566
582
|
export function clearSpeakerEmbeddings(name: string): boolean {
|
|
567
583
|
const store = loadProfileStore()
|
|
568
|
-
const profile = store.profiles.find(p => p.name
|
|
584
|
+
const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
|
|
569
585
|
if (!profile || profile.embeddings.length === 0) return false
|
|
570
586
|
|
|
571
587
|
console.log(`[speaker] Clearing ${profile.embeddings.length} embeddings for "${name}"`)
|
|
@@ -573,9 +589,7 @@ export function clearSpeakerEmbeddings(name: string): boolean {
|
|
|
573
589
|
profile.sources = []
|
|
574
590
|
|
|
575
591
|
// Remove from manager
|
|
576
|
-
|
|
577
|
-
try { manager.remove(name) } catch { /* ignore */ }
|
|
578
|
-
}
|
|
592
|
+
try { removeFromManager(name) } catch { /* ignore */ }
|
|
579
593
|
|
|
580
594
|
// Persist. A "fresh training" clear legitimately empties one profile but must
|
|
581
595
|
// never be able to empty the whole store, so allowEmpty stays off here.
|
|
@@ -621,7 +635,7 @@ export function mergeSpeakerProfiles(
|
|
|
621
635
|
options: { force?: boolean; dryRun?: boolean } = {},
|
|
622
636
|
): MergeReport {
|
|
623
637
|
const store = loadProfileStore()
|
|
624
|
-
const target = store
|
|
638
|
+
const target = findStoredProfile(store, into)
|
|
625
639
|
const report: MergeReport = {
|
|
626
640
|
into,
|
|
627
641
|
merged: [],
|
|
@@ -641,10 +655,11 @@ export function mergeSpeakerProfiles(
|
|
|
641
655
|
const eligible: string[] = []
|
|
642
656
|
const refused: { name: string; similarity: number; floor: number }[] = []
|
|
643
657
|
for (const name of from) {
|
|
644
|
-
|
|
645
|
-
const
|
|
646
|
-
if (
|
|
647
|
-
|
|
658
|
+
// The target is never its own source, but a twin stored under the same name in the other form is a different profile and merges.
|
|
659
|
+
const sources = store.profiles.filter(p => p !== target && sameSpeakerName(p.name, name))
|
|
660
|
+
if (sources.length === 0) { if (!sameSpeakerName(name, into)) report.missing.push(name); continue }
|
|
661
|
+
// Every stored copy of the name is merged, so every copy has to clear the floor; the lowest one is what is reported.
|
|
662
|
+
const similarity = Math.min(...sources.map(source => profileSimilarity(target, source)))
|
|
648
663
|
report.similarity[name] = Math.round(similarity * 1000) / 1000
|
|
649
664
|
if (similarity < MERGE_SIMILARITY_FLOOR && !options.force) {
|
|
650
665
|
refused.push({ name, similarity: report.similarity[name], floor: MERGE_SIMILARITY_FLOOR })
|
|
@@ -696,7 +711,7 @@ export function mergeSpeakerProfiles(
|
|
|
696
711
|
// tests (no 26 MB model), so this side effect has no test seam — the reason
|
|
697
712
|
// it is expressed as a single reused call rather than bespoke logic.
|
|
698
713
|
for (const name of outcome.mergedFrom) rebuildSpeakerInManager(name, [])
|
|
699
|
-
rebuildSpeakerInManager(
|
|
714
|
+
rebuildSpeakerInManager(target.name, target.embeddings)
|
|
700
715
|
writeProfileStore(store)
|
|
701
716
|
console.log(
|
|
702
717
|
`[speaker] Merged ${outcome.mergedFrom.join(', ')} into "${into}"`,
|
|
@@ -712,9 +727,7 @@ export function removeSpeakerProfile(name: string): { removedProfiles: number; r
|
|
|
712
727
|
const result = deleteProfileFromStore(store, name)
|
|
713
728
|
if (result.removedProfiles === 0) return result
|
|
714
729
|
|
|
715
|
-
|
|
716
|
-
try { manager.remove(name) } catch { /* the on-disk removal is the durable half */ }
|
|
717
|
-
}
|
|
730
|
+
try { removeFromManager(name) } catch { /* the on-disk removal is the durable half */ }
|
|
718
731
|
// allowEmpty: deleting the only enrolled person is a legitimate reset, and the
|
|
719
732
|
// caller has already confirmed it explicitly.
|
|
720
733
|
writeProfileStore(store, { allowEmpty: true })
|
|
@@ -729,7 +742,7 @@ export function retractEmbeddingsBySource(
|
|
|
729
742
|
matches: (source: string) => boolean,
|
|
730
743
|
): { removed: number; remaining: number } {
|
|
731
744
|
const store = loadProfileStore()
|
|
732
|
-
const profile = store.profiles.find(p => p.name
|
|
745
|
+
const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
|
|
733
746
|
if (!profile) return { removed: 0, remaining: 0 }
|
|
734
747
|
|
|
735
748
|
const removed = removeEmbeddingsBySource(profile, matches)
|
|
@@ -743,7 +756,7 @@ export function retractEmbeddingsBySource(
|
|
|
743
756
|
/** Get embedding count for a speaker */
|
|
744
757
|
export function getEmbeddingCount(name: string): number {
|
|
745
758
|
const store = loadProfileStore()
|
|
746
|
-
const profile = store.profiles.find(p => p.name
|
|
759
|
+
const profile = store.profiles.find(p => sameSpeakerName(p.name, name))
|
|
747
760
|
return profile?.embeddings.length ?? 0
|
|
748
761
|
}
|
|
749
762
|
|
|
@@ -1060,9 +1073,9 @@ function invalidateProfileCache(): void {
|
|
|
1060
1073
|
function restoreProfileFromDisk(name: string): void {
|
|
1061
1074
|
invalidateProfileCache()
|
|
1062
1075
|
try {
|
|
1063
|
-
const fresh = loadProfileStore().profiles.find(p => p.name
|
|
1064
|
-
if (fresh && fresh.embeddings.length > 0) rebuildSpeakerInManager(name, fresh.embeddings)
|
|
1065
|
-
else
|
|
1076
|
+
const fresh = loadProfileStore().profiles.find(p => sameSpeakerName(p.name, name))
|
|
1077
|
+
if (fresh && fresh.embeddings.length > 0) rebuildSpeakerInManager(fresh.name, fresh.embeddings)
|
|
1078
|
+
else removeFromManager(name)
|
|
1066
1079
|
} catch (err: unknown) {
|
|
1067
1080
|
console.error(`[speaker] Could not restore "${name}" from disk after a failed write:`, errMsg(err))
|
|
1068
1081
|
} finally {
|
|
@@ -1077,7 +1090,7 @@ function persistProfile(name: string, embedding: Float32Array, source: string =
|
|
|
1077
1090
|
try {
|
|
1078
1091
|
const store = loadProfileStore()
|
|
1079
1092
|
|
|
1080
|
-
let profile = store.profiles.find(p => p.name
|
|
1093
|
+
let profile = store.profiles.find(p => sameSpeakerName(p.name, name))
|
|
1081
1094
|
if (!profile) {
|
|
1082
1095
|
profile = { name, embeddings: [], sources: [] }
|
|
1083
1096
|
store.profiles.push(profile)
|
|
@@ -1131,10 +1144,8 @@ export function computeCentroid(embeddings: number[][]): Float32Array {
|
|
|
1131
1144
|
function rebuildSpeakerInManager(name: string, embeddings: number[][]): void {
|
|
1132
1145
|
if (!manager) return
|
|
1133
1146
|
try {
|
|
1134
|
-
// Remove existing entry
|
|
1135
|
-
|
|
1136
|
-
manager.remove(name)
|
|
1137
|
-
}
|
|
1147
|
+
// Remove the existing entry, whichever form it is held under
|
|
1148
|
+
removeFromManager(name)
|
|
1138
1149
|
if (embeddings.length === 0) return
|
|
1139
1150
|
|
|
1140
1151
|
// Register centroid of all embeddings
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Speaker names are compared and stored in NFC.
|
|
3
|
+
*
|
|
4
|
+
* Foundation's `Process` turns an argument into decomposed Unicode (NFD): COS Control's helper sent `Pólya` and the server
|
|
5
|
+
* received the bytes `50 6f cc 81 6c 79 61`, where NFC is `50 c3 b3 6c 79 61`. A sidecar written by the pipeline holds NFC, so a
|
|
6
|
+
* confirm of "Brigitta Pólya" answered 409 `No chunk in this meeting is labelled` while 11 chunks carried that name.
|
|
7
|
+
*
|
|
8
|
+
* Two rules, applied everywhere a name arrives in a request or is matched against stored data:
|
|
9
|
+
* 1. Normalize a name to NFC at the route boundary (`bodyName`, `nfc`), so nothing decomposed is ever WRITTEN to a sidecar, a
|
|
10
|
+
* ledger or a voice profile.
|
|
11
|
+
* 2. Compare with NFC on both sides (`sameSpeakerName`), so a name already stored decomposed (written before this existed)
|
|
12
|
+
* still matches its composed spelling. Stored data is not rewritten.
|
|
13
|
+
*/
|
|
14
|
+
export function nfc(name: string): string { return name.normalize('NFC') }
|
|
15
|
+
|
|
16
|
+
export function sameSpeakerName(a: unknown, b: unknown): boolean {
|
|
17
|
+
return typeof a === 'string' && typeof b === 'string' && (a === b || nfc(a) === nfc(b))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** A request value as a name: a string in NFC, or '' for anything else. */
|
|
21
|
+
export function bodyName(value: unknown): string { return typeof value === 'string' ? nfc(value) : '' }
|
|
22
|
+
|
|
23
|
+
/** A regular expression source that matches `name` in either form, for text that may hold either. */
|
|
24
|
+
export function nameForms(name: string, escape: (s: string) => string): string {
|
|
25
|
+
const composed = nfc(name), decomposed = name.normalize('NFD')
|
|
26
|
+
return composed === decomposed ? escape(composed) : `(?:${escape(composed)}|${escape(decomposed)})`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A record id carries a filename, and Control's helper hands it over decomposed: equal when they are the same text in NFC. */
|
|
30
|
+
export function sameRecordId(requested: unknown, expected: string): boolean {
|
|
31
|
+
return typeof requested === 'string' && (requested === expected || nfc(requested) === nfc(expected))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when raw text holds `name` in either Unicode form (a file written before names were normalized may hold the decomposed one). */
|
|
35
|
+
export function textHasName(raw: string, name: string): boolean {
|
|
36
|
+
return raw.includes(name) || raw.includes(nfc(name)) || raw.includes(name.normalize('NFD'))
|
|
37
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { readFileSync, readdirSync, lstatSync, realpathSync } from 'node:fs'
|
|
7
7
|
import { join } from 'node:path'
|
|
8
8
|
import { renameMeetingSpeaker } from './held-naming-batches.js'
|
|
9
|
+
import { nameForms, sameSpeakerName, textHasName } from './speaker-name-nfc.js'
|
|
9
10
|
|
|
10
11
|
/** A month directory, and nothing that merely looks like one. */
|
|
11
12
|
const CANONICAL_MONTH = /^\d{4}-\d{2}$/
|
|
@@ -153,7 +154,7 @@ export async function fanOutSpeakerRename(
|
|
|
153
154
|
if (!path.endsWith(SIDECAR_SUFFIX)) continue
|
|
154
155
|
const markdownPath = path.slice(0, -SIDECAR_SUFFIX.length) + '.md'
|
|
155
156
|
coveredMarkdown.add(markdownPath)
|
|
156
|
-
if (!raw
|
|
157
|
+
if (!textHasName(raw, from)) continue
|
|
157
158
|
try {
|
|
158
159
|
const doc = JSON.parse(raw)
|
|
159
160
|
if (!doc || Array.isArray(doc) || typeof doc !== 'object') throw new Error('sidecar is not a JSON object')
|
|
@@ -190,7 +191,7 @@ export async function fanOutSpeakerRename(
|
|
|
190
191
|
// Cloud-only and archived markdown has no raw G2 position authority. Keep it
|
|
191
192
|
// visible in the report rather than rewriting an attendee or quoted name.
|
|
192
193
|
for (const [path, raw] of entries) {
|
|
193
|
-
if (!path.endsWith('.md') || coveredMarkdown.has(path) || !raw
|
|
194
|
+
if (!path.endsWith('.md') || coveredMarkdown.has(path) || !textHasName(raw, from)) continue
|
|
194
195
|
if (hasNamedLabel(raw, from)) result.skipped.push({
|
|
195
196
|
path, reason: 'no G2 session sidecar; transcript labels cannot be safely aligned',
|
|
196
197
|
})
|
|
@@ -203,13 +204,13 @@ function errorText(error: unknown): string {
|
|
|
203
204
|
}
|
|
204
205
|
|
|
205
206
|
function carriesSpeaker(doc: Record<string, any>, name: string): boolean {
|
|
206
|
-
return (Array.isArray(doc.chunks) && doc.chunks.some((chunk: any) => chunk?.speaker
|
|
207
|
-
|| (Array.isArray(doc.chunkEntries) && doc.chunkEntries.some((entry: any) => entry?.chunk?.speaker
|
|
207
|
+
return (Array.isArray(doc.chunks) && doc.chunks.some((chunk: any) => sameSpeakerName(chunk?.speaker, name)))
|
|
208
|
+
|| (Array.isArray(doc.chunkEntries) && doc.chunkEntries.some((entry: any) => sameSpeakerName(entry?.chunk?.speaker, name)))
|
|
208
209
|
|| (Array.isArray(doc.batchSegments) && doc.batchSegments.some((segment: any) =>
|
|
209
|
-
Array.isArray(segment?.speakerWords) && segment.speakerWords.some((word: any) => word?.speaker
|
|
210
|
+
Array.isArray(segment?.speakerWords) && segment.speakerWords.some((word: any) => sameSpeakerName(word?.speaker, name))))
|
|
210
211
|
}
|
|
211
212
|
|
|
212
|
-
function escapedName(name: string): string { return name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') }
|
|
213
|
+
function escapedName(name: string): string { return nameForms(name, n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) }
|
|
213
214
|
function hasNamedLabel(markdown: string, name: string): boolean {
|
|
214
215
|
const escaped = escapedName(name)
|
|
215
216
|
return new RegExp(`^(?:\\[${escaped}\\]:|\\*\\*${escaped}(?::\\*\\*|\\*\\*)|[-*] ${escaped}$)`, 'm').test(markdown)
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
rawCosineSimilarity, saveProfileStore, rebuildAllProfiles, clearSpeakerEmbeddings,
|
|
14
14
|
} from './speaker-embeddings.js'
|
|
15
15
|
import { COS_SCRIPTS_DIR } from './python-bridge.js'
|
|
16
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
16
17
|
|
|
17
18
|
const CACHE_DIR = '/tmp/cos-speaker-training'
|
|
18
19
|
const PROGRESS_PATH = resolve(CACHE_DIR, 'training-progress.json')
|
|
@@ -520,7 +521,7 @@ export async function getTrainingStatus(): Promise<TrainingStatus> {
|
|
|
520
521
|
}
|
|
521
522
|
|
|
522
523
|
for (const name of enrolledNames) {
|
|
523
|
-
if (!speakers.some(s => s.name
|
|
524
|
+
if (!speakers.some(s => sameSpeakerName(s.name, name))) {
|
|
524
525
|
speakers.push({ name, segments: 0, meetings: 0, enrolled: true })
|
|
525
526
|
}
|
|
526
527
|
}
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { lstat, readFile, readdir, realpath } from 'node:fs/promises'
|
|
14
14
|
import { basename, dirname, join } from 'node:path'
|
|
15
15
|
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
|
16
|
+
import { readMacSpeakerMachineEvidence } from './mac-speaker-machine-evidence.js'
|
|
16
17
|
import { dataPath } from './data-dir.js'
|
|
17
18
|
import { confirmedLabels, confirmedChunks } from './meeting-corrections.js'
|
|
18
19
|
import { resolveCosOperationsDir, resolveMeetingLibrary } from './cos-operations-meetings.js'
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
} from './meeting-speaker-review.js'
|
|
26
27
|
import { getOwnerSpeakerLabel } from './profile.js'
|
|
27
28
|
import { readVoiceProfiles, type VoiceProfile } from './speaker-embeddings.js'
|
|
29
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
28
30
|
|
|
29
31
|
const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/
|
|
30
32
|
const SIDECAR = /\.g2-chunks\.json$/
|
|
@@ -294,11 +296,14 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
|
|
|
294
296
|
storedTitle,
|
|
295
297
|
)
|
|
296
298
|
|
|
299
|
+
const machineEvidence = readMacSpeakerMachineEvidence(record)
|
|
297
300
|
const review = reviewMeetingSpeakers(chunks as ReviewChunk[], {
|
|
298
301
|
owner,
|
|
299
302
|
phrasesPerVoice: 1,
|
|
300
303
|
confirmed: confirmedLabels(sessionId),
|
|
301
|
-
|
|
304
|
+
confirmedChunks: confirmedChunks(sessionId),
|
|
305
|
+
machineQualifiedPositions: machineEvidence.positions,
|
|
306
|
+
machineEvidenceStatus: machineEvidence.status,
|
|
302
307
|
durationMs: typeof record.durationMs === 'number' ? record.durationMs : undefined,
|
|
303
308
|
batchSegments: Array.isArray(record.batchSegments)
|
|
304
309
|
? record.batchSegments as SpeakerWordSegment[]
|
|
@@ -359,7 +364,7 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
|
|
|
359
364
|
.slice(0, MAX_APPEARANCES_PER_VOICE)
|
|
360
365
|
return {
|
|
361
366
|
name: profile.name,
|
|
362
|
-
isOwner: profile.name
|
|
367
|
+
isOwner: sameSpeakerName(profile.name, owner),
|
|
363
368
|
embeddings: profile.embeddings.length,
|
|
364
369
|
sources: sourceCounts(profile),
|
|
365
370
|
sourcesAligned: (profile.sources?.length ?? 0) === profile.embeddings.length,
|
|
@@ -16,6 +16,7 @@ import { existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:f
|
|
|
16
16
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
17
17
|
import { durableAtomicWriteFileSync, loadJsonOrQuarantine } from './atomic-fs.js'
|
|
18
18
|
import { EVICTION_ORDER, isMacAssign, macAssignCapFor, provenanceTier } from './embedding-eviction.js'
|
|
19
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
19
20
|
|
|
20
21
|
export interface VoiceProfile {
|
|
21
22
|
name: string
|
|
@@ -512,6 +513,11 @@ export interface MergeOutcome {
|
|
|
512
513
|
* removable later, and overwriting it to record a bookkeeping event would trade
|
|
513
514
|
* a useful fact for a useless one.
|
|
514
515
|
*/
|
|
516
|
+
/** The profile stored under `name`: the exact spelling first, else the one that is the same name in NFC. */
|
|
517
|
+
export function findStoredProfile(store: ProfileStore, name: string): VoiceProfile | undefined {
|
|
518
|
+
return store.profiles.find(p => p.name === name) ?? store.profiles.find(p => sameSpeakerName(p.name, name))
|
|
519
|
+
}
|
|
520
|
+
|
|
515
521
|
export function mergeProfilesInStore(
|
|
516
522
|
store: ProfileStore,
|
|
517
523
|
into: string,
|
|
@@ -519,7 +525,7 @@ export function mergeProfilesInStore(
|
|
|
519
525
|
options: { cap?: number } = {},
|
|
520
526
|
): MergeOutcome {
|
|
521
527
|
const cap = options.cap ?? 40
|
|
522
|
-
const target = store
|
|
528
|
+
const target = findStoredProfile(store, into)
|
|
523
529
|
const outcome: MergeOutcome = {
|
|
524
530
|
similarity: {},
|
|
525
531
|
samplesBefore: target?.embeddings.length ?? 0,
|
|
@@ -537,18 +543,23 @@ export function mergeProfilesInStore(
|
|
|
537
543
|
const sources = [...(target.sources ?? [])]
|
|
538
544
|
while (sources.length < embeddings.length) sources.push(UNKNOWN_SOURCE)
|
|
539
545
|
|
|
546
|
+
// Sources are the stored profiles themselves, never the spelling asked for: a name stored decomposed and its composed twin are two
|
|
547
|
+
// profiles (the twin is merged into the one that was asked for), and the target is never its own source.
|
|
548
|
+
const absorbed = new Set<VoiceProfile>()
|
|
540
549
|
for (const name of from) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
+
const found = store.profiles.filter(p => p !== target && !absorbed.has(p) && sameSpeakerName(p.name, name))
|
|
551
|
+
if (found.length === 0) { if (!store.profiles.some(p => sameSpeakerName(p.name, name) && p !== target)) outcome.missing.push(name); continue }
|
|
552
|
+
for (const source of found) {
|
|
553
|
+
outcome.similarity[name] = profileSimilarity(target, source)
|
|
554
|
+
const sourceSources = [...(source.sources ?? [])]
|
|
555
|
+
while (sourceSources.length < source.embeddings.length) sourceSources.push(UNKNOWN_SOURCE)
|
|
556
|
+
for (let i = 0; i < source.embeddings.length; i++) {
|
|
557
|
+
embeddings.push(source.embeddings[i])
|
|
558
|
+
sources.push(sourceSources[i] ?? UNKNOWN_SOURCE)
|
|
559
|
+
}
|
|
560
|
+
outcome.mergedFrom.push(source.name)
|
|
561
|
+
absorbed.add(source)
|
|
550
562
|
}
|
|
551
|
-
outcome.mergedFrom.push(name)
|
|
552
563
|
}
|
|
553
564
|
|
|
554
565
|
if (outcome.mergedFrom.length === 0) return outcome
|
|
@@ -569,8 +580,7 @@ export function mergeProfilesInStore(
|
|
|
569
580
|
target.sources = keep.map(i => sources[i])
|
|
570
581
|
outcome.samplesAfter = target.embeddings.length
|
|
571
582
|
|
|
572
|
-
|
|
573
|
-
store.profiles = store.profiles.filter(p => !removed.has(p.name))
|
|
583
|
+
store.profiles = store.profiles.filter(p => !absorbed.has(p))
|
|
574
584
|
return outcome
|
|
575
585
|
}
|
|
576
586
|
|
|
@@ -583,7 +593,7 @@ export function deleteProfileFromStore(
|
|
|
583
593
|
let removedProfiles = 0
|
|
584
594
|
let removedEmbeddings = 0
|
|
585
595
|
store.profiles = store.profiles.filter(profile => {
|
|
586
|
-
if (profile.name
|
|
596
|
+
if (!sameSpeakerName(profile.name, name)) return true
|
|
587
597
|
removedProfiles++
|
|
588
598
|
removedEmbeddings += profile.embeddings.length
|
|
589
599
|
return false
|
|
@@ -3,6 +3,7 @@ import { Router } from 'express'
|
|
|
3
3
|
import { JEV_MODEL, resolveJevKey } from '../lib/jev.js'
|
|
4
4
|
import { resolveSubqKey, subqKeyStatus } from '../lib/subq-key.js'
|
|
5
5
|
import { SUBQ_MODEL } from '../lib/decision-provider.js'
|
|
6
|
+
import { sessionFindAvailable } from '../lib/session-find.js'
|
|
6
7
|
import { decisionSettingsView, DecisionSettingsError, patchDecisionSettings, readDecisionSettings, writeDecisionSettings, type DecisionProvider, type DecisionSettings } from '../lib/decision-settings.js'
|
|
7
8
|
export interface DecisionProviderRouteDeps {
|
|
8
9
|
read: () => DecisionSettings
|
|
@@ -15,7 +16,9 @@ export function createDecisionProviderRouter(overrides: Partial<DecisionProvider
|
|
|
15
16
|
const deps: DecisionProviderRouteDeps = { read: readDecisionSettings, write: writeDecisionSettings, configured: () => ({ jev: !!resolveJevKey(), subq: !!resolveSubqKey() }), env: process.env, keyStatus: () => { const jev = resolveJevKey(); return { jev: { configured:!!jev, source:jev?.source ?? 'none' }, subq:subqKeyStatus() } }, ...overrides }
|
|
16
17
|
const router = Router()
|
|
17
18
|
const view = (settings: DecisionSettings) => ({ ...decisionSettingsView(settings, deps.configured(), deps.env), models: { jev: JEV_MODEL, subq: SUBQ_MODEL }, keyStatus: deps.keyStatus(),
|
|
18
|
-
capabilities: { choices: ['jev','subq'], workflows: ['session','completion','search'], hotReload: true, fallback: false, subqImages: true, subqKeySource: 'config-or-env'
|
|
19
|
+
capabilities: { choices: ['jev','subq'], workflows: ['session','completion','search'], hotReload: true, fallback: false, subqImages: true, subqKeySource: 'config-or-env',
|
|
20
|
+
// Present only while POST /api/agent-sessions/find can answer. Absent means no meaning search here, and Control does not ask (never keyed on version).
|
|
21
|
+
...(sessionFindAvailable(deps.env) ? { sessionFind: true } : {}) } })
|
|
19
22
|
router.use('/decision-provider', (_req,res,next) => { res.set('Cache-Control','private, no-store'); next() })
|
|
20
23
|
router.get('/decision-provider', (_req,res) => {
|
|
21
24
|
try { res.json(view(deps.read())) } catch { res.status(503).json({ error: { code: 'decision_settings_unavailable', message: 'Decision provider settings could not be read.' } }) }
|
package/server/routes/jev.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { CompletionChecker, readRepliesSince } from '../lib/work-completion.js'
|
|
|
14
14
|
import { isSafeSessionId, type AgentProvider } from '../lib/agent-session-store.js'
|
|
15
15
|
import { listBoard } from '../lib/task-store.js'
|
|
16
16
|
import { sharedWorkSearchJevClient } from '../lib/work-search.js'
|
|
17
|
+
import { sharedSessionFindJevClient } from '../lib/session-find.js'
|
|
17
18
|
import { isSafeDomainName } from '../lib/domains.js'
|
|
18
19
|
|
|
19
20
|
/** What the route reads from a meeting review (6.57.1): the server's own record, never client text. */
|
|
@@ -34,6 +35,8 @@ export interface JevRouteDependencies {
|
|
|
34
35
|
replies: (provider: AgentProvider, sessionId: string, afterMs: number | null) => Promise<string | null>
|
|
35
36
|
/** 6.65.0: Work search keeps its own client and breaker; a newly saved key clears that breaker too. */
|
|
36
37
|
searchJev: Pick<JevClient, 'resetForNewKey'>
|
|
38
|
+
/** Session find (Control 0.5.363) keeps its own client and breaker too. */
|
|
39
|
+
findJev: Pick<JevClient, 'resetForNewKey'>
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
/** Work identity first: it survives a rename, while a new card's id could equal an old identity's text hash. */
|
|
@@ -45,7 +48,7 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
|
|
|
45
48
|
const jev = overrides.jev ?? sharedJevClient()
|
|
46
49
|
const deps: JevRouteDependencies = { jev, recommender: new SessionRecommender(createDecisionClient('session', jev)), list: listBoard, validate: validateJevKey, save: saveJevKey,
|
|
47
50
|
completion: new CompletionChecker(createDecisionClient('completion', jev)), replies: (provider, sessionId, afterMs) => readRepliesSince(provider, sessionId, afterMs),
|
|
48
|
-
searchJev: sharedWorkSearchJevClient(), ...overrides }
|
|
51
|
+
searchJev: sharedWorkSearchJevClient(), findJev: sharedSessionFindJevClient(), ...overrides }
|
|
49
52
|
const router = Router()
|
|
50
53
|
router.use(['/jev-key', '/work-board/session-recommendation', '/work-board/completion-check'], (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
51
54
|
|
|
@@ -67,6 +70,7 @@ export function createJevRouter(overrides: Partial<JevRouteDependencies> = {}):
|
|
|
67
70
|
}
|
|
68
71
|
deps.jev.resetForNewKey()
|
|
69
72
|
deps.searchJev.resetForNewKey()
|
|
73
|
+
deps.findJev.resetForNewKey()
|
|
70
74
|
return res.json({ ok: true, ...deps.jev.status() })
|
|
71
75
|
})
|
|
72
76
|
|