@gotcos/glasses-server 6.21.26 → 6.21.28
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 +50 -0
- package/package.json +1 -1
- package/server/lib/meeting-scribe-content.ts +192 -0
- package/server/lib/meeting-speaker-review.ts +212 -0
- package/server/routes/meeting.ts +107 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,53 @@
|
|
|
1
|
+
## 6.21.28
|
|
2
|
+
|
|
3
|
+
- `GET /meeting/:sessionId/content` — the readable meeting plus two ready-made
|
|
4
|
+
clipboard forms. Resolution is operations-first, identical to `/speakers`, so
|
|
5
|
+
the list row and this view can never describe the same meeting differently.
|
|
6
|
+
|
|
7
|
+
The attendee block is REBUILT from the speaker review, not taken from the
|
|
8
|
+
scribe's own `## Attendees`, which applies no confidence floor: the 2026-08-06
|
|
9
|
+
IJO scribe lists 15 attendees for a 26-minute call including a name already
|
|
10
|
+
confirmed absent. Copying that verbatim into Slack, an email or an LLM prompt
|
|
11
|
+
launders a guess into a fact. Only asserted voices are named; the rest collapse
|
|
12
|
+
into one line with their combined talk time.
|
|
13
|
+
|
|
14
|
+
Two forms because they serve different jobs — measured on a real 26-minute
|
|
15
|
+
meeting, 3.6 KB summary for pasting into a message versus 28 KB with the
|
|
16
|
+
transcript for pasting into a model. Formatting lives server-side so it is
|
|
17
|
+
mutation-testable; Swift has no execution-test harness here.
|
|
18
|
+
|
|
19
|
+
- `meetingDate()` — `startTime` in the sidecar is epoch MILLISECONDS, not ISO.
|
|
20
|
+
Slicing the stringified number produced "1786123940", which renders as a
|
|
21
|
+
plausible-looking date field containing a timestamp. Caught only by running the
|
|
22
|
+
route against a real meeting; the unit tests would have stayed green.
|
|
23
|
+
|
|
24
|
+
## 6.21.27
|
|
25
|
+
|
|
26
|
+
- Per-speaker speaking time on the review. `speakingMs` per voice, plus
|
|
27
|
+
`voicedMs`, `attributedSpeakingMs`, `unattributedSpeakingMs` and
|
|
28
|
+
`notCapturedMs` on the meeting.
|
|
29
|
+
|
|
30
|
+
It reads the word timings the HQ batch pass ALREADY writes
|
|
31
|
+
(`batchSegments[].speakerWords`, present on 82 of 92 measured sidecars), so it
|
|
32
|
+
is real voiced time with silence excluded — no audio, no VAD, no embedding, and
|
|
33
|
+
it works past the 7-day audio retention. Without them it falls back to chunk
|
|
34
|
+
deltas capped at the capture ceiling, and `speakingTimeSource` says which ran.
|
|
35
|
+
|
|
36
|
+
Three things the arithmetic had to get right, each caught against real data:
|
|
37
|
+
word intervals OVERLAP, so a naive sum totals 1.2x-1.5x the meeting's own
|
|
38
|
+
duration — union counts overlap once and lands at 0.75x-0.97x. Speakers also
|
|
39
|
+
overlap EACH OTHER, so per-speaker figures legitimately exceed wall clock
|
|
40
|
+
(a real 5.2-minute capture summed to 6.0); the invariant is therefore
|
|
41
|
+
`voicedMs + notCapturedMs = durationMs`, never attributed + unattributed.
|
|
42
|
+
And the attributed/unattributed split is by `nameAsserted` PER VOICE, never
|
|
43
|
+
per segment: a per-segment floor cannot carry the owner or human-confirmed
|
|
44
|
+
waivers and contradicts the rows above it (measured on 2026-08-02 "G2 App
|
|
45
|
+
Fixes": the panel names MU for 47.3% of the meeting, a per-segment floor 14.9%).
|
|
46
|
+
|
|
47
|
+
- Uncapped chunk deltas credit dead air to whoever spoke last. On 2026-08-04
|
|
48
|
+
"Design Gaps" that turns 8.1 minutes of speech into 50.2, with a 36.6s MEDIAN
|
|
49
|
+
gap that no outlier rule would catch. Capped at the measured ceiling.
|
|
50
|
+
|
|
1
51
|
## 6.21.26
|
|
2
52
|
|
|
3
53
|
- `assertedSegments` added to the speaker review: how many segments belong to
|
package/package.json
CHANGED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// The readable meeting: what the scribe markdown says, and two clipboard forms.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS IS NOT JUST "SEND THE FILE". The scribe's `## Attendees` list applies
|
|
4
|
+
// NO confidence floor — it is the raw label set from capture. Measured on the
|
|
5
|
+
// 2026-08-06 IJO Post-Mortem: 15 attendees listed for a 26-minute call, one of
|
|
6
|
+
// them a name Miles had already confirmed was never in the room. Copying that
|
|
7
|
+
// verbatim into Slack, an email, or an LLM prompt launders a guess into a fact,
|
|
8
|
+
// which is the exact defect the display floor exists to prevent one layer up.
|
|
9
|
+
//
|
|
10
|
+
// So the attendee list served here is rebuilt from the speaker review: only
|
|
11
|
+
// voices the review ASSERTS get named, and everything else is reported as
|
|
12
|
+
// unidentified with its share of the talking.
|
|
13
|
+
//
|
|
14
|
+
// Formatting lives here rather than in Swift on purpose. Swift has no execution
|
|
15
|
+
// test harness in this project, and these strings are the product — they need to
|
|
16
|
+
// be mutation-testable.
|
|
17
|
+
|
|
18
|
+
/** One `## Heading` section of a scribe file, in document order. */
|
|
19
|
+
export interface ScribeSection {
|
|
20
|
+
heading: string
|
|
21
|
+
/** Body text with the heading line removed and edges trimmed. */
|
|
22
|
+
body: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ParsedScribe {
|
|
26
|
+
/** The `# Title` line, or '' when the file has none. */
|
|
27
|
+
title: string
|
|
28
|
+
sections: ScribeSection[]
|
|
29
|
+
/** Convenience lookups for the sections a UI renders directly. */
|
|
30
|
+
summary: string
|
|
31
|
+
topics: string
|
|
32
|
+
decisions: string
|
|
33
|
+
actions: string
|
|
34
|
+
transcript: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SECTION = /^##\s+(.+?)\s*$/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Split a scribe file into its `##` sections.
|
|
41
|
+
*
|
|
42
|
+
* Deliberately tolerant: a scribe with no headings returns everything as the
|
|
43
|
+
* summary rather than an empty object, because a partially-written file is more
|
|
44
|
+
* useful to a reviewer than nothing. `###` subheadings (the Action Items split
|
|
45
|
+
* into High Confidence / Needs Review) stay INSIDE their parent section — they
|
|
46
|
+
* are part of that section's body, not sections of their own.
|
|
47
|
+
*/
|
|
48
|
+
export function parseScribe(markdown: string): ParsedScribe {
|
|
49
|
+
const lines = markdown.split('\n')
|
|
50
|
+
let title = ''
|
|
51
|
+
const sections: ScribeSection[] = []
|
|
52
|
+
let current: ScribeSection | null = null
|
|
53
|
+
const preamble: string[] = []
|
|
54
|
+
|
|
55
|
+
for (const line of lines) {
|
|
56
|
+
if (!title && /^#\s+/.test(line)) { title = line.replace(/^#\s+/, '').trim(); continue }
|
|
57
|
+
const m = SECTION.exec(line)
|
|
58
|
+
if (m) {
|
|
59
|
+
if (current) sections.push({ ...current, body: current.body.trim() })
|
|
60
|
+
current = { heading: m[1], body: '' }
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
if (current) current.body += line + '\n'
|
|
64
|
+
else preamble.push(line)
|
|
65
|
+
}
|
|
66
|
+
if (current) sections.push({ ...current, body: current.body.trim() })
|
|
67
|
+
|
|
68
|
+
const find = (...names: string[]): string => {
|
|
69
|
+
for (const n of names) {
|
|
70
|
+
const s = sections.find(x => x.heading.toLowerCase() === n.toLowerCase())
|
|
71
|
+
if (s) return s.body
|
|
72
|
+
}
|
|
73
|
+
return ''
|
|
74
|
+
}
|
|
75
|
+
const summary = find('Summary')
|
|
76
|
+
return {
|
|
77
|
+
title,
|
|
78
|
+
sections,
|
|
79
|
+
// No headings at all: treat the whole file as the summary rather than
|
|
80
|
+
// returning a shell that renders as an empty meeting.
|
|
81
|
+
summary: summary || (sections.length === 0 ? markdown.trim() : ''),
|
|
82
|
+
topics: find('Topics Discussed', 'Topics'),
|
|
83
|
+
decisions: find('Decisions Made', 'Decisions'),
|
|
84
|
+
actions: find('Action Items', 'Actions'),
|
|
85
|
+
transcript: find('Transcript'),
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The meeting's calendar date, as `YYYY-MM-DD`.
|
|
91
|
+
*
|
|
92
|
+
* `startTime` in the sidecar is epoch MILLISECONDS (measured: 1786123940914),
|
|
93
|
+
* not an ISO string. Slicing the stringified number gives "1786123940", which
|
|
94
|
+
* renders in the clipboard as a plausible-looking date field containing a
|
|
95
|
+
* timestamp. LOCAL date on purpose — the scribe filename uses the local day, so
|
|
96
|
+
* a UTC conversion would disagree with the file for anything late in the evening.
|
|
97
|
+
*/
|
|
98
|
+
export function meetingDate(startTime: unknown): string {
|
|
99
|
+
let ms: number | null = null
|
|
100
|
+
if (typeof startTime === 'number' && Number.isFinite(startTime)) {
|
|
101
|
+
// Tolerate seconds as well as milliseconds: a 10-digit value is seconds.
|
|
102
|
+
ms = startTime < 1e12 ? startTime * 1000 : startTime
|
|
103
|
+
} else if (typeof startTime === 'string' && startTime.trim()) {
|
|
104
|
+
const t = Date.parse(startTime)
|
|
105
|
+
if (Number.isFinite(t)) ms = t
|
|
106
|
+
}
|
|
107
|
+
if (ms === null) return ''
|
|
108
|
+
const d = new Date(ms)
|
|
109
|
+
if (Number.isNaN(d.getTime())) return ''
|
|
110
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
111
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A voice as the clipboard should describe it. */
|
|
115
|
+
export interface AttendeeLine {
|
|
116
|
+
label: string
|
|
117
|
+
/** False when the review refuses to assert this name. */
|
|
118
|
+
asserted: boolean
|
|
119
|
+
speakingMs: number
|
|
120
|
+
/** Share of NAMED speaking time, 0..1, or null when unknown/unnamed. */
|
|
121
|
+
share: number | null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function mmss(ms: number): string {
|
|
125
|
+
const total = Math.round(ms / 1000)
|
|
126
|
+
const m = Math.floor(total / 60)
|
|
127
|
+
const s = total % 60
|
|
128
|
+
return m > 0 ? `${m}m ${s}s` : `${s}s`
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The attendee block, floor-applied.
|
|
133
|
+
*
|
|
134
|
+
* Asserted voices are named with their talk time. Everything else is collapsed
|
|
135
|
+
* into a single honest line rather than listed as people: a large unmatched
|
|
136
|
+
* cluster is frequently several different speakers, and naming them
|
|
137
|
+
* individually would be the 15-attendee defect again in a new place.
|
|
138
|
+
*/
|
|
139
|
+
export function renderAttendees(voices: AttendeeLine[]): string {
|
|
140
|
+
const named = voices.filter(v => v.asserted).sort((a, b) => b.speakingMs - a.speakingMs)
|
|
141
|
+
const rest = voices.filter(v => !v.asserted)
|
|
142
|
+
const out: string[] = []
|
|
143
|
+
for (const v of named) {
|
|
144
|
+
const pct = v.share === null ? '' : ` · ${Math.round(v.share * 100)}% of named speech`
|
|
145
|
+
out.push(`- ${v.label} — ${mmss(v.speakingMs)}${pct}`)
|
|
146
|
+
}
|
|
147
|
+
const restMs = rest.reduce((n, v) => n + v.speakingMs, 0)
|
|
148
|
+
if (rest.length > 0) {
|
|
149
|
+
out.push(`- Unidentified — ${mmss(restMs)} across ${rest.length} ` +
|
|
150
|
+
`voice${rest.length === 1 ? '' : 's'} the review could not name`)
|
|
151
|
+
}
|
|
152
|
+
return out.length > 0 ? out.join('\n') : '- (no voices identified)'
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface ClipboardInput {
|
|
156
|
+
title: string
|
|
157
|
+
date: string
|
|
158
|
+
durationMin: number
|
|
159
|
+
attendees: AttendeeLine[]
|
|
160
|
+
scribe: ParsedScribe
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Compact form: for pasting into Slack, email, or a note. No transcript. */
|
|
164
|
+
export function clipboardSummary(i: ClipboardInput): string {
|
|
165
|
+
const parts = [
|
|
166
|
+
`# ${i.title || 'Untitled meeting'}`,
|
|
167
|
+
`${i.date} · ${i.durationMin} minutes`,
|
|
168
|
+
'',
|
|
169
|
+
'## Who spoke',
|
|
170
|
+
renderAttendees(i.attendees),
|
|
171
|
+
]
|
|
172
|
+
for (const [heading, body] of [
|
|
173
|
+
['Summary', i.scribe.summary],
|
|
174
|
+
['Topics', i.scribe.topics],
|
|
175
|
+
['Decisions', i.scribe.decisions],
|
|
176
|
+
['Action items', i.scribe.actions],
|
|
177
|
+
] as const) {
|
|
178
|
+
if (body.trim()) parts.push('', `## ${heading}`, body.trim())
|
|
179
|
+
}
|
|
180
|
+
return parts.join('\n') + '\n'
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Full form: everything including the transcript, for pasting into an LLM. */
|
|
184
|
+
export function clipboardFull(i: ClipboardInput): string {
|
|
185
|
+
const head = clipboardSummary(i).trimEnd()
|
|
186
|
+
if (!i.scribe.transcript.trim()) {
|
|
187
|
+
// Say so rather than silently returning the summary — otherwise "Copy full"
|
|
188
|
+
// and "Copy summary" produce identical text with no explanation.
|
|
189
|
+
return head + '\n\n## Transcript\n(no transcript in this scribe)\n'
|
|
190
|
+
}
|
|
191
|
+
return head + '\n\n## Transcript\n' + i.scribe.transcript.trim() + '\n'
|
|
192
|
+
}
|
|
@@ -98,6 +98,17 @@ export const ASSERT_MIN_SEGMENTS = 3
|
|
|
98
98
|
|
|
99
99
|
export type Reliability = 'confident' | 'weak' | 'unreliable' | 'unattributed'
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Which measurement produced `speakingMs`.
|
|
103
|
+
*
|
|
104
|
+
* `words` is real voiced time from the HQ batch pass's word timings — silence is
|
|
105
|
+
* excluded by construction. `chunks` is wall-clock deltas with each chunk capped
|
|
106
|
+
* at the capture ceiling, which is coarser and still contains the sub-ceiling
|
|
107
|
+
* pauses. They are not comparable across meetings, so any UI showing a trend
|
|
108
|
+
* must not mix them silently.
|
|
109
|
+
*/
|
|
110
|
+
export type SpeakingTimeSource = 'words' | 'chunks'
|
|
111
|
+
|
|
101
112
|
export interface ThrashPair {
|
|
102
113
|
speaker: string
|
|
103
114
|
flipRate: number
|
|
@@ -122,6 +133,9 @@ export interface Phrase {
|
|
|
122
133
|
export interface VoiceReview {
|
|
123
134
|
label: string
|
|
124
135
|
segments: number
|
|
136
|
+
/** Voiced milliseconds credited to this voice. See `SpeakingTimeSource` for
|
|
137
|
+
* how it was measured — the two methods are not comparable. */
|
|
138
|
+
speakingMs: number
|
|
125
139
|
meanSimilarity: number | null
|
|
126
140
|
/** Mean consecutive-run length across the WHOLE meeting. Not comparable to
|
|
127
141
|
* the pair-scoped run in `thrashesWith`: in a 13-voice meeting every
|
|
@@ -244,6 +258,29 @@ export interface MeetingSpeakerReview {
|
|
|
244
258
|
* unrelated word-overlap measure and the two must not read as siblings.
|
|
245
259
|
*/
|
|
246
260
|
assertedSegments: number
|
|
261
|
+
/** How `speakingMs` was measured. */
|
|
262
|
+
speakingTimeSource: SpeakingTimeSource
|
|
263
|
+
/** Voiced ms belonging to voices shown WITH A NAME. The headline number. */
|
|
264
|
+
attributedSpeakingMs: number
|
|
265
|
+
/** Voiced ms belonging to voices the panel refuses to name. */
|
|
266
|
+
unattributedSpeakingMs: number
|
|
267
|
+
/**
|
|
268
|
+
* Voiced ms in the meeting, crosstalk counted ONCE.
|
|
269
|
+
*
|
|
270
|
+
* `attributed + unattributed` does NOT equal this, and must not be presented
|
|
271
|
+
* as though it does: when two people talk over each other both are credited,
|
|
272
|
+
* so per-speaker figures legitimately exceed wall clock. Measured on a real
|
|
273
|
+
* 5.2-minute capture the per-speaker times summed to 6.0 minutes.
|
|
274
|
+
*/
|
|
275
|
+
voicedMs: number
|
|
276
|
+
/**
|
|
277
|
+
* Wall clock that produced no voice at all.
|
|
278
|
+
*
|
|
279
|
+
* Reported rather than distributed, because it is frequently large and is not
|
|
280
|
+
* all silence: a measured 2026-08-06 capture lost 93% of its chunks in
|
|
281
|
+
* transfer. THE invariant is `voicedMs + notCapturedMs = durationMs`.
|
|
282
|
+
*/
|
|
283
|
+
notCapturedMs: number
|
|
247
284
|
durationMs: number
|
|
248
285
|
voices: VoiceReview[]
|
|
249
286
|
/** Chronological spans, so a ribbon can be a timeline instead of a share bar. */
|
|
@@ -401,6 +438,135 @@ export function selectPhrases(
|
|
|
401
438
|
.map(({ text, atMs, similarity, chunkIndex }) => ({ text, atMs, similarity, chunkIndex }))
|
|
402
439
|
}
|
|
403
440
|
|
|
441
|
+
/**
|
|
442
|
+
* The capture ceiling. A chunk is VAD-flushed with a 2.5s floor and a hard
|
|
443
|
+
* ceiling here, so no single chunk can carry more voice than this no matter how
|
|
444
|
+
* long the wall-clock gap before it was.
|
|
445
|
+
*
|
|
446
|
+
* Measured on 1,000 retained chunk WAVs: median 6,050ms, max 7,100ms (the
|
|
447
|
+
* ceiling overshoots because RMS is sampled every ~200ms), and ZERO chunks under
|
|
448
|
+
* 2s. The ceiling is the normal flush, not an edge case.
|
|
449
|
+
*/
|
|
450
|
+
export const CHUNK_CEILING_MS = 7_100
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Voiced milliseconds credited to each chunk, index-aligned to `chunks`.
|
|
454
|
+
*
|
|
455
|
+
* WHY A CAP. `elapsed` is a wall-clock offset, so the delta between consecutive
|
|
456
|
+
* chunks is that chunk's audio PLUS all the dead air before it. Uncapped, that
|
|
457
|
+
* dead air gets credited to whoever happened to speak last: on the 2026-08-04
|
|
458
|
+
* "Design Gaps" meeting the deltas sum to 50.2 minutes against 8.1 minutes of
|
|
459
|
+
* actual speech — a 6.2x inflation, with a 36.6s MEDIAN gap that would sail
|
|
460
|
+
* under any outlier rule. One chunk elsewhere in the corpus credits 77.5
|
|
461
|
+
* continuous minutes to a single speaker.
|
|
462
|
+
*
|
|
463
|
+
* Capping at the ceiling makes each chunk contribute at most the audio it could
|
|
464
|
+
* physically hold. Everything the cap removes is real elapsed time that was
|
|
465
|
+
* never captured, and it is reported as `notCapturedMs` rather than distributed.
|
|
466
|
+
*/
|
|
467
|
+
export function creditedChunkMs(chunks: ReviewChunk[]): number[] {
|
|
468
|
+
let prev = 0
|
|
469
|
+
return chunks.map(c => {
|
|
470
|
+
const at = typeof c.elapsed === 'number' && Number.isFinite(c.elapsed) ? c.elapsed : prev
|
|
471
|
+
// Monotonic: a sidecar with a backwards or missing elapsed contributes 0
|
|
472
|
+
// rather than a negative that would silently subtract from someone's total.
|
|
473
|
+
const delta = Math.max(0, at - prev)
|
|
474
|
+
prev = Math.max(prev, at)
|
|
475
|
+
return Math.min(delta, CHUNK_CEILING_MS)
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** One batch segment's word-level speaker timing, as the sidecar stores it. */
|
|
480
|
+
export interface SpeakerWordSegment {
|
|
481
|
+
/** Absolute ms offset of this segment; word times are RELATIVE to it. */
|
|
482
|
+
startElapsed?: number
|
|
483
|
+
speakerWords?: Array<{ start?: number; end?: number; speaker?: string }>
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Total length of a set of intervals, counting overlap ONCE. */
|
|
487
|
+
export function unionMs(intervals: Array<[number, number]>): number {
|
|
488
|
+
if (intervals.length === 0) return 0
|
|
489
|
+
const sorted = [...intervals].sort((a, b) => a[0] - b[0])
|
|
490
|
+
let total = 0
|
|
491
|
+
let [start, end] = sorted[0]
|
|
492
|
+
for (const [a, b] of sorted.slice(1)) {
|
|
493
|
+
if (a > end) { total += end - start; start = a; end = b }
|
|
494
|
+
else if (b > end) { end = b }
|
|
495
|
+
}
|
|
496
|
+
return total + (end - start)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Voiced milliseconds per speaker, from the word timings the HQ batch pass
|
|
501
|
+
* already wrote into the sidecar.
|
|
502
|
+
*
|
|
503
|
+
* WHY UNION AND NOT A SUM. Word intervals overlap — both within a segment and
|
|
504
|
+
* across the overlapping batch windows. Measured over six real meetings, naively
|
|
505
|
+
* summing word durations totals 1.20x to 1.50x the meeting's own duration, which
|
|
506
|
+
* is impossible for voiced time. Union counts overlap once and lands at 0.75x to
|
|
507
|
+
* 0.97x, always under wall clock, which is the shape the answer must have.
|
|
508
|
+
*
|
|
509
|
+
* This is real voiced time: silence between words is excluded by construction,
|
|
510
|
+
* so it needs no ceiling, no gap heuristic, and no retained audio.
|
|
511
|
+
*/
|
|
512
|
+
export function speakerWordIntervals(
|
|
513
|
+
segments: SpeakerWordSegment[],
|
|
514
|
+
): Map<string, Array<[number, number]>> {
|
|
515
|
+
const byLabel = new Map<string, Array<[number, number]>>()
|
|
516
|
+
for (const seg of segments) {
|
|
517
|
+
const offset = typeof seg.startElapsed === 'number' && Number.isFinite(seg.startElapsed)
|
|
518
|
+
? seg.startElapsed
|
|
519
|
+
: 0
|
|
520
|
+
for (const w of seg.speakerWords ?? []) {
|
|
521
|
+
const a = w.start
|
|
522
|
+
const b = w.end
|
|
523
|
+
if (typeof a !== 'number' || typeof b !== 'number') continue
|
|
524
|
+
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) continue
|
|
525
|
+
const label = w.speaker ?? ''
|
|
526
|
+
if (!byLabel.has(label)) byLabel.set(label, [])
|
|
527
|
+
byLabel.get(label)!.push([offset + a * 1000, offset + b * 1000])
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
return byLabel
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Voiced ms per speaker, overlap within a speaker counted once. */
|
|
534
|
+
export function speakerWordMs(segments: SpeakerWordSegment[]): Map<string, number> {
|
|
535
|
+
const out = new Map<string, number>()
|
|
536
|
+
for (const [label, ivs] of speakerWordIntervals(segments)) out.set(label, Math.round(unionMs(ivs)))
|
|
537
|
+
return out
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* The meeting-level decomposition.
|
|
542
|
+
*
|
|
543
|
+
* `voicedMs + notCapturedMs = durationMs` is the invariant that HOLDS.
|
|
544
|
+
* attributed + unattributed does NOT sum to voiced, because a named and an
|
|
545
|
+
* unnamed speaker can talk over each other and both are credited.
|
|
546
|
+
*/
|
|
547
|
+
function speakingBuckets(
|
|
548
|
+
voices: VoiceReview[],
|
|
549
|
+
intervalsFor: (label: string) => Array<[number, number]>,
|
|
550
|
+
durationMs: number,
|
|
551
|
+
): {
|
|
552
|
+
attributedSpeakingMs: number
|
|
553
|
+
unattributedSpeakingMs: number
|
|
554
|
+
voicedMs: number
|
|
555
|
+
notCapturedMs: number
|
|
556
|
+
} {
|
|
557
|
+
const gather = (pick: (v: VoiceReview) => boolean) =>
|
|
558
|
+
Math.round(unionMs(voices.filter(pick).flatMap(v => intervalsFor(v.label))))
|
|
559
|
+
const voiced = gather(() => true)
|
|
560
|
+
return {
|
|
561
|
+
attributedSpeakingMs: gather(v => v.nameAsserted),
|
|
562
|
+
unattributedSpeakingMs: gather(v => !v.nameAsserted),
|
|
563
|
+
voicedMs: voiced,
|
|
564
|
+
// Wall clock that produced no voice at all: silence, dropped chunks, and
|
|
565
|
+
// time the capture never saw. Reported, never distributed to a speaker.
|
|
566
|
+
notCapturedMs: Math.max(0, durationMs - voiced),
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
404
570
|
/** Build the whole review for one meeting's chunks. */
|
|
405
571
|
export function reviewMeetingSpeakers(
|
|
406
572
|
chunks: ReviewChunk[],
|
|
@@ -415,6 +581,12 @@ export function reviewMeetingSpeakers(
|
|
|
415
581
|
* the sidecar already carries it.
|
|
416
582
|
*/
|
|
417
583
|
confirmed?: Set<string>
|
|
584
|
+
/**
|
|
585
|
+
* The sidecar's `batchSegments`, when the HQ pass has run. Their word
|
|
586
|
+
* timings give real voiced time; without them speaking time falls back to
|
|
587
|
+
* capped chunk deltas.
|
|
588
|
+
*/
|
|
589
|
+
batchSegments?: SpeakerWordSegment[]
|
|
418
590
|
} = {},
|
|
419
591
|
): MeetingSpeakerReview {
|
|
420
592
|
const owner = options.owner ?? 'Me'
|
|
@@ -433,6 +605,33 @@ export function reviewMeetingSpeakers(
|
|
|
433
605
|
const labels = [...new Set(sequence)].filter(s => s.length > 0)
|
|
434
606
|
const named = labels.filter(l => !isUnattributed(l))
|
|
435
607
|
|
|
608
|
+
// Word timings when the HQ batch pass produced them (82 of 92 sidecars
|
|
609
|
+
// measured), capped chunk deltas otherwise. The two are NOT interchangeable —
|
|
610
|
+
// words are voiced time, deltas are wall clock with the silence capped off —
|
|
611
|
+
// so the answer carries which one produced it.
|
|
612
|
+
const wordIntervals = speakerWordIntervals(options.batchSegments ?? [])
|
|
613
|
+
const speakingTimeSource: SpeakingTimeSource = wordIntervals.size > 0 ? 'words' : 'chunks'
|
|
614
|
+
|
|
615
|
+
// The fallback expresses chunks as intervals too, so both paths decompose
|
|
616
|
+
// through the same union arithmetic. `elapsed` is the chunk END, so a chunk
|
|
617
|
+
// covers [end - credited, end] — non-overlapping by construction, because
|
|
618
|
+
// `credited` is already the capped gap to the previous chunk.
|
|
619
|
+
const chunkIntervals = new Map<string, Array<[number, number]>>()
|
|
620
|
+
if (speakingTimeSource === 'chunks') {
|
|
621
|
+
const credited = creditedChunkMs(chunks)
|
|
622
|
+
chunks.forEach((c, i) => {
|
|
623
|
+
if (credited[i] <= 0) return
|
|
624
|
+
const end = typeof c.elapsed === 'number' && Number.isFinite(c.elapsed) ? c.elapsed : 0
|
|
625
|
+
const label = c.speaker ?? ''
|
|
626
|
+
if (!chunkIntervals.has(label)) chunkIntervals.set(label, [])
|
|
627
|
+
chunkIntervals.get(label)!.push([end - credited[i], end])
|
|
628
|
+
})
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
const intervalsFor = (label: string): Array<[number, number]> =>
|
|
632
|
+
(speakingTimeSource === 'words' ? wordIntervals : chunkIntervals).get(label) ?? []
|
|
633
|
+
const speakingFor = (label: string): number => Math.round(unionMs(intervalsFor(label)))
|
|
634
|
+
|
|
436
635
|
const voices: VoiceReview[] = labels.map(label => {
|
|
437
636
|
const own = chunks.filter(c => (c.speaker ?? '') === label)
|
|
438
637
|
const sims = own.map(c => c.similarity).filter((s): s is number => typeof s === 'number' && s > 0)
|
|
@@ -499,6 +698,7 @@ export function reviewMeetingSpeakers(
|
|
|
499
698
|
return {
|
|
500
699
|
label,
|
|
501
700
|
segments: own.length,
|
|
701
|
+
speakingMs: speakingFor(label),
|
|
502
702
|
meanSimilarity: meanSim,
|
|
503
703
|
meanRun: Math.round(mean(runs) * 100) / 100,
|
|
504
704
|
longestRun: runs.length ? Math.max(...runs) : 0,
|
|
@@ -525,6 +725,18 @@ export function reviewMeetingSpeakers(
|
|
|
525
725
|
// list of rows reading "Unidentified voice", which is the confusion this
|
|
526
726
|
// number exists to remove.
|
|
527
727
|
assertedSegments: voices.reduce((n, v) => (v.nameAsserted ? n + v.segments : n), 0),
|
|
728
|
+
speakingTimeSource,
|
|
729
|
+
// UNION, not sum. Speakers overlap — crosstalk means two people are each
|
|
730
|
+
// correctly credited for the same wall-clock second, so per-speaker times
|
|
731
|
+
// legitimately add up to MORE than the meeting. Verified against a real
|
|
732
|
+
// 5.2-minute capture where the per-speaker figures summed to 6.0 minutes.
|
|
733
|
+
// Summing here produced a bucket total larger than the meeting itself.
|
|
734
|
+
//
|
|
735
|
+
// Split by nameAsserted PER VOICE, never per segment: a per-segment floor
|
|
736
|
+
// cannot express ASSERT_MIN_SEGMENTS nor carry the owner and confirmed
|
|
737
|
+
// waivers, so it contradicts the rows above it (measured on 2026-08-02 "G2
|
|
738
|
+
// App Fixes": panel names MU for 47.3%, per-segment floor 14.9%).
|
|
739
|
+
...speakingBuckets(voices, intervalsFor, durationMs),
|
|
528
740
|
durationMs,
|
|
529
741
|
voices,
|
|
530
742
|
timeline: speakerTimeline(chunks, durationMs),
|
package/server/routes/meeting.ts
CHANGED
|
@@ -118,8 +118,15 @@ import {
|
|
|
118
118
|
attachRawChunkIndices,
|
|
119
119
|
isUnattributed,
|
|
120
120
|
reviewMeetingSpeakers,
|
|
121
|
-
type
|
|
121
|
+
type SpeakerWordSegment,
|
|
122
122
|
} from '../lib/meeting-speaker-review.js'
|
|
123
|
+
import { type ReviewChunk } from '../lib/meeting-speaker-review.js'
|
|
124
|
+
import {
|
|
125
|
+
parseScribe,
|
|
126
|
+
clipboardSummary,
|
|
127
|
+
clipboardFull,
|
|
128
|
+
meetingDate,
|
|
129
|
+
} from '../lib/meeting-scribe-content.js'
|
|
123
130
|
import {
|
|
124
131
|
acquireMaintenanceWork,
|
|
125
132
|
maintenanceAdmissionsOpen,
|
|
@@ -735,6 +742,13 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
735
742
|
// timeline span end where it began — a 1.5pt sliver labelled "1s" for what
|
|
736
743
|
// may be a long closing monologue.
|
|
737
744
|
durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
|
|
745
|
+
// Word-level speaker timings from the HQ batch pass, present on 82 of 92
|
|
746
|
+
// measured sidecars. These give REAL voiced time per speaker; without them
|
|
747
|
+
// speaking time falls back to capped chunk deltas, which still carry the
|
|
748
|
+
// sub-ceiling pauses. `speakingTimeSource` on the response says which ran.
|
|
749
|
+
batchSegments: Array.isArray(sidecar.batchSegments)
|
|
750
|
+
? (sidecar.batchSegments as SpeakerWordSegment[])
|
|
751
|
+
: undefined,
|
|
738
752
|
})
|
|
739
753
|
res.set('Cache-Control', 'private, no-store')
|
|
740
754
|
res.json({
|
|
@@ -761,6 +775,98 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
761
775
|
// touched, and a failed intent write aborts without mutating anything. A
|
|
762
776
|
// process that dies mid-rewrite therefore leaves a visible pending correction
|
|
763
777
|
// rather than a silently half-relabelled meeting.
|
|
778
|
+
/**
|
|
779
|
+
* The readable meeting, plus two clipboard forms.
|
|
780
|
+
*
|
|
781
|
+
* Resolution is operations-first, identical to GET /speakers — the same session
|
|
782
|
+
* lives in both trees under different names and the list reads operations, so
|
|
783
|
+
* anything keyed on a session has to resolve there too or the row and this
|
|
784
|
+
* view disagree about the same meeting.
|
|
785
|
+
*
|
|
786
|
+
* The attendee list is REBUILT from the speaker review rather than taken from
|
|
787
|
+
* the scribe's own `## Attendees`, which applies no confidence floor: the
|
|
788
|
+
* 2026-08-06 IJO scribe lists 15 attendees for a 26-minute call including a
|
|
789
|
+
* name already confirmed absent. Copying that verbatim would launder a guess
|
|
790
|
+
* into a fact in whatever the reviewer pastes it into.
|
|
791
|
+
*/
|
|
792
|
+
router.get('/meeting/:sessionId/content', (req, res) => {
|
|
793
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
794
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
795
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
796
|
+
return
|
|
797
|
+
}
|
|
798
|
+
const operations = cosOperationsMeetingsConfigured()
|
|
799
|
+
? findCosOperationsMeetingBySessionId(sessionId)
|
|
800
|
+
: null
|
|
801
|
+
const saved = operations ? null : store.findBySessionId(sessionId)
|
|
802
|
+
if (!operations && !saved) {
|
|
803
|
+
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
804
|
+
return
|
|
805
|
+
}
|
|
806
|
+
const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
|
|
807
|
+
const title = operations?.title ?? saved!.title
|
|
808
|
+
const mdPath = operations?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
|
|
809
|
+
|
|
810
|
+
let sidecar: Record<string, unknown>
|
|
811
|
+
try {
|
|
812
|
+
sidecar = (JSON.parse(readFileSync(sidecarPath, 'utf-8')) ?? {}) as Record<string, unknown>
|
|
813
|
+
} catch {
|
|
814
|
+
res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
const rawChunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
|
|
818
|
+
const chunks = (Array.isArray(rawChunks) ? rawChunks : []) as ReviewChunk[]
|
|
819
|
+
|
|
820
|
+
const review = reviewMeetingSpeakers(attachRawChunkIndices(chunks, sidecar.chunkEntries), {
|
|
821
|
+
owner: getOwnerSpeakerLabel(),
|
|
822
|
+
confirmed: confirmedLabels(sessionId),
|
|
823
|
+
durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
|
|
824
|
+
batchSegments: Array.isArray(sidecar.batchSegments)
|
|
825
|
+
? (sidecar.batchSegments as SpeakerWordSegment[])
|
|
826
|
+
: undefined,
|
|
827
|
+
})
|
|
828
|
+
|
|
829
|
+
// Share is over NAMED speech and totals 100%, matching the panel. The union
|
|
830
|
+
// in attributedSpeakingMs counts crosstalk once, so dividing by it would let
|
|
831
|
+
// the shares exceed 100%.
|
|
832
|
+
const namedTotal = review.voices.reduce((n, v) => (v.nameAsserted ? n + v.speakingMs : n), 0)
|
|
833
|
+
const attendees = review.voices.map(v => ({
|
|
834
|
+
label: v.label,
|
|
835
|
+
asserted: v.nameAsserted,
|
|
836
|
+
speakingMs: v.speakingMs,
|
|
837
|
+
share: v.nameAsserted && namedTotal > 0 ? v.speakingMs / namedTotal : null,
|
|
838
|
+
}))
|
|
839
|
+
|
|
840
|
+
// A missing .md is not fatal — the review and the sidecar still describe the
|
|
841
|
+
// meeting, and a reviewer would rather have who-spoke than a 404.
|
|
842
|
+
let markdown = ''
|
|
843
|
+
try { markdown = readFileSync(mdPath, 'utf-8') } catch { markdown = '' }
|
|
844
|
+
const scribe = parseScribe(markdown)
|
|
845
|
+
|
|
846
|
+
const date = meetingDate(sidecar.startTime)
|
|
847
|
+
const durationMin = Math.round((review.durationMs || 0) / 60_000)
|
|
848
|
+
const clip = { title: scribe.title || title, date, durationMin, attendees, scribe }
|
|
849
|
+
|
|
850
|
+
res.set('Cache-Control', 'private, no-store')
|
|
851
|
+
res.json({
|
|
852
|
+
sessionId,
|
|
853
|
+
title: scribe.title || title,
|
|
854
|
+
date,
|
|
855
|
+
durationMin,
|
|
856
|
+
scribeAvailable: markdown.length > 0,
|
|
857
|
+
attendees,
|
|
858
|
+
speakingTimeSource: review.speakingTimeSource,
|
|
859
|
+
voicedMs: review.voicedMs,
|
|
860
|
+
summary: scribe.summary,
|
|
861
|
+
topics: scribe.topics,
|
|
862
|
+
decisions: scribe.decisions,
|
|
863
|
+
actions: scribe.actions,
|
|
864
|
+
transcriptChars: scribe.transcript.length,
|
|
865
|
+
clipboardSummary: clipboardSummary(clip),
|
|
866
|
+
clipboardFull: clipboardFull(clip),
|
|
867
|
+
})
|
|
868
|
+
})
|
|
869
|
+
|
|
764
870
|
router.post('/meeting/:sessionId/relabel', (req, res) => {
|
|
765
871
|
res.set('Cache-Control', 'private, no-store')
|
|
766
872
|
const sessionId = String(req.params.sessionId ?? '')
|