@gotcos/glasses-server 6.7.0 → 6.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/package.json +1 -1
- package/server/index.ts +4 -0
- package/server/lib/atomic-fs.ts +77 -1
- package/server/lib/batch-transcript-quality.ts +243 -0
- package/server/lib/meeting-batch-persistence.ts +53 -0
- package/server/lib/meeting-batch-transcribe.ts +249 -0
- package/server/lib/meeting-store.ts +594 -0
- package/server/routes/health.ts +1 -0
- package/server/routes/meeting.ts +329 -0
- package/server/routes/meetings.ts +66 -0
- package/server/routes/transcribe-stream.ts +116 -23
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 6.8.0
|
|
4
|
+
|
|
5
|
+
Public-safe meeting finalization for COS Glasses build 199.
|
|
6
|
+
|
|
7
|
+
- **Authenticated meeting save.** `POST /api/meeting/save` finalizes an existing
|
|
8
|
+
`transcribe-stream` session without adding coaching, private classification,
|
|
9
|
+
personal paths, or COS-only enrichment to the public package. Lost-chunk gaps,
|
|
10
|
+
original client timing, provider evidence, and sparse raw-audio indices remain
|
|
11
|
+
intact through deferred iPhone replay and save.
|
|
12
|
+
- **Durable standalone archive.** Canonical markdown and structured sidecars are
|
|
13
|
+
published atomically under `dataPath('recordings', 'YYYY-MM')`. Directories are
|
|
14
|
+
`0700`, files are `0600`, filenames are path-safe and session-unique, and an
|
|
15
|
+
fsync-backed sidecar-first/markdown-last commit keeps incomplete pairs hidden.
|
|
16
|
+
- **Review on the current client.** Authenticated `GET /api/meetings`, literal
|
|
17
|
+
`GET /api/meetings/detail`, and the build199-compatible dynamic detail route
|
|
18
|
+
list and read standalone recordings after process/package restarts. Traversal,
|
|
19
|
+
unsafe filenames, symlinked roots/months/files, absolute-path disclosure, and
|
|
20
|
+
cross-domain detail mismatches fail closed.
|
|
21
|
+
- **Transcript-quality bouncer.** Post-meeting batch text must preserve at least
|
|
22
|
+
50% live coverage, provide independent evidence when no live baseline exists,
|
|
23
|
+
and avoid repeated long segments/sentences/prefixes. Mixed timestamp coverage
|
|
24
|
+
falls back to complete batch text instead of dropping text-only segments.
|
|
25
|
+
- **Recovery evidence wins.** Canonical streaming text remains untouched when a
|
|
26
|
+
batch is rejected or cannot be applied. Pending WAVs are deleted only after
|
|
27
|
+
accepted text and its sidecar decision are both durable; every other outcome
|
|
28
|
+
retains audio for bounded two-hour cleanup. HQ batch decoders serialize and
|
|
29
|
+
refresh their cleanup lease while queued or active.
|
|
30
|
+
- **Capability detection.** `/api/health` now advertises
|
|
31
|
+
`features.meetingFinalization` for compatible clients.
|
|
32
|
+
|
|
3
33
|
## 6.7.0
|
|
4
34
|
|
|
5
35
|
Durable prompt recovery and self-healing local transcription for COS Glasses
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -18,6 +18,8 @@ import { queryRouter } from './routes/query.js'
|
|
|
18
18
|
import { transcribeRouter } from './routes/transcribe.js'
|
|
19
19
|
import { displayRouter } from './routes/display.js'
|
|
20
20
|
import { transcribeStreamRouter } from './routes/transcribe-stream.js'
|
|
21
|
+
import { meetingRouter } from './routes/meeting.js'
|
|
22
|
+
import { meetingsRouter } from './routes/meetings.js'
|
|
21
23
|
import { openaiCompatRouter } from './routes/openai-compat.js'
|
|
22
24
|
import { openaiKeyRouter } from './routes/openai-key.js'
|
|
23
25
|
import { messageRefRouter } from './routes/message-ref.js'
|
|
@@ -141,6 +143,8 @@ app.use('/api', queryRouter)
|
|
|
141
143
|
app.use('/api', transcribeRouter)
|
|
142
144
|
app.use('/api', displayRouter)
|
|
143
145
|
app.use('/api', transcribeStreamRouter)
|
|
146
|
+
app.use('/api', meetingRouter)
|
|
147
|
+
app.use('/api', meetingsRouter)
|
|
144
148
|
app.use('/api', openaiKeyRouter)
|
|
145
149
|
// v6.3.0 — Message History, cross-day 'reference message N', and history
|
|
146
150
|
// recovery for public npx users (previously full-COS-server only).
|
package/server/lib/atomic-fs.ts
CHANGED
|
@@ -7,7 +7,20 @@
|
|
|
7
7
|
// Use for sessions.json, archive/*.json, and any other durable JSON we can't
|
|
8
8
|
// afford to lose.
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
closeSync,
|
|
12
|
+
constants,
|
|
13
|
+
existsSync,
|
|
14
|
+
fchmodSync,
|
|
15
|
+
fsyncSync,
|
|
16
|
+
openSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
renameSync,
|
|
19
|
+
unlinkSync,
|
|
20
|
+
writeFileSync,
|
|
21
|
+
} from 'node:fs'
|
|
22
|
+
import { randomBytes } from 'node:crypto'
|
|
23
|
+
import { basename, dirname, join } from 'node:path'
|
|
11
24
|
|
|
12
25
|
export function atomicWriteFileSync(path: string, data: string | Buffer, options: { mode?: number } = {}): void {
|
|
13
26
|
const tmp = `${path}.tmp`
|
|
@@ -15,6 +28,69 @@ export function atomicWriteFileSync(path: string, data: string | Buffer, options
|
|
|
15
28
|
renameSync(tmp, path)
|
|
16
29
|
}
|
|
17
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Publish private durable state without ever exposing a partially-written file.
|
|
33
|
+
*
|
|
34
|
+
* The existing `atomicWriteFileSync` intentionally remains lightweight for
|
|
35
|
+
* high-frequency caches. Meeting finalization uses this stronger variant:
|
|
36
|
+
* bytes and file metadata are fsync'd before rename, the destination mode is
|
|
37
|
+
* forced after publish, and the containing directory is fsync'd where the
|
|
38
|
+
* platform supports it. A randomized exclusive temp name also prevents two
|
|
39
|
+
* independent writers from sharing `<path>.tmp`.
|
|
40
|
+
*/
|
|
41
|
+
export function durableAtomicWriteFileSync(
|
|
42
|
+
path: string,
|
|
43
|
+
data: string | Buffer,
|
|
44
|
+
options: { mode?: number } = {},
|
|
45
|
+
): void {
|
|
46
|
+
const mode = options.mode ?? 0o600
|
|
47
|
+
const dir = dirname(path)
|
|
48
|
+
const tmp = join(
|
|
49
|
+
dir,
|
|
50
|
+
`.${basename(path)}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`,
|
|
51
|
+
)
|
|
52
|
+
let fd: number | null = null
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const noFollow = constants.O_NOFOLLOW ?? 0
|
|
56
|
+
fd = openSync(
|
|
57
|
+
tmp,
|
|
58
|
+
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow,
|
|
59
|
+
mode,
|
|
60
|
+
)
|
|
61
|
+
writeFileSync(fd, data)
|
|
62
|
+
fchmodSync(fd, mode)
|
|
63
|
+
fsyncSync(fd)
|
|
64
|
+
closeSync(fd)
|
|
65
|
+
fd = null
|
|
66
|
+
|
|
67
|
+
// The exclusive temp inode already has its final private mode. Apply chmod
|
|
68
|
+
// before the commit so no failure can be reported after rename succeeded.
|
|
69
|
+
renameSync(tmp, path)
|
|
70
|
+
|
|
71
|
+
// Persist the directory entry as well as the file contents. Some virtual
|
|
72
|
+
// filesystems do not support directory fsync; the already-fsync'd file and
|
|
73
|
+
// atomic rename still provide the strongest available behavior there.
|
|
74
|
+
let dirFd: number | null = null
|
|
75
|
+
try {
|
|
76
|
+
dirFd = openSync(dir, constants.O_RDONLY)
|
|
77
|
+
fsyncSync(dirFd)
|
|
78
|
+
} catch {
|
|
79
|
+
// Best available durability on filesystems that reject directory fsync.
|
|
80
|
+
} finally {
|
|
81
|
+
if (dirFd !== null) {
|
|
82
|
+
try { closeSync(dirFd) } catch { /* commit already succeeded */ }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (fd !== null) {
|
|
87
|
+
try { closeSync(fd) } catch { /* already closed */ }
|
|
88
|
+
}
|
|
89
|
+
try { unlinkSync(tmp) } catch { /* publish may already have renamed it */ }
|
|
90
|
+
throw error
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
18
94
|
/**
|
|
19
95
|
* Read + JSON.parse with explicit missing-vs-corrupt distinction.
|
|
20
96
|
* - File missing: returns { status: 'missing' } — caller starts fresh silently
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import type { WhisperWord } from './whisper-local.js'
|
|
2
|
+
|
|
3
|
+
export interface BatchSegment {
|
|
4
|
+
startChunkIdx: number
|
|
5
|
+
endChunkIdx: number
|
|
6
|
+
startElapsed: number
|
|
7
|
+
endElapsed: number
|
|
8
|
+
speakers: string[]
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface BatchResult {
|
|
12
|
+
segment: BatchSegment
|
|
13
|
+
text: string
|
|
14
|
+
words: WhisperWord[]
|
|
15
|
+
speakerWords: Array<{ word: string; start: number; end: number; speaker: string }>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface BatchTranscription {
|
|
19
|
+
transcriptionQuality: 'batch' | 'streaming'
|
|
20
|
+
batchTranscript?: string
|
|
21
|
+
batchSegments?: BatchResult[]
|
|
22
|
+
qualityReport?: BatchQualityReport
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface BatchTranscriptSelection {
|
|
26
|
+
text: string
|
|
27
|
+
source: 'speaker-words' | 'batch-text'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type BatchQualityReason =
|
|
31
|
+
| 'accepted'
|
|
32
|
+
| 'insufficient-coverage'
|
|
33
|
+
| 'insufficient-batch-evidence'
|
|
34
|
+
| 'repetitive-output'
|
|
35
|
+
|
|
36
|
+
export interface BatchQualityReport {
|
|
37
|
+
accepted: boolean
|
|
38
|
+
reason: BatchQualityReason
|
|
39
|
+
batchWordCount: number
|
|
40
|
+
streamingWordCount: number
|
|
41
|
+
coverageRatio: number
|
|
42
|
+
timedWordCount: number
|
|
43
|
+
timedWordRatio: number
|
|
44
|
+
maxRepeatedUnitCount: number
|
|
45
|
+
duplicateWordRatio: number
|
|
46
|
+
repeatedUnit?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RepetitionStats {
|
|
50
|
+
maxRepeatedUnitCount: number
|
|
51
|
+
duplicateWordRatio: number
|
|
52
|
+
repeatedUnit?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function countWords(text: string): number {
|
|
56
|
+
return text.trim().split(/\s+/).filter(Boolean).length
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Speaker timestamps are optional on fallback transcription backends. Use the
|
|
61
|
+
* attributed form only when every non-empty segment is represented; otherwise
|
|
62
|
+
* persist the complete batch text so text-only segments cannot disappear.
|
|
63
|
+
*/
|
|
64
|
+
export function selectBatchTranscriptForPersistence(
|
|
65
|
+
batchTranscript: string,
|
|
66
|
+
batchSegments: readonly Pick<BatchResult, 'text' | 'speakerWords'>[] | undefined,
|
|
67
|
+
): BatchTranscriptSelection {
|
|
68
|
+
const nonEmpty = (batchSegments ?? []).filter(segment => countWords(segment.text) > 0)
|
|
69
|
+
const completeSpeakerCoverage = nonEmpty.length > 0 && nonEmpty.every(segment => {
|
|
70
|
+
const textWords = countWords(segment.text)
|
|
71
|
+
return segment.speakerWords.length >= Math.max(1, Math.floor(textWords * 0.75))
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
if (!completeSpeakerCoverage) return { text: batchTranscript, source: 'batch-text' }
|
|
75
|
+
|
|
76
|
+
const lines: string[] = []
|
|
77
|
+
for (const segment of nonEmpty) {
|
|
78
|
+
let currentSpeaker = ''
|
|
79
|
+
let currentWords: string[] = []
|
|
80
|
+
for (const speakerWord of segment.speakerWords) {
|
|
81
|
+
const word = speakerWord.word.trim()
|
|
82
|
+
if (!word) continue
|
|
83
|
+
const speaker = speakerWord.speaker || 'Ext'
|
|
84
|
+
if (speaker !== currentSpeaker) {
|
|
85
|
+
if (currentWords.length > 0) lines.push(`[${currentSpeaker}]: ${currentWords.join(' ')}`)
|
|
86
|
+
currentSpeaker = speaker
|
|
87
|
+
currentWords = [word]
|
|
88
|
+
} else {
|
|
89
|
+
currentWords.push(word)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (currentWords.length > 0) lines.push(`[${currentSpeaker}]: ${currentWords.join(' ')}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return lines.length > 0
|
|
96
|
+
? { text: lines.join('\n'), source: 'speaker-words' }
|
|
97
|
+
: { text: batchTranscript, source: 'batch-text' }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeQualityUnit(text: string): string {
|
|
101
|
+
return text
|
|
102
|
+
.toLowerCase()
|
|
103
|
+
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
|
104
|
+
.replace(/\s+/g, ' ')
|
|
105
|
+
.trim()
|
|
106
|
+
// A repeated template with only a changing index/date/amount is still the
|
|
107
|
+
// same unit and must not manufacture apparent transcript coverage.
|
|
108
|
+
.replace(/\b\d+\b/g, '<n>')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function toLongQualityUnit(raw: string): { normalized: string; sample: string; wordCount: number } | null {
|
|
112
|
+
const sample = raw.replace(/\s+/g, ' ').trim()
|
|
113
|
+
const normalized = normalizeQualityUnit(sample)
|
|
114
|
+
const wordCount = countWords(normalized)
|
|
115
|
+
// Exempt short acknowledgements and ordinary meeting refrains. The observed
|
|
116
|
+
// failure repeated a complete, long prompted sentence dozens of times.
|
|
117
|
+
if (wordCount < 8 || normalized.length < 40) return null
|
|
118
|
+
return { normalized, sample, wordCount }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toLongPrefixUnit(raw: string): { normalized: string; sample: string; wordCount: number } | null {
|
|
122
|
+
const unit = toLongQualityUnit(raw)
|
|
123
|
+
if (!unit) return null
|
|
124
|
+
const prefixWords = unit.normalized.split(/\s+/).slice(0, 12)
|
|
125
|
+
if (prefixWords.length < 12) return null
|
|
126
|
+
return { normalized: prefixWords.join(' '), sample: unit.sample, wordCount: prefixWords.length }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function measureRepetition(
|
|
130
|
+
units: readonly { normalized: string; sample: string; wordCount: number }[],
|
|
131
|
+
batchWordCount: number,
|
|
132
|
+
): RepetitionStats {
|
|
133
|
+
const counts = new Map<string, { count: number; wordCount: number; sample: string }>()
|
|
134
|
+
for (const unit of units) {
|
|
135
|
+
const current = counts.get(unit.normalized)
|
|
136
|
+
if (current) current.count += 1
|
|
137
|
+
else counts.set(unit.normalized, { count: 1, wordCount: unit.wordCount, sample: unit.sample })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let maxRepeatedUnitCount = 0
|
|
141
|
+
let duplicateWords = 0
|
|
142
|
+
let repeatedUnit: string | undefined
|
|
143
|
+
for (const entry of counts.values()) {
|
|
144
|
+
if (entry.count <= 1) continue
|
|
145
|
+
duplicateWords += (entry.count - 1) * entry.wordCount
|
|
146
|
+
if (entry.count > maxRepeatedUnitCount) {
|
|
147
|
+
maxRepeatedUnitCount = entry.count
|
|
148
|
+
repeatedUnit = entry.sample.slice(0, 160)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
maxRepeatedUnitCount,
|
|
154
|
+
duplicateWordRatio: batchWordCount > 0 ? Math.min(1, duplicateWords / batchWordCount) : 0,
|
|
155
|
+
...(repeatedUnit ? { repeatedUnit } : {}),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Deterministic bouncer for a post-meeting transcript candidate. Coverage by
|
|
161
|
+
* itself is unsafe: Whisper can repeat one prompted sentence many times and
|
|
162
|
+
* appear to recover the whole meeting. Measure long exact units at segment,
|
|
163
|
+
* sentence, and prefix granularity while leaving short conversational repeats
|
|
164
|
+
* alone.
|
|
165
|
+
*/
|
|
166
|
+
export function evaluateBatchQuality(
|
|
167
|
+
batchResults: readonly Pick<BatchResult, 'text' | 'words'>[],
|
|
168
|
+
streamingWordCount: number,
|
|
169
|
+
): BatchQualityReport {
|
|
170
|
+
const batchText = batchResults.map(result => result.text).join(' ')
|
|
171
|
+
const batchWordCount = countWords(batchText)
|
|
172
|
+
const safeStreamingWordCount = Math.max(0, streamingWordCount)
|
|
173
|
+
const coverageRatio = safeStreamingWordCount > 0
|
|
174
|
+
? batchWordCount / safeStreamingWordCount
|
|
175
|
+
: (batchWordCount > 0 ? 1 : 0)
|
|
176
|
+
const timedWordCount = batchResults.reduce((sum, result) => sum + (result.words?.length ?? 0), 0)
|
|
177
|
+
const timedWordRatio = batchWordCount > 0 ? timedWordCount / batchWordCount : 0
|
|
178
|
+
const nonEmptySegmentCount = batchResults.filter(result => countWords(result.text) > 0).length
|
|
179
|
+
|
|
180
|
+
const segmentUnits = batchResults
|
|
181
|
+
.map(result => toLongQualityUnit(result.text))
|
|
182
|
+
.filter((unit): unit is NonNullable<typeof unit> => unit !== null)
|
|
183
|
+
const segmentStats = measureRepetition(segmentUnits, batchWordCount)
|
|
184
|
+
|
|
185
|
+
const sentenceUnits: Array<{ normalized: string; sample: string; wordCount: number }> = []
|
|
186
|
+
for (const result of batchResults) {
|
|
187
|
+
for (const sentence of result.text.match(/[^.!?\n]+(?:[.!?]+|$)/g) ?? []) {
|
|
188
|
+
const unit = toLongQualityUnit(sentence)
|
|
189
|
+
if (unit) sentenceUnits.push(unit)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const sentenceStats = measureRepetition(sentenceUnits, batchWordCount)
|
|
193
|
+
|
|
194
|
+
// Keep segment and sentence prefix populations separate. A one-sentence
|
|
195
|
+
// segment otherwise counts twice and can turn two valid repeats into four.
|
|
196
|
+
const segmentPrefixStats = measureRepetition(
|
|
197
|
+
batchResults
|
|
198
|
+
.map(result => toLongPrefixUnit(result.text))
|
|
199
|
+
.filter((unit): unit is NonNullable<typeof unit> => unit !== null),
|
|
200
|
+
batchWordCount,
|
|
201
|
+
)
|
|
202
|
+
const sentencePrefixStats = measureRepetition(
|
|
203
|
+
batchResults.flatMap(result => (
|
|
204
|
+
result.text.match(/[^.!?\n]+(?:[.!?]+|$)/g) ?? []
|
|
205
|
+
).map(sentence => toLongPrefixUnit(sentence)))
|
|
206
|
+
.filter((unit): unit is NonNullable<typeof unit> => unit !== null),
|
|
207
|
+
batchWordCount,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
const repetition = [segmentStats, sentenceStats, segmentPrefixStats, sentencePrefixStats]
|
|
211
|
+
.reduce((strongest, candidate) => (
|
|
212
|
+
candidate.duplicateWordRatio > strongest.duplicateWordRatio ? candidate : strongest
|
|
213
|
+
))
|
|
214
|
+
|
|
215
|
+
const repetitiveOutput = (
|
|
216
|
+
repetition.maxRepeatedUnitCount >= 4 && repetition.duplicateWordRatio >= 0.08
|
|
217
|
+
) || (
|
|
218
|
+
repetition.maxRepeatedUnitCount >= 8 && repetition.duplicateWordRatio >= 0.03
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const reason: BatchQualityReason = safeStreamingWordCount === 0 && (
|
|
222
|
+
batchWordCount < 50 || nonEmptySegmentCount < 2
|
|
223
|
+
)
|
|
224
|
+
? 'insufficient-batch-evidence'
|
|
225
|
+
: coverageRatio < 0.5
|
|
226
|
+
? 'insufficient-coverage'
|
|
227
|
+
: repetitiveOutput
|
|
228
|
+
? 'repetitive-output'
|
|
229
|
+
: 'accepted'
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
accepted: reason === 'accepted',
|
|
233
|
+
reason,
|
|
234
|
+
batchWordCount,
|
|
235
|
+
streamingWordCount: safeStreamingWordCount,
|
|
236
|
+
coverageRatio: Number(coverageRatio.toFixed(4)),
|
|
237
|
+
timedWordCount,
|
|
238
|
+
timedWordRatio: Number(timedWordRatio.toFixed(4)),
|
|
239
|
+
maxRepeatedUnitCount: repetition.maxRepeatedUnitCount,
|
|
240
|
+
duplicateWordRatio: Number(repetition.duplicateWordRatio.toFixed(4)),
|
|
241
|
+
...(repetition.repeatedUnit ? { repeatedUnit: repetition.repeatedUnit } : {}),
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { durableAtomicWriteFileSync } from './atomic-fs.js'
|
|
3
|
+
import type { BatchTranscription } from './batch-transcript-quality.js'
|
|
4
|
+
|
|
5
|
+
/** Replace only the final canonical transcript section. Missing markers fail closed. */
|
|
6
|
+
export function replaceMeetingTranscriptAtomic(meetingPath: string, transcript: string): boolean {
|
|
7
|
+
const content = readFileSync(meetingPath, 'utf8')
|
|
8
|
+
if (!/## Transcript\n\n[\s\S]*$/.test(content)) return false
|
|
9
|
+
const updated = content
|
|
10
|
+
.replace(/\| \*\*Transcription quality\*\* \| [^|\n]+ \|/i, '| **Transcription quality** | batch |')
|
|
11
|
+
.replace(/## Transcript\n\n[\s\S]*$/, `## Transcript\n\n${transcript}\n`)
|
|
12
|
+
durableAtomicWriteFileSync(meetingPath, updated, { mode: 0o600 })
|
|
13
|
+
return true
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Persist the batch decision without ever storing a rejected candidate as canonical. */
|
|
17
|
+
export function persistBatchDecisionSidecar(
|
|
18
|
+
sidecarPath: string,
|
|
19
|
+
batchResult: BatchTranscription,
|
|
20
|
+
batchApplied: boolean,
|
|
21
|
+
): boolean {
|
|
22
|
+
if (!existsSync(sidecarPath)) return false
|
|
23
|
+
const parsed = JSON.parse(readFileSync(sidecarPath, 'utf8')) as unknown
|
|
24
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false
|
|
25
|
+
const sidecar = parsed as Record<string, unknown>
|
|
26
|
+
sidecar.transcriptionQuality = batchApplied ? 'batch' : 'streaming'
|
|
27
|
+
sidecar.batchApplied = batchApplied
|
|
28
|
+
if (batchResult.qualityReport) sidecar.batchQualityReport = batchResult.qualityReport
|
|
29
|
+
|
|
30
|
+
if (batchApplied) {
|
|
31
|
+
sidecar.batchTranscript = batchResult.batchTranscript
|
|
32
|
+
sidecar.batchSegments = batchResult.batchSegments?.map(result => ({
|
|
33
|
+
startChunkIdx: result.segment.startChunkIdx,
|
|
34
|
+
endChunkIdx: result.segment.endChunkIdx,
|
|
35
|
+
startElapsed: result.segment.startElapsed,
|
|
36
|
+
endElapsed: result.segment.endElapsed,
|
|
37
|
+
text: result.text,
|
|
38
|
+
words: result.words,
|
|
39
|
+
speakerWords: result.speakerWords,
|
|
40
|
+
}))
|
|
41
|
+
} else {
|
|
42
|
+
delete sidecar.batchTranscript
|
|
43
|
+
delete sidecar.batchSegments
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
durableAtomicWriteFileSync(sidecarPath, JSON.stringify(sidecar, null, 2), { mode: 0o600 })
|
|
47
|
+
return true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Raw audio is disposable only after canonical text and decision metadata are durable. */
|
|
51
|
+
export function canDeletePendingBatchAudio(batchApplied: boolean, metadataPersisted: boolean): boolean {
|
|
52
|
+
return batchApplied && metadataPersisted
|
|
53
|
+
}
|