@gotcos/glasses-server 6.45.5 → 6.46.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +2 -2
- package/server/index.ts +2 -0
- package/server/lib/cos-operations-meetings.ts +1 -1
- package/server/lib/g2-ops-handoff.ts +27 -5
- package/server/lib/held-naming-batches.ts +527 -0
- package/server/lib/held-voice-groups.ts +103 -12
- package/server/lib/meeting-batch-persistence.ts +1 -0
- package/server/lib/meeting-corrections.ts +43 -5
- package/server/lib/meeting-speaker-labels.ts +192 -0
- package/server/lib/meeting-speaker-review.ts +18 -5
- package/server/lib/meeting-sync-lock.ts +26 -0
- package/server/lib/speaker-embeddings.ts +1 -1
- package/server/lib/speaker-rename-fanout.ts +91 -177
- package/server/lib/training-audio-save.ts +24 -0
- package/server/lib/voice-directory.ts +2 -1
- package/server/lib/voice-enrolment-selection.ts +25 -0
- package/server/lib/voice-profile-store.ts +8 -3
- package/server/routes/meeting.ts +5 -1
- package/server/routes/transcribe-stream.ts +13 -41
- package/server/routes/voice.ts +29 -43
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/** Durable held naming: server-stored preview, per-copy receipts, explicit recovery. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs'
|
|
4
|
+
import { dirname, join, resolve } from 'node:path'
|
|
5
|
+
import { dataPath } from './data-dir.js'
|
|
6
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
7
|
+
import { collectHeldSamples, discardHeldSamples, enrollHeldGroup, HeldGroupError, sampleKey, suggestProfile, previewDiscard, projectHeldEnrollment, type HeldSampleRef } from './held-voice-groups.js'
|
|
8
|
+
import { readVoiceProfiles, rawCosineSimilarity } from './speaker-embeddings.js'
|
|
9
|
+
import { getOwnerSpeakerLabel } from './profile.js'
|
|
10
|
+
import { coherentClusterContaining, VOICE_COHERENCE_FLOOR } from './voice-enrolment-selection.js'
|
|
11
|
+
import { readChunkEmbeddings } from './chunk-embedding-store.js'
|
|
12
|
+
import { findCosOperationsMeetingBySessionId, resolveCosOperationsDir } from './cos-operations-meetings.js'
|
|
13
|
+
import { MeetingStore } from './meeting-store.js'
|
|
14
|
+
import { appendCorrection, closeInterruptedCorrections, pendingCorrections, readCorrections, appliedCorrections, type CorrectionRow } from './meeting-corrections.js'
|
|
15
|
+
import { patchValue, relabelSpeakerPositions, undoSpeakerMarkdown, strippedTranscript, validatedRawMap, type SpeakerPatch } from './meeting-speaker-labels.js'
|
|
16
|
+
import { acquireMeetingSyncLock } from './meeting-sync-lock.js'
|
|
17
|
+
|
|
18
|
+
export const NAMING_CAPABILITIES = {preview:true,apply:true,undo:true,version:1} as const
|
|
19
|
+
const PREVIEW_TTL = 15 * 60 * 1000
|
|
20
|
+
const HIGH_COUNT = 25
|
|
21
|
+
const hash = (value: unknown) => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(value)).digest('hex')
|
|
22
|
+
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error)
|
|
23
|
+
const plainRef = ({sessionId,chunkIndex}: HeldSampleRef) => ({sessionId,chunkIndex})
|
|
24
|
+
const foldName = (name: string) => name.normalize('NFKC').toLocaleLowerCase('und')
|
|
25
|
+
export function resolveStoredName(name: string): {name:string;owner:boolean} {
|
|
26
|
+
const stored = readVoiceProfiles().profiles.filter(p=>foldName(p.name) === foldName(name.trim()))
|
|
27
|
+
if (stored.length > 1) throw new HeldGroupError(400,'ambiguous_name','Multiple stored voices have that spelling. Rename the duplicate profiles first.',{names:stored.map(p=>p.name)})
|
|
28
|
+
const owner = getOwnerSpeakerLabel()
|
|
29
|
+
const isOwner = foldName(owner) === foldName(name.trim()) || (stored[0] && foldName(stored[0].name) === foldName(owner))
|
|
30
|
+
return {name:isOwner ? owner : stored[0]?.name ?? name.trim(),owner:!!isOwner}
|
|
31
|
+
}
|
|
32
|
+
function voiceVersion(name: string) {
|
|
33
|
+
const path = dataPath('voice-profiles.json')
|
|
34
|
+
const profiles = readVoiceProfiles().profiles
|
|
35
|
+
return {storeMtime:existsSync(path)?statSync(path).mtimeMs:0,profileCount:profiles.length,targetSamples:profiles.find(p=>p.name===name)?.embeddings.length ?? 0}
|
|
36
|
+
}
|
|
37
|
+
function evidenceVersion(refs:HeldSampleRef[]) {
|
|
38
|
+
return hash([...new Set(refs.map(r=>r.sessionId))].sort().map(sessionId=>({sessionId,rows:readChunkEmbeddings(sessionId).rows.map(r=>({i:r.i,embedding:Array.from(r.embedding)})),cache:existsSync(dataPath('held-voice-embeddings',`${sessionId}.json`))?readFileSync(dataPath('held-voice-embeddings',`${sessionId}.json`),'utf8'):''})))
|
|
39
|
+
}
|
|
40
|
+
function assertMarkdownWords(before:string,after:string) {
|
|
41
|
+
const clean=(s:string)=>s.replace(/\n?<!-- speaker-labels-newer-than-graph -->\n?/g,'')
|
|
42
|
+
if(strippedTranscript(clean(before))!==strippedTranscript(clean(after)))throw new Error('Transcript word-preservation assertion failed')
|
|
43
|
+
}
|
|
44
|
+
function pruneExpiredPreviews() {
|
|
45
|
+
const dir=dataPath('meeting-corrections')
|
|
46
|
+
if(!existsSync(dir))return
|
|
47
|
+
for(const f of readdirSync(dir).filter(f=>/^preview-[a-f0-9]+\.json$/.test(f)).slice(0,100)){
|
|
48
|
+
try{const p=JSON.parse(readFileSync(join(dir,f),'utf8'));if(Date.parse(p.expiresAt)<Date.now())unlinkSync(join(dir,f))}catch{/* unreadable previews are rejected by their reader */}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
interface CopyPlan {
|
|
52
|
+
copy:'local'|'operations'
|
|
53
|
+
correctionRevision:number
|
|
54
|
+
lifecycleRevision:number
|
|
55
|
+
sidecarHash:string
|
|
56
|
+
markdownHash:string
|
|
57
|
+
rawMap:number[]
|
|
58
|
+
blended:boolean
|
|
59
|
+
}
|
|
60
|
+
interface MeetingPlan {
|
|
61
|
+
sessionId:string
|
|
62
|
+
status:'ready'|'blocked'|'applied'|'failed'|'reverted'
|
|
63
|
+
error?:string
|
|
64
|
+
namedChunks:number[]
|
|
65
|
+
widerChunks:number[]
|
|
66
|
+
labelled:number
|
|
67
|
+
roomRisk:boolean
|
|
68
|
+
widerScores?:Array<{chunkIndex:number;similarity:number;agreeing:number;roomOnly:boolean}>
|
|
69
|
+
playback:Array<{sessionId:string;chunkIndex:number;position:number}>
|
|
70
|
+
copies:CopyPlan[]
|
|
71
|
+
}
|
|
72
|
+
interface StoredPreview {
|
|
73
|
+
previewHash:string
|
|
74
|
+
expiresAt:string
|
|
75
|
+
speaker:string
|
|
76
|
+
owner:boolean
|
|
77
|
+
requiresListening:boolean
|
|
78
|
+
voiceVersion:ReturnType<typeof voiceVersion>
|
|
79
|
+
evidenceHash:string
|
|
80
|
+
eligibleMembers:HeldSampleRef[]
|
|
81
|
+
projectedVoiceHash:string
|
|
82
|
+
plan:ReturnType<typeof enrollHeldGroup>
|
|
83
|
+
members:HeldSampleRef[]
|
|
84
|
+
meetings:MeetingPlan[]
|
|
85
|
+
memberOutcomes:Array<HeldSampleRef & {status:string}>
|
|
86
|
+
}
|
|
87
|
+
interface CopyReceipt {
|
|
88
|
+
copy:'local'|'operations'
|
|
89
|
+
status:'pending'|'sidecar_written'|'applied'|'reverted'|'failed'
|
|
90
|
+
error?:string
|
|
91
|
+
patches:SpeakerPatch[]
|
|
92
|
+
beforeSidecar:string
|
|
93
|
+
afterSidecar:string
|
|
94
|
+
beforeMarkdown:string
|
|
95
|
+
afterMarkdown:string
|
|
96
|
+
blended:boolean
|
|
97
|
+
}
|
|
98
|
+
interface BatchMeeting extends MeetingPlan { intentId:string;receipts:CopyReceipt[] }
|
|
99
|
+
export interface NamingMemberOutcome extends HeldSampleRef {
|
|
100
|
+
status:'applied'|'no_transcript_position'|'skipped'|'failed'|'reverted'|'unknown'
|
|
101
|
+
enrollmentStatus:'enrolled'|'represented'|'not_enrolled'|'unknown'
|
|
102
|
+
labelStatus:'labelled'|'no_transcript_position'|'unchanged'|'failed'|'reverted'|'unknown'
|
|
103
|
+
audioStatus:'deleted'|'retained'|'missing'|'unknown'
|
|
104
|
+
position:number|null
|
|
105
|
+
previewStatus?:string
|
|
106
|
+
reason?:string
|
|
107
|
+
}
|
|
108
|
+
interface BatchRecord {
|
|
109
|
+
batchId:string
|
|
110
|
+
speaker:string
|
|
111
|
+
at:string
|
|
112
|
+
status:'pending'|'applied'|'partial'|'interrupted'|'reverted'
|
|
113
|
+
previewHash:string
|
|
114
|
+
members:HeldSampleRef[]
|
|
115
|
+
requestedMembers?:HeldSampleRef[]
|
|
116
|
+
memberOutcomes?:NamingMemberOutcome[]
|
|
117
|
+
meetings:BatchMeeting[]
|
|
118
|
+
enrolled:number
|
|
119
|
+
profileEmbeddings?:number
|
|
120
|
+
plan?:ReturnType<typeof enrollHeldGroup>
|
|
121
|
+
deleted:number
|
|
122
|
+
deleteMembers?:HeldSampleRef[]
|
|
123
|
+
voiceVersion:ReturnType<typeof voiceVersion>
|
|
124
|
+
lockMode:'sync'|'standalone'
|
|
125
|
+
}
|
|
126
|
+
function recordPath(kind:'preview'|'batch',id:string) {
|
|
127
|
+
if (!/^[a-f0-9-]{16,80}$/.test(id)) throw new HeldGroupError(400,'invalid_handle','Invalid naming handle')
|
|
128
|
+
return dataPath('meeting-corrections',`${kind}-${id}.json`)
|
|
129
|
+
}
|
|
130
|
+
function writeRecord(kind:'preview'|'batch',id:string,value:unknown) {
|
|
131
|
+
if(kind==='batch') {
|
|
132
|
+
const batch=value as BatchRecord
|
|
133
|
+
if(batch.memberOutcomes)batch.memberOutcomes=batchMembers(batch)
|
|
134
|
+
}
|
|
135
|
+
const path=recordPath(kind,id)
|
|
136
|
+
mkdirSync(dirname(path),{recursive:true,mode:0o700})
|
|
137
|
+
durableAtomicWriteFileSync(path,JSON.stringify(value,null,2)+'\n',{mode:0o600})
|
|
138
|
+
}
|
|
139
|
+
function previewMemberStatus(p:StoredPreview,ref:HeldSampleRef):string {
|
|
140
|
+
const key=sampleKey(ref)
|
|
141
|
+
return p.memberOutcomes.find(o=>sampleKey(o)===key)?.status
|
|
142
|
+
??(p.plan.leftBehind.some(o=>sampleKey(o)===key)?'left_held':p.plan.notReady.some(o=>sampleKey(o)===key)?'not_ready':'missing')
|
|
143
|
+
}
|
|
144
|
+
function initialMemberOutcomes(p:StoredPreview):NamingMemberOutcome[] {
|
|
145
|
+
return p.members.map(ref=>{
|
|
146
|
+
const previewStatus=previewMemberStatus(p,ref),eligible=['ready','no_transcript_position'].includes(previewStatus)
|
|
147
|
+
const position=p.meetings.find(m=>m.sessionId===ref.sessionId)?.copies[0]?.rawMap.indexOf(ref.chunkIndex)??-1
|
|
148
|
+
return {...plainRef(ref),previewStatus,position:position<0?null:position,status:eligible?'unknown':'skipped',enrollmentStatus:eligible?'unknown':'not_enrolled',labelStatus:'unchanged',audioStatus:previewStatus==='missing'?'missing':'retained',...(!eligible?{reason:previewStatus}:{})}
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
/** Preserve exact enrollment/deletion receipts; derive label completion from
|
|
152
|
+
* durable meeting receipts, including partial writes and later Undo retries. */
|
|
153
|
+
function batchMembers(batch:BatchRecord):NamingMemberOutcome[] {
|
|
154
|
+
if(!batch.memberOutcomes)return(batch.requestedMembers??batch.members).map(ref=>({...plainRef(ref),status:'unknown',enrollmentStatus:'unknown',labelStatus:'unknown',audioStatus:'unknown',position:null,reason:'legacy_receipt_has_no_member_outcome'}))
|
|
155
|
+
return batch.memberOutcomes.map(member=>{
|
|
156
|
+
if(!['ready','no_transcript_position'].includes(member.previewStatus??''))return member
|
|
157
|
+
const meeting=batch.meetings.find(m=>m.sessionId===member.sessionId)
|
|
158
|
+
if(!meeting)return member
|
|
159
|
+
if(meeting.status==='reverted')return{...member,status:'reverted',labelStatus:member.position===null?'no_transcript_position':'reverted',reason:undefined}
|
|
160
|
+
if(meeting.status==='failed'||meeting.error||meeting.receipts.some(r=>r.status==='failed'))return{...member,status:'failed',labelStatus:'failed',reason:meeting.error??'One or more meeting copies failed'}
|
|
161
|
+
if(meeting.status==='applied')return{...member,status:member.position===null?'no_transcript_position':'applied',labelStatus:member.position===null?'no_transcript_position':'labelled',reason:member.enrollmentStatus==='not_enrolled'?'Selected voice sample was refused by the profile store':undefined}
|
|
162
|
+
return{...member,status:'unknown',labelStatus:meeting.receipts.length?'unknown':'unchanged'}
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
function readRecord<T>(kind:'preview'|'batch',id:string):T {
|
|
166
|
+
try { return JSON.parse(readFileSync(recordPath(kind,id),'utf8')) as T }
|
|
167
|
+
catch (e) {if(e instanceof HeldGroupError) throw e;throw new HeldGroupError(409,'preview_expired','This naming preview is unavailable. Preview again.')}
|
|
168
|
+
}
|
|
169
|
+
function assertFile(path:string) {
|
|
170
|
+
const stat = lstatSync(path)
|
|
171
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o222) === 0) throw new Error('Meeting copy is missing, unsafe, or read-only')
|
|
172
|
+
if (/ \d+(\.[A-Za-z0-9-]+)*\.(md|json)$/.test(path)) throw new Error('iCloud conflict copy is not canonical')
|
|
173
|
+
}
|
|
174
|
+
let sessionBusy:(sessionId:string)=>boolean=()=>false
|
|
175
|
+
export function setNamingSessionLiveness(check:(sessionId:string)=>boolean):void {sessionBusy=check}
|
|
176
|
+
interface ResolvedCopy {copy:'local'|'operations';sidecarPath:string;meetingPath:string;doc:Record<string,any>;raw:string;markdown:string;blended:boolean}
|
|
177
|
+
function checkedRevision(value:unknown):number {
|
|
178
|
+
if(value===undefined||value===null)return 0
|
|
179
|
+
if(typeof value!=='number'||!Number.isSafeInteger(value)||value<0)throw new Error('Invalid meeting revision')
|
|
180
|
+
return value
|
|
181
|
+
}
|
|
182
|
+
function resolveCopies(sessionId:string):ResolvedCopy[] {
|
|
183
|
+
if(sessionBusy(sessionId)||existsSync(dataPath('meeting-finalization-jobs',`${sessionId}.json`)))throw new Error('Meeting is recording or finalizing; retry after it finishes')
|
|
184
|
+
const local = new MeetingStore().findBySessionId(sessionId)
|
|
185
|
+
if (!local) throw new Error('Local meeting copy is missing')
|
|
186
|
+
const paths:Array<{copy:'local'|'operations';sidecarPath:string;meetingPath:string}> = [{copy:'local',sidecarPath:local.sidecarPath,meetingPath:local.filepath}]
|
|
187
|
+
const operations = resolveCosOperationsDir()
|
|
188
|
+
if (operations) {
|
|
189
|
+
const found = findCosOperationsMeetingBySessionId(sessionId)
|
|
190
|
+
if (!found) throw new Error('Operations meeting copy is missing; retry after sync')
|
|
191
|
+
paths.push({copy:'operations',sidecarPath:found.sidecarPath,meetingPath:found.meetingPath})
|
|
192
|
+
} else if (process.env.COS_OPERATIONS_DIR?.trim() || process.env.COS_SCRIPTS_DIR?.trim()) {
|
|
193
|
+
// An explicitly configured but unavailable iCloud root is not standalone.
|
|
194
|
+
throw new Error('Configured operations folder is unavailable; retry after sync')
|
|
195
|
+
}
|
|
196
|
+
return paths.map(p=>{
|
|
197
|
+
assertFile(p.sidecarPath)
|
|
198
|
+
const raw=readFileSync(p.sidecarPath,'utf8'),doc=JSON.parse(raw)
|
|
199
|
+
if(doc.sessionId!==sessionId) throw new Error('Meeting identity changed')
|
|
200
|
+
checkedRevision(doc.correctionRevision);checkedRevision(doc.lifecycleRevision)
|
|
201
|
+
let meetingPath=p.meetingPath,blended=false
|
|
202
|
+
if(!existsSync(meetingPath) && p.copy==='operations' && operations) {
|
|
203
|
+
const parent = Array.isArray(doc.blended_into)?doc.blended_into[0]:doc.claimed_parent
|
|
204
|
+
if(typeof parent!=='string') throw new Error('Operations markdown is missing')
|
|
205
|
+
meetingPath=resolve(operations,parent)
|
|
206
|
+
if(!meetingPath.startsWith(realpathSync(operations)+'/')) throw new Error('Unsafe blended parent path')
|
|
207
|
+
blended=true
|
|
208
|
+
}
|
|
209
|
+
assertFile(meetingPath)
|
|
210
|
+
const markdown=readFileSync(meetingPath,'utf8')
|
|
211
|
+
if ((p.copy === 'local' && doc.finalizationState && doc.finalizationState !== 'complete') || doc.finalizationRequired === true || ['recording','saving','finalizing'].includes(doc.lifecycleState) || ['pending','running','queued'].includes(doc.enrichmentState) || doc.syncState === 'claimed') throw new Error('Meeting is recording, finalizing, or pending enrichment; retry after it finishes')
|
|
212
|
+
if(/<!-- g2-hq-state: pending -->/.test(markdown)||['queued','running'].includes(doc.hqState)) throw new Error('HQ transcription is running; preview again after it finishes')
|
|
213
|
+
return {...p,meetingPath,doc,raw,markdown,blended}
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
const snapshot=(c:ResolvedCopy):CopyPlan=>({copy:c.copy,correctionRevision:Number(c.doc.correctionRevision??0),lifecycleRevision:Number(c.doc.lifecycleRevision??0),sidecarHash:hash(c.raw),markdownHash:hash(c.markdown),rawMap:validatedRawMap(c.doc),blended:c.blended})
|
|
217
|
+
function previewView(p:StoredPreview) {
|
|
218
|
+
return {success:true,kind:'preview',...p.plan,dryRun:true,previewHash:p.previewHash,expiresAt:p.expiresAt,speaker:p.speaker,owner:p.owner,ownerWarning:p.owner?'This is the device owner. Listen and acknowledge before applying.':undefined,requiresListening:p.requiresListening,meetings:p.meetings,members:p.members.map(r=>({...r,status:previewMemberStatus(p,r)})),namingCapabilities:NAMING_CAPABILITIES,message:`Preview: ${p.plan.coherent} held samples; ${p.meetings.reduce((n,m)=>n+m.labelled,0)} transcript positions. ${p.requiresListening?'Listen to the wider matches before Apply.':''}`}
|
|
219
|
+
}
|
|
220
|
+
export function previewHeldNaming(name:string,members:HeldSampleRef[]) {
|
|
221
|
+
pruneExpiredPreviews()
|
|
222
|
+
const resolved=resolveStoredName(name)
|
|
223
|
+
const plan=enrollHeldGroup(resolved.name,members,{dryRun:true})
|
|
224
|
+
const core=plan.coreMembers??[]
|
|
225
|
+
const only=new Map<string,Set<number>>()
|
|
226
|
+
for(const r of core){const set=only.get(r.sessionId)??new Set<number>();set.add(r.chunkIndex);only.set(r.sessionId,set)}
|
|
227
|
+
const held=collectHeldSamples({only,budgetMs:2000}).samples
|
|
228
|
+
const vectors=new Map(held.map(s=>[sampleKey(s),s.embedding]))
|
|
229
|
+
if(resolved.owner){
|
|
230
|
+
const owner=readVoiceProfiles().profiles.find(p=>p.name===resolved.name)
|
|
231
|
+
if(!owner || held.some(s=>!owner.embeddings.some(e=>rawCosineSimilarity(s.embedding,new Float32Array(e))>=0.65))) throw new HeldGroupError(422,'owner_similarity_low','These samples do not reach the owner verification floor (0.65). Nothing changed.')
|
|
232
|
+
}
|
|
233
|
+
const meetings:MeetingPlan[]=[], memberOutcomes:StoredPreview['memberOutcomes']=[]
|
|
234
|
+
const resolvedMeetings=new Map<string,ResolvedCopy[]>()
|
|
235
|
+
const unidentified=(copies:ResolvedCopy[],position:number)=>copies.every(c=>/^(ext|unknown|unidentified(?:\s+\d+)?|speaker\s*\d+)$/i.test(String(c.doc.chunks[position]?.speaker??'')))
|
|
236
|
+
// First establish admissible named evidence across EVERY copy. A skipped
|
|
237
|
+
// member never participates in either the enrollment projection or clustering.
|
|
238
|
+
for(const sessionId of [...new Set(core.map(s=>s.sessionId))].sort()) {
|
|
239
|
+
const refs=core.filter(s=>s.sessionId===sessionId)
|
|
240
|
+
const meeting:MeetingPlan={sessionId,status:'ready',namedChunks:[],widerChunks:[],labelled:0,roomRisk:false,playback:[],copies:[]}
|
|
241
|
+
try {
|
|
242
|
+
if(pendingCorrections(sessionId).length) throw new Error('An interrupted correction needs review')
|
|
243
|
+
const copies=resolveCopies(sessionId)
|
|
244
|
+
meeting.copies=copies.map(snapshot)
|
|
245
|
+
const raw=meeting.copies[0].rawMap
|
|
246
|
+
if(meeting.copies.some(c=>JSON.stringify(c.rawMap)!==JSON.stringify(raw))) throw new Error('Meeting copies disagree on raw chunk mapping')
|
|
247
|
+
if(copies.some(c=>hash(c.doc.chunks.map((row:any)=>row.speaker))!==hash(copies[0].doc.chunks.map((row:any)=>row.speaker))))throw new Error('Meeting copies disagree on speaker labels')
|
|
248
|
+
for(const ref of refs) memberOutcomes.push({...ref,status:raw.includes(ref.chunkIndex)?unidentified(copies,raw.indexOf(ref.chunkIndex))?'ready':'no_longer_Ext':'no_transcript_position'})
|
|
249
|
+
meeting.namedChunks=refs.map(r=>raw.indexOf(r.chunkIndex)).filter(i=>i>=0&&unidentified(copies,i))
|
|
250
|
+
meeting.labelled=meeting.namedChunks.length
|
|
251
|
+
// Prove the named edit before admitting the meeting as training evidence.
|
|
252
|
+
for(const c of copies)if(meeting.labelled)relabelSpeakerPositions(c.doc,c.blended?'':c.markdown,meeting.namedChunks,resolved.name)
|
|
253
|
+
resolvedMeetings.set(sessionId,copies)
|
|
254
|
+
}catch(e){meeting.status='blocked';meeting.error=errorText(e);meeting.labelled=0;for(const ref of refs){const prior=memberOutcomes.find(o=>sampleKey(o)===sampleKey(ref));if(prior)prior.status='blocked';else memberOutcomes.push({...ref,status:'blocked'})}}
|
|
255
|
+
meetings.push(meeting)
|
|
256
|
+
}
|
|
257
|
+
const eligibleMembers=core.filter(r=>['ready','no_transcript_position'].includes(memberOutcomes.find(o=>sampleKey(o)===sampleKey(r))?.status??''))
|
|
258
|
+
const projection=eligibleMembers.length?projectHeldEnrollment(resolved.name,eligibleMembers):null
|
|
259
|
+
const updated=projection?.profiles??readVoiceProfiles().profiles
|
|
260
|
+
const projectedVoiceHash=hash(updated.map(p=>({name:p.name,embeddings:p.embeddings,sources:p.sources})))
|
|
261
|
+
const augmented=updated.find(p=>p.name===resolved.name)
|
|
262
|
+
const storedTarget=readVoiceProfiles().profiles.find(p=>p.name===resolved.name)
|
|
263
|
+
for(const meeting of meetings){
|
|
264
|
+
if(meeting.status!=='ready')continue
|
|
265
|
+
const sessionId=meeting.sessionId,copies=resolvedMeetings.get(sessionId)!,raw=meeting.copies[0].rawMap
|
|
266
|
+
const refs=eligibleMembers.filter(s=>s.sessionId===sessionId)
|
|
267
|
+
if(!refs.length)continue
|
|
268
|
+
const bank=[...new Map(readChunkEmbeddings(sessionId).rows.map(row=>[row.i,row])).values()]
|
|
269
|
+
const seeds=refs.map(r=>vectors.get(sampleKey(r))).filter((v):v is Float32Array=>!!v)
|
|
270
|
+
const seedRaw=new Set(refs.map(r=>r.chunkIndex))
|
|
271
|
+
const candidates=bank.filter(r=>raw.includes(r.i)&&!seedRaw.has(r.i)&&unidentified(copies,raw.indexOf(r.i)))
|
|
272
|
+
.filter(r=>!(storedTarget?.embeddings??[]).some(e=>rawCosineSimilarity(r.embedding,new Float32Array(e))>=0.999))
|
|
273
|
+
const matches=candidates.flatMap(r=>{
|
|
274
|
+
// Ordinary suggestions exclude the owner; deliberate owner naming keeps
|
|
275
|
+
// the same scorer and adds the owner's stricter 0.65 verification gate.
|
|
276
|
+
const suggestion=suggestProfile(r.embedding,updated,{ownerLabel:resolved.owner?undefined:getOwnerSpeakerLabel()})
|
|
277
|
+
if(suggestion?.name!==resolved.name || (resolved.owner&&suggestion.similarity<0.65))return[]
|
|
278
|
+
const agreeing=(augmented?.embeddings??[]).flatMap((e,i)=>rawCosineSimilarity(r.embedding,new Float32Array(e))>=VOICE_COHERENCE_FLOOR?[augmented?.sources?.[i]??'unknown']:[])
|
|
279
|
+
const roomOnly=agreeing.length>0&&agreeing.every(source=>source.endsWith(`:${sessionId}`))
|
|
280
|
+
return[{...r,suggestion,roomOnly}]
|
|
281
|
+
})
|
|
282
|
+
const cluster=coherentClusterContaining(seeds,matches.map(r=>r.embedding),VOICE_COHERENCE_FLOOR)
|
|
283
|
+
const wider=cluster.members.filter(i=>i>=seeds.length).map(i=>matches[i-seeds.length])
|
|
284
|
+
meeting.widerChunks=wider.map(r=>raw.indexOf(r.i))
|
|
285
|
+
meeting.widerScores=wider.map(r=>({chunkIndex:r.i,similarity:r.suggestion.similarity,agreeing:r.suggestion.agreeing,roomOnly:r.roomOnly}))
|
|
286
|
+
meeting.labelled=new Set([...meeting.namedChunks,...meeting.widerChunks]).size
|
|
287
|
+
meeting.roomRisk=wider.some(r=>r.roomOnly)
|
|
288
|
+
meeting.playback=[...new Set([...meeting.namedChunks,...meeting.widerChunks])].map(position=>({sessionId,position,chunkIndex:raw[position]}))
|
|
289
|
+
for(const c of copies)if(meeting.labelled)relabelSpeakerPositions(c.doc,c.blended?'':c.markdown,[...meeting.namedChunks,...meeting.widerChunks],resolved.name)
|
|
290
|
+
}
|
|
291
|
+
const version=voiceVersion(resolved.name)
|
|
292
|
+
const evidenceHash=evidenceVersion(members)
|
|
293
|
+
const payload={evidenceHash,eligibleMembers,projectedVoiceHash,selectedMembers:projection?.selectedMembers??[],members:members.map(plainRef),resolvedName:resolved.name,perMeeting:meetings.map(m=>({sessionId:m.sessionId,perCopyRevs:m.copies,chunkLists:[m.namedChunks,m.widerChunks]})),voiceVersion:version,cluster:core}
|
|
294
|
+
const previewHash=hash(payload)
|
|
295
|
+
const preview:StoredPreview={previewHash,expiresAt:new Date(Date.now()+PREVIEW_TTL).toISOString(),speaker:resolved.name,owner:resolved.owner,requiresListening:meetings.some(m=>m.widerChunks.length>=HIGH_COUNT),voiceVersion:version,evidenceHash,eligibleMembers,projectedVoiceHash,plan,members:members.map(plainRef),meetings,memberOutcomes}
|
|
296
|
+
writeRecord('preview',previewHash,preview)
|
|
297
|
+
return previewView(preview)
|
|
298
|
+
}
|
|
299
|
+
function batchView(batch:BatchRecord) {
|
|
300
|
+
return {success:true,...batch.plan,dryRun:false,kind:batch.status==='reverted'?'undone':'applied',batchId:batch.batchId,undoHandle:batch.batchId,speaker:batch.speaker,status:batch.status,partial:batch.status==='partial'||batch.status==='interrupted',members:batchMembers(batch),meetings:batch.meetings.map(m=>({...m,labelsNewerThanGraph:m.receipts.some(r=>['applied','sidecar_written','reverted'].includes(r.status)),receipts:m.receipts.map(r=>({copy:r.copy,status:r.status,error:r.error}))})),enrolled:batch.enrolled,profileEmbeddings:batch.profileEmbeddings??batch.plan?.profileEmbeddings??0,deleted:batch.deleted,lockMode:batch.lockMode,namingCapabilities:NAMING_CAPABILITIES,message:'Voice samples kept for training. Search refreshes at up to 10 meetings per sync; graph labels may lag.'}
|
|
301
|
+
}
|
|
302
|
+
let mutationBusy=false
|
|
303
|
+
export async function applyHeldNaming(name:string,members:HeldSampleRef[],previewHash:string,options:{confirm?:boolean;ownerAck?:boolean;listened?:boolean}={}) {
|
|
304
|
+
if(!options.confirm) throw new HeldGroupError(400,'confirmation_required','Preview first, then confirm Apply.')
|
|
305
|
+
const preview=readRecord<StoredPreview>('preview',previewHash)
|
|
306
|
+
if(resolveStoredName(name).name!==preview.speaker||resolveStoredName(name).owner!==preview.owner||hash(members.map(plainRef))!==hash(preview.members)) throw new HeldGroupError(409,'preview_mismatch','The name or samples changed. Preview again.')
|
|
307
|
+
if(preview.owner&&!options.ownerAck) throw new HeldGroupError(400,'owner_confirmation_required','Acknowledge that these samples are the device owner.')
|
|
308
|
+
const prior=listNamingBatches().find(b=>b.previewHash===previewHash)
|
|
309
|
+
if(prior) {
|
|
310
|
+
if(prior.deleteMembers) {const missing=new Set(previewDiscard(prior.deleteMembers).missing.map(sampleKey));prior.deleted=missing.size;for(const member of prior.memberOutcomes??[])if(missing.has(sampleKey(member)))member.audioStatus='deleted';writeRecord('batch',prior.batchId,prior)}
|
|
311
|
+
return batchView(prior)
|
|
312
|
+
}
|
|
313
|
+
if(Date.now()>Date.parse(preview.expiresAt)) throw new HeldGroupError(409,'preview_expired','The preview expired. Preview again.')
|
|
314
|
+
if(preview.requiresListening&&!options.listened) throw new HeldGroupError(400,'listening_required','Listen to the wider matches before applying this many labels.')
|
|
315
|
+
if(mutationBusy) throw new HeldGroupError(409,'naming_running','Another naming is running; retry.')
|
|
316
|
+
mutationBusy=true
|
|
317
|
+
let lock:Awaited<ReturnType<typeof acquireMeetingSyncLock>>|undefined
|
|
318
|
+
try {
|
|
319
|
+
try{lock=await acquireMeetingSyncLock()}catch(e){throw new HeldGroupError(409,'sync_running',errorText(e))}
|
|
320
|
+
if(hash(voiceVersion(preview.speaker))!==hash(preview.voiceVersion)) throw new HeldGroupError(409,'voice_version_changed','Voice profiles changed. Preview again.')
|
|
321
|
+
if(evidenceVersion(preview.members)!==preview.evidenceHash)throw new HeldGroupError(409,'evidence_changed','Voice evidence changed. Preview again.')
|
|
322
|
+
// Re-read held membership, not just the unchanged profile mtime.
|
|
323
|
+
const current=enrollHeldGroup(preview.speaker,preview.members,{dryRun:true})
|
|
324
|
+
if(hash(current.coreMembers)!==hash(preview.plan.coreMembers)) throw new HeldGroupError(409,'members_changed','Held samples changed. Preview again.')
|
|
325
|
+
const batch:BatchRecord={batchId:randomUUID(),speaker:preview.speaker,at:new Date().toISOString(),status:'pending',previewHash,members:preview.plan.coreMembers??[],requestedMembers:preview.members,memberOutcomes:initialMemberOutcomes(preview),meetings:preview.meetings.map(m=>({...m,intentId:randomUUID(),receipts:[]})),enrolled:0,deleted:0,voiceVersion:preview.voiceVersion,lockMode:lock.mode,plan:preview.plan}
|
|
326
|
+
// Validate all copies at write-time before changing voice store or recording intents.
|
|
327
|
+
for(const meeting of batch.meetings){
|
|
328
|
+
if(meeting.status!=='ready')continue
|
|
329
|
+
try{const copies=resolveCopies(meeting.sessionId);if(hash(copies.map(snapshot))!==hash(meeting.copies))throw new Error('Meeting changed since preview');if(pendingCorrections(meeting.sessionId).length)throw new Error('Another correction is pending')}
|
|
330
|
+
catch(e){meeting.status='failed';meeting.error=errorText(e)}
|
|
331
|
+
}
|
|
332
|
+
const ready=new Set(batch.meetings.filter(m=>m.status==='ready').map(m=>m.sessionId))
|
|
333
|
+
const eligible=preview.eligibleMembers.filter(r=>ready.has(r.sessionId))
|
|
334
|
+
if(preview.meetings.some(m=>m.widerChunks.length>0)&&hash(eligible)!==hash(preview.eligibleMembers))throw new HeldGroupError(409,'eligible_evidence_changed','A meeting providing voice evidence changed. Preview again before wider labels apply.')
|
|
335
|
+
if(eligible.length){const projected=projectHeldEnrollment(preview.speaker,eligible);if(preview.meetings.some(m=>m.widerChunks.length>0)&&hash(projected.profiles.map(p=>({name:p.name,embeddings:p.embeddings,sources:p.sources})))!==preview.projectedVoiceHash)throw new HeldGroupError(409,'projected_voice_changed','The projected voice changed. Preview again.')}
|
|
336
|
+
if(!eligible.length) throw new HeldGroupError(409,'meetings_changed','No meeting can safely apply. Preview again.',{meetings:batch.meetings})
|
|
337
|
+
writeRecord('batch',batch.batchId,batch)
|
|
338
|
+
let enrollment:ReturnType<typeof enrollHeldGroup>
|
|
339
|
+
try{enrollment=enrollHeldGroup(batch.speaker,eligible,{deleteAudio:false})}
|
|
340
|
+
catch(e){
|
|
341
|
+
batch.status='partial'
|
|
342
|
+
// A known pre-write refusal differs from an interrupted/throwing store,
|
|
343
|
+
// whose individual enrollment result cannot safely be reconstructed.
|
|
344
|
+
if(e instanceof HeldGroupError&&['nothing_enrolled','speaker_model_unavailable','no_samples','incoherent'].includes(e.reason))for(const member of batch.memberOutcomes??[])if(member.enrollmentStatus==='unknown')member.enrollmentStatus='not_enrolled'
|
|
345
|
+
for(const m of batch.meetings)if(m.status==='ready'){m.status='failed';m.error=errorText(e)}
|
|
346
|
+
writeRecord('batch',batch.batchId,batch);throw e
|
|
347
|
+
}
|
|
348
|
+
batch.enrolled=enrollment.enrolled
|
|
349
|
+
const accepted=new Set(enrollment.enrolledMembers?.map(sampleKey)),rejected=new Set(enrollment.rejectedMembers?.map(sampleKey)),represented=new Set(enrollment.coreMembers?.map(sampleKey))
|
|
350
|
+
for(const member of batch.memberOutcomes??[]){const key=sampleKey(member);if(accepted.has(key))member.enrollmentStatus='enrolled';else if(rejected.has(key))member.enrollmentStatus='not_enrolled';else if(represented.has(key))member.enrollmentStatus='represented';else if(member.enrollmentStatus==='unknown')member.enrollmentStatus='not_enrolled'}
|
|
351
|
+
batch.profileEmbeddings=readVoiceProfiles().profiles.find(p=>p.name===batch.speaker)?.embeddings.length??0
|
|
352
|
+
writeRecord('batch',batch.batchId,batch)
|
|
353
|
+
for(const meeting of batch.meetings){
|
|
354
|
+
if(meeting.status!=='ready')continue
|
|
355
|
+
const positions=[...new Set([...meeting.namedChunks,...meeting.widerChunks])].sort((a,b)=>a-b)
|
|
356
|
+
const row:CorrectionRow={id:meeting.intentId,phase:'intent',at:batch.at,from:'held samples',to:batch.speaker,chunks:positions,scope:'meeting',source:`ext-group:${meeting.sessionId}`,batchId:batch.batchId}
|
|
357
|
+
try{
|
|
358
|
+
const copies=resolveCopies(meeting.sessionId)
|
|
359
|
+
if(hash(copies.map(snapshot))!==hash(meeting.copies))throw new Error('Meeting changed at write-time')
|
|
360
|
+
if(meeting.widerChunks.length){
|
|
361
|
+
const bank=new Map(readChunkEmbeddings(meeting.sessionId).rows.map(r=>[r.i,r.embedding]))
|
|
362
|
+
const actual=readVoiceProfiles().profiles
|
|
363
|
+
for(const position of meeting.widerChunks){const emb=bank.get(meeting.copies[0].rawMap[position]);const suggestion=emb?suggestProfile(emb,actual,{ownerLabel:preview.owner?undefined:getOwnerSpeakerLabel()}):null;if(suggestion?.name!==batch.speaker||(preview.owner&&suggestion.similarity<0.65))throw new Error('Enrolled voice no longer supports a wider match; preview again')}
|
|
364
|
+
}
|
|
365
|
+
for(const c of copies){
|
|
366
|
+
const edit=relabelSpeakerPositions(c.doc,c.blended?'':c.markdown,positions,batch.speaker,batch.batchId)
|
|
367
|
+
meeting.receipts.push({copy:c.copy,status:'pending',patches:edit.patches,beforeSidecar:c.raw,afterSidecar:JSON.stringify(edit.doc,null,2)+'\n',beforeMarkdown:c.markdown,afterMarkdown:c.blended?c.markdown+(c.markdown.includes('<!-- speaker-labels-newer-than-graph -->')?'':'\n<!-- speaker-labels-newer-than-graph -->\n'):edit.markdown,blended:c.blended})
|
|
368
|
+
}
|
|
369
|
+
row.priorLabels=Object.fromEntries(positions.map(i=>[String(i),String(copies[0].doc.chunks[i].speaker??'')]))
|
|
370
|
+
writeRecord('batch',batch.batchId,batch)
|
|
371
|
+
if(!appendCorrection(meeting.sessionId,row))throw new Error('Cannot write correction intent')
|
|
372
|
+
for(const receipt of meeting.receipts){
|
|
373
|
+
// Resolve by sessionId AGAIN: preview paths and earlier paths are never authority.
|
|
374
|
+
const current=resolveCopies(meeting.sessionId).find(c=>c.copy===receipt.copy)!
|
|
375
|
+
if(current.raw!==receipt.beforeSidecar||current.markdown!==receipt.beforeMarkdown)throw new Error('Meeting changed immediately before copy write')
|
|
376
|
+
durableAtomicWriteFileSync(current.sidecarPath,receipt.afterSidecar,{mode:0o600})
|
|
377
|
+
receipt.status='sidecar_written';writeRecord('batch',batch.batchId,batch)
|
|
378
|
+
assertMarkdownWords(receipt.beforeMarkdown,receipt.afterMarkdown)
|
|
379
|
+
durableAtomicWriteFileSync(current.meetingPath,receipt.afterMarkdown,{mode:0o600})
|
|
380
|
+
receipt.status='applied';writeRecord('batch',batch.batchId,batch)
|
|
381
|
+
}
|
|
382
|
+
if(!appendCorrection(meeting.sessionId,{...row,phase:'applied',proseStale:true}))throw new Error('Cannot record applied correction')
|
|
383
|
+
if(positions.length&&!appendCorrection(meeting.sessionId,{...row,phase:'confirmed-chunks'}))throw new Error('Cannot record chunk confirmation')
|
|
384
|
+
meeting.status='applied'
|
|
385
|
+
}catch(e){meeting.status='failed';meeting.error=errorText(e);appendCorrection(meeting.sessionId,{...row,phase:'failed',error:meeting.error})}
|
|
386
|
+
writeRecord('batch',batch.batchId,batch)
|
|
387
|
+
}
|
|
388
|
+
batch.status=batch.meetings.every(m=>m.status==='applied')?'applied':'partial'
|
|
389
|
+
// This receipt is durable BEFORE deleting any audio, including a crash midway in deletion.
|
|
390
|
+
writeRecord('batch',batch.batchId,batch)
|
|
391
|
+
const applied=new Set(batch.meetings.filter(m=>m.status==='applied').map(m=>m.sessionId))
|
|
392
|
+
batch.deleteMembers=(enrollment.coreMembers??[]).filter(r=>applied.has(r.sessionId))
|
|
393
|
+
writeRecord('batch',batch.batchId,batch)
|
|
394
|
+
const removed=new Set(discardHeldSamples(batch.deleteMembers).removed.map(sampleKey))
|
|
395
|
+
batch.deleted=removed.size
|
|
396
|
+
for(const member of batch.memberOutcomes??[])if(removed.has(sampleKey(member)))member.audioStatus='deleted'
|
|
397
|
+
writeRecord('batch',batch.batchId,batch)
|
|
398
|
+
return batchView(batch)
|
|
399
|
+
}finally{lock?.release();mutationBusy=false}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function listNamingBatches():BatchRecord[]{
|
|
403
|
+
const dir=dataPath('meeting-corrections')
|
|
404
|
+
if(!existsSync(dir))return[]
|
|
405
|
+
return readdirSync(dir).filter(f=>/^batch-[a-f0-9-]+\.json$/.test(f)).flatMap(f=>{try{return[JSON.parse(readFileSync(join(dir,f),'utf8')) as BatchRecord]}catch{return[]}}).sort((a,b)=>b.at.localeCompare(a.at))
|
|
406
|
+
}
|
|
407
|
+
export function namingBatchList(){return{namingCapabilities:NAMING_CAPABILITIES,batches:listNamingBatches().map(b=>({...batchView(b),previewHash:b.previewHash}))}}
|
|
408
|
+
export function recoverInterruptedNaming():number {
|
|
409
|
+
const closed=closeInterruptedCorrections()
|
|
410
|
+
for(const batch of listNamingBatches())if(batch.status==='pending'){batch.status='interrupted';for(const m of batch.meetings)if(m.status==='ready'){m.status='failed';m.error='Interrupted naming — review to resume or revert'}writeRecord('batch',batch.batchId,batch)}
|
|
411
|
+
return closed
|
|
412
|
+
}
|
|
413
|
+
export function resumeHeldNaming(batchId:string){
|
|
414
|
+
const batch=readRecord<BatchRecord>('batch',batchId)
|
|
415
|
+
const pending=batch.members.filter(r=>batch.meetings.find(m=>m.sessionId===r.sessionId)?.status!=='applied')
|
|
416
|
+
if(batch.meetings.some(m=>m.receipts.some(r=>r.status==='sidecar_written'||r.status==='pending'&&r.beforeSidecar!==r.afterSidecar)))throw new HeldGroupError(409,'revert_required','This interrupted naming may have written a copy. Undo it before previewing again.')
|
|
417
|
+
return previewHeldNaming(batch.speaker,pending)
|
|
418
|
+
}
|
|
419
|
+
export async function undoHeldNaming(batchId:string,confirm:boolean){
|
|
420
|
+
if(!confirm)throw new HeldGroupError(400,'confirmation_required','Confirm Undo to restore these labels. Enrolled voice samples stay kept.')
|
|
421
|
+
const batch=readRecord<BatchRecord>('batch',batchId)
|
|
422
|
+
if(batch.status==='reverted')return{...batchView(batch),samplesKept:true,audioRestored:false}
|
|
423
|
+
if(mutationBusy)throw new HeldGroupError(409,'naming_running','Another naming is running; retry.')
|
|
424
|
+
mutationBusy=true
|
|
425
|
+
let lock:Awaited<ReturnType<typeof acquireMeetingSyncLock>>|undefined
|
|
426
|
+
try{
|
|
427
|
+
try{lock=await acquireMeetingSyncLock()}catch(e){throw new HeldGroupError(409,'sync_running',errorText(e))}
|
|
428
|
+
for(const meeting of batch.meetings){
|
|
429
|
+
if(!meeting.receipts.length)continue
|
|
430
|
+
const undoId=randomUUID(),row:CorrectionRow={id:undoId,phase:'intent',at:new Date().toISOString(),from:batch.speaker,to:'prior labels',chunks:[...meeting.namedChunks,...meeting.widerChunks],scope:'meeting',batchId:batch.batchId,source:'undo'}
|
|
431
|
+
try{
|
|
432
|
+
const later=appliedCorrections(meeting.sessionId).filter(r=>r.batchId!==batchId&&r.at>=batch.at)
|
|
433
|
+
if(later.some(r=>r.chunks.length===0||r.chunks.some(i=>row.chunks.includes(i))))throw new Error('A later batch touched these positions; undo it first')
|
|
434
|
+
if(!appendCorrection(meeting.sessionId,row))throw new Error('Cannot record undo intent')
|
|
435
|
+
for(const receipt of meeting.receipts){
|
|
436
|
+
if(receipt.status==='reverted')continue
|
|
437
|
+
try{
|
|
438
|
+
const c=resolveCopies(meeting.sessionId).find(c=>c.copy===receipt.copy)
|
|
439
|
+
if(!c)throw new Error('Meeting copy missing')
|
|
440
|
+
const doc=structuredClone(c.doc)
|
|
441
|
+
const before=JSON.parse(receipt.beforeSidecar)
|
|
442
|
+
if(c.raw!==receipt.beforeSidecar){
|
|
443
|
+
for(const patch of [...receipt.patches].reverse())patchValue(doc,patch,true)
|
|
444
|
+
doc.speakers=[...new Set(doc.chunks.map((ch:any)=>ch.speaker).filter(Boolean))]
|
|
445
|
+
doc.correctionRevision=Number(doc.correctionRevision??0)+1
|
|
446
|
+
doc.labelsNewerThanGraph=true
|
|
447
|
+
}
|
|
448
|
+
const md=receipt.blended?(c.markdown.includes('<!-- speaker-labels-newer-than-graph -->')?c.markdown:c.markdown+'\n<!-- speaker-labels-newer-than-graph -->\n'):undoSpeakerMarkdown(receipt.beforeMarkdown,receipt.afterMarkdown,c.markdown,doc.speakers??[])
|
|
449
|
+
const clean=(s:string)=>s.replace(/\n?<!-- speaker-labels-newer-than-graph -->\n?/g,'')
|
|
450
|
+
if(strippedTranscript(clean(md))!==strippedTranscript(clean(c.markdown)))throw new Error('Undo word-preservation assertion failed')
|
|
451
|
+
assertFile(c.sidecarPath);assertFile(c.meetingPath)
|
|
452
|
+
durableAtomicWriteFileSync(c.sidecarPath,JSON.stringify(c.raw===receipt.beforeSidecar?before:doc,null,2)+'\n',{mode:0o600})
|
|
453
|
+
durableAtomicWriteFileSync(c.meetingPath,md,{mode:0o600})
|
|
454
|
+
receipt.status='reverted';delete receipt.error;writeRecord('batch',batch.batchId,batch)
|
|
455
|
+
}catch(e){receipt.status='failed';receipt.error=errorText(e)}
|
|
456
|
+
}
|
|
457
|
+
if(meeting.receipts.every(r=>r.status==='reverted')){if(!appendCorrection(meeting.sessionId,{...row,phase:'reverted'}))throw new Error('Cannot record undo outcome');meeting.status='reverted';delete meeting.error}
|
|
458
|
+
else throw new Error('One or more meeting copies could not be restored')
|
|
459
|
+
}catch(e){meeting.error=errorText(e);appendCorrection(meeting.sessionId,{...row,phase:'failed',error:meeting.error})}
|
|
460
|
+
writeRecord('batch',batch.batchId,batch)
|
|
461
|
+
}
|
|
462
|
+
batch.status=batch.meetings.filter(m=>m.receipts.length).every(m=>m.status==='reverted')?'reverted':'partial'
|
|
463
|
+
writeRecord('batch',batch.batchId,batch)
|
|
464
|
+
return{...batchView(batch),kind:'undone',samplesKept:true,audioRestored:false}
|
|
465
|
+
}finally{lock?.release();mutationBusy=false}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Profile merge fan-out uses the exact correction primitive and receipts, with
|
|
469
|
+
* enrolment/audio deletion off. Dry run neither records a preview nor writes a ledger. */
|
|
470
|
+
export async function renameMeetingSpeaker(sessionId:string,from:string,to:string,options:{apply?:boolean;operationsDir?:string;expectedSidecarPath?:string}={}) : Promise<{copies:Array<{sidecarPath:string;meetingPath:string;labels:number;transcript:number;attendees:number;unresolvedTurns:number}>;batchId?:string;error?:string}> {
|
|
471
|
+
if(options.operationsDir && resolveCosOperationsDir()!==realpathSync(options.operationsDir)) throw new Error('Operations root differs from the requested fan-out root')
|
|
472
|
+
if(from===to||!from.trim()||!to.trim())return{copies:[]}
|
|
473
|
+
if(options.apply&&mutationBusy)throw new Error('Another naming is running; retry')
|
|
474
|
+
let lock:Awaited<ReturnType<typeof acquireMeetingSyncLock>>|undefined
|
|
475
|
+
let batch:BatchRecord|undefined
|
|
476
|
+
let row:CorrectionRow|undefined
|
|
477
|
+
if(options.apply)mutationBusy=true
|
|
478
|
+
try{
|
|
479
|
+
if(options.apply)lock=await acquireMeetingSyncLock()
|
|
480
|
+
if(pendingCorrections(sessionId).length)throw new Error('An interrupted correction needs review')
|
|
481
|
+
const copies=resolveCopies(sessionId)
|
|
482
|
+
const operations=copies.find(c=>c.copy==='operations')
|
|
483
|
+
if(options.expectedSidecarPath && (!operations || realpathSync(operations.sidecarPath)!==realpathSync(options.expectedSidecarPath)))throw new Error('Session resolves to a different sidecar; duplicate or archived copy')
|
|
484
|
+
const maps=copies.map(c=>validatedRawMap(c.doc))
|
|
485
|
+
if(maps.some(map=>hash(map)!==hash(maps[0])))throw new Error('Meeting copies disagree on raw mapping')
|
|
486
|
+
const positions:number[]=copies[0].doc.chunks.flatMap((c:any,i:number)=>c.speaker===from?[i]:[])
|
|
487
|
+
// Repair historical chunks-only renames too: HQ words still carry `from`
|
|
488
|
+
// even when the compacted chunk already says `to` (the 6.27.10 trap).
|
|
489
|
+
for(const c of copies)for(const segment of c.doc.batchSegments??[])for(const word of segment.speakerWords??[]){
|
|
490
|
+
if(word.speaker!==from)continue
|
|
491
|
+
const absolute=Number(segment.startElapsed??0)+Number(word.start)*1000
|
|
492
|
+
let position=-1,distance=Infinity
|
|
493
|
+
const raw=validatedRawMap(c.doc)
|
|
494
|
+
raw.forEach((index,i)=>{if(index<segment.startChunkIdx||index>segment.endChunkIdx)return;const d=Math.abs(Number(c.doc.chunks[i].elapsed)-absolute);if(d<distance){distance=d;position=i}})
|
|
495
|
+
if(distance>3500||position<0)throw new Error('Old HQ speaker has no safe raw chunk mapping')
|
|
496
|
+
if(![from,to].includes(c.doc.chunks[position].speaker))throw new Error('HQ speaker conflicts with an unrelated named chunk')
|
|
497
|
+
if(!positions.includes(position))positions.push(position)
|
|
498
|
+
}
|
|
499
|
+
positions.sort((a,b)=>a-b)
|
|
500
|
+
if(!positions.length)return{copies:[]}
|
|
501
|
+
if(copies.some(c=>positions.some((i:number)=>![from,to].includes(c.doc.chunks[i]?.speaker))))throw new Error('Meeting copies disagree on prior speaker labels')
|
|
502
|
+
const batchId=randomUUID()
|
|
503
|
+
const edits=copies.map(c=>relabelSpeakerPositions(c.doc,c.blended?'':c.markdown,positions,to,batchId))
|
|
504
|
+
const results=copies.map((c,i)=>({sidecarPath:c.sidecarPath,meetingPath:c.meetingPath,labels:positions.length,transcript:edits[i].transcript,attendees:edits[i].attendees,unresolvedTurns:edits[i].unresolvedTurns}))
|
|
505
|
+
if(!options.apply)return{copies:results}
|
|
506
|
+
row={id:randomUUID(),phase:'intent',at:new Date().toISOString(),from,to,chunks:positions,scope:'meeting',batchId,source:'profile-merge',priorLabels:Object.fromEntries(positions.map((i:number)=>[String(i),String(copies[0].doc.chunks[i].speaker??'')]))}
|
|
507
|
+
const meeting:BatchMeeting={sessionId,status:'ready',namedChunks:positions,widerChunks:[],labelled:positions.length,roomRisk:false,playback:positions.map((position:number)=>({sessionId,position,chunkIndex:maps[0][position]})),copies:copies.map(snapshot),intentId:row.id,receipts:copies.map((c,i)=>({copy:c.copy,status:'pending',patches:edits[i].patches,beforeSidecar:c.raw,afterSidecar:JSON.stringify(edits[i].doc,null,2)+'\n',beforeMarkdown:c.markdown,afterMarkdown:c.blended?c.markdown+(c.markdown.includes('<!-- speaker-labels-newer-than-graph -->')?'':'\n<!-- speaker-labels-newer-than-graph -->\n'):edits[i].markdown,blended:c.blended}))}
|
|
508
|
+
batch={batchId,speaker:to,at:row.at,status:'pending',previewHash:'',members:[],meetings:[meeting],enrolled:0,deleted:0,voiceVersion:voiceVersion(to),lockMode:lock!.mode}
|
|
509
|
+
writeRecord('batch',batchId,batch)
|
|
510
|
+
if(!appendCorrection(sessionId,row))throw new Error('Cannot write correction intent')
|
|
511
|
+
for(const receipt of meeting.receipts){
|
|
512
|
+
const c=resolveCopies(sessionId).find(c=>c.copy===receipt.copy)!
|
|
513
|
+
if(c.raw!==receipt.beforeSidecar||c.markdown!==receipt.beforeMarkdown)throw new Error('Meeting changed before copy write')
|
|
514
|
+
durableAtomicWriteFileSync(c.sidecarPath,receipt.afterSidecar,{mode:0o600});receipt.status='sidecar_written';writeRecord('batch',batchId,batch)
|
|
515
|
+
assertMarkdownWords(receipt.beforeMarkdown,receipt.afterMarkdown)
|
|
516
|
+
durableAtomicWriteFileSync(c.meetingPath,receipt.afterMarkdown,{mode:0o600});receipt.status='applied';writeRecord('batch',batchId,batch)
|
|
517
|
+
}
|
|
518
|
+
if(!appendCorrection(sessionId,{...row,phase:'applied',proseStale:true}))throw new Error('Cannot record applied correction')
|
|
519
|
+
if(!appendCorrection(sessionId,{...row,phase:'confirmed-chunks'}))throw new Error('Cannot record chunk confirmation')
|
|
520
|
+
meeting.status='applied';batch.status='applied';writeRecord('batch',batchId,batch)
|
|
521
|
+
return{copies:results,batchId}
|
|
522
|
+
}catch(e){
|
|
523
|
+
if(batch){batch.status='partial';batch.meetings[0].status='failed';batch.meetings[0].error=errorText(e);writeRecord('batch',batch.batchId,batch)}
|
|
524
|
+
if(row)appendCorrection(sessionId,{...row,phase:'failed',error:errorText(e)})
|
|
525
|
+
throw e
|
|
526
|
+
}finally{lock?.release();if(options.apply)mutationBusy=false}
|
|
527
|
+
}
|