@gotcos/glasses-server 6.95.0 → 6.98.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 +157 -0
- package/README.md +4 -0
- package/managed-runtime-contract.json +13 -0
- package/package.json +3 -1
- package/server/index.ts +12 -0
- package/server/lib/attached-provider-adapter.ts +35 -1
- package/server/lib/claude-bridge.ts +47 -0
- package/server/lib/codex-bridge.ts +11 -1
- package/server/lib/codex-rollout-watermark.ts +42 -0
- package/server/lib/cursor-bridge.ts +2 -1
- package/server/lib/decision-provider.ts +4 -2
- package/server/lib/dictation-clean-models.ts +2 -0
- package/server/lib/dictation-clean.ts +2 -2
- package/server/lib/dictation-polish-pipeline.ts +22 -2
- package/server/lib/dictation-prewarm.ts +16 -0
- package/server/lib/direct-bridge.ts +14 -2
- package/server/lib/direct-sse.ts +6 -1
- package/server/lib/display-bus.ts +1 -1
- package/server/lib/fork-thread.ts +32 -1
- package/server/lib/held-naming-batches.ts +44 -7
- package/server/lib/held-voice-groups.ts +5 -4
- package/server/lib/job-audit-caller.ts +20 -0
- package/server/lib/keyed-sessions.ts +21 -1
- package/server/lib/lens-gist-engines.ts +17 -5
- package/server/lib/live-cos-ask.ts +29 -6
- package/server/lib/live-cue-verdicts.ts +43 -8
- package/server/lib/live-cues-engine.ts +4 -1
- package/server/lib/live-cues-filter.ts +50 -0
- package/server/lib/live-cues-general-prompts.ts +137 -0
- package/server/lib/live-cues-general-topic.ts +245 -0
- package/server/lib/live-cues-general.ts +715 -0
- package/server/lib/live-cues-v2-data.ts +453 -0
- package/server/lib/live-cues-v2-grounded.ts +262 -0
- package/server/lib/live-cues-v2-judge-b.ts +59 -0
- package/server/lib/live-cues-v2-names.ts +49 -0
- package/server/lib/live-cues-v2-notes.ts +8 -4
- package/server/lib/live-cues-v2-open-items.ts +132 -0
- package/server/lib/live-cues-v2-pacing.ts +45 -0
- package/server/lib/live-cues-v2-prompts-kinds.ts +9 -4
- package/server/lib/live-cues-v2-settings.ts +89 -10
- package/server/lib/live-cues-v2.ts +570 -47
- package/server/lib/mac-recording-asr.ts +6 -3
- package/server/lib/mac-speaker-call-human.ts +371 -0
- package/server/lib/mac-speaker-call-interval-human.ts +499 -0
- package/server/lib/mac-speaker-call-interval-projection.ts +233 -0
- package/server/lib/mac-speaker-call-original-view.ts +328 -0
- package/server/lib/mac-speaker-call-producer-worker.ts +615 -0
- package/server/lib/mac-speaker-call-producer.ts +539 -0
- package/server/lib/mac-speaker-call-store.ts +264 -0
- package/server/lib/mac-speaker-call-view.ts +373 -0
- package/server/lib/mac-speaker-live-compute.ts +72 -0
- package/server/lib/mac-speaker-live-memory.ts +414 -0
- package/server/lib/mac-speaker-live-projection.ts +126 -0
- package/server/lib/mac-speaker-live-save.ts +90 -0
- package/server/lib/mac-speaker-live-store.ts +71 -0
- package/server/lib/mac-speaker-live-worker.ts +58 -0
- package/server/lib/mac-speaker-live.ts +175 -0
- package/server/lib/mac-speaker-profile-extractor.ts +15 -10
- package/server/lib/mac-speaker-shadow-repair.ts +307 -0
- package/server/lib/mac-voice-assignment.ts +46 -27
- package/server/lib/mac-voice-display.ts +195 -0
- package/server/lib/mac-voice-naming-batch.ts +1067 -0
- package/server/lib/mac-voice-suggestions.ts +548 -0
- package/server/lib/maintenance-lifecycle.ts +2 -0
- package/server/lib/media-store.ts +2 -1
- package/server/lib/meeting-ask-pasted.ts +135 -0
- package/server/lib/meeting-ask-review.ts +8 -1
- package/server/lib/meeting-ask-runner.ts +4 -1
- package/server/lib/meeting-ask-runtime.ts +189 -17
- package/server/lib/meeting-ask-store.ts +2 -0
- package/server/lib/meeting-ask-web.ts +284 -13
- package/server/lib/meeting-batch-progress.ts +51 -5
- package/server/lib/meeting-batch-transcribe.ts +35 -2
- package/server/lib/meeting-corrections.ts +2 -1
- package/server/lib/meeting-fact-check.ts +3 -0
- package/server/lib/meeting-finalization-jobs.ts +69 -2
- package/server/lib/meeting-public-topic.ts +6 -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 +187 -21
- package/server/lib/meeting-store.ts +6 -1
- package/server/lib/parakeet.ts +51 -5
- package/server/lib/pending-batch-retention.ts +58 -0
- package/server/lib/private-reference.ts +38 -0
- package/server/lib/provider-usage.ts +561 -0
- package/server/lib/public-https-url.ts +32 -0
- package/server/lib/query-job-runtime.ts +3 -0
- package/server/lib/rich-media-safety.ts +23 -2
- 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-shadow-decide.ts +59 -22
- package/server/lib/speaker-shadow-pair-source.ts +91 -0
- package/server/lib/speaker-shadow-paired.ts +268 -0
- package/server/lib/speaker-shadow-report.ts +2 -2
- package/server/lib/speaker-shadow-worker.ts +50 -15
- package/server/lib/speaker-shadow.ts +80 -19
- package/server/lib/speaker-trainer.ts +2 -1
- package/server/lib/token-audit.ts +36 -6
- package/server/lib/transcript-cleanup.ts +3 -2
- package/server/lib/voice-directory.ts +10 -5
- package/server/lib/voice-profile-store.ts +24 -14
- package/server/lib/web-lookup-ledger.ts +151 -0
- package/server/lib/work-review-runtime.ts +2 -0
- package/server/lib/work-search.ts +3 -1
- package/server/lib/xlsx-text.ts +277 -0
- package/server/routes/decision-provider.ts +4 -1
- package/server/routes/jev.ts +5 -1
- package/server/routes/live-cues.ts +36 -14
- package/server/routes/meeting.ts +515 -61
- package/server/routes/session-find.ts +73 -0
- package/server/routes/transcribe-stream.ts +114 -33
- package/server/routes/voice.ts +28 -18
- package/server/scripts/live-cues-v3-mutation-gate.mjs +311 -0
- package/server/scripts/mac-voice-assignment-6930-mutation-gate.mjs +5 -5
- package/shared/live-cues-v2.ts +38 -5
- package/shared/media-attachment.ts +2 -0
- package/shared/meeting-ask.ts +11 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/** Paired G2/Mac diagnostics only. No remap, identity proof, meeting write or training. */
|
|
2
|
+
import { checkSpeakerName } from './speaker-name.js'
|
|
3
|
+
import { SHADOW_GATES, SHADOW_SAMPLE_RATE, isShadowVoiceLabel, type ShadowAccepted, type ShadowSegment, type ShadowTurn, type ShadowVoiceEntry } from './speaker-shadow-decide.js'
|
|
4
|
+
|
|
5
|
+
export const PAIRED_SHADOW_GATES = Object.freeze({ nameAllSpeechFraction: 0.8, nameSupportSeconds: 30, shortMinSeconds: 5, shortMaxSeconds: 15, shortAnchorRatio: 1.1 })
|
|
6
|
+
export type ShadowPairAlignment =
|
|
7
|
+
| { status: 'qualified'; evidenceId: string; offsetMs: number; maxErrorMs: number }
|
|
8
|
+
| { status: 'unpaired' | 'ambiguous' | 'time_only' }
|
|
9
|
+
export interface ShadowG2Chunk { speaker: string; elapsed: number; audioDurationMs?: number; durationMs?: number }
|
|
10
|
+
export interface ShadowG2Recording { startTime: string | number; chunks: readonly ShadowG2Chunk[] }
|
|
11
|
+
/** Raw anchorRatio is required for short nominations. Rounded compatibility values cannot qualify them. */
|
|
12
|
+
export type PairedShadowAcousticEvidence = ShadowVoiceEntry & { anchorRatio?: number | null }
|
|
13
|
+
export interface PairedShadowInput {
|
|
14
|
+
macStartTime: string | number
|
|
15
|
+
totalSamples: number
|
|
16
|
+
segments: readonly ShadowSegment[]
|
|
17
|
+
regularTurns: readonly ShadowTurn[]
|
|
18
|
+
/** Final accepted groups only; rejected/co-located voices must not be added here. */
|
|
19
|
+
acceptedGroups: readonly ShadowAccepted[]
|
|
20
|
+
/** Existing acoustic rows, including rejected short fragments. Never recomputed from names. */
|
|
21
|
+
voiceEvidence: readonly PairedShadowAcousticEvidence[]
|
|
22
|
+
g2?: ShadowG2Recording
|
|
23
|
+
alignment: ShadowPairAlignment
|
|
24
|
+
}
|
|
25
|
+
export interface PairedNameSupport { name: string; seconds: number; fractionOfAllSpeech: number; fractionOfKnownName: number }
|
|
26
|
+
export interface PairedShadowAgreement {
|
|
27
|
+
allSpeechSeconds: number
|
|
28
|
+
knownNameSeconds: number
|
|
29
|
+
unknownOrUnpairedSeconds: number
|
|
30
|
+
conflictingG2Seconds: number
|
|
31
|
+
productionConflictSeconds: number
|
|
32
|
+
nativeUncoveredSeconds: number
|
|
33
|
+
nativeConflictSeconds: number
|
|
34
|
+
wrongClusterSeconds: number
|
|
35
|
+
names: PairedNameSupport[]
|
|
36
|
+
diagnosticTopName: string | null
|
|
37
|
+
diagnosticAllSpeechGate: boolean
|
|
38
|
+
}
|
|
39
|
+
export interface PairedShadowGroup extends PairedShadowAgreement {
|
|
40
|
+
agreementScope: 'accepted_voice_speech'
|
|
41
|
+
wholeCluster: PairedShadowAgreement & { scope: 'whole_native_cluster_speech' }
|
|
42
|
+
cluster: string
|
|
43
|
+
voices: string[]
|
|
44
|
+
candidateName: string | null
|
|
45
|
+
reasons: string[]
|
|
46
|
+
}
|
|
47
|
+
export interface PairedShadowVoice extends PairedShadowAgreement { voice: string; cluster: string | null }
|
|
48
|
+
export interface PairedShadowSpan { startSample: number; endSample: number; cluster: string; candidateName: string }
|
|
49
|
+
export interface PairedShadowShortCandidate { voice: string; cluster: string; candidateName: string; seconds: number }
|
|
50
|
+
export interface PairedShadowReport {
|
|
51
|
+
mode: 'provisional_shadow_only'
|
|
52
|
+
alignmentStatus: ShadowPairAlignment['status']
|
|
53
|
+
candidateNominationAllowed: boolean
|
|
54
|
+
accuracyClaim: false
|
|
55
|
+
humanTruthClaim: false
|
|
56
|
+
warnings: string[]
|
|
57
|
+
groups: PairedShadowGroup[]
|
|
58
|
+
voices: PairedShadowVoice[]
|
|
59
|
+
extCandidates: PairedShadowSpan[]
|
|
60
|
+
shortCandidates: PairedShadowShortCandidate[]
|
|
61
|
+
shortWithheld: Array<{ voice: string; reasons: string[] }>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A plausible saved G2 name is still a machine label, never human ground truth. */
|
|
65
|
+
export function isPairedShadowName(label: string): boolean {
|
|
66
|
+
const name = label.trim().replace(/\s+/gu, ' ')
|
|
67
|
+
if (/^(?:ext|unknown|mu|me|speaker(?:[\s_-]*(?:\d+|n))?|voice(?:[\s_-]*(?:\d+|n))?)$/iu.test(name)) return false
|
|
68
|
+
return checkSpeakerName(name).ok
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Row = readonly [number, number, string]
|
|
72
|
+
type Counts = Map<string, number>
|
|
73
|
+
interface Stats { all: number; known: number; unknown: number; conflict: number; production: number; uncovered: number; nativeConflict: number; wrong: number; names: Counts }
|
|
74
|
+
const stats = (): Stats => ({ all: 0, known: 0, unknown: 0, conflict: 0, production: 0, uncovered: 0, nativeConflict: 0, wrong: 0, names: new Map() })
|
|
75
|
+
const add = (map: Counts, key: string, amount: number) => { const n = (map.get(key) ?? 0) + amount; if (n) map.set(key, n); else map.delete(key) }
|
|
76
|
+
const epoch = (value: string | number): number => typeof value === 'number' ? value : Date.parse(value)
|
|
77
|
+
const finite = (value: unknown): value is number => typeof value === 'number' && Number.isFinite(value)
|
|
78
|
+
const SR = SHADOW_SAMPLE_RATE
|
|
79
|
+
|
|
80
|
+
function accumulate(s: Stats, amount: number, production: Counts, native: Counts, g2: Counts, cluster: string | null, allowUnlabelledProduction = false): void {
|
|
81
|
+
s.all += amount
|
|
82
|
+
const name = g2.size === 1 ? [...g2.keys()][0] : null
|
|
83
|
+
if (g2.size > 1) s.conflict += amount
|
|
84
|
+
else if (name && isPairedShadowName(name)) s.known += amount
|
|
85
|
+
else s.unknown += amount
|
|
86
|
+
if (production.size > 1) s.production += amount
|
|
87
|
+
if (!native.size) s.uncovered += amount
|
|
88
|
+
else if (native.size > 1) s.nativeConflict += amount
|
|
89
|
+
else if (cluster !== null && !native.has(cluster)) s.wrong += amount
|
|
90
|
+
if ((production.size === 1 || (allowUnlabelledProduction && production.size === 0)) && native.size === 1 && cluster !== null && native.has(cluster) && name && g2.size === 1 && isPairedShadowName(name)) add(s.names, name, amount)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function agreement(s: Stats): PairedShadowAgreement {
|
|
94
|
+
const names = [...s.names].map(([name, n]) => ({ name, seconds: n / SR, fractionOfAllSpeech: s.all ? n / s.all : 0, fractionOfKnownName: s.known ? n / s.known : 0 }))
|
|
95
|
+
.sort((a, b) => b.seconds - a.seconds || a.name.localeCompare(b.name))
|
|
96
|
+
const best = names[0]
|
|
97
|
+
const unique = !!best && (!names[1] || names[1].seconds !== best.seconds)
|
|
98
|
+
return { allSpeechSeconds: s.all / SR, knownNameSeconds: s.known / SR, unknownOrUnpairedSeconds: s.unknown / SR, conflictingG2Seconds: s.conflict / SR,
|
|
99
|
+
productionConflictSeconds: s.production / SR, nativeUncoveredSeconds: s.uncovered / SR, nativeConflictSeconds: s.nativeConflict / SR, wrongClusterSeconds: s.wrong / SR,
|
|
100
|
+
names, diagnosticTopName: unique ? best.name : null,
|
|
101
|
+
diagnosticAllSpeechGate: unique && best.fractionOfAllSpeech >= PAIRED_SHADOW_GATES.nameAllSpeechFraction && best.seconds >= PAIRED_SHADOW_GATES.nameSupportSeconds }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Sweep half-open sample intervals once. Duplicate same-label rows union; different
|
|
106
|
+
* simultaneous labels withhold votes. Every group/voice denominator includes its
|
|
107
|
+
* unknown, uncovered and conflicting time, so named-only percentages cannot qualify it.
|
|
108
|
+
* offsetMs adds to the G2 wall clock. Only caller-qualified alignment enables nominations;
|
|
109
|
+
* timestamp coincidence alone is diagnostic. maxErrorMs erodes G2 vote intervals.
|
|
110
|
+
*/
|
|
111
|
+
export function evaluatePairedSpeakerShadow(input: PairedShadowInput): PairedShadowReport {
|
|
112
|
+
if (!Number.isSafeInteger(input.totalSamples) || input.totalSamples <= 0 || input.totalSamples > 4 * 3600 * SR) throw new Error('invalid paired shadow timeline')
|
|
113
|
+
if (input.segments.length + input.regularTurns.length + (input.g2?.chunks.length ?? 0) > 100_000) throw new Error('paired shadow interval budget exceeded')
|
|
114
|
+
const warnings = new Set<string>()
|
|
115
|
+
let safe = true
|
|
116
|
+
const macStart = epoch(input.macStartTime)
|
|
117
|
+
if (!finite(macStart)) { warnings.add('invalid Mac startTime'); safe = false }
|
|
118
|
+
const align = input.alignment
|
|
119
|
+
let qualified = align.status === 'qualified' && !!align.evidenceId.trim() && finite(align.offsetMs) && finite(align.maxErrorMs) && align.maxErrorMs >= 0
|
|
120
|
+
if (align.status === 'qualified' && !qualified) { warnings.add('invalid qualified alignment evidence'); safe = false }
|
|
121
|
+
if (align.status !== 'qualified') warnings.add(`${align.status}: time alignment is uncertain; diagnostic only`)
|
|
122
|
+
// Time-only still reports wall-clock overlap; it cannot transfer names.
|
|
123
|
+
const offset = qualified && align.status === 'qualified' ? align.offsetMs : 0
|
|
124
|
+
const erosion = qualified && align.status === 'qualified' ? Math.ceil(align.maxErrorMs * SR / 1000) : 0
|
|
125
|
+
const production: Row[] = [], native: Row[] = [], paired: Row[] = []
|
|
126
|
+
const clip = (rows: Row[], lo: number, hi: number, label: string, source: string) => {
|
|
127
|
+
if (!Number.isSafeInteger(lo) || !Number.isSafeInteger(hi) || hi <= lo || typeof label !== 'string' || !label.trim()) { warnings.add(`invalid ${source} interval`); safe = false; return }
|
|
128
|
+
const a = Math.max(0, lo), b = Math.min(input.totalSamples, hi)
|
|
129
|
+
if (a < b) rows.push([a, b, label.trim().replace(/\s+/gu, ' ')])
|
|
130
|
+
}
|
|
131
|
+
for (const [lo, hi, label] of input.segments) clip(production, lo, hi, label, 'production')
|
|
132
|
+
for (const turn of input.regularTurns) clip(native, Math.round(turn.start * SR), Math.round(turn.end * SR), turn.speaker, 'native')
|
|
133
|
+
if (!input.g2) { warnings.add('no paired G2 recording'); qualified = false }
|
|
134
|
+
else {
|
|
135
|
+
const g2Start = epoch(input.g2.startTime)
|
|
136
|
+
if (!finite(g2Start)) { warnings.add('invalid G2 startTime'); safe = false }
|
|
137
|
+
for (const chunk of input.g2.chunks) {
|
|
138
|
+
const duration = chunk.audioDurationMs ?? chunk.durationMs
|
|
139
|
+
if (!finite(chunk.elapsed) || chunk.elapsed < 0 || !finite(duration) || duration <= 0 || (chunk.audioDurationMs !== undefined && chunk.durationMs !== undefined && chunk.audioDurationMs !== chunk.durationMs)) {
|
|
140
|
+
warnings.add('invalid or conflicting G2 elapsed/duration'); safe = false; continue
|
|
141
|
+
}
|
|
142
|
+
const lo = Math.round((g2Start - macStart + offset + chunk.elapsed) * SR / 1000) + erosion
|
|
143
|
+
const hi = Math.round((g2Start - macStart + offset + chunk.elapsed + duration) * SR / 1000) - erosion
|
|
144
|
+
// Entire interval inside alignment uncertainty contributes no name support.
|
|
145
|
+
if (hi <= lo && finite(lo) && finite(hi)) continue
|
|
146
|
+
clip(paired, lo, hi, chunk.speaker, 'G2')
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const groups = new Map<string, { voices: string[]; stats: Stats; wholeStats: Stats }>()
|
|
150
|
+
const groupOfVoice = new Map<string, string>()
|
|
151
|
+
for (const group of input.acceptedGroups) {
|
|
152
|
+
const voices = [group.keepLabel, ...group.merge]
|
|
153
|
+
if (!group.cluster || groups.has(group.cluster) || new Set(voices).size !== voices.length || voices.some(v => !isShadowVoiceLabel(v) || groupOfVoice.has(v))) throw new Error('ambiguous accepted paired group')
|
|
154
|
+
groups.set(group.cluster, { voices, stats: stats(), wholeStats: stats() })
|
|
155
|
+
for (const voice of voices) groupOfVoice.set(voice, group.cluster)
|
|
156
|
+
}
|
|
157
|
+
const evidence = new Map<string, PairedShadowAcousticEvidence>()
|
|
158
|
+
for (const row of input.voiceEvidence) {
|
|
159
|
+
if (evidence.has(row.voice)) throw new Error('duplicate paired voice evidence')
|
|
160
|
+
evidence.set(row.voice, row)
|
|
161
|
+
}
|
|
162
|
+
const byVoice = new Map<string, Stats>()
|
|
163
|
+
for (const [, , label] of production) if (isShadowVoiceLabel(label) && !byVoice.has(label)) byVoice.set(label, stats())
|
|
164
|
+
type Event = { at: number; kind: number; label: string; delta: number }
|
|
165
|
+
const events: Event[] = []
|
|
166
|
+
for (const [kind, rows] of [production, native, paired].entries()) for (const [lo, hi, label] of rows) {
|
|
167
|
+
events.push({ at: lo, kind, label, delta: 1 }, { at: hi, kind, label, delta: -1 })
|
|
168
|
+
}
|
|
169
|
+
events.sort((a, b) => a.at - b.at)
|
|
170
|
+
const active = [new Map<string, number>(), new Map<string, number>(), new Map<string, number>()]
|
|
171
|
+
const ext: Array<{ lo: number; hi: number; cluster: string; name: string }> = []
|
|
172
|
+
for (let i = 0; i < events.length;) {
|
|
173
|
+
const at = events[i].at
|
|
174
|
+
while (i < events.length && events[i].at === at) { const event = events[i++]; add(active[event.kind], event.label, event.delta) }
|
|
175
|
+
const end = events[i]?.at
|
|
176
|
+
if (end === undefined || end <= at) continue
|
|
177
|
+
const touched = new Set<string>()
|
|
178
|
+
for (const voice of active[0].keys()) {
|
|
179
|
+
const s = byVoice.get(voice)
|
|
180
|
+
if (s) accumulate(s, end - at, active[0], active[1], active[2], evidence.get(voice)?.topCluster ?? groupOfVoice.get(voice) ?? null)
|
|
181
|
+
const group = groupOfVoice.get(voice)
|
|
182
|
+
if (group) touched.add(group)
|
|
183
|
+
}
|
|
184
|
+
for (const cluster of touched) accumulate(groups.get(cluster)!.stats, end - at, active[0], active[1], active[2], cluster)
|
|
185
|
+
// Full native-cluster union includes Ext, rejected/named labels and no-ASR time.
|
|
186
|
+
// Concurrent different clusters/production labels remain in the denominator but cannot vote.
|
|
187
|
+
for (const cluster of active[1].keys()) {
|
|
188
|
+
const group = groups.get(cluster)
|
|
189
|
+
if (group) accumulate(group.wholeStats, end - at, active[0], active[1], active[2], cluster, true)
|
|
190
|
+
}
|
|
191
|
+
if (active[0].size === 1 && active[0].has('Ext') && active[1].size === 1 && active[2].size === 1) {
|
|
192
|
+
const cluster = [...active[1].keys()][0], name = [...active[2].keys()][0]
|
|
193
|
+
if (groups.has(cluster) && isPairedShadowName(name)) ext.push({ lo: at, hi: end, cluster, name })
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const allow = qualified && safe
|
|
197
|
+
const groupRows: PairedShadowGroup[] = [...groups].map(([cluster, group]) => {
|
|
198
|
+
const result = agreement(group.stats), wholeCluster = { ...agreement(group.wholeStats), scope: 'whole_native_cluster_speech' as const }, reasons: string[] = []
|
|
199
|
+
if (!allow) reasons.push('paired alignment is not qualified')
|
|
200
|
+
if (!result.diagnosticAllSpeechGate) reasons.push('name requires 80% of ALL accepted-voice speech and at least 30s support')
|
|
201
|
+
if (!wholeCluster.diagnosticAllSpeechGate) reasons.push('name requires 80% of ALL native-cluster speech and at least 30s support')
|
|
202
|
+
if (result.diagnosticTopName !== wholeCluster.diagnosticTopName) reasons.push('accepted voices and whole native cluster disagree on G2 name')
|
|
203
|
+
const both = result.diagnosticAllSpeechGate && wholeCluster.diagnosticAllSpeechGate && result.diagnosticTopName === wholeCluster.diagnosticTopName
|
|
204
|
+
return { ...result, agreementScope: 'accepted_voice_speech', wholeCluster, cluster, voices: group.voices, candidateName: allow && both ? result.diagnosticTopName : null, reasons }
|
|
205
|
+
})
|
|
206
|
+
const named = new Map(groupRows.filter(g => g.candidateName).map(g => [g.cluster, g.candidateName!]))
|
|
207
|
+
const voices = [...byVoice].map(([voice, s]) => ({ ...agreement(s), voice, cluster: evidence.get(voice)?.topCluster ?? groupOfVoice.get(voice) ?? null }))
|
|
208
|
+
const extCandidates: PairedShadowSpan[] = []
|
|
209
|
+
if (allow) for (const span of ext) if (named.get(span.cluster) === span.name) {
|
|
210
|
+
const previous = extCandidates.at(-1)
|
|
211
|
+
if (previous && previous.endSample === span.lo && previous.cluster === span.cluster && previous.candidateName === span.name) previous.endSample = span.hi
|
|
212
|
+
else extCandidates.push({ startSample: span.lo, endSample: span.hi, cluster: span.cluster, candidateName: span.name })
|
|
213
|
+
}
|
|
214
|
+
const shortCandidates: PairedShadowShortCandidate[] = [], shortWithheld: PairedShadowReport['shortWithheld'] = []
|
|
215
|
+
for (const voice of voices) {
|
|
216
|
+
if (voice.allSpeechSeconds < PAIRED_SHADOW_GATES.shortMinSeconds || voice.allSpeechSeconds > PAIRED_SHADOW_GATES.shortMaxSeconds) continue
|
|
217
|
+
const row = evidence.get(voice.voice), reasons: string[] = []
|
|
218
|
+
if (!allow) reasons.push('paired alignment is not qualified')
|
|
219
|
+
if (!row || !finite(row.seconds) || Math.abs(row.seconds - voice.allSpeechSeconds) > 1 / SR) reasons.push('missing or inconsistent acoustic evidence')
|
|
220
|
+
if (!row || !finite(row.majority) || row.majority < SHADOW_GATES.majority) reasons.push('acoustic majority below 0.8')
|
|
221
|
+
if (!row || !finite(row.coveredFraction) || row.coveredFraction < SHADOW_GATES.minCoveredFraction) reasons.push('acoustic coverage below 0.5')
|
|
222
|
+
if (!row?.outlierPass) reasons.push('acoustic outlier check failed')
|
|
223
|
+
if (!row || !finite(row.anchorRatio) || row.anchorRatio < 0 || row.anchorRatio > PAIRED_SHADOW_GATES.shortAnchorRatio) reasons.push('raw anchor ratio unavailable or above 1.1')
|
|
224
|
+
const name = voice.cluster ? named.get(voice.cluster) : undefined
|
|
225
|
+
const support = voice.names.find(n => n.name === name)
|
|
226
|
+
if (!name || !support || support.fractionOfAllSpeech < PAIRED_SHADOW_GATES.nameAllSpeechFraction || voice.diagnosticTopName !== name) reasons.push('fragment and group lack same-time G2 name agreement')
|
|
227
|
+
if (reasons.length) shortWithheld.push({ voice: voice.voice, reasons })
|
|
228
|
+
else shortCandidates.push({ voice: voice.voice, cluster: voice.cluster!, candidateName: name!, seconds: voice.allSpeechSeconds })
|
|
229
|
+
}
|
|
230
|
+
return { mode: 'provisional_shadow_only', alignmentStatus: align.status, candidateNominationAllowed: allow, accuracyClaim: false, humanTruthClaim: false,
|
|
231
|
+
warnings: [...warnings], groups: groupRows, voices, extCandidates, shortCandidates, shortWithheld }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Readable paired-recording diagnostics. All labels here remain provisional. */
|
|
235
|
+
export function renderPairedSpeakerShadow(report: PairedShadowReport): string {
|
|
236
|
+
const esc = (value: string) => value.replace(/[|\r\n]/gu, ' ').replace(/[*`<>]/gu, '')
|
|
237
|
+
const seconds = (n: number) => `${n.toFixed(1)} s`
|
|
238
|
+
const lines = ['## Paired G2 diagnostics', '',
|
|
239
|
+
`Alignment: **${report.alignmentStatus}**. ${report.candidateNominationAllowed ? 'Provisional shadow nominations are allowed.' : 'Nominations withheld; overlap statistics are diagnostic only.'}`,
|
|
240
|
+
'Saved G2 names are machine labels. This comparison makes no accuracy or human-truth claim.', '',
|
|
241
|
+
'Accepted-voice scope: only the final accepted keep/merge voices; this is not the whole native cluster.', '',
|
|
242
|
+
'| Cluster | All accepted-voice speech | Unique known-name time | Top name support / all speech | Top name support / known-name time | Name nomination |',
|
|
243
|
+
'|---|---:|---:|---|---|---|']
|
|
244
|
+
for (const group of report.groups) {
|
|
245
|
+
const best = group.names[0]
|
|
246
|
+
const support = best ? `${esc(best.name)}: ${seconds(best.seconds)}` : 'None'
|
|
247
|
+
lines.push(`| ${esc(group.cluster)} | ${seconds(group.allSpeechSeconds)} | ${seconds(group.knownNameSeconds)} | ${support}; ${((best?.fractionOfAllSpeech ?? 0) * 100).toFixed(1)}% | ${((best?.fractionOfKnownName ?? 0) * 100).toFixed(1)}% | ${group.candidateName ? esc(group.candidateName) + ' (provisional)' : 'Withheld'} |`)
|
|
248
|
+
}
|
|
249
|
+
lines.push('', 'Whole native-cluster scope includes Ext, other source labels and native speech without ASR labels.', '',
|
|
250
|
+
'| Cluster | All native-cluster speech | Unique known-name time | Top name support / all speech | Top name support / known-name time |',
|
|
251
|
+
'|---|---:|---:|---|---|')
|
|
252
|
+
for (const group of report.groups) {
|
|
253
|
+
const whole = group.wholeCluster, best = whole.names[0]
|
|
254
|
+
lines.push(`| ${esc(group.cluster)} | ${seconds(whole.allSpeechSeconds)} | ${seconds(whole.knownNameSeconds)} | ${best ? `${esc(best.name)}: ${seconds(best.seconds)}` : 'None'}; ${((best?.fractionOfAllSpeech ?? 0) * 100).toFixed(1)}% | ${((best?.fractionOfKnownName ?? 0) * 100).toFixed(1)}% |`)
|
|
255
|
+
}
|
|
256
|
+
lines.push('', 'A name must pass 80% of ALL speech and 30 seconds in BOTH scopes with the same name. Known-name percentages alone cannot qualify it.', '',
|
|
257
|
+
'| Voice | All speech | Known-name time | Top name / all speech | Production conflict | Native conflict / uncovered |',
|
|
258
|
+
'|---|---:|---:|---|---:|---|')
|
|
259
|
+
for (const voice of report.voices) {
|
|
260
|
+
const best = voice.names[0]
|
|
261
|
+
lines.push(`| ${esc(voice.voice)} | ${seconds(voice.allSpeechSeconds)} | ${seconds(voice.knownNameSeconds)} | ${best ? `${esc(best.name)} ${((best.fractionOfAllSpeech) * 100).toFixed(1)}%` : 'None'} | ${seconds(voice.productionConflictSeconds)} | ${seconds(voice.nativeConflictSeconds)} / ${seconds(voice.nativeUncoveredSeconds)} |`)
|
|
262
|
+
}
|
|
263
|
+
lines.push('', `Ext span nominations: ${report.extCandidates.length}. Short-fragment nominations: ${report.shortCandidates.length}. These lists do not change labels or remaps.`)
|
|
264
|
+
for (const group of report.groups) if (group.reasons.length) lines.push(`- ${esc(group.cluster)}: ${group.reasons.map(esc).join('; ')}.`)
|
|
265
|
+
for (const row of report.shortWithheld) lines.push(`- ${esc(row.voice)} short fragment withheld: ${row.reasons.map(esc).join('; ')}.`)
|
|
266
|
+
for (const warning of report.warnings) lines.push(`- ${esc(warning)}.`)
|
|
267
|
+
return lines.join('\n') + '\n'
|
|
268
|
+
}
|
|
@@ -24,7 +24,7 @@ export interface ShadowReportInput {
|
|
|
24
24
|
const STRIP = new RegExp(`^${PY_SPACE_CLASS}+|${PY_SPACE_CLASS}+$`, 'gu')
|
|
25
25
|
const quote = (text: string | undefined) => Array.from((text ?? '').replace(STRIP, '')).slice(0, 140).join('')
|
|
26
26
|
const chunkFile = (dir: string, index: number) => join(dir, `chunk_${String(index).padStart(4, '0')}.wav`)
|
|
27
|
-
const HEADER = ['| Voice | Speech | Covered | Top cluster, majority | Outlier check | Anchor ratio
|
|
27
|
+
const HEADER = ['| Voice | Speech | Covered | Top cluster, majority | Outlier check | Anchor ratio gate |', '|---|---:|---:|---|---|---|']
|
|
28
28
|
|
|
29
29
|
/** propose.py fmt_voice. */
|
|
30
30
|
export function shadowVoiceRow(m: ShadowVoiceEntry): string {
|
|
@@ -49,7 +49,7 @@ export function renderShadowReport(input: ShadowReportInput): string {
|
|
|
49
49
|
: `- Meeting: ${pyFixed(F.minutes, 1)} min, ${F.originals} originals (${F.verifiedAgainstSidecar} verified against the sidecar hashes; ${F.unhashedOriginals} had no hash, all empty-text).`,
|
|
50
50
|
`- FluidAudio found ${P.fluidAudioClusters} clusters. Labels never merged: ${P.frozenOtherLabels.join(', ') || 'none'}.`,
|
|
51
51
|
'- Gates: majority ≥0.8 of covered speech on one cluster (and ≥50% covered), ≥15 s of speech, embedding centroid inside the cluster spread (p95).',
|
|
52
|
-
'-
|
|
52
|
+
'- Anchor ratio must be ≤1.5x against the kept voice\'s own spread; missing or degenerate evidence rejects a merge.',
|
|
53
53
|
'- How to check: play two or three pointers for each voice in a row. Same person? The merge is right.',
|
|
54
54
|
...(F.humanProtectedVoices.length ? [`- A person already edited chunks holding ${F.humanProtectedVoices.join(', ')}. Those edits outrank any proposal here.`] : []),
|
|
55
55
|
`- Session \`${input.sessionId}\`.`, '',
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
// RSS cap and kills the whole process group; this side also stops the CLI on its own
|
|
7
7
|
// deadline and exits if its parent goes away.
|
|
8
8
|
//
|
|
9
|
-
// READ ONLY, LOG ONLY
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
9
|
+
// Shadow remains READ ONLY, LOG ONLY: its temporary native outputs are removed.
|
|
10
|
+
// A separately default-off reviewed closed-call consumer may retain the SAME verified
|
|
11
|
+
// execution exports in its own data directory. It never edits BASE, names, profiles or
|
|
12
|
+
// corrections here; later derived-view publication has an independent queue/admission.
|
|
13
13
|
//
|
|
14
14
|
// The CLI is FluidAudio 0.17.4 `process --mode offline --overlapping-segments
|
|
15
15
|
// --export-embeddings`, the exact invocation of round3/propose.py, run through
|
|
@@ -36,6 +36,9 @@ import {
|
|
|
36
36
|
} from './speaker-shadow-timeline.js'
|
|
37
37
|
import { renderShadowReport } from './speaker-shadow-report.js'
|
|
38
38
|
import { pyRound } from './speaker-shadow-numeric.js'
|
|
39
|
+
import { findShadowPairSource } from './speaker-shadow-pair-source.js'
|
|
40
|
+
import { evaluatePairedSpeakerShadow, renderPairedSpeakerShadow, type PairedShadowReport } from './speaker-shadow-paired.js'
|
|
41
|
+
import { beginMacSpeakerCallNative,retainMacSpeakerCallNative,type MacSpeakerCallProducerRequest,type MacSpeakerCallNativeRef } from './mac-speaker-call-producer-worker.js'
|
|
39
42
|
|
|
40
43
|
export const SHADOW_TMP_PREFIX = 'cos-speaker-shadow-'
|
|
41
44
|
/** The CLI's flags, verbatim from round3/propose.py (via scripts/guard.py). */
|
|
@@ -56,6 +59,10 @@ export interface ShadowJob {
|
|
|
56
59
|
homeDir: string
|
|
57
60
|
tmpRoot: string
|
|
58
61
|
cliTimeoutMs: number
|
|
62
|
+
/** Separate default-off consumer, admitted independently from log-only shadow. */
|
|
63
|
+
callProducer?: MacSpeakerCallProducerRequest
|
|
64
|
+
shadowLogRequested?: boolean
|
|
65
|
+
callProducerSwapStart?:number
|
|
59
66
|
}
|
|
60
67
|
|
|
61
68
|
export type ShadowSkipReason =
|
|
@@ -85,7 +92,7 @@ export interface ShadowRunFacts {
|
|
|
85
92
|
}
|
|
86
93
|
|
|
87
94
|
export type ShadowJobOutcome =
|
|
88
|
-
| { status: 'proposed'; facts: ShadowRunFacts; proposals: ShadowProposals; report: { title: string; markdown: string } }
|
|
95
|
+
| { status: 'proposed'; facts: ShadowRunFacts; proposals: ShadowProposals; paired?: PairedShadowReport & { pairSessionId?: string; pairSourceSha256?: string; discoveryReason: string }; report: { title: string; markdown: string };callProducerExport?:MacSpeakerCallNativeRef; callProducerFailure?:{stage:'retain';reason:string} }
|
|
89
96
|
| { status: 'skipped'; reason: ShadowSkipReason; detail: string }
|
|
90
97
|
| { status: 'failed'; reason: ShadowFailReason; detail: string }
|
|
91
98
|
|
|
@@ -122,17 +129,19 @@ function nsirdDirs(): Set<string> {
|
|
|
122
129
|
const skip = (reason: ShadowSkipReason, detail: string): ShadowJobOutcome => ({ status: 'skipped', reason, detail })
|
|
123
130
|
const fail = (reason: ShadowFailReason, detail: string): ShadowJobOutcome => ({ status: 'failed', reason, detail: detail.slice(0, 300) })
|
|
124
131
|
|
|
125
|
-
function runCli(job: ShadowJob, wavPath: string, nativePath: string, embPath: string, work: string, deps: ShadowWorkerDeps): Promise<{ code: number | null; signal: NodeJS.Signals | null; stderr: string; timedOut: boolean; error?: string }> {
|
|
132
|
+
function runCli(job: ShadowJob, wavPath: string, nativePath: string, embPath: string, work: string, deps: ShadowWorkerDeps): Promise<{ code: number | null; signal: NodeJS.Signals | null; stderr: string; timedOut: boolean;closed:boolean; error?: string }> {
|
|
126
133
|
const args = ['-p', parakeetSandboxProfile(work, job.homeDir), job.cliPath, 'process', wavPath, '--mode', 'offline', '--output', nativePath, '--overlapping-segments', '--export-embeddings', embPath]
|
|
127
134
|
return new Promise(resolvePromise => {
|
|
135
|
+
if(deps.signal?.aborted){resolvePromise({code:null,signal:null,stderr:'',timedOut:false,closed:false,error:'cancelled before native admission'});return}
|
|
136
|
+
const deadline=Date.now()+job.cliTimeoutMs
|
|
128
137
|
let child: ChildProcess
|
|
129
138
|
try {
|
|
130
|
-
child = (deps.spawn ?? (spawn as unknown as SpawnLike))(job.sandboxExec, args, { stdio: ['ignore', 'ignore', 'pipe'], env: parakeetChildEnv(job.homeDir, work) })
|
|
139
|
+
child = (deps.spawn ?? (spawn as unknown as SpawnLike))(job.sandboxExec, args, { stdio: ['ignore', 'ignore', 'pipe'], env: {...parakeetChildEnv(job.homeDir, work),...(job.callProducer?{OMP_NUM_THREADS:'1',VECLIB_MAXIMUM_THREADS:'1',OPENBLAS_NUM_THREADS:'1'}:{})} })
|
|
131
140
|
} catch (error) {
|
|
132
|
-
resolvePromise({ code: null, signal: null, stderr: '', timedOut: false, error: error instanceof Error ? error.message : String(error) })
|
|
141
|
+
resolvePromise({ code: null, signal: null, stderr: '', timedOut: false,closed:false, error: error instanceof Error ? error.message : String(error) })
|
|
133
142
|
return
|
|
134
143
|
}
|
|
135
|
-
let stderr = '', timedOut = false, settled = false
|
|
144
|
+
let stderr = '', timedOut = false, settled = false, childError:string|undefined
|
|
136
145
|
const done = (value: Parameters<typeof resolvePromise>[0]) => { if (settled) return; settled = true; clearTimeout(timer); deps.signal?.removeEventListener('abort', onAbort); resolvePromise(value) }
|
|
137
146
|
const kill = () => { try { child.kill('SIGKILL') } catch { /* gone */ } }
|
|
138
147
|
// SIGKILL: CoreML work does not stop for TERM.
|
|
@@ -140,8 +149,8 @@ function runCli(job: ShadowJob, wavPath: string, nativePath: string, embPath: st
|
|
|
140
149
|
const onAbort = () => kill()
|
|
141
150
|
deps.signal?.addEventListener('abort', onAbort, { once: true })
|
|
142
151
|
child.stderr?.on('data', (d: Buffer) => { if (stderr.length < 65_536) stderr += d.toString() })
|
|
143
|
-
child.on('error', error => done({ code: null, signal: null, stderr, timedOut, error: error.message }))
|
|
144
|
-
child.on('close', (code, signal) => done({ code, signal, stderr, timedOut }))
|
|
152
|
+
child.on('error', error => {childError=error.message;if(!child.pid)done({ code: null, signal: null, stderr, timedOut,closed:false, error: error.message });else kill()})
|
|
153
|
+
child.on('close', (code, signal) => done({ code, signal, stderr, timedOut:timedOut||Date.now()>deadline,closed:true,error:childError }))
|
|
145
154
|
})
|
|
146
155
|
}
|
|
147
156
|
|
|
@@ -152,8 +161,8 @@ export async function runShadowJob(job: ShadowJob, deps: ShadowWorkerDeps = {}):
|
|
|
152
161
|
let work: string | null = null
|
|
153
162
|
try {
|
|
154
163
|
// 1. The finalized meeting, unchanged since it finalized.
|
|
155
|
-
let sidecar: Record<string, unknown
|
|
156
|
-
try {
|
|
164
|
+
let sidecar: Record<string, unknown>,sourceJSON:string
|
|
165
|
+
try { sourceJSON=await readFile(job.sidecarPath, 'utf8');sidecar = JSON.parse(sourceJSON) } catch { return skip('bad_sidecar', 'the sidecar is missing or unreadable') }
|
|
157
166
|
if (sidecar.sessionId !== job.sessionId) return skip('bad_sidecar', 'the sidecar belongs to another session')
|
|
158
167
|
if (sidecar.finalizationState !== 'complete') return skip('not_finalized', `finalizationState=${String(sidecar.finalizationState)}`)
|
|
159
168
|
const entries = shadowEntries(sidecar)
|
|
@@ -178,11 +187,19 @@ export async function runShadowJob(job: ShadowJob, deps: ShadowWorkerDeps = {}):
|
|
|
178
187
|
const wav = pcm16WavFile(timeline.pcm)
|
|
179
188
|
const rebuildMs = now() - started
|
|
180
189
|
|
|
190
|
+
// The platform temp root may spell /private/var as /var. Pin the owned
|
|
191
|
+
// directory itself; retained-output readers keep their strict canonical path fence.
|
|
181
192
|
work = mkdtempSync(join(job.tmpRoot, SHADOW_TMP_PREFIX))
|
|
193
|
+
work = realpathSync(work)
|
|
182
194
|
const wavPath = join(work, 'timeline.wav'), nativePath = join(work, 'native.json'), embPath = join(work, 'embeddings.json')
|
|
183
195
|
await writeFile(wavPath, wav, { mode: 0o600, flag: 'wx' })
|
|
184
196
|
const timelineWAVSHA256 = createHash('sha256').update(wav).digest('hex')
|
|
185
197
|
prepareShadowHome(job.homeDir, models.dir)
|
|
198
|
+
let producerContext:ReturnType<typeof beginMacSpeakerCallNative>|null=null
|
|
199
|
+
if(job.callProducer){
|
|
200
|
+
try{producerContext=beginMacSpeakerCallNative(job.callProducer,{sourceJSON,audioDir:job.audioDir,cliPath:job.cliPath,modelsDir:models.dir,timeline:{rows:timeline.rows,total:timeline.total,pcmSHA256:timeline.pcmSHA256}})}
|
|
201
|
+
catch(error){if(job.shadowLogRequested===false)return skip('bad_sidecar',`independent consumer withheld: ${error instanceof Error?error.message:String(error)}`)}
|
|
202
|
+
}
|
|
186
203
|
|
|
187
204
|
// 4. One offline diarization.
|
|
188
205
|
const nativeStarted = now()
|
|
@@ -205,6 +222,7 @@ export async function runShadowJob(job: ShadowJob, deps: ShadowWorkerDeps = {}):
|
|
|
205
222
|
|
|
206
223
|
// 5. The gates, exactly as the research tool applies them.
|
|
207
224
|
let proposals: ShadowProposals, clipped: number
|
|
225
|
+
let paired: Extract<ShadowJobOutcome, { status: 'proposed' }>['paired']
|
|
208
226
|
try {
|
|
209
227
|
const turns = nativeTurns(native.segments as Array<{ speakerId: unknown; startTimeSeconds: unknown; endTimeSeconds: unknown }>, timeline.total / SHADOW_SAMPLE_RATE)
|
|
210
228
|
clipped = turns.clipped
|
|
@@ -213,6 +231,19 @@ export async function runShadowJob(job: ShadowJob, deps: ShadowWorkerDeps = {}):
|
|
|
213
231
|
}
|
|
214
232
|
const D = decide(segs, timeline.total, turns.turns, emb as ShadowEmbeddingRow[], SHADOW_GATES.floorSeconds, SHADOW_GATES.majority)
|
|
215
233
|
proposals = buildShadowProposals(D, segs, sources, turns.turns)
|
|
234
|
+
// Pair scoring is advisory; an unavailable G2 must not suppress the acoustic report.
|
|
235
|
+
try {
|
|
236
|
+
const source = await findShadowPairSource(job.sidecarPath, sidecar, timeline.total / SHADOW_SAMPLE_RATE * 1000)
|
|
237
|
+
paired = { ...evaluatePairedSpeakerShadow({ macStartTime: sidecar.startTime as number, totalSamples: timeline.total,
|
|
238
|
+
segments: segs, regularTurns: turns.turns, acceptedGroups: proposals.accepted,
|
|
239
|
+
voiceEvidence: [...proposals.accepted.flatMap(group => group.voices), ...proposals.rejected],
|
|
240
|
+
g2: source.g2, alignment: { status: source.status } }),
|
|
241
|
+
pairSessionId: source.sessionId, pairSourceSha256: source.sourceSha256, discoveryReason: source.reason }
|
|
242
|
+
} catch {
|
|
243
|
+
paired = { ...evaluatePairedSpeakerShadow({ macStartTime: sidecar.startTime as number, totalSamples: timeline.total,
|
|
244
|
+
segments: segs, regularTurns: turns.turns, acceptedGroups: proposals.accepted, voiceEvidence: [],
|
|
245
|
+
alignment: { status: 'unpaired' } }), discoveryReason: 'pair discovery or scoring unavailable' }
|
|
246
|
+
}
|
|
216
247
|
} catch (error) { return fail('bad_cli_output', error instanceof Error ? error.message : String(error)) }
|
|
217
248
|
|
|
218
249
|
const humanProtectedVoices = shadowHumanProtectedVoices(entries)
|
|
@@ -233,8 +264,12 @@ export async function runShadowJob(job: ShadowJob, deps: ShadowWorkerDeps = {}):
|
|
|
233
264
|
}
|
|
234
265
|
const title = await readShadowMeetingTitle(job.sidecarPath)
|
|
235
266
|
const chunkText = new Map(entries.map(e => [e.chunkIndex, typeof e.chunk.text === 'string' ? e.chunk.text : '']))
|
|
236
|
-
|
|
237
|
-
|
|
267
|
+
let markdown = renderShadowReport({ title, sessionId: job.sessionId, proposals, facts, audioDir: job.audioDir, chunkText })
|
|
268
|
+
if (paired) markdown += `\n${renderPairedSpeakerShadow(paired)}\nDiscovery: ${paired.discoveryReason}\n`
|
|
269
|
+
let callProducerExport:MacSpeakerCallNativeRef|undefined
|
|
270
|
+
let callProducerFailure:Extract<ShadowJobOutcome,{status:'proposed'}>['callProducerFailure']
|
|
271
|
+
if(producerContext){try{callProducerExport=retainMacSpeakerCallNative(producerContext,{nativePath,embeddingPath:embPath,exitCode:run.code,closed:run.closed,networkDenied:true})}catch(error){callProducerFailure={stage:'retain',reason:(error instanceof Error?error.message:'native export retention failed').slice(0,200)}}}
|
|
272
|
+
return { status: 'proposed', proposals, paired, facts, report: { title, markdown },...(callProducerExport?{callProducerExport}:{}),...(callProducerFailure?{callProducerFailure}:{}) }
|
|
238
273
|
} catch (error) {
|
|
239
274
|
if (error instanceof ShadowInputError) return skip(error.reason, error.message)
|
|
240
275
|
return fail('internal', error instanceof Error ? error.message : String(error))
|