@gotcos/glasses-server 6.21.28 → 6.21.30
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 +110 -19
- package/package.json +1 -1
- package/server/lib/meeting-scribe-content.ts +446 -84
- package/server/routes/meeting.ts +82 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,25 +1,116 @@
|
|
|
1
|
+
## 6.21.30
|
|
2
|
+
|
|
3
|
+
- **A name you removed by hand is now stated, and the stale write-up is called
|
|
4
|
+
out.** De-attribution rewrites the sidecar, the attendee list and the transcript
|
|
5
|
+
labels, but deliberately leaves narrative prose alone, because substituting into
|
|
6
|
+
a written sentence mangles grammar and can hit the wrong person. The applied
|
|
7
|
+
correction row has recorded `proseStale: true` for exactly this since the feature
|
|
8
|
+
shipped — and NOTHING read it.
|
|
9
|
+
|
|
10
|
+
Real case, 2026-08-07: Miles removed "Clem Ukaoma" from a personal call that was
|
|
11
|
+
only him and Queen (his father's voice matched a similar profile). All 8 label
|
|
12
|
+
sites were rewritten correctly. The LLM summary still opened "Miles, Queen, and
|
|
13
|
+
Clem talk through..." and the payload said nothing at all. The allowlist covered
|
|
14
|
+
it only as "not confirmed", which is far too weak: he did not fail to confirm
|
|
15
|
+
that person, he explicitly said they were not in the room.
|
|
16
|
+
|
|
17
|
+
The payload now carries `removedNames` and states it above the write-up: *"You
|
|
18
|
+
removed "Clem Ukaoma" from this meeting. The write-up below was written before
|
|
19
|
+
that and still uses the name: treat every mention of it as a capture error, not
|
|
20
|
+
a participant."* The prose is left intact — the record stays, the correction
|
|
21
|
+
travels beside it.
|
|
22
|
+
|
|
23
|
+
- **The overlap note is gated on how long the meeting RAN, not on voiced time.**
|
|
24
|
+
Gating on voiced time tripped it on 16 of 23 real meetings, because any overlap
|
|
25
|
+
at all exceeds the union of voiced speech. At 70% it stopped being a signal and
|
|
26
|
+
became boilerplate on something pasted into Slack. Rows adding to more than the
|
|
27
|
+
meeting length is the genuinely confusing case — 71m of rows inside a 66-minute
|
|
28
|
+
meeting — and that is roughly a tenth of meetings.
|
|
29
|
+
|
|
30
|
+
Worth recording for the next person who tests this: only the WORD-TIMING path
|
|
31
|
+
can overflow. Measured, the chunk-estimate path credits a contested second to
|
|
32
|
+
one speaker (two speakers at identical timestamps gave MU 273s and Gina 0s), so
|
|
33
|
+
a chunk-sourced meeting can never trip the note.
|
|
34
|
+
|
|
35
|
+
## 6.21.29
|
|
36
|
+
|
|
37
|
+
Everything below was found by two rounds of adversarial review of 6.21.28 against
|
|
38
|
+
the real corpus. **Published 6.21.28 contains the route and none of these fixes**
|
|
39
|
+
— its `meeting-scribe-content.ts` is 192 lines with no `renderProvenance`, no
|
|
40
|
+
`cleanBody`, and no coverage floor (verified by extracting the tarball, not by
|
|
41
|
+
reading the changelog). If you are on 6.21.28, per-voice shares are printed
|
|
42
|
+
without the floor.
|
|
43
|
+
|
|
44
|
+
- **The compact form no longer ships a transcript.** `contains('transcript')`
|
|
45
|
+
kept only the longest match and the loser fell through to `extras`, which went
|
|
46
|
+
into BOTH forms — so the "no transcript" summary carried a second full
|
|
47
|
+
transcript on 112 of 2,090 real scribes, worst case 78,652 characters, printed
|
|
48
|
+
directly above its own "Transcript omitted" line. All transcript-shaped
|
|
49
|
+
sections are now consumed; extras beyond 2,000 characters are omitted from the
|
|
50
|
+
compact form only. The size ceiling matters because a heading cannot be
|
|
51
|
+
trusted: one real scribe hides a full transcript under `G2 Glasses Enrichment`.
|
|
52
|
+
|
|
53
|
+
- **"Voice matching confirmed" was false for two of the three ways a name is
|
|
54
|
+
asserted.** `nameAsserted` is one boolean over three different warrants — the
|
|
55
|
+
cosine floor, a human typing the name, and the wearer exemption (identity comes
|
|
56
|
+
from holding the device, not from a score). The route carried `isOwner` and
|
|
57
|
+
`confirmedByHuman` and discarded both. The output now names the warrant per
|
|
58
|
+
person: `"MU" by wearing the device; "Gina Obert" by voice match; "Luke Henry"
|
|
59
|
+
because a human named that voice`. Labels are quoted, so a typed "Smith, John"
|
|
60
|
+
cannot read as two people, and the caveat now covers different SPELLINGS of a
|
|
61
|
+
confirmed name (labels are `MU` while the prose says "Miles").
|
|
62
|
+
|
|
63
|
+
- **A lone named voice no longer gets a share.** "100% of identified speech" is
|
|
64
|
+
always true with one name and reads as "he did all the talking" — on 23 real
|
|
65
|
+
meetings, including one where 6m 29s was unidentified.
|
|
66
|
+
|
|
67
|
+
- **Rows that overlap say so.** Each per-voice figure is that voice's own union,
|
|
68
|
+
but two people talking over each other is counted once EACH, so the rows add to
|
|
69
|
+
more than the meeting on 34 of 323 real meetings (71m of rows inside 66
|
|
70
|
+
minutes). The figures are right; adding them is what misleads, so the block now
|
|
71
|
+
explains the overlap instead of shrinking anyone.
|
|
72
|
+
|
|
73
|
+
- **"(no transcript in this scribe)" was a lie on 140 of 399 sidecars.** Those
|
|
74
|
+
meetings have no write-up yet while the recording holds the speech — one case
|
|
75
|
+
today had 27,442 characters in the sidecar this route had just parsed. It now
|
|
76
|
+
distinguishes "not written up yet" from "nobody spoke".
|
|
77
|
+
|
|
78
|
+
- **The payload says which business it is.** 98 of 251 real meetings are
|
|
79
|
+
`personal` and 25 of 251 summaries carry compensation, termination, or legal
|
|
80
|
+
content, while the buttons are framed for Slack and email. `/speakers` has
|
|
81
|
+
always carried `domain`; this route dropped it. Personal meetings also get an
|
|
82
|
+
explicit line before the content.
|
|
83
|
+
|
|
84
|
+
- **A derived note is never relabelled as the transcript.** With no real
|
|
85
|
+
transcript, a `Transcript Enrichment (from raw recording)` note won by default
|
|
86
|
+
and was printed under `## Transcript` with its real heading destroyed. The
|
|
87
|
+
section's own heading is now used, and an `## Attendees (from transcript)`
|
|
88
|
+
variant can no longer win the slot at all — nor slip into `extras`, where it
|
|
89
|
+
printed the unfloored name list this module exists to replace.
|
|
90
|
+
|
|
91
|
+
- **The generator stamp is stripped by POSITION, not wording.** Measured across
|
|
92
|
+
1,227 real stamps, four generator names appear ("Meeting Intelligence System",
|
|
93
|
+
"COS Split Pipeline", "COS Meeting Intelligence", "Manual Granola Paste"), so a
|
|
94
|
+
name-anchored regex leaks some — I shipped one that leaked 7. Being the last
|
|
95
|
+
line is the invariant, which also means a mid-body italic line a human wrote
|
|
96
|
+
now survives. `<!-- g2-needs-domain-review -->` and `<details open>` are
|
|
97
|
+
stripped too; the internal marker reached 129 of 362 real clipboards.
|
|
98
|
+
|
|
99
|
+
- Also: `mmss` no longer renders `NaNm NaNs`; `meetingDate(1)` no longer returns
|
|
100
|
+
1969-12-31; a date-only ISO string no longer reports the previous day; each
|
|
101
|
+
clipboard form is rendered once instead of twice per request.
|
|
102
|
+
|
|
1
103
|
## 6.21.28
|
|
2
104
|
|
|
105
|
+
**Published. Contains the route only — see 6.21.29 for what it is missing.**
|
|
106
|
+
|
|
3
107
|
- `GET /meeting/:sessionId/content` — the readable meeting plus two ready-made
|
|
4
|
-
clipboard forms.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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.
|
|
108
|
+
clipboard forms. Operations-first resolution, identical to `/speakers`, so the
|
|
109
|
+
list row and this view can never describe the same meeting differently.
|
|
110
|
+
|
|
111
|
+
- The attendee block is rebuilt from the review rather than reusing the scribe's
|
|
112
|
+
own `## Attendees`, which applies no confidence floor: 2026-08 alone carries
|
|
113
|
+
scribes listing 55, 21, 20, 19, 18, 18, 17 and 15 attendees.
|
|
23
114
|
|
|
24
115
|
## 6.21.27
|
|
25
116
|
|
package/package.json
CHANGED
|
@@ -1,24 +1,44 @@
|
|
|
1
|
-
// The readable meeting
|
|
1
|
+
// The readable meeting, and the two forms of it that leave the machine.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
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.
|
|
3
|
+
// WHAT v1 GOT WRONG, because the shape of the fix follows from it.
|
|
9
4
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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.
|
|
13
11
|
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// be
|
|
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.
|
|
17
37
|
|
|
18
38
|
/** One `## Heading` section of a scribe file, in document order. */
|
|
19
39
|
export interface ScribeSection {
|
|
20
40
|
heading: string
|
|
21
|
-
/** Body
|
|
41
|
+
/** Body with the heading line removed, scaffolding stripped, edges trimmed. */
|
|
22
42
|
body: string
|
|
23
43
|
}
|
|
24
44
|
|
|
@@ -26,85 +46,212 @@ export interface ParsedScribe {
|
|
|
26
46
|
/** The `# Title` line, or '' when the file has none. */
|
|
27
47
|
title: string
|
|
28
48
|
sections: ScribeSection[]
|
|
29
|
-
/** Convenience lookups for the sections a UI renders directly. */
|
|
30
49
|
summary: string
|
|
31
50
|
topics: string
|
|
32
51
|
decisions: string
|
|
33
52
|
actions: string
|
|
34
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[]
|
|
35
77
|
}
|
|
36
78
|
|
|
37
|
-
const
|
|
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
|
+
}
|
|
38
122
|
|
|
39
123
|
/**
|
|
40
|
-
* Split a scribe
|
|
124
|
+
* Split a scribe into its `##` sections.
|
|
41
125
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
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.
|
|
47
133
|
*/
|
|
48
134
|
export function parseScribe(markdown: string): ParsedScribe {
|
|
49
|
-
const lines = markdown.split('\n')
|
|
135
|
+
const lines = markdown.replace(/\r\n?/g, '\n').split('\n')
|
|
50
136
|
let title = ''
|
|
51
137
|
const sections: ScribeSection[] = []
|
|
52
138
|
let current: ScribeSection | null = null
|
|
53
|
-
|
|
139
|
+
let inFence = false
|
|
54
140
|
|
|
55
141
|
for (const line of lines) {
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
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
|
+
}
|
|
62
151
|
}
|
|
63
152
|
if (current) current.body += line + '\n'
|
|
64
|
-
else preamble.push(line)
|
|
65
153
|
}
|
|
66
|
-
if (current) sections.push({ ...current, body: current.body
|
|
154
|
+
if (current) sections.push({ ...current, body: cleanBody(current.body) })
|
|
67
155
|
|
|
68
|
-
const
|
|
156
|
+
const used = new Set<ScribeSection>()
|
|
157
|
+
/** Exact heading match, first name wins. */
|
|
158
|
+
const exact = (...names: string[]): string => {
|
|
69
159
|
for (const n of names) {
|
|
70
|
-
const s = sections.find(x => x.heading.toLowerCase() === n.toLowerCase())
|
|
71
|
-
if (s) return s.body
|
|
160
|
+
const s = sections.find(x => x.heading.toLowerCase() === n.toLowerCase() && !used.has(x))
|
|
161
|
+
if (s) { used.add(s); return s.body }
|
|
72
162
|
}
|
|
73
163
|
return ''
|
|
74
164
|
}
|
|
75
|
-
|
|
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
|
+
|
|
76
212
|
return {
|
|
77
213
|
title,
|
|
78
214
|
sections,
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
summary: summary || (sections.length === 0 ? markdown.
|
|
82
|
-
topics
|
|
83
|
-
decisions
|
|
84
|
-
actions
|
|
85
|
-
transcript
|
|
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),
|
|
86
225
|
}
|
|
87
226
|
}
|
|
88
227
|
|
|
89
228
|
/**
|
|
90
229
|
* The meeting's calendar date, as `YYYY-MM-DD`.
|
|
91
230
|
*
|
|
92
|
-
* `startTime`
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* a UTC conversion would disagree with the file for anything late in the evening.
|
|
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.
|
|
97
235
|
*/
|
|
98
236
|
export function meetingDate(startTime: unknown): string {
|
|
99
237
|
let ms: number | null = null
|
|
100
238
|
if (typeof startTime === 'number' && Number.isFinite(startTime)) {
|
|
101
|
-
//
|
|
102
|
-
|
|
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
|
|
103
242
|
} else if (typeof startTime === 'string' && startTime.trim()) {
|
|
104
|
-
const
|
|
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))
|
|
105
251
|
if (Number.isFinite(t)) ms = t
|
|
106
252
|
}
|
|
107
|
-
|
|
253
|
+
// 0 and negatives are not real capture times; they rendered as "1969-12-31".
|
|
254
|
+
if (ms === null || ms <= 0) return ''
|
|
108
255
|
const d = new Date(ms)
|
|
109
256
|
if (Number.isNaN(d.getTime())) return ''
|
|
110
257
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
@@ -117,58 +264,244 @@ export interface AttendeeLine {
|
|
|
117
264
|
/** False when the review refuses to assert this name. */
|
|
118
265
|
asserted: boolean
|
|
119
266
|
speakingMs: number
|
|
120
|
-
/** Share of
|
|
267
|
+
/** Share of identified speech, 0..1, or null when unknown/unnamed. */
|
|
121
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
|
|
122
279
|
}
|
|
123
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
|
+
|
|
124
290
|
function mmss(ms: number): string {
|
|
125
|
-
|
|
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))
|
|
126
294
|
const m = Math.floor(total / 60)
|
|
127
295
|
const s = total % 60
|
|
128
|
-
|
|
296
|
+
if (m === 0) return `${s}s`
|
|
297
|
+
return s === 0 ? `${m}m` : `${m}m ${s}s`
|
|
129
298
|
}
|
|
130
299
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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. Only used to phrase the crosstalk note.
|
|
318
|
+
*/
|
|
319
|
+
voicedMs: number
|
|
320
|
+
/**
|
|
321
|
+
* How long the meeting RAN. This is what the overlap note is gated on.
|
|
322
|
+
*
|
|
323
|
+
* Gating on `voicedMs` instead tripped on 16 of 23 real meetings, because any
|
|
324
|
+
* overlap at all exceeds the union of voiced time — at 70% the line stops being
|
|
325
|
+
* a signal and becomes boilerplate on something Miles pastes into Slack. Rows
|
|
326
|
+
* adding to more than the meeting LENGTH is the genuinely confusing case (71m
|
|
327
|
+
* of rows inside a 66-minute meeting), and that is ~10% of meetings.
|
|
328
|
+
*/
|
|
329
|
+
durationMs: number
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** The attendee block, floor-applied. */
|
|
333
|
+
export function renderAttendees(voices: AttendeeLine[], o: AttendeeRenderOptions): string {
|
|
140
334
|
const named = voices.filter(v => v.asserted).sort((a, b) => b.speakingMs - a.speakingMs)
|
|
141
335
|
const rest = voices.filter(v => !v.asserted)
|
|
336
|
+
// A share needs someone to be a share OF. With one named voice it is always
|
|
337
|
+
// "100% of identified speech" — 23 real meetings — and on screen that reads as
|
|
338
|
+
// "he did all the talking" when the same meeting had 6m 29s unidentified.
|
|
339
|
+
const showShares = o.coverage !== null &&
|
|
340
|
+
o.coverage >= SHARE_COVERAGE_FLOOR &&
|
|
341
|
+
named.length >= 2
|
|
142
342
|
const out: string[] = []
|
|
143
343
|
for (const v of named) {
|
|
144
|
-
const pct =
|
|
145
|
-
|
|
344
|
+
const pct = showShares && v.share !== null
|
|
345
|
+
? ` · ${Math.round(v.share * 100)}% of identified speech`
|
|
346
|
+
: ''
|
|
347
|
+
out.push(`- ${v.label}: ${mmss(v.speakingMs)}${pct}`)
|
|
146
348
|
}
|
|
147
|
-
const restMs = rest.reduce((n, v) => n + v.speakingMs, 0)
|
|
148
349
|
if (rest.length > 0) {
|
|
149
|
-
out.push(`- Unidentified
|
|
350
|
+
out.push(`- Unidentified: ${mmss(o.unattributedMs)} across ${rest.length} ` +
|
|
150
351
|
`voice${rest.length === 1 ? '' : 's'} the review could not name`)
|
|
151
352
|
}
|
|
353
|
+
// Each figure above is that voice's OWN union of speaking time, so crosstalk is
|
|
354
|
+
// counted once per person — but two people talking over each other is counted
|
|
355
|
+
// once EACH, and the rows then add to more than the meeting. 34 of 323 real
|
|
356
|
+
// meetings overflow, worst case 71m of rows inside 66 minutes. The numbers are
|
|
357
|
+
// right; adding them is what misleads, so say so rather than shrink anyone.
|
|
358
|
+
const rowTotal = named.reduce((t, v) => t + (Number.isFinite(v.speakingMs) ? v.speakingMs : 0), 0) +
|
|
359
|
+
(rest.length > 0 && Number.isFinite(o.unattributedMs) ? o.unattributedMs : 0)
|
|
360
|
+
if (o.durationMs > 0 && rowTotal > o.durationMs * 1.02) {
|
|
361
|
+
out.push(`- (these overlap: people talked over each other, so the rows add up to ` +
|
|
362
|
+
`more than the ${mmss(o.durationMs)} the meeting ran)`)
|
|
363
|
+
}
|
|
152
364
|
return out.length > 0 ? out.join('\n') : '- (no voices identified)'
|
|
153
365
|
}
|
|
154
366
|
|
|
367
|
+
/**
|
|
368
|
+
* The provenance block: who was confirmed, and a warning covering everyone else.
|
|
369
|
+
*
|
|
370
|
+
* An ALLOWLIST rather than a blocklist. The prose and transcript below carry raw
|
|
371
|
+
* capture labels, some of which the review rejected, and a reader pasting this
|
|
372
|
+
* into a model has no other way to tell which. Naming the confirmed set is short
|
|
373
|
+
* and complete.
|
|
374
|
+
*/
|
|
375
|
+
/**
|
|
376
|
+
* Names a human explicitly REMOVED from this meeting, still present in the prose.
|
|
377
|
+
*
|
|
378
|
+
* De-attribution rewrites the sidecar, the attendee list and the transcript
|
|
379
|
+
* labels, but deliberately leaves narrative prose alone — substituting into a
|
|
380
|
+
* written sentence mangles grammar and can hit the wrong person. So the applied
|
|
381
|
+
* correction row records `proseStale: true` and, until now, NOTHING read it.
|
|
382
|
+
*
|
|
383
|
+
* Real case, 2026-08-07: Miles removed "Clem Ukaoma" from a personal call that
|
|
384
|
+
* was only him and Queen (his father's voice matched a similar profile). All 8
|
|
385
|
+
* label sites were rewritten correctly; the LLM summary still opened "Miles,
|
|
386
|
+
* Queen, and Clem talk through..." and the payload said nothing. An allowlist
|
|
387
|
+
* ("this name was not confirmed") is far too weak here — he did not fail to
|
|
388
|
+
* confirm this person, he explicitly said they were not in the room.
|
|
389
|
+
*/
|
|
390
|
+
export interface RemovedName {
|
|
391
|
+
label: string
|
|
392
|
+
/** True when narrative prose still carries the name. */
|
|
393
|
+
proseStale: boolean
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export function renderProvenance(
|
|
397
|
+
voices: AttendeeLine[],
|
|
398
|
+
coverage: number | null,
|
|
399
|
+
removed: RemovedName[] = [],
|
|
400
|
+
): string {
|
|
401
|
+
const named = voices.filter(v => v.asserted)
|
|
402
|
+
// Quoted because a label may legitimately contain a comma — `invalidLabelReason`
|
|
403
|
+
// rejects brackets and newlines but not commas, and a typed "Smith, John" would
|
|
404
|
+
// otherwise read as two confirmed people.
|
|
405
|
+
const q = (v: AttendeeLine) => `"${v.label}"`
|
|
406
|
+
const clauses: string[] = []
|
|
407
|
+
const owner = named.filter(v => v.isOwner).map(q)
|
|
408
|
+
const human = named.filter(v => !v.isOwner && v.confirmedByHuman).map(q)
|
|
409
|
+
const matched = named.filter(v => !v.isOwner && !v.confirmedByHuman).map(q)
|
|
410
|
+
if (owner.length) clauses.push(`${owner.join(', ')} by wearing the device`)
|
|
411
|
+
if (matched.length) clauses.push(`${matched.join(', ')} by voice match`)
|
|
412
|
+
if (human.length) clauses.push(`${human.join(', ')} because a human named that voice`)
|
|
413
|
+
const lines: string[] = [
|
|
414
|
+
clauses.length > 0
|
|
415
|
+
? `Names established here: ${clauses.join('; ')}.`
|
|
416
|
+
: 'No name in this meeting was established by any means.',
|
|
417
|
+
'Every other name below — and any different spelling of the ones just listed — ' +
|
|
418
|
+
'comes from the raw capture or the write-up and was NOT confirmed. Some may be ' +
|
|
419
|
+
'people mentioned rather than people present.',
|
|
420
|
+
]
|
|
421
|
+
// Before the coverage caveat: an explicit human removal outranks every other
|
|
422
|
+
// statement in this block, and it must not read as one more hedge.
|
|
423
|
+
const stale = removed.filter(r => r.proseStale).map(r => `"${r.label}"`)
|
|
424
|
+
const quiet = removed.filter(r => !r.proseStale).map(r => `"${r.label}"`)
|
|
425
|
+
if (stale.length > 0) {
|
|
426
|
+
lines.push(
|
|
427
|
+
`You removed ${stale.join(', ')} from this meeting. The write-up below was ` +
|
|
428
|
+
'written before that and still uses the name: treat every mention of it as a ' +
|
|
429
|
+
'capture error, not a participant.',
|
|
430
|
+
)
|
|
431
|
+
}
|
|
432
|
+
if (quiet.length > 0) {
|
|
433
|
+
lines.push(`You also removed ${quiet.join(', ')} from this meeting.`)
|
|
434
|
+
}
|
|
435
|
+
if (coverage !== null && coverage < SHARE_COVERAGE_FLOOR) {
|
|
436
|
+
lines.push(
|
|
437
|
+
`Only ${Math.round(coverage * 100)}% of the voice in this meeting was ` +
|
|
438
|
+
'identified, so speaking shares are not reported.',
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
return lines.join(' ')
|
|
442
|
+
}
|
|
443
|
+
|
|
155
444
|
export interface ClipboardInput {
|
|
445
|
+
/**
|
|
446
|
+
* Which business this meeting belongs to, when known.
|
|
447
|
+
*
|
|
448
|
+
* 98 of 251 real meetings are `personal`, and 25 of 251 summaries carry
|
|
449
|
+
* compensation, termination, or legal content — while the buttons are framed
|
|
450
|
+
* for Slack and email. Nothing in the payload distinguished a 1:1 about
|
|
451
|
+
* someone's bonus from a marketing sync. The sibling /speakers route already
|
|
452
|
+
* carries this; this one dropped it.
|
|
453
|
+
*/
|
|
454
|
+
domain: string
|
|
455
|
+
/**
|
|
456
|
+
* Characters of transcript present in the RECORDING, whether or not a write-up
|
|
457
|
+
* exists. Distinguishes "not written up yet" from "nobody spoke".
|
|
458
|
+
*/
|
|
459
|
+
capturedChars: number
|
|
460
|
+
/** UNION of voiced time, for the crosstalk-overflow note. */
|
|
461
|
+
voicedMs: number
|
|
462
|
+
/** How long the meeting ran. Gates the crosstalk-overflow note. */
|
|
463
|
+
durationMs: number
|
|
464
|
+
/** Names a human explicitly de-attributed from this meeting. */
|
|
465
|
+
removed: RemovedName[]
|
|
156
466
|
title: string
|
|
157
467
|
date: string
|
|
158
468
|
durationMin: number
|
|
159
469
|
attendees: AttendeeLine[]
|
|
160
470
|
scribe: ParsedScribe
|
|
471
|
+
/** Both required, so neither can be omitted. See AttendeeRenderOptions. */
|
|
472
|
+
coverage: number | null
|
|
473
|
+
unattributedMs: number
|
|
161
474
|
}
|
|
162
475
|
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
476
|
+
/**
|
|
477
|
+
* Per-extra size ceiling for the COMPACT form.
|
|
478
|
+
*
|
|
479
|
+
* A heading cannot be trusted to reveal a transcript: one real scribe carries a
|
|
480
|
+
* full one under `## G2 Glasses Enrichment ... (Combined)`. Gating on SIZE
|
|
481
|
+
* catches that generically, where a keyword list never would. The full form has
|
|
482
|
+
* no ceiling.
|
|
483
|
+
*/
|
|
484
|
+
export const SUMMARY_EXTRA_MAX_CHARS = 2000
|
|
485
|
+
|
|
486
|
+
function header(i: ClipboardInput, compact: boolean): string[] {
|
|
487
|
+
const when = [i.date, i.durationMin > 0 ? `${i.durationMin} minutes` : '']
|
|
488
|
+
.filter(Boolean).join(' · ')
|
|
489
|
+
const parts = [`# ${i.title || 'Untitled meeting'}`]
|
|
490
|
+
// Domain on the same line as the date. A reader about to paste this into a
|
|
491
|
+
// channel should see "personal" before they see the content, not after.
|
|
492
|
+
const line = [when, i.domain ? `${i.domain.replace(/_/g, ' ')} meeting` : ''].filter(Boolean).join(' · ')
|
|
493
|
+
if (line) parts.push(line)
|
|
494
|
+
if (i.domain === 'personal') {
|
|
495
|
+
parts.push('', '> Personal meeting. Check before pasting this anywhere shared.')
|
|
496
|
+
}
|
|
497
|
+
parts.push(
|
|
498
|
+
'', '## Who spoke',
|
|
499
|
+
renderAttendees(i.attendees, {
|
|
500
|
+
coverage: i.coverage, unattributedMs: i.unattributedMs,
|
|
501
|
+
voicedMs: i.voicedMs, durationMs: i.durationMs,
|
|
502
|
+
}),
|
|
503
|
+
'', renderProvenance(i.attendees, i.coverage, i.removed),
|
|
504
|
+
)
|
|
172
505
|
for (const [heading, body] of [
|
|
173
506
|
['Summary', i.scribe.summary],
|
|
174
507
|
['Topics', i.scribe.topics],
|
|
@@ -177,16 +510,45 @@ export function clipboardSummary(i: ClipboardInput): string {
|
|
|
177
510
|
] as const) {
|
|
178
511
|
if (body.trim()) parts.push('', `## ${heading}`, body.trim())
|
|
179
512
|
}
|
|
513
|
+
for (const s of i.scribe.extras) {
|
|
514
|
+
const body = s.body.trim()
|
|
515
|
+
if (compact && body.length > SUMMARY_EXTRA_MAX_CHARS) {
|
|
516
|
+
parts.push('', `## ${s.heading}`, `_Omitted from the summary (${body.length} characters). Use Copy full._`)
|
|
517
|
+
continue
|
|
518
|
+
}
|
|
519
|
+
parts.push('', `## ${s.heading}`, body)
|
|
520
|
+
}
|
|
521
|
+
return parts
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Compact form: for pasting into a message. No transcript. */
|
|
525
|
+
export function clipboardSummary(i: ClipboardInput): string {
|
|
526
|
+
const parts = header(i, true)
|
|
527
|
+
// State that a transcript exists but was omitted. Without this, a reader
|
|
528
|
+
// holding only the summary cannot tell whether one exists at all.
|
|
529
|
+
const tx = i.scribe.transcript.trim()
|
|
530
|
+
if (tx) parts.push('', `_Transcript omitted (${tx.length} characters). Use Copy full for it._`)
|
|
180
531
|
return parts.join('\n') + '\n'
|
|
181
532
|
}
|
|
182
533
|
|
|
183
|
-
/** Full form: everything including the transcript, for pasting into
|
|
534
|
+
/** Full form: everything including the transcript, for pasting into a model. */
|
|
184
535
|
export function clipboardFull(i: ClipboardInput): string {
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
536
|
+
const parts = header(i, false)
|
|
537
|
+
const tx = i.scribe.transcript.trim()
|
|
538
|
+
// The section's OWN heading, so a `Transcript Enrichment` note is never
|
|
539
|
+
// presented as the transcript.
|
|
540
|
+
// "(no transcript in this scribe)" was a lie on 140 of 399 real sidecars: the
|
|
541
|
+
// write-up has not been generated yet, while the recording itself holds the
|
|
542
|
+
// speech — one case today had 27,442 characters sitting in the sidecar this
|
|
543
|
+
// route had just parsed. Say which of the two is actually true.
|
|
544
|
+
const fallback = i.capturedChars > 0
|
|
545
|
+
? `(no write-up saved for this meeting yet — ${i.capturedChars} characters of ` +
|
|
546
|
+
`transcript are in the recording, and will appear here once it is written up)`
|
|
547
|
+
: '(no transcript in this scribe)'
|
|
548
|
+
parts.push('', `## ${i.scribe.transcriptHeading || 'Transcript'}`, tx || fallback)
|
|
549
|
+
// Additional transcript sections, full form only.
|
|
550
|
+
for (const o of i.scribe.otherTranscripts) {
|
|
551
|
+
if (o.body.trim()) parts.push('', `## ${o.heading}`, o.body.trim())
|
|
190
552
|
}
|
|
191
|
-
return
|
|
553
|
+
return parts.join('\n') + '\n'
|
|
192
554
|
}
|
package/server/routes/meeting.ts
CHANGED
|
@@ -8,7 +8,7 @@ import { Router } from 'express'
|
|
|
8
8
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
9
9
|
import { cleanTranscriptLines } from '../lib/hallucination-filter.js'
|
|
10
10
|
import { durableAtomicWriteFileSync } from '../lib/atomic-fs.js'
|
|
11
|
-
import { appendCorrection, pendingCorrections } from '../lib/meeting-corrections.js'
|
|
11
|
+
import { appendCorrection, appliedCorrections, pendingCorrections } from '../lib/meeting-corrections.js'
|
|
12
12
|
import { isSampleFromSession, untraceableSampleCount } from '../lib/training-audio-provenance.js'
|
|
13
13
|
import { sendAudioFile } from '../lib/send-audio.js'
|
|
14
14
|
import { chunkDiagnostics } from '../lib/chunk-embedding-diagnostics.js'
|
|
@@ -118,14 +118,15 @@ import {
|
|
|
118
118
|
attachRawChunkIndices,
|
|
119
119
|
isUnattributed,
|
|
120
120
|
reviewMeetingSpeakers,
|
|
121
|
+
type ReviewChunk,
|
|
121
122
|
type SpeakerWordSegment,
|
|
122
123
|
} from '../lib/meeting-speaker-review.js'
|
|
123
|
-
import { type ReviewChunk } from '../lib/meeting-speaker-review.js'
|
|
124
124
|
import {
|
|
125
125
|
parseScribe,
|
|
126
126
|
clipboardSummary,
|
|
127
127
|
clipboardFull,
|
|
128
128
|
meetingDate,
|
|
129
|
+
SHARE_COVERAGE_FLOOR,
|
|
129
130
|
} from '../lib/meeting-scribe-content.js'
|
|
130
131
|
import {
|
|
131
132
|
acquireMaintenanceWork,
|
|
@@ -815,11 +816,22 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
815
816
|
return
|
|
816
817
|
}
|
|
817
818
|
const rawChunks = Array.isArray(sidecar) ? sidecar : sidecar.chunks
|
|
818
|
-
|
|
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[]
|
|
819
828
|
|
|
820
829
|
const review = reviewMeetingSpeakers(attachRawChunkIndices(chunks, sidecar.chunkEntries), {
|
|
821
830
|
owner: getOwnerSpeakerLabel(),
|
|
822
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,
|
|
823
835
|
durationMs: typeof sidecar.durationMs === 'number' ? sidecar.durationMs : undefined,
|
|
824
836
|
batchSegments: Array.isArray(sidecar.batchSegments)
|
|
825
837
|
? (sidecar.batchSegments as SpeakerWordSegment[])
|
|
@@ -835,6 +847,11 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
835
847
|
asserted: v.nameAsserted,
|
|
836
848
|
speakingMs: v.speakingMs,
|
|
837
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,
|
|
838
855
|
}))
|
|
839
856
|
|
|
840
857
|
// A missing .md is not fatal — the review and the sidecar still describe the
|
|
@@ -845,7 +862,52 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
845
862
|
|
|
846
863
|
const date = meetingDate(sidecar.startTime)
|
|
847
864
|
const durationMin = Math.round((review.durationMs || 0) / 60_000)
|
|
848
|
-
|
|
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
|
+
voicedMs: review.voicedMs,
|
|
884
|
+
// The overlap note is gated on how long the meeting RAN. Gating on voiced
|
|
885
|
+
// time tripped on 16 of 23 real meetings.
|
|
886
|
+
durationMs: review.durationMs || 0,
|
|
887
|
+
// Names this human explicitly de-attributed. The ledger has recorded
|
|
888
|
+
// `proseStale` on every applied de-attribution since the feature shipped and
|
|
889
|
+
// NOTHING has ever read it: on 2026-08-07 Miles removed "Clem Ukaoma" from a
|
|
890
|
+
// call that was only him and Queen, all 8 label sites were rewritten, and the
|
|
891
|
+
// summary still opened "Miles, Queen, and Clem talk through..." with no
|
|
892
|
+
// indication anywhere in the payload.
|
|
893
|
+
removed: appliedCorrections(sessionId)
|
|
894
|
+
.filter(r => isUnattributed(r.to))
|
|
895
|
+
.map(r => ({ label: r.from, proseStale: r.proseStale === true })),
|
|
896
|
+
// Which business this is. The sibling /speakers route has always carried
|
|
897
|
+
// this and this one dropped it, so a personal 1:1 about someone's
|
|
898
|
+
// compensation was byte-identical to a marketing sync.
|
|
899
|
+
domain: operations?.domain ?? saved?.domain ?? '',
|
|
900
|
+
// Transcript actually captured, whether or not a write-up exists yet. 140
|
|
901
|
+
// of 399 real sidecars have no .md, and the fallback text claimed there was
|
|
902
|
+
// no transcript while holding one.
|
|
903
|
+
capturedChars: chunks.reduce(
|
|
904
|
+
(t, c) => t + (typeof c?.text === 'string' ? c.text.length : 0), 0),
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Rendered ONCE. Calling each builder twice to measure its own length meant
|
|
908
|
+
// two extra full renders per request, transcript included.
|
|
909
|
+
const summaryText = clipboardSummary(clip)
|
|
910
|
+
const fullText = clipboardFull(clip)
|
|
849
911
|
|
|
850
912
|
res.set('Cache-Control', 'private, no-store')
|
|
851
913
|
res.json({
|
|
@@ -861,9 +923,23 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
|
|
|
861
923
|
topics: scribe.topics,
|
|
862
924
|
decisions: scribe.decisions,
|
|
863
925
|
actions: scribe.actions,
|
|
926
|
+
extras: scribe.extras.map(x => ({ heading: x.heading, body: x.body })),
|
|
864
927
|
transcriptChars: scribe.transcript.length,
|
|
865
|
-
|
|
866
|
-
|
|
928
|
+
// Captured vs written-up are different facts and the panel needs both:
|
|
929
|
+
// transcriptChars is 0 for a meeting with 27,442 characters of speech and
|
|
930
|
+
// no write-up yet.
|
|
931
|
+
capturedChars: clip.capturedChars,
|
|
932
|
+
domain: clip.domain,
|
|
933
|
+
// So the panel can warn above the write-up, not just the clipboard.
|
|
934
|
+
removedNames: clip.removed,
|
|
935
|
+
coverage,
|
|
936
|
+
sharesReported: coverage !== null && coverage >= SHARE_COVERAGE_FLOOR,
|
|
937
|
+
clipboardSummary: summaryText,
|
|
938
|
+
clipboardFull: fullText,
|
|
939
|
+
// Sizes of the ACTUAL strings, so the button label and the copy
|
|
940
|
+
// confirmation can never quote two different numbers for one click.
|
|
941
|
+
summaryChars: summaryText.length,
|
|
942
|
+
fullChars: fullText.length,
|
|
867
943
|
})
|
|
868
944
|
})
|
|
869
945
|
|