@gotcos/glasses-server 6.76.1 → 6.77.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 +40 -0
- package/managed-runtime-contract.json +8 -0
- package/package.json +1 -1
- package/server/index.ts +6 -1
- package/server/lib/cos-bridge-notes.ts +73 -0
- package/server/lib/decision-provider.ts +9 -0
- package/server/lib/fuzzy-correct.ts +77 -49
- package/server/lib/live-cos-ask.ts +41 -7
- package/server/lib/live-cues-v2-notes.ts +152 -0
- package/server/lib/live-cues-v2-settings.ts +63 -0
- package/server/lib/live-cues-v2.ts +198 -38
- package/server/lib/transcript-cleanup-context.ts +234 -0
- package/server/lib/transcript-cleanup-ledger.ts +304 -0
- package/server/lib/transcript-cleanup-model.ts +217 -0
- package/server/lib/transcript-cleanup-screen.ts +249 -0
- package/server/lib/transcript-cleanup-spans.ts +358 -0
- package/server/lib/transcript-cleanup-state.ts +201 -0
- package/server/lib/transcript-cleanup-suggestions.ts +126 -0
- package/server/lib/transcript-cleanup-text.ts +200 -0
- package/server/lib/transcript-cleanup.ts +427 -0
- package/server/routes/glossary.ts +38 -19
- package/server/routes/health.ts +3 -0
- package/server/routes/live-cues.ts +32 -3
- package/server/routes/meeting.ts +65 -0
- package/server/routes/transcribe.ts +3 -0
- package/server/routes/transcript-cleanup.ts +237 -0
- package/server/scripts/live-cues-v2-notes-mutation-gate.mjs +170 -0
- package/server/scripts/transcript-cleanup-mutation-gate.mjs +219 -0
- package/shared/live-cues-v2.ts +9 -4
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// Transcript cleanup (6.77.0): a post-save pass that resolves the words a transcription
|
|
2
|
+
// engine was unsure about, using the meeting's text and screen context.
|
|
3
|
+
//
|
|
4
|
+
// Miles, 2026-10-17: "We need the ability to run a Sonnet or a Haiku to clean up some of
|
|
5
|
+
// those passages where we're unsure. It should have some screen awareness as well as some
|
|
6
|
+
// textual awareness so that the word hallucinations are resolved."
|
|
7
|
+
//
|
|
8
|
+
// WHERE IT RUNS. Once per saved meeting, from the durable finalization job (routes/
|
|
9
|
+
// meeting.ts), after the HQ batch transcript and the Nemotron relabel are written and
|
|
10
|
+
// BEFORE the meeting is handed to COS operations or summarised, so both see cleaned text.
|
|
11
|
+
// NEVER during live transcription: no transcription module imports this file (Miles's rule
|
|
12
|
+
// "Transcription MUST NEVER call claude -p"; pinned by a test), and a meeting whose session
|
|
13
|
+
// is still live is skipped.
|
|
14
|
+
//
|
|
15
|
+
// WHAT IT DOES.
|
|
16
|
+
// 1. Reads the saved `## Transcript` and the sidecar's batch words (confidence, time).
|
|
17
|
+
// 2. Builds the context pack (transcript-cleanup-context.ts).
|
|
18
|
+
// 3. Deterministic fixes: fuzzy-correct with the dictionary and this meeting's own screen
|
|
19
|
+
// and file terms (transcript-cleanup-spans.ts). Free.
|
|
20
|
+
// 4. Picks the doubtful spans with no model (transcript-cleanup-spans.ts).
|
|
21
|
+
// 5. One batched model call for the spans, unless a stop applies (transcript-cleanup-
|
|
22
|
+
// state.ts); every proposed edit is validated (transcript-cleanup-model.ts).
|
|
23
|
+
// 6. Applies fixes and accepted edits through the undoable ledger
|
|
24
|
+
// (transcript-cleanup-ledger.ts) and records dictionary suggestions for written forms
|
|
25
|
+
// the meeting's context supplied (transcript-cleanup-suggestions.ts).
|
|
26
|
+
// 7. One trace row: counts, tokens and milliseconds. No transcript text, no screen text,
|
|
27
|
+
// no names in any log line.
|
|
28
|
+
|
|
29
|
+
import { existsSync, mkdirSync, appendFileSync, readFileSync, renameSync, statSync } from 'node:fs'
|
|
30
|
+
import { dirname } from 'node:path'
|
|
31
|
+
import { LensGistLimitError, runLensGistEngine } from './lens-gist-engines.js'
|
|
32
|
+
import { resolveProviderBinary } from './provider-binary.js'
|
|
33
|
+
import { getVocabulary, getWhisperCorrections } from './profile.js'
|
|
34
|
+
import { ownerCardFromProfile, type OwnerCard } from './meeting-ask-owner.js'
|
|
35
|
+
import { alignWordInfo, fold, joinTranscriptLines, parseTranscriptLines, tokenize, wordInfoFromSidecar } from './transcript-cleanup-text.js'
|
|
36
|
+
import { addNotes, buildContextPack, evidenceSources, inDictionary, knownKeys, nameTargets, type CleanupFile, type CleanupNote, type ContextPack } from './transcript-cleanup-context.js'
|
|
37
|
+
import { deterministicFixes, selectSpans, type SpanReason } from './transcript-cleanup-spans.js'
|
|
38
|
+
import { buildCleanupPrompt, CLEANUP_SYSTEM, MODEL_LIMITS, parseCleanupReply, validateCleanupEdits, type RejectReason } from './transcript-cleanup-model.js'
|
|
39
|
+
import {
|
|
40
|
+
appendLedger, applyPlannedEdits, alreadyCleaned, commitCleanTranscript, CleanupWriteError, newEditId, newRunId,
|
|
41
|
+
readTranscriptSection, sha256, type AppliedEdit, type PlannedEdit,
|
|
42
|
+
} from './transcript-cleanup-ledger.js'
|
|
43
|
+
import { cleanupLimits, resolveCleanupModel, resolveCleanupSwitch, transcriptCleanupBudget, transcriptCleanupSettings, type TranscriptCleanupBudget, type TranscriptCleanupSettings } from './transcript-cleanup-state.js'
|
|
44
|
+
import { suggestionStore, type SuggestionStore } from './transcript-cleanup-suggestions.js'
|
|
45
|
+
import { cleanupDir, readScreenContext, type ScreenLine } from './transcript-cleanup-screen.js'
|
|
46
|
+
|
|
47
|
+
export interface ModelRun { text: string; inputTokens?: number; outputTokens?: number; model?: string }
|
|
48
|
+
|
|
49
|
+
export interface CleanupDeps {
|
|
50
|
+
env: NodeJS.ProcessEnv
|
|
51
|
+
now: () => Date
|
|
52
|
+
settings: TranscriptCleanupSettings
|
|
53
|
+
budget: TranscriptCleanupBudget
|
|
54
|
+
suggestions: SuggestionStore
|
|
55
|
+
sessionLive: (sessionId: string) => boolean
|
|
56
|
+
claudeAvailable: () => boolean
|
|
57
|
+
callModel: (modelId: string, system: string, prompt: string, opts: { timeoutMs: number; maxOutputTokens: number }) => Promise<ModelRun>
|
|
58
|
+
speakers: () => Promise<string[]> | string[]
|
|
59
|
+
vocabulary: () => string[]
|
|
60
|
+
corrections: () => Record<string, string>
|
|
61
|
+
owner: () => OwnerCard | null
|
|
62
|
+
files: (sessionId: string) => Promise<CleanupFile[]> | CleanupFile[]
|
|
63
|
+
screen: (sessionId: string) => ScreenLine[]
|
|
64
|
+
searchNotes: (queries: string[], sessionId: string, signal: AbortSignal) => Promise<CleanupNote[]>
|
|
65
|
+
trace: (row: CleanupTrace) => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface CleanupTrace {
|
|
69
|
+
at: string
|
|
70
|
+
sessionId: string
|
|
71
|
+
runId?: string
|
|
72
|
+
status: CleanupOutcome['status']
|
|
73
|
+
reason?: string
|
|
74
|
+
modelSkipped?: string
|
|
75
|
+
model?: string
|
|
76
|
+
words: number
|
|
77
|
+
aligned: number
|
|
78
|
+
fixes: number
|
|
79
|
+
spansFound: number
|
|
80
|
+
spansSent: number
|
|
81
|
+
byReason?: Record<SpanReason, number>
|
|
82
|
+
accepted: number
|
|
83
|
+
kept: number
|
|
84
|
+
rejected: Partial<Record<RejectReason, number>>
|
|
85
|
+
modelError?: string
|
|
86
|
+
suggestions: number
|
|
87
|
+
screenLines: number
|
|
88
|
+
files: number
|
|
89
|
+
notes: number
|
|
90
|
+
promptChars: number
|
|
91
|
+
inputTokens: number
|
|
92
|
+
outputTokens: number
|
|
93
|
+
modelMs: number
|
|
94
|
+
ms: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface CleanupOutcome {
|
|
98
|
+
status: 'skipped' | 'done' | 'failed'
|
|
99
|
+
reason?: string
|
|
100
|
+
/** Set when fuzzy fixes ran but the model was not asked (correction_skipped style). */
|
|
101
|
+
modelSkipped?: string
|
|
102
|
+
runId?: string
|
|
103
|
+
applied: number
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const NOTES_BUDGET_MS = 3_000
|
|
107
|
+
const inProgress = new Set<string>()
|
|
108
|
+
|
|
109
|
+
/** The local searches the notes lookup uses. Each one may fail or answer nothing; none leaves the Mac. */
|
|
110
|
+
export interface NotesSearchers {
|
|
111
|
+
memories(query: string, signal: AbortSignal): Promise<Array<{ id: string; title?: string; snippet?: string }>>
|
|
112
|
+
meetings(query: string, signal: AbortSignal): Promise<Array<{ recordId: string; sessionId?: string; title: string; snippet?: string }>>
|
|
113
|
+
/** The memory bridge listing (cos-bridge-notes.ts): on the public memory runtime it is where the owner's notes are. */
|
|
114
|
+
bridge(signal: AbortSignal): Promise<Array<{ id: string; content: string; summary?: string }>>
|
|
115
|
+
score(rows: Array<{ id: string; content: string; summary?: string }>, query: string): Array<{ row: { id: string; content: string; summary?: string } }>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Notes for the names the doubtful spans are near: memories and earlier meetings by keyword (never this meeting), and,
|
|
120
|
+
* when memory search finds nothing for a name, the memory bridge listing scored by keyword, as the Live COS Ask does.
|
|
121
|
+
*/
|
|
122
|
+
export function createNotesSearch(searchers: NotesSearchers): CleanupDeps['searchNotes'] {
|
|
123
|
+
return async (queries, sessionId, signal) => {
|
|
124
|
+
const notes: CleanupNote[] = []
|
|
125
|
+
const seen = new Set<string>()
|
|
126
|
+
let listed: Array<{ id: string; content: string; summary?: string }> | null = null
|
|
127
|
+
const push = (note: CleanupNote) => { if (!seen.has(note.ref)) { seen.add(note.ref); notes.push(note) } }
|
|
128
|
+
for (const query of queries) {
|
|
129
|
+
if (signal.aborted) break
|
|
130
|
+
let memoryHits = 0
|
|
131
|
+
try {
|
|
132
|
+
for (const hit of (await searchers.memories(query, signal)).slice(0, 3)) {
|
|
133
|
+
if (!hit.id) continue
|
|
134
|
+
memoryHits++
|
|
135
|
+
push({ id: '', ref: `memory:${hit.id}`, kind: 'memory', title: hit.title || hit.id, snippet: hit.snippet || '', query })
|
|
136
|
+
}
|
|
137
|
+
} catch { /* a source that fails is skipped */ }
|
|
138
|
+
if (!memoryHits && !signal.aborted) {
|
|
139
|
+
try {
|
|
140
|
+
listed ??= await searchers.bridge(signal)
|
|
141
|
+
for (const { row } of searchers.score(listed, query).slice(0, 2)) {
|
|
142
|
+
push({ id: '', ref: `memory:${row.id}`, kind: 'memory', title: (row.summary || row.content.split('\n')[0] || row.id).slice(0, 120), snippet: row.content, query })
|
|
143
|
+
}
|
|
144
|
+
} catch { listed = [] }
|
|
145
|
+
}
|
|
146
|
+
if (signal.aborted) break
|
|
147
|
+
try {
|
|
148
|
+
for (const hit of (await searchers.meetings(query, signal)).slice(0, 3)) {
|
|
149
|
+
if (!hit.recordId || hit.sessionId === sessionId) continue
|
|
150
|
+
push({ id: '', ref: `meeting:${hit.recordId}`, kind: 'meeting', title: hit.title, snippet: hit.snippet || '', query })
|
|
151
|
+
}
|
|
152
|
+
} catch { /* skipped */ }
|
|
153
|
+
}
|
|
154
|
+
return notes
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const notesSearchDefault: CleanupDeps['searchNotes'] = async (queries, sessionId, signal) => {
|
|
159
|
+
const [{ searchMemories }, { searchMeetingLibrary }, bridgeNotes] = await Promise.all([
|
|
160
|
+
import('./context-library-search.js'), import('./meeting-library-search.js'), import('./cos-bridge-notes.js'),
|
|
161
|
+
])
|
|
162
|
+
return createNotesSearch({
|
|
163
|
+
memories: async (query, s) => (await searchMemories({ query, limit: 3, semantic: false, signal: s })).hits,
|
|
164
|
+
meetings: async (query, s) => (await searchMeetingLibrary({ query, limit: 3, semantic: false, signal: s })).hits,
|
|
165
|
+
bridge: s => bridgeNotes.listBridgeMemories(s, NOTES_BUDGET_MS),
|
|
166
|
+
score: (rows, query) => bridgeNotes.scoreBridgeMemories(rows, query),
|
|
167
|
+
})(queries, sessionId, signal)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const filesDefault: CleanupDeps['files'] = async sessionId => {
|
|
171
|
+
try {
|
|
172
|
+
const { MeetingAskStore } = await import('./meeting-ask-store.js')
|
|
173
|
+
return new MeetingAskStore().files(sessionId).filter(f => f.status === 'ready' && typeof f.text === 'string' && f.text.trim()).map(f => ({ name: f.name, text: f.text }))
|
|
174
|
+
} catch { return [] }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const speakersDefault: CleanupDeps['speakers'] = async () => {
|
|
178
|
+
try { return (await import('./speaker-embeddings.js')).getAllSpeakerNames() } catch { return [] }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** One row per meeting, rotated once at 2 MB (about 4,000 meetings) so it can never grow without bound. */
|
|
182
|
+
export const TRACE_MAX_BYTES = 2 * 1024 * 1024
|
|
183
|
+
function traceDefault(row: CleanupTrace): void {
|
|
184
|
+
const file = cleanupDir('trace.jsonl')
|
|
185
|
+
try {
|
|
186
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
|
|
187
|
+
try { if (statSync(file).size > TRACE_MAX_BYTES) renameSync(file, `${file}.1`) } catch { /* no file yet */ }
|
|
188
|
+
appendFileSync(file, JSON.stringify(row) + '\n', { mode: 0o600 })
|
|
189
|
+
} catch { /* best effort */ }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function defaultCleanupDeps(): CleanupDeps {
|
|
193
|
+
return {
|
|
194
|
+
env: process.env,
|
|
195
|
+
now: () => new Date(),
|
|
196
|
+
settings: transcriptCleanupSettings(),
|
|
197
|
+
budget: transcriptCleanupBudget(),
|
|
198
|
+
suggestions: suggestionStore(),
|
|
199
|
+
sessionLive: () => false,
|
|
200
|
+
claudeAvailable: () => resolveProviderBinary('claude').ok,
|
|
201
|
+
callModel: async (modelId, system, prompt, opts) => {
|
|
202
|
+
const run = await runLensGistEngine('claude', modelId, prompt, { system, timeoutMs: opts.timeoutMs, source: 'transcript-cleanup', maxOutputTokens: opts.maxOutputTokens })
|
|
203
|
+
return { text: run.text, inputTokens: run.usage?.inputTokens, outputTokens: run.usage?.outputTokens, model: run.model }
|
|
204
|
+
},
|
|
205
|
+
speakers: speakersDefault,
|
|
206
|
+
vocabulary: () => getVocabulary(),
|
|
207
|
+
corrections: () => getWhisperCorrections(),
|
|
208
|
+
owner: () => ownerCardFromProfile(),
|
|
209
|
+
files: filesDefault,
|
|
210
|
+
screen: sessionId => readScreenContext(sessionId),
|
|
211
|
+
searchNotes: notesSearchDefault,
|
|
212
|
+
trace: traceDefault,
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function titleOf(markdown: string, sidecar: Record<string, unknown>): string {
|
|
217
|
+
if (typeof sidecar.title === 'string' && sidecar.title.trim()) return sidecar.title.trim()
|
|
218
|
+
return /^# (.+)$/m.exec(markdown)?.[1]?.trim() ?? ''
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Run the cleanup for one saved meeting. Never throws: every failure is an outcome. */
|
|
222
|
+
export async function runTranscriptCleanup(
|
|
223
|
+
input: { sessionId: string; meetingPath: string; sidecarPath: string },
|
|
224
|
+
partial: Partial<CleanupDeps> = {},
|
|
225
|
+
): Promise<CleanupOutcome> {
|
|
226
|
+
const deps: CleanupDeps = { ...defaultCleanupDeps(), ...partial }
|
|
227
|
+
const started = Date.now()
|
|
228
|
+
// Switch first, before ANY read or write: off means nothing happens at all.
|
|
229
|
+
if (!resolveCleanupSwitch(deps.env, deps.settings).enabled) return { status: 'skipped', reason: 'switch_off', applied: 0 }
|
|
230
|
+
|
|
231
|
+
const trace: CleanupTrace = {
|
|
232
|
+
at: deps.now().toISOString(), sessionId: input.sessionId, status: 'skipped', words: 0, aligned: 0, fixes: 0, spansFound: 0, spansSent: 0,
|
|
233
|
+
accepted: 0, kept: 0, rejected: {}, suggestions: 0, screenLines: 0, files: 0, notes: 0, promptChars: 0, inputTokens: 0, outputTokens: 0, modelMs: 0, ms: 0,
|
|
234
|
+
}
|
|
235
|
+
const finish = (outcome: CleanupOutcome): CleanupOutcome => {
|
|
236
|
+
trace.status = outcome.status
|
|
237
|
+
if (outcome.reason) trace.reason = outcome.reason
|
|
238
|
+
if (outcome.modelSkipped) trace.modelSkipped = outcome.modelSkipped
|
|
239
|
+
if (outcome.runId) trace.runId = outcome.runId
|
|
240
|
+
trace.ms = Date.now() - started
|
|
241
|
+
try { deps.trace(trace) } catch { /* trace is best effort */ }
|
|
242
|
+
console.log(`[transcript-cleanup] ${outcome.status}${outcome.reason ? ` (${outcome.reason})` : ''}${outcome.modelSkipped ? ` model skipped: ${outcome.modelSkipped}` : ''}: `
|
|
243
|
+
+ `${trace.spansFound} spans found, ${trace.spansSent} sent, ${trace.accepted} accepted, ${Object.values(trace.rejected).reduce((a, b) => a + (b ?? 0), 0)} rejected, `
|
|
244
|
+
+ `${trace.fixes} fixes, ${trace.inputTokens + trace.outputTokens} tokens, ${trace.ms} ms`)
|
|
245
|
+
return outcome
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (inProgress.has(input.sessionId)) return finish({ status: 'skipped', reason: 'busy', applied: 0 })
|
|
249
|
+
inProgress.add(input.sessionId)
|
|
250
|
+
try {
|
|
251
|
+
let live = false
|
|
252
|
+
try { live = deps.sessionLive(input.sessionId) } catch { live = true }
|
|
253
|
+
if (live) return finish({ status: 'skipped', reason: 'session_live', applied: 0 })
|
|
254
|
+
if (!existsSync(input.meetingPath) || !existsSync(input.sidecarPath)) return finish({ status: 'skipped', reason: 'meeting_missing', applied: 0 })
|
|
255
|
+
const markdown = readFileSync(input.meetingPath, 'utf8')
|
|
256
|
+
const sidecarText = readFileSync(input.sidecarPath, 'utf8')
|
|
257
|
+
let sidecar: Record<string, unknown>
|
|
258
|
+
try { sidecar = JSON.parse(sidecarText) as Record<string, unknown> } catch { return finish({ status: 'failed', reason: 'sidecar_unreadable', applied: 0 }) }
|
|
259
|
+
const section = readTranscriptSection(markdown)
|
|
260
|
+
if (!section || !section.trim()) return finish({ status: 'skipped', reason: 'no_transcript', applied: 0 })
|
|
261
|
+
const inputSha = sha256(section)
|
|
262
|
+
if (alreadyCleaned(input.sessionId, inputSha)) return finish({ status: 'skipped', reason: 'already_done', applied: 0 })
|
|
263
|
+
|
|
264
|
+
const runId = newRunId()
|
|
265
|
+
const at = () => deps.now().toISOString()
|
|
266
|
+
if (!appendLedger(input.sessionId, [{ kind: 'run', runId, phase: 'intent', at: at(), inputSha }])) {
|
|
267
|
+
// An unrecorded edit is exactly what the ledger exists to prevent.
|
|
268
|
+
return finish({ status: 'failed', reason: 'ledger_unwritable', applied: 0 })
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const lines = parseTranscriptLines(section)
|
|
272
|
+
const tokens = tokenize(lines)
|
|
273
|
+
trace.words = tokens.length
|
|
274
|
+
trace.aligned = alignWordInfo(tokens, wordInfoFromSidecar(sidecar))
|
|
275
|
+
const startMs = typeof sidecar.startTime === 'number' ? sidecar.startTime : null
|
|
276
|
+
const screen = deps.screen(input.sessionId)
|
|
277
|
+
const files = await deps.files(input.sessionId)
|
|
278
|
+
const pack: ContextPack = buildContextPack({
|
|
279
|
+
title: titleOf(markdown, sidecar),
|
|
280
|
+
date: startMs !== null ? new Date(startMs).toLocaleDateString('en-CA') : '',
|
|
281
|
+
attendees: Array.isArray(sidecar.speakers) ? sidecar.speakers.filter((s): s is string => typeof s === 'string') : [],
|
|
282
|
+
speakers: await deps.speakers(),
|
|
283
|
+
owner: deps.owner(),
|
|
284
|
+
vocabulary: deps.vocabulary(),
|
|
285
|
+
corrections: deps.corrections(),
|
|
286
|
+
files,
|
|
287
|
+
screen,
|
|
288
|
+
})
|
|
289
|
+
trace.screenLines = pack.screen.length
|
|
290
|
+
trace.files = pack.files.length
|
|
291
|
+
|
|
292
|
+
// Free deterministic fixes first.
|
|
293
|
+
// The curated dictionary and the owner only: attendee names swapped a product for a surname on real
|
|
294
|
+
// meetings (Slack to Black), so people reach the model as context and near-name spans instead.
|
|
295
|
+
const strongTargets = [...pack.vocabulary, ...pack.corrections.map(c => c.written), ...(pack.owner ? [pack.owner.name] : [])]
|
|
296
|
+
const targets = nameTargets(pack)
|
|
297
|
+
const known = knownKeys(pack)
|
|
298
|
+
const fixes = deterministicFixes(tokens, strongTargets, targets, known)
|
|
299
|
+
trace.fixes = fixes.length
|
|
300
|
+
for (const fix of fixes) { const t = tokens[fix.token]; t.core = fix.replacement; t.key = fold(fix.replacement) }
|
|
301
|
+
|
|
302
|
+
const limits = cleanupLimits(deps.env)
|
|
303
|
+
const selection = selectSpans(lines, tokens, targets, known, { maxSpans: limits.maxSpans, fixed: new Set(fixes.map(f => f.token)) })
|
|
304
|
+
trace.spansFound = selection.found
|
|
305
|
+
trace.byReason = selection.byReason
|
|
306
|
+
|
|
307
|
+
const planned: PlannedEdit[] = fixes.map(f => ({ line: f.line, start: f.start, end: f.end, replacement: f.replacement, source: 'fuzzy' as const, reasons: [f.fromMeeting ? 'meeting_term' : 'dictionary'] }))
|
|
308
|
+
let modelSkipped: string | undefined
|
|
309
|
+
const model = resolveCleanupModel(deps.env, deps.settings)
|
|
310
|
+
if (!selection.spans.length) modelSkipped = 'no_spans'
|
|
311
|
+
else if (deps.env.ANTHROPIC_API_KEY && deps.env.COS_ALLOW_API_FALLBACK !== '1') modelSkipped = 'api_key_in_env'
|
|
312
|
+
else if (!deps.claudeAvailable()) modelSkipped = 'claude_not_found'
|
|
313
|
+
else modelSkipped = deps.budget.gate(limits) ?? undefined
|
|
314
|
+
|
|
315
|
+
if (!modelSkipped) {
|
|
316
|
+
// Local notes for the names the spans are near, bounded in time.
|
|
317
|
+
const queries = [...new Set(selection.spans.flatMap(s => s.near))].slice(0, 4)
|
|
318
|
+
if (queries.length) {
|
|
319
|
+
const abort = new AbortController()
|
|
320
|
+
const timer = setTimeout(() => abort.abort(), NOTES_BUDGET_MS)
|
|
321
|
+
try {
|
|
322
|
+
const found = await Promise.race([
|
|
323
|
+
deps.searchNotes(queries, input.sessionId, abort.signal),
|
|
324
|
+
new Promise<CleanupNote[]>(resolve => abort.signal.addEventListener('abort', () => resolve([]), { once: true })),
|
|
325
|
+
])
|
|
326
|
+
addNotes(pack, found.map((n, i) => ({ ...n, id: `N${i + 1}` })))
|
|
327
|
+
} catch { /* notes are optional */ } finally { clearTimeout(timer); abort.abort() }
|
|
328
|
+
trace.notes = pack.notes.length
|
|
329
|
+
}
|
|
330
|
+
const built = buildCleanupPrompt(pack, selection.spans, startMs)
|
|
331
|
+
trace.spansSent = built.sent.length
|
|
332
|
+
trace.promptChars = built.chars
|
|
333
|
+
trace.model = model.id
|
|
334
|
+
if (!built.sent.length) modelSkipped = 'prompt_too_large'
|
|
335
|
+
else if (!deps.budget.charge(limits)) modelSkipped = 'daily_calls'
|
|
336
|
+
else {
|
|
337
|
+
const modelStarted = Date.now()
|
|
338
|
+
try {
|
|
339
|
+
const run = await deps.callModel(model.id, CLEANUP_SYSTEM, built.prompt, { timeoutMs: MODEL_LIMITS.timeoutMs, maxOutputTokens: MODEL_LIMITS.maxOutputTokens })
|
|
340
|
+
trace.modelMs = Date.now() - modelStarted
|
|
341
|
+
trace.inputTokens = run.inputTokens ?? Math.ceil(built.chars / 4)
|
|
342
|
+
trace.outputTokens = run.outputTokens ?? Math.ceil(run.text.length / 4)
|
|
343
|
+
deps.budget.recordUsage(trace.inputTokens + trace.outputTokens)
|
|
344
|
+
const raw = parseCleanupReply(run.text)
|
|
345
|
+
deps.budget.recordSuccess()
|
|
346
|
+
const checked = validateCleanupEdits(raw, selection.spans, built.sent, pack)
|
|
347
|
+
trace.accepted = checked.accepted.length
|
|
348
|
+
trace.kept = checked.kept
|
|
349
|
+
trace.rejected = checked.rejected
|
|
350
|
+
for (const { span, text } of checked.accepted) planned.push({ line: span.line, start: span.start, end: span.end, replacement: text, source: 'model', reasons: span.reasons })
|
|
351
|
+
} catch (error) {
|
|
352
|
+
trace.modelMs = trace.modelMs || Date.now() - modelStarted
|
|
353
|
+
const limit = error instanceof LensGistLimitError
|
|
354
|
+
deps.budget.recordFailure(limits, limit)
|
|
355
|
+
trace.modelError = limit ? 'provider_limit' : error instanceof Error && error.message === 'no_json_edits' ? 'bad_reply' : /timed out/.test(String((error as Error)?.message)) ? 'timeout' : 'call_failed'
|
|
356
|
+
modelSkipped = 'model_failed'
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!planned.length) {
|
|
362
|
+
appendLedger(input.sessionId, [{ kind: 'run', runId, phase: 'done', at: at(), inputSha, outputSha: inputSha, model: trace.model, ...(modelSkipped ? { modelSkipped } : {}) }])
|
|
363
|
+
return finish({ status: 'done', runId, applied: 0, ...(modelSkipped ? { modelSkipped } : {}) })
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const { lines: cleaned, applied } = applyPlannedEdits(lines, planned)
|
|
367
|
+
const transcript = joinTranscriptLines(cleaned)
|
|
368
|
+
const outputSha = sha256(transcript)
|
|
369
|
+
const stamp = at()
|
|
370
|
+
const editRows = applied.map(e => ({ kind: 'edit' as const, id: newEditId(), runId, at: stamp, line: e.line, offset: e.offset, original: e.original, replacement: e.replacement, before: e.before, after: e.after, lineSha: e.lineSha, source: e.source, ...(e.reasons ? { reasons: e.reasons } : {}) }))
|
|
371
|
+
if (!appendLedger(input.sessionId, editRows)) return finish({ status: 'failed', reason: 'ledger_unwritable', runId, applied: 0 })
|
|
372
|
+
try {
|
|
373
|
+
commitCleanTranscript({
|
|
374
|
+
meetingPath: input.meetingPath, sidecarPath: input.sidecarPath, markdown, sidecarText, transcript,
|
|
375
|
+
summary: { version: 1, runId, at: stamp, model: trace.model ?? null, fixes: applied.filter(e => e.source === 'fuzzy').length, edits: applied.filter(e => e.source === 'model').length, liveEdits: applied.length, ...(modelSkipped ? { modelSkipped } : {}) },
|
|
376
|
+
})
|
|
377
|
+
} catch (error) {
|
|
378
|
+
const code = error instanceof CleanupWriteError ? error.code : 'write_failed'
|
|
379
|
+
appendLedger(input.sessionId, [{ kind: 'run', runId, phase: 'failed', at: at(), inputSha, reason: code }])
|
|
380
|
+
return finish({ status: 'failed', reason: code, runId, applied: 0, ...(modelSkipped ? { modelSkipped } : {}) })
|
|
381
|
+
}
|
|
382
|
+
appendLedger(input.sessionId, [{ kind: 'run', runId, phase: 'done', at: at(), inputSha, outputSha, model: trace.model, ...(modelSkipped ? { modelSkipped } : {}) }])
|
|
383
|
+
trace.suggestions = recordSuggestions(applied, pack, input.sessionId, stamp, deps)
|
|
384
|
+
return finish({ status: 'done', runId, applied: applied.length, ...(modelSkipped ? { modelSkipped } : {}) })
|
|
385
|
+
} catch (error) {
|
|
386
|
+
console.warn(`[transcript-cleanup] failed: ${error instanceof Error ? error.name : 'error'}`)
|
|
387
|
+
return finish({ status: 'failed', reason: 'error', applied: 0 })
|
|
388
|
+
} finally {
|
|
389
|
+
inProgress.delete(input.sessionId)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const WORDS = /[\p{L}\p{N}][\p{L}\p{N}'’-]*/gu
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* A dictionary suggestion for each written form an accepted edit took from the meeting's
|
|
397
|
+
* context and the dictionary lacks. The whole replacement, each word and each adjacent pair
|
|
398
|
+
* are checked against the context terms.
|
|
399
|
+
*/
|
|
400
|
+
export function recordSuggestions(applied: readonly AppliedEdit[], pack: ContextPack, sessionId: string, at: string, deps: Pick<CleanupDeps, 'suggestions' | 'vocabulary' | 'corrections'>): number {
|
|
401
|
+
let recorded = 0
|
|
402
|
+
let vocabulary: string[] = [], corrections: string[] = []
|
|
403
|
+
try { vocabulary = deps.vocabulary(); corrections = Object.values(deps.corrections()) } catch { /* treat as empty */ }
|
|
404
|
+
const dictionary = new Set([...vocabulary, ...corrections].map(w => w.trim().toLowerCase()))
|
|
405
|
+
for (const edit of applied) {
|
|
406
|
+
const heardKeys = new Set([...edit.original.matchAll(WORDS)].map(m => fold(m[0])))
|
|
407
|
+
const ws = [...edit.replacement.matchAll(WORDS)].map(m => m[0])
|
|
408
|
+
// Longest first: "Queen Ukaoma" is one suggestion, not that plus "Ukaoma".
|
|
409
|
+
const candidates = [...new Set<string>([edit.replacement, ...ws.slice(0, -1).map((w, i) => `${w} ${ws[i + 1]}`), ...ws])]
|
|
410
|
+
.sort((a, b) => b.split(' ').length - a.split(' ').length)
|
|
411
|
+
const covered = new Set<string>()
|
|
412
|
+
for (const candidate of candidates) {
|
|
413
|
+
const term = pack.terms.get(fold(candidate))
|
|
414
|
+
if (!term || inDictionary(term) || heardKeys.has(term.key)) continue
|
|
415
|
+
const parts = term.display.split(/\s+/).map(fold)
|
|
416
|
+
if (parts.every(p => covered.has(p))) continue
|
|
417
|
+
// Every source a term outside the dictionary can have is evidence (evidenceSources), and record()
|
|
418
|
+
// refuses an empty list as well.
|
|
419
|
+
const evidence = evidenceSources(term)
|
|
420
|
+
for (const p of parts) covered.add(p)
|
|
421
|
+
try {
|
|
422
|
+
if (deps.suggestions.record({ written: term.display, heard: edit.original, evidence, sessionId, at, inDictionary: w => dictionary.has(w.trim().toLowerCase()) })) recorded++
|
|
423
|
+
} catch { /* a suggestion is never worth failing the cleanup */ }
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return recorded
|
|
427
|
+
}
|
|
@@ -198,7 +198,7 @@ function commitWords(words: GlossaryWords, nextLog: CorrectionLogEntry[], previo
|
|
|
198
198
|
resetVocabEchoCache()
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
-
type Outcome = { status: number; body: unknown }
|
|
201
|
+
export type Outcome = { status: number; body: unknown }
|
|
202
202
|
|
|
203
203
|
function serialized(work: () => Outcome): Promise<Outcome> {
|
|
204
204
|
const run = putChain.catch(() => {}).then(work)
|
|
@@ -206,17 +206,27 @@ function serialized(work: () => Outcome): Promise<Outcome> {
|
|
|
206
206
|
return run
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Learn one correction inside the glossary write chain, exactly as POST
|
|
211
|
+
* /transcription-glossary/correction does (logged, undoable). Exported for the transcript
|
|
212
|
+
* cleanup's dictionary suggestions (6.77.0), so an accepted suggestion uses this writer and
|
|
213
|
+
* never a second copy of it.
|
|
214
|
+
*/
|
|
215
|
+
export function glossaryLearnCorrection(body: unknown): Promise<Outcome> {
|
|
216
|
+
return serialized(() => {
|
|
217
|
+
const previousLog = readCorrectionsLog()
|
|
218
|
+
const learned = learnCorrection(storedWords(), (body ?? {}) as { heard: unknown; written: unknown; source: unknown }, { id: randomUUID(), at: new Date().toISOString() })
|
|
219
|
+
if (!learned.ok) return { status: learned.status, body: { error: learned.error } }
|
|
220
|
+
if (learned.unchanged) return { status: 200, body: { unchanged: true, entry: null, glossary: currentGlossary() } }
|
|
221
|
+
commitWords(learned.words, appendLog(previousLog, learned.entry), previousLog)
|
|
222
|
+
return { status: 200, body: { unchanged: false, entry: learned.entry, glossary: currentGlossary() } }
|
|
223
|
+
})
|
|
224
|
+
}
|
|
225
|
+
|
|
209
226
|
glossaryRouter.post('/transcription-glossary/correction', async (req, res) => {
|
|
210
227
|
const body = req.body ?? {}
|
|
211
228
|
try {
|
|
212
|
-
const outcome = await
|
|
213
|
-
const previousLog = readCorrectionsLog()
|
|
214
|
-
const learned = learnCorrection(storedWords(), body, { id: randomUUID(), at: new Date().toISOString() })
|
|
215
|
-
if (!learned.ok) return { status: learned.status, body: { error: learned.error } }
|
|
216
|
-
if (learned.unchanged) return { status: 200, body: { unchanged: true, entry: null, glossary: currentGlossary() } }
|
|
217
|
-
commitWords(learned.words, appendLog(previousLog, learned.entry), previousLog)
|
|
218
|
-
return { status: 200, body: { unchanged: false, entry: learned.entry, glossary: currentGlossary() } }
|
|
219
|
-
})
|
|
229
|
+
const outcome = await glossaryLearnCorrection(body)
|
|
220
230
|
return res.status(outcome.status).json(outcome.body)
|
|
221
231
|
} catch (err) {
|
|
222
232
|
return res.status(500).json({ error: errMsg(err) })
|
|
@@ -250,20 +260,29 @@ glossaryRouter.post('/transcription-glossary/correction/undo', async (req, res)
|
|
|
250
260
|
// ── Single-item hand edits (6.73.12) ─────────────────────────────────────────
|
|
251
261
|
// One word or one correction at a time, inside the same chain as PUT and learn,
|
|
252
262
|
// so COS Control never has to read the whole map and write it back.
|
|
263
|
+
function runSingleEdit(edit: (words: GlossaryWords, raw: unknown) => ReturnType<typeof addVocabulary>, raw: unknown): Promise<Outcome> {
|
|
264
|
+
return serialized(() => {
|
|
265
|
+
const result = edit(storedWords(), raw)
|
|
266
|
+
if (!result.ok) return { status: result.status, body: { error: result.error } }
|
|
267
|
+
if (!result.unchanged) {
|
|
268
|
+
updateProfileFields({ vocabulary: result.words.vocabulary, whisper_corrections: JSON.stringify(result.words.corrections) })
|
|
269
|
+
resetDecoderCaches()
|
|
270
|
+
resetVocabEchoCache()
|
|
271
|
+
}
|
|
272
|
+
return { status: 200, body: { unchanged: result.unchanged, glossary: currentGlossary() } }
|
|
273
|
+
})
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Add one vocabulary word inside the glossary write chain (same as POST /transcription-glossary/vocabulary/add). */
|
|
277
|
+
export function glossaryAddVocabulary(term: unknown): Promise<Outcome> {
|
|
278
|
+
return runSingleEdit(addVocabulary, term)
|
|
279
|
+
}
|
|
280
|
+
|
|
253
281
|
function singleEdit(path: string, field: 'term' | 'heard', edit: (words: GlossaryWords, raw: unknown) => ReturnType<typeof addVocabulary>) {
|
|
254
282
|
glossaryRouter.post(path, async (req, res) => {
|
|
255
283
|
const raw = (req.body ?? {})[field]
|
|
256
284
|
try {
|
|
257
|
-
const outcome = await
|
|
258
|
-
const result = edit(storedWords(), raw)
|
|
259
|
-
if (!result.ok) return { status: result.status, body: { error: result.error } }
|
|
260
|
-
if (!result.unchanged) {
|
|
261
|
-
updateProfileFields({ vocabulary: result.words.vocabulary, whisper_corrections: JSON.stringify(result.words.corrections) })
|
|
262
|
-
resetDecoderCaches()
|
|
263
|
-
resetVocabEchoCache()
|
|
264
|
-
}
|
|
265
|
-
return { status: 200, body: { unchanged: result.unchanged, glossary: currentGlossary() } }
|
|
266
|
-
})
|
|
285
|
+
const outcome = await runSingleEdit(edit, raw)
|
|
267
286
|
return res.status(outcome.status).json(outcome.body)
|
|
268
287
|
} catch (err) {
|
|
269
288
|
return res.status(500).json({ error: errMsg(err) })
|
package/server/routes/health.ts
CHANGED
|
@@ -69,6 +69,7 @@ import { maintenanceLifecycle } from '../lib/maintenance-lifecycle.js'
|
|
|
69
69
|
import { getLocalTtsHealth, refreshLocalTtsHealth } from '../lib/tts-local.js'
|
|
70
70
|
import { liveCuesCapability } from '../lib/live-cues-capability.js'
|
|
71
71
|
import { liveCuesV2Advertised } from '../lib/live-cues-v2.js'
|
|
72
|
+
import { transcriptCleanupAdvertised } from './transcript-cleanup.js'
|
|
72
73
|
import { getMeetingSyncSnapshot } from '../lib/meeting-batch-progress.js'
|
|
73
74
|
import { listUnsavedCaptures } from '../lib/unsaved-audio-quarantine.js'
|
|
74
75
|
import { getStrandedCaptures } from './transcribe-stream.js'
|
|
@@ -497,6 +498,8 @@ healthRouter.get('/health', async (_req, res) => {
|
|
|
497
498
|
cliDebug: CLI_DEBUG_CAPABILITY,
|
|
498
499
|
liveCues,
|
|
499
500
|
...(liveCuesV2Advertised() ? { liveCuesV2: liveCuesV2Advertised() } : {}),
|
|
501
|
+
// 6.77.0: codes only (this route is public): the protocol and whether the switch is on.
|
|
502
|
+
transcriptCleanup: transcriptCleanupAdvertised(),
|
|
500
503
|
richMedia: {
|
|
501
504
|
text: true,
|
|
502
505
|
pdf: richMedia.pdf,
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// POST /live-cues/start { sessionId } -> { armed, sessionId, pipelinesUsed }
|
|
5
5
|
// POST /live-cues/stop { sessionId } -> { nudgesGenerated, nudges }
|
|
6
6
|
// GET /live-cues/status -> engine snapshot + capability
|
|
7
|
+
// GET /live-cues/v2/settings -> { enabled, source: env|setting|default, capability } (6.76.2)
|
|
8
|
+
// POST /live-cues/v2/settings { enabled } -> same shape; 409 { error: 'env_forced' } when env forces v2 on
|
|
7
9
|
|
|
8
10
|
import { Router } from 'express'
|
|
9
11
|
import { errMsg } from '../lib/utils.js'
|
|
@@ -14,7 +16,7 @@ import {
|
|
|
14
16
|
LiveCuesArmError,
|
|
15
17
|
} from '../lib/live-cues-engine.js'
|
|
16
18
|
import { liveCuesCapability } from '../lib/live-cues-capability.js'
|
|
17
|
-
import { LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled } from '../lib/live-cues-v2.js'
|
|
19
|
+
import { LiveCuesV2ArmError, liveCuesV2, liveCuesV2Advertised, liveCuesV2Enabled, liveCuesV2SettingsView, peekLiveCuesV2, setLiveCuesV2Enabled } from '../lib/live-cues-v2.js'
|
|
18
20
|
|
|
19
21
|
const SESSION_ID = /^[A-Za-z0-9:_-]{3,96}$/
|
|
20
22
|
const CUE_ID = /^[A-Za-z0-9-]{8,64}$/
|
|
@@ -22,6 +24,10 @@ function bodySessionId(body: unknown): string {
|
|
|
22
24
|
const raw = (body as { sessionId?: unknown })?.sessionId
|
|
23
25
|
return typeof raw === 'string' ? raw.trim() : ''
|
|
24
26
|
}
|
|
27
|
+
/** v2 owns this meeting: v2 is on, or it armed the meeting before v2 was switched off at runtime. */
|
|
28
|
+
function v2Owns(sessionId: string): boolean {
|
|
29
|
+
return liveCuesV2Enabled() || (peekLiveCuesV2()?.knows(sessionId) ?? false)
|
|
30
|
+
}
|
|
25
31
|
|
|
26
32
|
export const liveCuesRouter = Router()
|
|
27
33
|
|
|
@@ -59,14 +65,14 @@ liveCuesRouter.post('/live-cues/stop', (req, res) => {
|
|
|
59
65
|
// Shape matches the existing client stop handler: it reads nudgesGenerated
|
|
60
66
|
// for its log line and hydrates meetingNudgeHistory from nudges when SSE
|
|
61
67
|
// missed events (the 200-event replay buffer is flooded by transcript_chunk).
|
|
62
|
-
if (
|
|
68
|
+
if (v2Owns(sessionId)) return res.json(liveCuesV2().end(sessionId))
|
|
63
69
|
return res.json(disarmLiveCues(sessionId))
|
|
64
70
|
})
|
|
65
71
|
|
|
66
72
|
liveCuesRouter.get('/live-cues/status', (req, res) => {
|
|
67
73
|
const sessionId = typeof req.query.sessionId === 'string' ? req.query.sessionId.trim() : ''
|
|
68
74
|
// v2 with a sessionId answers the per-meeting shape; without one the v1 shape is unchanged (plus v2 capability).
|
|
69
|
-
if (sessionId &&
|
|
75
|
+
if (sessionId && v2Owns(sessionId)) {
|
|
70
76
|
if (!SESSION_ID.test(sessionId)) return res.status(400).json({ error: 'invalid sessionId', reason: 'invalid_session_id' })
|
|
71
77
|
return res.json(liveCuesV2().status(sessionId))
|
|
72
78
|
}
|
|
@@ -101,3 +107,26 @@ liveCuesRouter.post('/live-cues/feedback', (req, res) => {
|
|
|
101
107
|
if (!liveCuesV2Enabled()) return res.status(404).json({ error: 'live cues v2 is off', reason: 'v2_disabled' })
|
|
102
108
|
return res.json(liveCuesV2().recordFeedback({ sessionId, cueId, vote: body.vote, reason: body.reason as string | undefined }))
|
|
103
109
|
})
|
|
110
|
+
|
|
111
|
+
// COS Control's live cues v2 toggle (6.76.2). Token-authenticated like every /api route. The setting lives in this server's
|
|
112
|
+
// data dir (live-cues-v2-settings.json, atomic write) and applies without a restart: /api/health and /api/models advertise
|
|
113
|
+
// liveCuesV2 only while it is on. capability.memory and capability.decisionProvider say where notes and the gate come from.
|
|
114
|
+
liveCuesRouter.get('/live-cues/v2/settings', (_req, res) => {
|
|
115
|
+
res.set('Cache-Control', 'private, no-store')
|
|
116
|
+
try { return res.json(liveCuesV2SettingsView()) } catch { return res.status(503).json({ error: 'settings_unavailable' }) }
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
liveCuesRouter.post('/live-cues/v2/settings', (req, res) => {
|
|
120
|
+
res.set('Cache-Control', 'private, no-store')
|
|
121
|
+
const body = req.body as { enabled?: unknown } | undefined
|
|
122
|
+
if (!body || typeof body !== 'object' || Array.isArray(body) || typeof body.enabled !== 'boolean') {
|
|
123
|
+
return res.status(400).json({ error: 'invalid_request' })
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
const result = setLiveCuesV2Enabled(body.enabled)
|
|
127
|
+
if (!result.ok) return res.status(409).json({ error: result.error })
|
|
128
|
+
return res.json(result.view)
|
|
129
|
+
} catch {
|
|
130
|
+
return res.status(500).json({ error: 'settings_write_failed' })
|
|
131
|
+
}
|
|
132
|
+
})
|