@gotcos/glasses-server 6.94.2 → 6.94.4
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 +24 -0
- package/README.md +4 -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-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-speaker-review.ts +9 -8
- package/server/lib/session-find-ask.ts +288 -0
- package/server/lib/session-find-collect.ts +715 -0
- package/server/lib/session-find-redact.ts +254 -0
- package/server/lib/session-find.ts +345 -0
- package/server/lib/speaker-calibration-log.ts +3 -2
- package/server/lib/speaker-embeddings.ts +46 -35
- package/server/lib/speaker-name-nfc.ts +37 -0
- package/server/lib/speaker-rename-fanout.ts +7 -6
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/voice-directory.ts +2 -1
- 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 +11 -10
- package/server/routes/session-find.ts +73 -0
- package/server/routes/voice.ts +25 -18
|
@@ -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
|
}
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from './meeting-speaker-review.js'
|
|
27
27
|
import { getOwnerSpeakerLabel } from './profile.js'
|
|
28
28
|
import { readVoiceProfiles, type VoiceProfile } from './speaker-embeddings.js'
|
|
29
|
+
import { sameSpeakerName } from './speaker-name-nfc.js'
|
|
29
30
|
|
|
30
31
|
const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/
|
|
31
32
|
const SIDECAR = /\.g2-chunks\.json$/
|
|
@@ -363,7 +364,7 @@ export async function buildVoiceDirectorySnapshot(): Promise<VoiceDirectorySnaps
|
|
|
363
364
|
.slice(0, MAX_APPEARANCES_PER_VOICE)
|
|
364
365
|
return {
|
|
365
366
|
name: profile.name,
|
|
366
|
-
isOwner: profile.name
|
|
367
|
+
isOwner: sameSpeakerName(profile.name, owner),
|
|
367
368
|
embeddings: profile.embeddings.length,
|
|
368
369
|
sources: sourceCounts(profile),
|
|
369
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
|
|
package/server/routes/meeting.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readMacSpeakerMachineEvidence } from '../lib/mac-speaker-machine-eviden
|
|
|
3
3
|
import { dataPath } from '../lib/data-dir.js'
|
|
4
4
|
import { PHONE_CAPTURE_ID, PHONE_SOURCE_TYPE, assertPhoneProvenance, readPhoneRecording, requirePhoneRecording, closePhoneRecording } from '../lib/phone-recording.js'
|
|
5
5
|
import { MAC_CAPTURE_ID, readMacRecording, requireMacRecording, macChunkReceipt, closeMacRecording } from '../lib/mac-recording.js'
|
|
6
|
+
import { bodyName, sameRecordId, sameSpeakerName } from '../lib/speaker-name-nfc.js'
|
|
6
7
|
import { readSpeakerPolicy, writeSpeakerPolicy, isSpeakerMode } from '../lib/meeting-speaker-policy.js'
|
|
7
8
|
// POST /api/meeting/save — finalize an existing transcribe-stream session into
|
|
8
9
|
// the standalone public meeting store. The live transcript and chunk metadata
|
|
@@ -261,7 +262,7 @@ function markdownUnreadableReason(operations: { mergedScribeConflict?: string[]
|
|
|
261
262
|
}
|
|
262
263
|
|
|
263
264
|
function requestedRecordMatches(requested: unknown, expected: string): boolean {
|
|
264
|
-
return requested == null || requested === '' || requested
|
|
265
|
+
return requested == null || requested === '' || sameRecordId(requested, expected)
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
interface MeetingSessionSource {
|
|
@@ -1967,7 +1968,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
1967
1968
|
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
1968
1969
|
return
|
|
1969
1970
|
}
|
|
1970
|
-
const speaker =
|
|
1971
|
+
const speaker = bodyName(req.body?.speaker).trim()
|
|
1971
1972
|
if (!speaker) {
|
|
1972
1973
|
res.status(400).json({ error: 'speaker is required', reason: 'invalid_label' })
|
|
1973
1974
|
return
|
|
@@ -2000,7 +2001,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2000
2001
|
}
|
|
2001
2002
|
|
|
2002
2003
|
// Only rows that ACTUALLY landed, and only those that named this speaker.
|
|
2003
|
-
const rows = appliedCorrections(sessionId).filter(r => r.to
|
|
2004
|
+
const rows = appliedCorrections(sessionId).filter(r => sameSpeakerName(r.to, speaker) && r.chunks.length > 0)
|
|
2004
2005
|
if (rows.length === 0) {
|
|
2005
2006
|
res.status(404).json({ error: `No applied correction named "${speaker}" in this meeting`, reason: 'no_correction' })
|
|
2006
2007
|
return
|
|
@@ -2047,8 +2048,8 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2047
2048
|
return
|
|
2048
2049
|
}
|
|
2049
2050
|
|
|
2050
|
-
const from =
|
|
2051
|
-
const to =
|
|
2051
|
+
const from = bodyName(req.body?.from)
|
|
2052
|
+
const to = bodyName(req.body?.to)
|
|
2052
2053
|
const chunks = Array.isArray(req.body?.chunks)
|
|
2053
2054
|
? (req.body.chunks as unknown[]).filter((n): n is number => Number.isInteger(n) && (n as number) >= 0)
|
|
2054
2055
|
: []
|
|
@@ -2401,7 +2402,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2401
2402
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
2402
2403
|
return
|
|
2403
2404
|
}
|
|
2404
|
-
const label =
|
|
2405
|
+
const label = bodyName(req.body?.label)
|
|
2405
2406
|
const bad = invalidLabelReason(label)
|
|
2406
2407
|
if (bad) {
|
|
2407
2408
|
res.status(400).json({ error: `label: ${bad}`, reason: 'invalid_label' })
|
|
@@ -2449,7 +2450,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2449
2450
|
const doc = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as Record<string, unknown>
|
|
2450
2451
|
const rows = Array.isArray(doc.chunks) ? doc.chunks : []
|
|
2451
2452
|
carried = rows.filter(r => r && typeof r === 'object'
|
|
2452
|
-
&& (r as Record<string, unknown>).speaker
|
|
2453
|
+
&& sameSpeakerName((r as Record<string, unknown>).speaker, label)).length
|
|
2453
2454
|
} catch {
|
|
2454
2455
|
res.status(500).json({ error: 'Could not read the chunk sidecar', reason: 'sidecar_unreadable' })
|
|
2455
2456
|
return
|
|
@@ -2492,7 +2493,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2492
2493
|
res.status(evidenceRefusal.status).json(evidenceRefusal.body)
|
|
2493
2494
|
return
|
|
2494
2495
|
}
|
|
2495
|
-
const from =
|
|
2496
|
+
const from = bodyName(req.body?.from)
|
|
2496
2497
|
const bad = invalidLabelReason(from)
|
|
2497
2498
|
if (bad) {
|
|
2498
2499
|
res.status(400).json({ error: `from: ${bad}`, reason: 'invalid_label' })
|
|
@@ -2568,7 +2569,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2568
2569
|
if (Array.isArray(parsed.chunks)) {
|
|
2569
2570
|
for (const c of parsed.chunks) {
|
|
2570
2571
|
const sp = (c as { speaker?: unknown } | null)?.speaker
|
|
2571
|
-
if (typeof sp === 'string' && !existingSpeakers.
|
|
2572
|
+
if (typeof sp === 'string' && !existingSpeakers.some(x => sameSpeakerName(x, sp))) existingSpeakers.push(sp)
|
|
2572
2573
|
}
|
|
2573
2574
|
}
|
|
2574
2575
|
} catch { /* the relabel below reports a corrupt sidecar */ }
|
|
@@ -2581,7 +2582,7 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
2581
2582
|
|
|
2582
2583
|
// What this would remove from the profile, and what it CANNOT reach.
|
|
2583
2584
|
// readVoiceProfiles returns a ProfileStore, not an array.
|
|
2584
|
-
const profile = readVoiceProfiles().profiles.find(p => p.name
|
|
2585
|
+
const profile = readVoiceProfiles().profiles.find(p => sameSpeakerName(p.name, from))
|
|
2585
2586
|
const profileSources: Array<string | undefined> = profile
|
|
2586
2587
|
? Array.from({ length: profile.embeddings.length }, (_, i) => profile.sources?.[i])
|
|
2587
2588
|
: []
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /api/agent-sessions/find (Control 0.5.363): which of this Mac's agent sessions the person means, by meaning.
|
|
3
|
+
* Auth comes from the parent /api mount. See lib/session-find.ts for what leaves the Mac, the cost controls and the gates.
|
|
4
|
+
*
|
|
5
|
+
* Body `{ query, consentedProvider, stage }`, exactly those keys. `query` is a string; the words are redacted and cut at 200
|
|
6
|
+
* code points before use. `consentedProvider` is `"jev"`, `"subq"` or `null`: the provider the person agreed to send to.
|
|
7
|
+
* `stage` is `"first"` (the newest 254 sessions) or `"full"` (the next 254 too, unless the first already settles it).
|
|
8
|
+
*
|
|
9
|
+
* A search that cannot run answers HTTP 200 with `available: false`, a `reason` and a `message`, so Control keeps its word
|
|
10
|
+
* search and says one line about why. A malformed body is 400. A walk that fails (the session store, the keyed-sessions file) is
|
|
11
|
+
* also a 200 with `reason: failed`, so Control keeps its word search. 503 `session_find_unavailable` is left for an unexpected
|
|
12
|
+
* error outside the finder's own handling: try later.
|
|
13
|
+
* An older server answers 404, which Control reads as "no meaning search here".
|
|
14
|
+
*
|
|
15
|
+
* A closed connection stops the walk and any stage that has not started. A model request already sent is not aborted (it is
|
|
16
|
+
* billed either way). The query and the sessions never reach a log line.
|
|
17
|
+
*/
|
|
18
|
+
import { Router, type Response } from 'express'
|
|
19
|
+
import { isAbortError } from '../lib/session-find-collect.js'
|
|
20
|
+
import { SESSION_FIND_LIMITS, SessionFinder, unavailableAnswer, type SessionFindAnswer } from '../lib/session-find.js'
|
|
21
|
+
|
|
22
|
+
export interface SessionFindRouteDependencies {
|
|
23
|
+
finder: Pick<SessionFinder, 'find'>
|
|
24
|
+
now: () => number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const BODY_KEYS = new Set(['query', 'consentedProvider', 'stage'])
|
|
28
|
+
/** One log line per degrade reason per 10 minutes: Control falls back quietly, so the server says why. Fixed words only, never the query or a session. */
|
|
29
|
+
export const SESSION_FIND_LOG_EVERY_MS = 10 * 60_000
|
|
30
|
+
|
|
31
|
+
export function createSessionFindRouter(overrides: Partial<SessionFindRouteDependencies> = {}): Router {
|
|
32
|
+
let finder: Pick<SessionFinder, 'find'> | null = overrides.finder ?? null
|
|
33
|
+
const now = overrides.now ?? Date.now
|
|
34
|
+
const lastLogged = new Map<string, number>()
|
|
35
|
+
const logDegrade = (reason: string) => {
|
|
36
|
+
const at = now()
|
|
37
|
+
const last = lastLogged.get(reason)
|
|
38
|
+
if (last !== undefined && at - last < SESSION_FIND_LOG_EVERY_MS) return
|
|
39
|
+
lastLogged.set(reason, at)
|
|
40
|
+
console.warn(`[session-find] unavailable: ${reason}`)
|
|
41
|
+
}
|
|
42
|
+
const router = Router()
|
|
43
|
+
router.use('/agent-sessions/find', (_req, res, next) => { res.set('Cache-Control', 'private, no-store'); next() })
|
|
44
|
+
|
|
45
|
+
router.post('/agent-sessions/find', async (req, res: Response) => {
|
|
46
|
+
const body = req.body
|
|
47
|
+
const bad = () => res.status(400).json({ error: { code: 'invalid_find_request',
|
|
48
|
+
message: 'Send a query, the provider you consented to (jev, subq or null) and a stage of first or full.' } })
|
|
49
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || Object.keys(body).some(key => !BODY_KEYS.has(key))) return bad()
|
|
50
|
+
if (typeof body.query !== 'string' || body.query.length > SESSION_FIND_LIMITS.queryRawMax) return bad()
|
|
51
|
+
if (body.consentedProvider !== null && body.consentedProvider !== undefined && body.consentedProvider !== 'jev' && body.consentedProvider !== 'subq') return bad()
|
|
52
|
+
if (body.stage !== 'first' && body.stage !== 'full') return bad()
|
|
53
|
+
|
|
54
|
+
const controller = new AbortController()
|
|
55
|
+
// `close` also fires when a response has finished normally; only a connection that closed before the answer counts.
|
|
56
|
+
res.on('close', () => { if (!res.writableEnded) controller.abort() })
|
|
57
|
+
let answer: SessionFindAnswer
|
|
58
|
+
try {
|
|
59
|
+
finder ??= new SessionFinder()
|
|
60
|
+
answer = await finder.find({ query: body.query, consentedProvider: body.consentedProvider ?? null, stage: body.stage }, controller.signal)
|
|
61
|
+
} catch (error) {
|
|
62
|
+
if (isAbortError(error) || controller.signal.aborted) return
|
|
63
|
+
console.error('[session-find] search failed')
|
|
64
|
+
return res.status(503).json({ error: { code: 'session_find_unavailable', message: 'Meaning search could not read your sessions. Try again shortly.' } })
|
|
65
|
+
}
|
|
66
|
+
if (controller.signal.aborted) return
|
|
67
|
+
if (!answer.available && answer.reason) logDegrade(answer.reason)
|
|
68
|
+
return res.json(answer)
|
|
69
|
+
})
|
|
70
|
+
return router
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export { unavailableAnswer }
|