@gotcos/glasses-server 6.21.27 → 6.21.29
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 +80 -0
- package/package.json +1 -1
- package/server/lib/meeting-scribe-content.ts +501 -0
- package/server/routes/meeting.ts +164 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,83 @@
|
|
|
1
|
+
## 6.21.29
|
|
2
|
+
|
|
3
|
+
Everything below was found by two rounds of adversarial review of 6.21.28 against
|
|
4
|
+
the real corpus. **Published 6.21.28 contains the route and none of these fixes**
|
|
5
|
+
— its `meeting-scribe-content.ts` is 192 lines with no `renderProvenance`, no
|
|
6
|
+
`cleanBody`, and no coverage floor (verified by extracting the tarball, not by
|
|
7
|
+
reading the changelog). If you are on 6.21.28, per-voice shares are printed
|
|
8
|
+
without the floor.
|
|
9
|
+
|
|
10
|
+
- **The compact form no longer ships a transcript.** `contains('transcript')`
|
|
11
|
+
kept only the longest match and the loser fell through to `extras`, which went
|
|
12
|
+
into BOTH forms — so the "no transcript" summary carried a second full
|
|
13
|
+
transcript on 112 of 2,090 real scribes, worst case 78,652 characters, printed
|
|
14
|
+
directly above its own "Transcript omitted" line. All transcript-shaped
|
|
15
|
+
sections are now consumed; extras beyond 2,000 characters are omitted from the
|
|
16
|
+
compact form only. The size ceiling matters because a heading cannot be
|
|
17
|
+
trusted: one real scribe hides a full transcript under `G2 Glasses Enrichment`.
|
|
18
|
+
|
|
19
|
+
- **"Voice matching confirmed" was false for two of the three ways a name is
|
|
20
|
+
asserted.** `nameAsserted` is one boolean over three different warrants — the
|
|
21
|
+
cosine floor, a human typing the name, and the wearer exemption (identity comes
|
|
22
|
+
from holding the device, not from a score). The route carried `isOwner` and
|
|
23
|
+
`confirmedByHuman` and discarded both. The output now names the warrant per
|
|
24
|
+
person: `"MU" by wearing the device; "Gina Obert" by voice match; "Luke Henry"
|
|
25
|
+
because a human named that voice`. Labels are quoted, so a typed "Smith, John"
|
|
26
|
+
cannot read as two people, and the caveat now covers different SPELLINGS of a
|
|
27
|
+
confirmed name (labels are `MU` while the prose says "Miles").
|
|
28
|
+
|
|
29
|
+
- **A lone named voice no longer gets a share.** "100% of identified speech" is
|
|
30
|
+
always true with one name and reads as "he did all the talking" — on 23 real
|
|
31
|
+
meetings, including one where 6m 29s was unidentified.
|
|
32
|
+
|
|
33
|
+
- **Rows that overlap say so.** Each per-voice figure is that voice's own union,
|
|
34
|
+
but two people talking over each other is counted once EACH, so the rows add to
|
|
35
|
+
more than the meeting on 34 of 323 real meetings (71m of rows inside 66
|
|
36
|
+
minutes). The figures are right; adding them is what misleads, so the block now
|
|
37
|
+
explains the overlap instead of shrinking anyone.
|
|
38
|
+
|
|
39
|
+
- **"(no transcript in this scribe)" was a lie on 140 of 399 sidecars.** Those
|
|
40
|
+
meetings have no write-up yet while the recording holds the speech — one case
|
|
41
|
+
today had 27,442 characters in the sidecar this route had just parsed. It now
|
|
42
|
+
distinguishes "not written up yet" from "nobody spoke".
|
|
43
|
+
|
|
44
|
+
- **The payload says which business it is.** 98 of 251 real meetings are
|
|
45
|
+
`personal` and 25 of 251 summaries carry compensation, termination, or legal
|
|
46
|
+
content, while the buttons are framed for Slack and email. `/speakers` has
|
|
47
|
+
always carried `domain`; this route dropped it. Personal meetings also get an
|
|
48
|
+
explicit line before the content.
|
|
49
|
+
|
|
50
|
+
- **A derived note is never relabelled as the transcript.** With no real
|
|
51
|
+
transcript, a `Transcript Enrichment (from raw recording)` note won by default
|
|
52
|
+
and was printed under `## Transcript` with its real heading destroyed. The
|
|
53
|
+
section's own heading is now used, and an `## Attendees (from transcript)`
|
|
54
|
+
variant can no longer win the slot at all — nor slip into `extras`, where it
|
|
55
|
+
printed the unfloored name list this module exists to replace.
|
|
56
|
+
|
|
57
|
+
- **The generator stamp is stripped by POSITION, not wording.** Measured across
|
|
58
|
+
1,227 real stamps, four generator names appear ("Meeting Intelligence System",
|
|
59
|
+
"COS Split Pipeline", "COS Meeting Intelligence", "Manual Granola Paste"), so a
|
|
60
|
+
name-anchored regex leaks some — I shipped one that leaked 7. Being the last
|
|
61
|
+
line is the invariant, which also means a mid-body italic line a human wrote
|
|
62
|
+
now survives. `<!-- g2-needs-domain-review -->` and `<details open>` are
|
|
63
|
+
stripped too; the internal marker reached 129 of 362 real clipboards.
|
|
64
|
+
|
|
65
|
+
- Also: `mmss` no longer renders `NaNm NaNs`; `meetingDate(1)` no longer returns
|
|
66
|
+
1969-12-31; a date-only ISO string no longer reports the previous day; each
|
|
67
|
+
clipboard form is rendered once instead of twice per request.
|
|
68
|
+
|
|
69
|
+
## 6.21.28
|
|
70
|
+
|
|
71
|
+
**Published. Contains the route only — see 6.21.29 for what it is missing.**
|
|
72
|
+
|
|
73
|
+
- `GET /meeting/:sessionId/content` — the readable meeting plus two ready-made
|
|
74
|
+
clipboard forms. Operations-first resolution, identical to `/speakers`, so the
|
|
75
|
+
list row and this view can never describe the same meeting differently.
|
|
76
|
+
|
|
77
|
+
- The attendee block is rebuilt from the review rather than reusing the scribe's
|
|
78
|
+
own `## Attendees`, which applies no confidence floor: 2026-08 alone carries
|
|
79
|
+
scribes listing 55, 21, 20, 19, 18, 18, 17 and 15 attendees.
|
|
80
|
+
|
|
1
81
|
## 6.21.27
|
|
2
82
|
|
|
3
83
|
- Per-speaker speaking time on the review. `speakingMs` per voice, plus
|
package/package.json
CHANGED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// The readable meeting, and the two forms of it that leave the machine.
|
|
2
|
+
//
|
|
3
|
+
// WHAT v1 GOT WRONG, because the shape of the fix follows from it.
|
|
4
|
+
//
|
|
5
|
+
// v1 floored the attendee block and shipped every other section verbatim. On the
|
|
6
|
+
// 2026-08-06 IJO Post-Mortem — where the review asserts exactly ONE voice — all
|
|
7
|
+
// 14 unasserted names still reached the clipboard, including one Miles had
|
|
8
|
+
// already confirmed was never in the room, as `[Name]:` transcript labels. The
|
|
9
|
+
// stated contract, "only asserted voices are named", was false for the largest
|
|
10
|
+
// output.
|
|
11
|
+
//
|
|
12
|
+
// The fix is NOT redaction. Two reasons:
|
|
13
|
+
// 1. Transcript labels are EVIDENCE, and they came from the same identifier
|
|
14
|
+
// whose verdict we would be applying — rewriting them is circular and
|
|
15
|
+
// destroys the raw record the reviewer needs to judge for themselves.
|
|
16
|
+
// 2. A name in the prose is frequently a person MENTIONED, not one who SPOKE.
|
|
17
|
+
// An action item naming Jessica Thompson does not claim she was in the room.
|
|
18
|
+
// Stripping it would replace one wrong claim with a different one.
|
|
19
|
+
//
|
|
20
|
+
// So the output carries an ALLOWLIST: who voice matching confirmed, plus a plain
|
|
21
|
+
// statement that every other name below is unverified capture output. Honest,
|
|
22
|
+
// lossless, and short — a blocklist ran to 14 names on that meeting and 55 on
|
|
23
|
+
// another.
|
|
24
|
+
//
|
|
25
|
+
// v1's second error: it printed "100% of named speech" on a meeting that was
|
|
26
|
+
// 10.5% identified, and 49% of real meetings sit under that floor. A share is a
|
|
27
|
+
// fraction of the IDENTIFIED speech, so at 40% coverage "53%" may be 21% of the
|
|
28
|
+
// room — precise-looking and wrong. Hence SHARE_COVERAGE_FLOOR.
|
|
29
|
+
//
|
|
30
|
+
// And a correction to v1's own comment, which claimed the Control panel already
|
|
31
|
+
// suppressed those shares: it did not. `Views.swift` drew a percentage for every
|
|
32
|
+
// asserted voice with no coverage condition at all — the only `0.6` comparison in
|
|
33
|
+
// the app swapped a caption's colour. Measured: the panel showed shares the
|
|
34
|
+
// clipboard refused on 170 of 355 real meetings, while this file asserted twice
|
|
35
|
+
// that the two "can never disagree". The gate is now IN the panel row. Never
|
|
36
|
+
// describe another surface's behaviour from a comment; read its code.
|
|
37
|
+
|
|
38
|
+
/** One `## Heading` section of a scribe file, in document order. */
|
|
39
|
+
export interface ScribeSection {
|
|
40
|
+
heading: string
|
|
41
|
+
/** Body with the heading line removed, scaffolding stripped, edges trimmed. */
|
|
42
|
+
body: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ParsedScribe {
|
|
46
|
+
/** The `# Title` line, or '' when the file has none. */
|
|
47
|
+
title: string
|
|
48
|
+
sections: ScribeSection[]
|
|
49
|
+
summary: string
|
|
50
|
+
topics: string
|
|
51
|
+
decisions: string
|
|
52
|
+
actions: string
|
|
53
|
+
transcript: string
|
|
54
|
+
/**
|
|
55
|
+
* The heading the transcript body actually came FROM.
|
|
56
|
+
*
|
|
57
|
+
* When a file has no real transcript, a `Transcript Enrichment (from raw
|
|
58
|
+
* recording)` note wins by default and v1 relabelled derived analysis as
|
|
59
|
+
* `## Transcript` while destroying its real heading — the reader could not
|
|
60
|
+
* tell. Carry the heading and print it.
|
|
61
|
+
*/
|
|
62
|
+
transcriptHeading: string
|
|
63
|
+
/**
|
|
64
|
+
* Further transcript-ish sections beyond the longest. Carried so nothing is
|
|
65
|
+
* lost, but they belong ONLY in the full form — 112 real scribes have two.
|
|
66
|
+
*/
|
|
67
|
+
otherTranscripts: ScribeSection[]
|
|
68
|
+
/**
|
|
69
|
+
* Sections that are none of the above, in document order.
|
|
70
|
+
*
|
|
71
|
+
* v1 discarded these, losing 116,820 characters across the corpus including
|
|
72
|
+
* `Granola Structured Notes (canonical)` — Miles's own write-up — and
|
|
73
|
+
* `Fathom Action Items (with exact timestamps)`. "Copy the meeting" has to
|
|
74
|
+
* copy the meeting.
|
|
75
|
+
*/
|
|
76
|
+
extras: ScribeSection[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const HEADING = /^(#{1,6})\s+(.+?)\s*$/
|
|
80
|
+
const FENCE = /^\s*(```|~~~)/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Strip markdown scaffolding that is structure rather than content.
|
|
84
|
+
*
|
|
85
|
+
* 160 of 227 real scribes wrap the transcript in a `<details>` disclosure and
|
|
86
|
+
* 161 end with a generator stamp. Both land in whatever the final section is, and
|
|
87
|
+
* both are noise in every destination — the stamp especially, because it is
|
|
88
|
+
* generation time and reads like meeting content.
|
|
89
|
+
*/
|
|
90
|
+
export function cleanBody(body: string): string {
|
|
91
|
+
const kept: string[] = []
|
|
92
|
+
for (const line of body.split('\n')) {
|
|
93
|
+
const t = line.trim()
|
|
94
|
+
// `<details open>` and `<details markdown="1">` both occur in LLM-authored
|
|
95
|
+
// markdown and neither matched the bare-tag test.
|
|
96
|
+
if (/^<\/?details(\s[^>]*)?>$/i.test(t)) continue
|
|
97
|
+
if (/^<summary>.*<\/summary>$/i.test(t)) continue
|
|
98
|
+
// Internal pipeline triage markers. `g2-needs-domain-review` reached 129 of
|
|
99
|
+
// 362 reachable clipboards — more frequent than the generator stamp, and it
|
|
100
|
+
// is not meeting content.
|
|
101
|
+
if (/^<!--[\s\S]*-->$/.test(t)) continue
|
|
102
|
+
kept.push(line)
|
|
103
|
+
}
|
|
104
|
+
// Drop trailing rules AND the blank lines between them, left orphaned by the
|
|
105
|
+
// stamp that sat underneath. Popping rules alone stops at the first blank line
|
|
106
|
+
// the stamp left behind, so the rule survived — caught by test, not by reading.
|
|
107
|
+
// The generator stamp is stripped HERE, as a footer, by POSITION not wording.
|
|
108
|
+
// Measured across 1,227 real stamps, four different generator names appear
|
|
109
|
+
// ("Meeting Intelligence System", "COS Split Pipeline", "COS Meeting
|
|
110
|
+
// Intelligence", "Manual Granola Paste"), so any name-anchored regex leaks
|
|
111
|
+
// some — I tried one and it leaked 7. Being the last line IS the invariant,
|
|
112
|
+
// and it also means a mid-body italic line a human wrote ("the deck was
|
|
113
|
+
// *Generated by the team* last week") now survives, where the old
|
|
114
|
+
// line-by-line filter deleted it wherever it appeared.
|
|
115
|
+
const trailingNoise = (line: string) =>
|
|
116
|
+
line.trim() === '' ||
|
|
117
|
+
/^\s*(-{3,}|\*{3,}|_{3,})\s*$/.test(line) ||
|
|
118
|
+
/^\*Generated by .*\*$/i.test(line.trim())
|
|
119
|
+
while (kept.length > 0 && trailingNoise(kept[kept.length - 1])) kept.pop()
|
|
120
|
+
return kept.join('\n').trim()
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Split a scribe into its `##` sections.
|
|
125
|
+
*
|
|
126
|
+
* FENCE-AWARE. A `#` inside a fenced code block is code, not a heading. Without
|
|
127
|
+
* this, a fence truncates its section at the first `##` inside it and can lift a
|
|
128
|
+
* shell comment into the meeting title. No real scribe carries a fence today, but
|
|
129
|
+
* the Granola sections are LLM-authored markdown, so one will arrive.
|
|
130
|
+
*
|
|
131
|
+
* `###` subheadings stay INSIDE their parent — Action Items splitting into High
|
|
132
|
+
* Confidence / Needs Review is one section's body, not two sections.
|
|
133
|
+
*/
|
|
134
|
+
export function parseScribe(markdown: string): ParsedScribe {
|
|
135
|
+
const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
|
|
136
|
+
let title = ''
|
|
137
|
+
const sections: ScribeSection[] = []
|
|
138
|
+
let current: ScribeSection | null = null
|
|
139
|
+
let inFence = false
|
|
140
|
+
|
|
141
|
+
for (const line of lines) {
|
|
142
|
+
if (FENCE.test(line)) inFence = !inFence
|
|
143
|
+
if (!inFence) {
|
|
144
|
+
const m = HEADING.exec(line)
|
|
145
|
+
if (m && m[1].length === 1 && !title) { title = m[2].trim(); continue }
|
|
146
|
+
if (m && m[1].length === 2) {
|
|
147
|
+
if (current) sections.push({ ...current, body: cleanBody(current.body) })
|
|
148
|
+
current = { heading: m[2], body: '' }
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (current) current.body += line + '\n'
|
|
153
|
+
}
|
|
154
|
+
if (current) sections.push({ ...current, body: cleanBody(current.body) })
|
|
155
|
+
|
|
156
|
+
const used = new Set<ScribeSection>()
|
|
157
|
+
/** Exact heading match, first name wins. */
|
|
158
|
+
const exact = (...names: string[]): string => {
|
|
159
|
+
for (const n of names) {
|
|
160
|
+
const s = sections.find(x => x.heading.toLowerCase() === n.toLowerCase() && !used.has(x))
|
|
161
|
+
if (s) { used.add(s); return s.body }
|
|
162
|
+
}
|
|
163
|
+
return ''
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* CONTAINS match, longest body wins.
|
|
167
|
+
*
|
|
168
|
+
* Prefix matching is the obvious rule and it is WRONG: 113 scribes head the
|
|
169
|
+
* transcript `G2 Speaker-Separated Transcript` — the speaker-attributed one,
|
|
170
|
+
* the most useful thing to hand a model — which does not start with the word.
|
|
171
|
+
* Measured across 1,930 transcript headings, prefix catches 94%, contains 100%.
|
|
172
|
+
* Longest-body separates a real transcript from a short
|
|
173
|
+
* `Transcript Enrichment (from raw recording)` note in the same file.
|
|
174
|
+
*/
|
|
175
|
+
const otherTranscripts: ScribeSection[] = []
|
|
176
|
+
let transcriptHeading = ''
|
|
177
|
+
const contains = (needle: string): string => {
|
|
178
|
+
const hits = sections
|
|
179
|
+
.filter(x => {
|
|
180
|
+
const h = x.heading.toLowerCase()
|
|
181
|
+
// `## Attendees (from transcript)` is eligible on a naive match and would
|
|
182
|
+
// promote the unfloored attendee list — the thing this module drops — into
|
|
183
|
+
// the transcript slot. Never let an attendee heading win.
|
|
184
|
+
return h.includes(needle) && !h.includes('attendee') && !used.has(x)
|
|
185
|
+
})
|
|
186
|
+
.sort((a, b) => b.body.length - a.body.length)
|
|
187
|
+
if (hits.length === 0) return ''
|
|
188
|
+
transcriptHeading = hits[0].heading
|
|
189
|
+
// Consume EVERY transcript-ish section. Keeping only the longest let the
|
|
190
|
+
// losers fall through to `extras`, and `extras` goes into BOTH clipboard
|
|
191
|
+
// forms — so the "compact, no transcript" summary shipped a second full
|
|
192
|
+
// transcript on 112 of 2,090 real scribes, worst case 78,652 characters,
|
|
193
|
+
// directly above its own "Transcript omitted" line.
|
|
194
|
+
for (const h of hits) used.add(h)
|
|
195
|
+
otherTranscripts.push(...hits.slice(1))
|
|
196
|
+
return hits[0].body
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const summary = exact('Summary')
|
|
200
|
+
const topics = exact('Topics Discussed', 'Topics')
|
|
201
|
+
const decisions = exact('Decisions Made', 'Decisions')
|
|
202
|
+
const actions = exact('Action Items', 'Actions')
|
|
203
|
+
const transcript = contains('transcript')
|
|
204
|
+
// Attendees is consumed and DROPPED on purpose: it is the unfloored label list
|
|
205
|
+
// this module exists to replace. CONTAINS, not exact — `## Attendees (from
|
|
206
|
+
// transcript)` slipped past the exact match, fell through to `extras`, and
|
|
207
|
+
// printed the unfloored names verbatim in both clipboard forms.
|
|
208
|
+
for (const x of sections) {
|
|
209
|
+
if (x.heading.toLowerCase().includes('attendee')) used.add(x)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
title,
|
|
214
|
+
sections,
|
|
215
|
+
// Heading-less fallback drops the `# Title` line the parser already consumed;
|
|
216
|
+
// 18 real files otherwise printed their title twice in the clipboard.
|
|
217
|
+
summary: summary || (sections.length === 0 ? cleanBody(markdown.split('\n').filter(l => !/^#\s+/.test(l)).join('\n')) : ''),
|
|
218
|
+
topics,
|
|
219
|
+
decisions,
|
|
220
|
+
actions,
|
|
221
|
+
transcript,
|
|
222
|
+
transcriptHeading,
|
|
223
|
+
otherTranscripts,
|
|
224
|
+
extras: sections.filter(x => !used.has(x) && x.body.trim().length > 0),
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The meeting's calendar date, as `YYYY-MM-DD`.
|
|
230
|
+
*
|
|
231
|
+
* `startTime` is epoch MILLISECONDS (measured: 1786123940914), not ISO. Slicing
|
|
232
|
+
* the stringified number gave "1786123940" — a plausible-looking date field
|
|
233
|
+
* holding a timestamp. LOCAL date, because the scribe filename uses the local day
|
|
234
|
+
* and a UTC conversion would disagree with the file late in the evening.
|
|
235
|
+
*/
|
|
236
|
+
export function meetingDate(startTime: unknown): string {
|
|
237
|
+
let ms: number | null = null
|
|
238
|
+
if (typeof startTime === 'number' && Number.isFinite(startTime)) {
|
|
239
|
+
// Seconds-vs-ms by magnitude, but only within a plausible capture era:
|
|
240
|
+
// startTime = 1 was read as 1 second and rendered "1969-12-31".
|
|
241
|
+
ms = startTime < 1e12 ? (startTime > 1e8 ? startTime * 1000 : 0) : startTime
|
|
242
|
+
} else if (typeof startTime === 'string' && startTime.trim()) {
|
|
243
|
+
const numeric = Number(startTime)
|
|
244
|
+
const t = Number.isFinite(numeric)
|
|
245
|
+
? (numeric < 1e12 ? numeric * 1000 : numeric)
|
|
246
|
+
// A date-only ISO string parses as UTC midnight, so a local getDate()
|
|
247
|
+
// then reports the PREVIOUS day. Pin it to local noon.
|
|
248
|
+
: (/^\d{4}-\d{2}-\d{2}$/.test(startTime.trim())
|
|
249
|
+
? Date.parse(`${startTime.trim()}T12:00:00`)
|
|
250
|
+
: Date.parse(startTime))
|
|
251
|
+
if (Number.isFinite(t)) ms = t
|
|
252
|
+
}
|
|
253
|
+
// 0 and negatives are not real capture times; they rendered as "1969-12-31".
|
|
254
|
+
if (ms === null || ms <= 0) return ''
|
|
255
|
+
const d = new Date(ms)
|
|
256
|
+
if (Number.isNaN(d.getTime())) return ''
|
|
257
|
+
const pad = (n: number) => String(n).padStart(2, '0')
|
|
258
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A voice as the clipboard should describe it. */
|
|
262
|
+
export interface AttendeeLine {
|
|
263
|
+
label: string
|
|
264
|
+
/** False when the review refuses to assert this name. */
|
|
265
|
+
asserted: boolean
|
|
266
|
+
speakingMs: number
|
|
267
|
+
/** Share of identified speech, 0..1, or null when unknown/unnamed. */
|
|
268
|
+
share: number | null
|
|
269
|
+
/**
|
|
270
|
+
* WHY this name is asserted. `nameAsserted` is one boolean over THREE
|
|
271
|
+
* different warrants — passed the cosine floor, a human typed it, or the
|
|
272
|
+
* wearer is exempt because identity comes from holding the device. v1 printed
|
|
273
|
+
* all three as "Voice matching confirmed", which is false for two of them and
|
|
274
|
+
* is exactly the laundering this module exists to prevent. The review already
|
|
275
|
+
* carries both flags; the route discarded them.
|
|
276
|
+
*/
|
|
277
|
+
isOwner: boolean
|
|
278
|
+
confirmedByHuman: boolean
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Coverage below which per-voice shares are NOT reported.
|
|
283
|
+
*
|
|
284
|
+
* Mirrors Control's `speakingCoverage < 0.6` gate exactly, so the clipboard and
|
|
285
|
+
* the panel can never disagree about whether a share is trustworthy. 49% of real
|
|
286
|
+
* meetings sit below it.
|
|
287
|
+
*/
|
|
288
|
+
export const SHARE_COVERAGE_FLOOR = 0.6
|
|
289
|
+
|
|
290
|
+
function mmss(ms: number): string {
|
|
291
|
+
// NaN survives Math.max and renders "NaNm NaNs".
|
|
292
|
+
if (!Number.isFinite(ms)) return '0s'
|
|
293
|
+
const total = Math.max(0, Math.round(ms / 1000))
|
|
294
|
+
const m = Math.floor(total / 60)
|
|
295
|
+
const s = total % 60
|
|
296
|
+
if (m === 0) return `${s}s`
|
|
297
|
+
return s === 0 ? `${m}m` : `${m}m ${s}s`
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export interface AttendeeRenderOptions {
|
|
301
|
+
/**
|
|
302
|
+
* Identified share of voiced time, 0..1, or null when unknown. Null suppresses
|
|
303
|
+
* shares — failing closed, because unknown coverage cannot justify a
|
|
304
|
+
* precise-looking percentage.
|
|
305
|
+
*/
|
|
306
|
+
coverage: number | null
|
|
307
|
+
/**
|
|
308
|
+
* UNION of unattributed speaking time, from the review.
|
|
309
|
+
*
|
|
310
|
+
* Required rather than optional so it cannot be forgotten. Summing per-voice
|
|
311
|
+
* figures double-counts crosstalk: v1 printed "30m 21s across 15 voices" inside
|
|
312
|
+
* a 26-minute meeting while the union on the same review said 22m 17s. The
|
|
313
|
+
* server CHANGELOG states the invariant that sum breaks.
|
|
314
|
+
*/
|
|
315
|
+
unattributedMs: number
|
|
316
|
+
/**
|
|
317
|
+
* UNION of all voiced time. Used only to detect that the rows overlap — see
|
|
318
|
+
* the crosstalk note in `renderAttendees`. 0 disables the check.
|
|
319
|
+
*/
|
|
320
|
+
voicedMs: number
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** The attendee block, floor-applied. */
|
|
324
|
+
export function renderAttendees(voices: AttendeeLine[], o: AttendeeRenderOptions): string {
|
|
325
|
+
const named = voices.filter(v => v.asserted).sort((a, b) => b.speakingMs - a.speakingMs)
|
|
326
|
+
const rest = voices.filter(v => !v.asserted)
|
|
327
|
+
// A share needs someone to be a share OF. With one named voice it is always
|
|
328
|
+
// "100% of identified speech" — 23 real meetings — and on screen that reads as
|
|
329
|
+
// "he did all the talking" when the same meeting had 6m 29s unidentified.
|
|
330
|
+
const showShares = o.coverage !== null &&
|
|
331
|
+
o.coverage >= SHARE_COVERAGE_FLOOR &&
|
|
332
|
+
named.length >= 2
|
|
333
|
+
const out: string[] = []
|
|
334
|
+
for (const v of named) {
|
|
335
|
+
const pct = showShares && v.share !== null
|
|
336
|
+
? ` · ${Math.round(v.share * 100)}% of identified speech`
|
|
337
|
+
: ''
|
|
338
|
+
out.push(`- ${v.label}: ${mmss(v.speakingMs)}${pct}`)
|
|
339
|
+
}
|
|
340
|
+
if (rest.length > 0) {
|
|
341
|
+
out.push(`- Unidentified: ${mmss(o.unattributedMs)} across ${rest.length} ` +
|
|
342
|
+
`voice${rest.length === 1 ? '' : 's'} the review could not name`)
|
|
343
|
+
}
|
|
344
|
+
// Each figure above is that voice's OWN union of speaking time, so crosstalk is
|
|
345
|
+
// counted once per person — but two people talking over each other is counted
|
|
346
|
+
// once EACH, and the rows then add to more than the meeting. 34 of 323 real
|
|
347
|
+
// meetings overflow, worst case 71m of rows inside 66 minutes. The numbers are
|
|
348
|
+
// right; adding them is what misleads, so say so rather than shrink anyone.
|
|
349
|
+
const rowTotal = named.reduce((t, v) => t + (Number.isFinite(v.speakingMs) ? v.speakingMs : 0), 0) +
|
|
350
|
+
(rest.length > 0 && Number.isFinite(o.unattributedMs) ? o.unattributedMs : 0)
|
|
351
|
+
if (o.voicedMs > 0 && rowTotal > o.voicedMs * 1.02) {
|
|
352
|
+
out.push(`- (these overlap: people talked over each other, so the rows add to ` +
|
|
353
|
+
`more than the ${mmss(o.voicedMs)} of talking)`)
|
|
354
|
+
}
|
|
355
|
+
return out.length > 0 ? out.join('\n') : '- (no voices identified)'
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* The provenance block: who was confirmed, and a warning covering everyone else.
|
|
360
|
+
*
|
|
361
|
+
* An ALLOWLIST rather than a blocklist. The prose and transcript below carry raw
|
|
362
|
+
* capture labels, some of which the review rejected, and a reader pasting this
|
|
363
|
+
* into a model has no other way to tell which. Naming the confirmed set is short
|
|
364
|
+
* and complete.
|
|
365
|
+
*/
|
|
366
|
+
export function renderProvenance(voices: AttendeeLine[], coverage: number | null): string {
|
|
367
|
+
const named = voices.filter(v => v.asserted)
|
|
368
|
+
// Quoted because a label may legitimately contain a comma — `invalidLabelReason`
|
|
369
|
+
// rejects brackets and newlines but not commas, and a typed "Smith, John" would
|
|
370
|
+
// otherwise read as two confirmed people.
|
|
371
|
+
const q = (v: AttendeeLine) => `"${v.label}"`
|
|
372
|
+
const clauses: string[] = []
|
|
373
|
+
const owner = named.filter(v => v.isOwner).map(q)
|
|
374
|
+
const human = named.filter(v => !v.isOwner && v.confirmedByHuman).map(q)
|
|
375
|
+
const matched = named.filter(v => !v.isOwner && !v.confirmedByHuman).map(q)
|
|
376
|
+
if (owner.length) clauses.push(`${owner.join(', ')} by wearing the device`)
|
|
377
|
+
if (matched.length) clauses.push(`${matched.join(', ')} by voice match`)
|
|
378
|
+
if (human.length) clauses.push(`${human.join(', ')} because a human named that voice`)
|
|
379
|
+
const lines: string[] = [
|
|
380
|
+
clauses.length > 0
|
|
381
|
+
? `Names established here: ${clauses.join('; ')}.`
|
|
382
|
+
: 'No name in this meeting was established by any means.',
|
|
383
|
+
'Every other name below — and any different spelling of the ones just listed — ' +
|
|
384
|
+
'comes from the raw capture or the write-up and was NOT confirmed. Some may be ' +
|
|
385
|
+
'people mentioned rather than people present.',
|
|
386
|
+
]
|
|
387
|
+
if (coverage !== null && coverage < SHARE_COVERAGE_FLOOR) {
|
|
388
|
+
lines.push(
|
|
389
|
+
`Only ${Math.round(coverage * 100)}% of the voice in this meeting was ` +
|
|
390
|
+
'identified, so speaking shares are not reported.',
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
return lines.join(' ')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export interface ClipboardInput {
|
|
397
|
+
/**
|
|
398
|
+
* Which business this meeting belongs to, when known.
|
|
399
|
+
*
|
|
400
|
+
* 98 of 251 real meetings are `personal`, and 25 of 251 summaries carry
|
|
401
|
+
* compensation, termination, or legal content — while the buttons are framed
|
|
402
|
+
* for Slack and email. Nothing in the payload distinguished a 1:1 about
|
|
403
|
+
* someone's bonus from a marketing sync. The sibling /speakers route already
|
|
404
|
+
* carries this; this one dropped it.
|
|
405
|
+
*/
|
|
406
|
+
domain: string
|
|
407
|
+
/**
|
|
408
|
+
* Characters of transcript present in the RECORDING, whether or not a write-up
|
|
409
|
+
* exists. Distinguishes "not written up yet" from "nobody spoke".
|
|
410
|
+
*/
|
|
411
|
+
capturedChars: number
|
|
412
|
+
/** UNION of voiced time, for the crosstalk-overflow note. */
|
|
413
|
+
voicedMs: number
|
|
414
|
+
title: string
|
|
415
|
+
date: string
|
|
416
|
+
durationMin: number
|
|
417
|
+
attendees: AttendeeLine[]
|
|
418
|
+
scribe: ParsedScribe
|
|
419
|
+
/** Both required, so neither can be omitted. See AttendeeRenderOptions. */
|
|
420
|
+
coverage: number | null
|
|
421
|
+
unattributedMs: number
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Per-extra size ceiling for the COMPACT form.
|
|
426
|
+
*
|
|
427
|
+
* A heading cannot be trusted to reveal a transcript: one real scribe carries a
|
|
428
|
+
* full one under `## G2 Glasses Enrichment ... (Combined)`. Gating on SIZE
|
|
429
|
+
* catches that generically, where a keyword list never would. The full form has
|
|
430
|
+
* no ceiling.
|
|
431
|
+
*/
|
|
432
|
+
export const SUMMARY_EXTRA_MAX_CHARS = 2000
|
|
433
|
+
|
|
434
|
+
function header(i: ClipboardInput, compact: boolean): string[] {
|
|
435
|
+
const when = [i.date, i.durationMin > 0 ? `${i.durationMin} minutes` : '']
|
|
436
|
+
.filter(Boolean).join(' · ')
|
|
437
|
+
const parts = [`# ${i.title || 'Untitled meeting'}`]
|
|
438
|
+
// Domain on the same line as the date. A reader about to paste this into a
|
|
439
|
+
// channel should see "personal" before they see the content, not after.
|
|
440
|
+
const line = [when, i.domain ? `${i.domain.replace(/_/g, ' ')} meeting` : ''].filter(Boolean).join(' · ')
|
|
441
|
+
if (line) parts.push(line)
|
|
442
|
+
if (i.domain === 'personal') {
|
|
443
|
+
parts.push('', '> Personal meeting. Check before pasting this anywhere shared.')
|
|
444
|
+
}
|
|
445
|
+
parts.push(
|
|
446
|
+
'', '## Who spoke',
|
|
447
|
+
renderAttendees(i.attendees, {
|
|
448
|
+
coverage: i.coverage, unattributedMs: i.unattributedMs, voicedMs: i.voicedMs,
|
|
449
|
+
}),
|
|
450
|
+
'', renderProvenance(i.attendees, i.coverage),
|
|
451
|
+
)
|
|
452
|
+
for (const [heading, body] of [
|
|
453
|
+
['Summary', i.scribe.summary],
|
|
454
|
+
['Topics', i.scribe.topics],
|
|
455
|
+
['Decisions', i.scribe.decisions],
|
|
456
|
+
['Action items', i.scribe.actions],
|
|
457
|
+
] as const) {
|
|
458
|
+
if (body.trim()) parts.push('', `## ${heading}`, body.trim())
|
|
459
|
+
}
|
|
460
|
+
for (const s of i.scribe.extras) {
|
|
461
|
+
const body = s.body.trim()
|
|
462
|
+
if (compact && body.length > SUMMARY_EXTRA_MAX_CHARS) {
|
|
463
|
+
parts.push('', `## ${s.heading}`, `_Omitted from the summary (${body.length} characters). Use Copy full._`)
|
|
464
|
+
continue
|
|
465
|
+
}
|
|
466
|
+
parts.push('', `## ${s.heading}`, body)
|
|
467
|
+
}
|
|
468
|
+
return parts
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Compact form: for pasting into a message. No transcript. */
|
|
472
|
+
export function clipboardSummary(i: ClipboardInput): string {
|
|
473
|
+
const parts = header(i, true)
|
|
474
|
+
// State that a transcript exists but was omitted. Without this, a reader
|
|
475
|
+
// holding only the summary cannot tell whether one exists at all.
|
|
476
|
+
const tx = i.scribe.transcript.trim()
|
|
477
|
+
if (tx) parts.push('', `_Transcript omitted (${tx.length} characters). Use Copy full for it._`)
|
|
478
|
+
return parts.join('\n') + '\n'
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** Full form: everything including the transcript, for pasting into a model. */
|
|
482
|
+
export function clipboardFull(i: ClipboardInput): string {
|
|
483
|
+
const parts = header(i, false)
|
|
484
|
+
const tx = i.scribe.transcript.trim()
|
|
485
|
+
// The section's OWN heading, so a `Transcript Enrichment` note is never
|
|
486
|
+
// presented as the transcript.
|
|
487
|
+
// "(no transcript in this scribe)" was a lie on 140 of 399 real sidecars: the
|
|
488
|
+
// write-up has not been generated yet, while the recording itself holds the
|
|
489
|
+
// speech — one case today had 27,442 characters sitting in the sidecar this
|
|
490
|
+
// route had just parsed. Say which of the two is actually true.
|
|
491
|
+
const fallback = i.capturedChars > 0
|
|
492
|
+
? `(no write-up saved for this meeting yet — ${i.capturedChars} characters of ` +
|
|
493
|
+
`transcript are in the recording, and will appear here once it is written up)`
|
|
494
|
+
: '(no transcript in this scribe)'
|
|
495
|
+
parts.push('', `## ${i.scribe.transcriptHeading || 'Transcript'}`, tx || fallback)
|
|
496
|
+
// Additional transcript sections, full form only.
|
|
497
|
+
for (const o of i.scribe.otherTranscripts) {
|
|
498
|
+
if (o.body.trim()) parts.push('', `## ${o.heading}`, o.body.trim())
|
|
499
|
+
}
|
|
500
|
+
return parts.join('\n') + '\n'
|
|
501
|
+
}
|
package/server/routes/meeting.ts
CHANGED
|
@@ -118,9 +118,16 @@ import {
|
|
|
118
118
|
attachRawChunkIndices,
|
|
119
119
|
isUnattributed,
|
|
120
120
|
reviewMeetingSpeakers,
|
|
121
|
-
type SpeakerWordSegment,
|
|
122
121
|
type ReviewChunk,
|
|
122
|
+
type SpeakerWordSegment,
|
|
123
123
|
} from '../lib/meeting-speaker-review.js'
|
|
124
|
+
import {
|
|
125
|
+
parseScribe,
|
|
126
|
+
clipboardSummary,
|
|
127
|
+
clipboardFull,
|
|
128
|
+
meetingDate,
|
|
129
|
+
SHARE_COVERAGE_FLOOR,
|
|
130
|
+
} from '../lib/meeting-scribe-content.js'
|
|
124
131
|
import {
|
|
125
132
|
acquireMaintenanceWork,
|
|
126
133
|
maintenanceAdmissionsOpen,
|
|
@@ -769,6 +776,162 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
769
776
|
// touched, and a failed intent write aborts without mutating anything. A
|
|
770
777
|
// process that dies mid-rewrite therefore leaves a visible pending correction
|
|
771
778
|
// rather than a silently half-relabelled meeting.
|
|
779
|
+
/**
|
|
780
|
+
* The readable meeting, plus two clipboard forms.
|
|
781
|
+
*
|
|
782
|
+
* Resolution is operations-first, identical to GET /speakers — the same session
|
|
783
|
+
* lives in both trees under different names and the list reads operations, so
|
|
784
|
+
* anything keyed on a session has to resolve there too or the row and this
|
|
785
|
+
* view disagree about the same meeting.
|
|
786
|
+
*
|
|
787
|
+
* The attendee list is REBUILT from the speaker review rather than taken from
|
|
788
|
+
* the scribe's own `## Attendees`, which applies no confidence floor: the
|
|
789
|
+
* 2026-08-06 IJO scribe lists 15 attendees for a 26-minute call including a
|
|
790
|
+
* name already confirmed absent. Copying that verbatim would launder a guess
|
|
791
|
+
* into a fact in whatever the reviewer pastes it into.
|
|
792
|
+
*/
|
|
793
|
+
router.get('/meeting/:sessionId/content', (req, res) => {
|
|
794
|
+
const sessionId = String(req.params.sessionId ?? '')
|
|
795
|
+
if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
|
|
796
|
+
res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
const operations = cosOperationsMeetingsConfigured()
|
|
800
|
+
? findCosOperationsMeetingBySessionId(sessionId)
|
|
801
|
+
: null
|
|
802
|
+
const saved = operations ? null : store.findBySessionId(sessionId)
|
|
803
|
+
if (!operations && !saved) {
|
|
804
|
+
res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
|
|
808
|
+
const title = operations?.title ?? saved!.title
|
|
809
|
+
const mdPath = operations?.meetingPath ?? sidecarPath.replace(/\.g2-chunks\.json$/, '.md')
|
|
810
|
+
|
|
811
|
+
let sidecar: Record<string, unknown>
|
|
812
|
+
try {
|
|
813
|
+
sidecar = (JSON.parse(readFileSync(sidecarPath, 'utf-8')) ?? {}) as Record<string, unknown>
|
|
814
|
+
} catch {
|
|
815
|
+
res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
const rawChunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
|
|
819
|
+
if (!Array.isArray(rawChunks)) {
|
|
820
|
+
// Mirrors GET /speakers exactly. Coercing to [] returned 200 with an empty
|
|
821
|
+
// floor while still shipping the full verbatim prose and transcript — the
|
|
822
|
+
// worst pairing, and the two routes would have disagreed about the same
|
|
823
|
+
// session (panel 422, copy buttons fine).
|
|
824
|
+
res.status(422).json({ error: 'Chunk sidecar holds no chunk array', reason: 'sidecar_empty' })
|
|
825
|
+
return
|
|
826
|
+
}
|
|
827
|
+
const chunks = rawChunks as ReviewChunk[]
|
|
828
|
+
|
|
829
|
+
const review = reviewMeetingSpeakers(attachRawChunkIndices(chunks, sidecar.chunkEntries), {
|
|
830
|
+
owner: getOwnerSpeakerLabel(),
|
|
831
|
+
confirmed: confirmedLabels(sessionId),
|
|
832
|
+
// This route renders no phrases; the default of 3 per voice computed and
|
|
833
|
+
// discarded 48 transcript excerpts on a 16-voice meeting.
|
|
834
|
+
phrasesPerVoice: 1,
|
|
835
|
+
durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
|
|
836
|
+
batchSegments: Array.isArray(sidecar.batchSegments)
|
|
837
|
+
? (sidecar.batchSegments as SpeakerWordSegment[])
|
|
838
|
+
: undefined,
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
// Share is over NAMED speech and totals 100%, matching the panel. The union
|
|
842
|
+
// in attributedSpeakingMs counts crosstalk once, so dividing by it would let
|
|
843
|
+
// the shares exceed 100%.
|
|
844
|
+
const namedTotal = review.voices.reduce((n, v) => (v.nameAsserted ? n + v.speakingMs : n), 0)
|
|
845
|
+
const attendees = review.voices.map(v => ({
|
|
846
|
+
label: v.label,
|
|
847
|
+
asserted: v.nameAsserted,
|
|
848
|
+
speakingMs: v.speakingMs,
|
|
849
|
+
share: v.nameAsserted && namedTotal > 0 ? v.speakingMs / namedTotal : null,
|
|
850
|
+
// WHY the name is asserted, not just that it is. The review has always
|
|
851
|
+
// carried these and this route dropped them, so the clipboard credited
|
|
852
|
+
// "voice matching" for the wearer exemption and for names a human typed.
|
|
853
|
+
isOwner: v.isOwner,
|
|
854
|
+
confirmedByHuman: v.confirmedByHuman,
|
|
855
|
+
}))
|
|
856
|
+
|
|
857
|
+
// A missing .md is not fatal — the review and the sidecar still describe the
|
|
858
|
+
// meeting, and a reviewer would rather have who-spoke than a 404.
|
|
859
|
+
let markdown = ''
|
|
860
|
+
try { markdown = readFileSync(mdPath, 'utf-8') } catch { markdown = '' }
|
|
861
|
+
const scribe = parseScribe(markdown)
|
|
862
|
+
|
|
863
|
+
const date = meetingDate(sidecar.startTime)
|
|
864
|
+
const durationMin = Math.round((review.durationMs || 0) / 60_000)
|
|
865
|
+
// EXACTLY Control's speakingCoverage: attributed / (attributed + unattributed).
|
|
866
|
+
// Not attributed/voicedMs — voicedMs is the union of everyone, which would
|
|
867
|
+
// give a different number and let the clipboard and the panel disagree about
|
|
868
|
+
// whether a share is trustworthy. Null when there is no voice at all, which
|
|
869
|
+
// suppresses shares rather than dividing by zero.
|
|
870
|
+
const coverageDenominator = review.attributedSpeakingMs + review.unattributedSpeakingMs
|
|
871
|
+
const coverage = coverageDenominator > 0
|
|
872
|
+
? review.attributedSpeakingMs / coverageDenominator
|
|
873
|
+
: null
|
|
874
|
+
const clip = {
|
|
875
|
+
title: scribe.title || title,
|
|
876
|
+
date,
|
|
877
|
+
durationMin,
|
|
878
|
+
attendees,
|
|
879
|
+
scribe,
|
|
880
|
+
coverage,
|
|
881
|
+
// The UNION, never the sum of per-voice figures — crosstalk is counted once.
|
|
882
|
+
unattributedMs: review.unattributedSpeakingMs,
|
|
883
|
+
// Union of ALL voiced time. Only used to notice that the per-voice rows add
|
|
884
|
+
// to more than the meeting, which happens whenever people talk over
|
|
885
|
+
// each other — 34 of 323 real meetings.
|
|
886
|
+
voicedMs: review.voicedMs,
|
|
887
|
+
// Which business this is. The sibling /speakers route has always carried
|
|
888
|
+
// this and this one dropped it, so a personal 1:1 about someone's
|
|
889
|
+
// compensation was byte-identical to a marketing sync.
|
|
890
|
+
domain: operations?.domain ?? saved?.domain ?? '',
|
|
891
|
+
// Transcript actually captured, whether or not a write-up exists yet. 140
|
|
892
|
+
// of 399 real sidecars have no .md, and the fallback text claimed there was
|
|
893
|
+
// no transcript while holding one.
|
|
894
|
+
capturedChars: chunks.reduce(
|
|
895
|
+
(t, c) => t + (typeof c?.text === 'string' ? c.text.length : 0), 0),
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Rendered ONCE. Calling each builder twice to measure its own length meant
|
|
899
|
+
// two extra full renders per request, transcript included.
|
|
900
|
+
const summaryText = clipboardSummary(clip)
|
|
901
|
+
const fullText = clipboardFull(clip)
|
|
902
|
+
|
|
903
|
+
res.set('Cache-Control', 'private, no-store')
|
|
904
|
+
res.json({
|
|
905
|
+
sessionId,
|
|
906
|
+
title: scribe.title || title,
|
|
907
|
+
date,
|
|
908
|
+
durationMin,
|
|
909
|
+
scribeAvailable: markdown.length > 0,
|
|
910
|
+
attendees,
|
|
911
|
+
speakingTimeSource: review.speakingTimeSource,
|
|
912
|
+
voicedMs: review.voicedMs,
|
|
913
|
+
summary: scribe.summary,
|
|
914
|
+
topics: scribe.topics,
|
|
915
|
+
decisions: scribe.decisions,
|
|
916
|
+
actions: scribe.actions,
|
|
917
|
+
extras: scribe.extras.map(x => ({ heading: x.heading, body: x.body })),
|
|
918
|
+
transcriptChars: scribe.transcript.length,
|
|
919
|
+
// Captured vs written-up are different facts and the panel needs both:
|
|
920
|
+
// transcriptChars is 0 for a meeting with 27,442 characters of speech and
|
|
921
|
+
// no write-up yet.
|
|
922
|
+
capturedChars: clip.capturedChars,
|
|
923
|
+
domain: clip.domain,
|
|
924
|
+
coverage,
|
|
925
|
+
sharesReported: coverage !== null && coverage >= SHARE_COVERAGE_FLOOR,
|
|
926
|
+
clipboardSummary: summaryText,
|
|
927
|
+
clipboardFull: fullText,
|
|
928
|
+
// Sizes of the ACTUAL strings, so the button label and the copy
|
|
929
|
+
// confirmation can never quote two different numbers for one click.
|
|
930
|
+
summaryChars: summaryText.length,
|
|
931
|
+
fullChars: fullText.length,
|
|
932
|
+
})
|
|
933
|
+
})
|
|
934
|
+
|
|
772
935
|
router.post('/meeting/:sessionId/relabel', (req, res) => {
|
|
773
936
|
res.set('Cache-Control', 'private, no-store')
|
|
774
937
|
const sessionId = String(req.params.sessionId ?? '')
|