@gotcos/glasses-server 6.27.12 → 6.28.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.
@@ -975,6 +975,96 @@ export function createMeetingRouter(deps: MeetingRouteDependencies = {}): Router
975
975
  })
976
976
  })
977
977
 
978
+ /**
979
+ * Replay a recorded correction's enrolment — the retroactive path.
980
+ *
981
+ * WHY. Enrolment fires inside `POST /relabel`, so a voice named BEFORE that shipped
982
+ * has a correct transcript and no profile. Kirstyn Blum is the live case: named
983
+ * across two meetings (60 and 109 chunks), 182 mentions in the sidecars, absent from
984
+ * a 77-profile store. Re-running the rename cannot help — she is already a real
985
+ * name there, so the placeholder guard correctly declines.
986
+ *
987
+ * The correction LEDGER already holds exactly what enrolment needs: the original
988
+ * `from`, the `to`, and the precise chunk indices, written at apply time.
989
+ *
990
+ * This runs IN-PROCESS on purpose. The voice store is owned by the running server,
991
+ * which holds it in memory and rewrites it wholesale; an external process that
992
+ * enrols directly has its work silently clobbered on the next persist. That is not
993
+ * hypothetical — an attempt on 2026-08-13 validated cleanly, selected 20 samples,
994
+ * and left the store untouched at its Aug 7 mtime.
995
+ *
996
+ * SAME GATES, no exceptions. It calls `enrolNamedVoice`, so raw-index mapping,
997
+ * refusal when unmappable, voice coherence, the diversity cap and the
998
+ * `correction:<sessionId>` tag all apply identically. Rows whose `from` is a real
999
+ * person are skipped by that function's own placeholder rule, which is what keeps a
1000
+ * mis-attribution correction (Allison Wheeler -> Kirstyn) out of the training set.
1001
+ *
1002
+ * FAILS CLOSED. Without `confirm: true` it reports what it would enrol and writes
1003
+ * nothing.
1004
+ */
1005
+ router.post('/meeting/:sessionId/backfill-enrolment', (req, res) => {
1006
+ res.set('Cache-Control', 'private, no-store')
1007
+ const sessionId = String(req.params.sessionId ?? '')
1008
+ if (!/^[A-Za-z0-9:_-]{3,96}$/.test(sessionId)) {
1009
+ res.status(400).json({ error: 'Invalid sessionId', reason: 'invalid_session_id' })
1010
+ return
1011
+ }
1012
+ const speaker = typeof req.body?.speaker === 'string' ? req.body.speaker.trim() : ''
1013
+ if (!speaker) {
1014
+ res.status(400).json({ error: 'speaker is required', reason: 'invalid_label' })
1015
+ return
1016
+ }
1017
+
1018
+ const operations = cosOperationsMeetingsConfigured()
1019
+ ? findCosOperationsMeetingBySessionId(sessionId)
1020
+ : null
1021
+ const saved = operations ? null : store.findBySessionId(sessionId)
1022
+ if (!operations && !saved) {
1023
+ res.status(404).json({ error: 'No saved meeting for this session', reason: 'meeting_not_found' })
1024
+ return
1025
+ }
1026
+ const sidecarPath = operations?.sidecarPath ?? saved!.sidecarPath
1027
+ let parsedSidecar: Record<string, unknown> | null = null
1028
+ try {
1029
+ const doc = JSON.parse(readFileSync(sidecarPath, 'utf-8')) as unknown
1030
+ if (doc && typeof doc === 'object' && !Array.isArray(doc)) parsedSidecar = doc as Record<string, unknown>
1031
+ } catch {
1032
+ res.status(422).json({ error: 'Chunk sidecar is missing or unreadable', reason: 'sidecar_unreadable' })
1033
+ return
1034
+ }
1035
+
1036
+ // Only rows that ACTUALLY landed, and only those that named this speaker.
1037
+ const rows = appliedCorrections(sessionId).filter(r => r.to === speaker && r.chunks.length > 0)
1038
+ if (rows.length === 0) {
1039
+ res.status(404).json({ error: `No applied correction named "${speaker}" in this meeting`, reason: 'no_correction' })
1040
+ return
1041
+ }
1042
+
1043
+ const confirm = req.body?.confirm === true
1044
+ const reports = rows.map(row => ({
1045
+ correctionId: row.id,
1046
+ from: row.from,
1047
+ chunks: row.chunks.length,
1048
+ // Dry run still evaluates every gate — a preview that skips them would be a
1049
+ // guess about what the real call is going to do.
1050
+ report: enrolNamedVoice({
1051
+ sessionId, from: row.from, to: speaker, changed: row.chunks, sidecar: parsedSidecar!, dryRun: !confirm,
1052
+ }),
1053
+ }))
1054
+
1055
+ res.json({
1056
+ ok: true,
1057
+ speaker,
1058
+ confirmed: confirm,
1059
+ corrections: reports,
1060
+ totals: {
1061
+ eligible: reports.filter(r => r.report.attempted > 0).length,
1062
+ skippedNamedSource: reports.filter(r => r.report.attempted === 0 && !r.report.skipped).length,
1063
+ enrolled: reports.reduce((n, r) => n + r.report.enrolled, 0),
1064
+ },
1065
+ })
1066
+ })
1067
+
978
1068
  router.post('/meeting/:sessionId/relabel', (req, res) => {
979
1069
  res.set('Cache-Control', 'private, no-store')
980
1070
  const sessionId = String(req.params.sessionId ?? '')