@fayz-ai/plugin-scribe 0.10.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/dist/components/DraftPanel.d.ts +14 -0
  3. package/dist/components/DraftPanel.d.ts.map +1 -0
  4. package/dist/components/ScribeConsentDialog.d.ts +12 -0
  5. package/dist/components/ScribeConsentDialog.d.ts.map +1 -0
  6. package/dist/components/ScribeRecordingPill.d.ts +16 -0
  7. package/dist/components/ScribeRecordingPill.d.ts.map +1 -0
  8. package/dist/components/ScribeRecoveryBanner.d.ts +8 -0
  9. package/dist/components/ScribeRecoveryBanner.d.ts.map +1 -0
  10. package/dist/components/ScribeSessionPage.d.ts +13 -0
  11. package/dist/components/ScribeSessionPage.d.ts.map +1 -0
  12. package/dist/components/ScribeShellMount.d.ts +19 -0
  13. package/dist/components/ScribeShellMount.d.ts.map +1 -0
  14. package/dist/components/TranscriptPanel.d.ts +10 -0
  15. package/dist/components/TranscriptPanel.d.ts.map +1 -0
  16. package/dist/data/supabase.d.ts +63 -0
  17. package/dist/data/supabase.d.ts.map +1 -0
  18. package/dist/data/tables.d.ts +31 -0
  19. package/dist/data/tables.d.ts.map +1 -0
  20. package/dist/index.d.ts +13 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +2910 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/lib/config.d.ts +19 -0
  25. package/dist/lib/config.d.ts.map +1 -0
  26. package/dist/lib/config.test.d.ts +2 -0
  27. package/dist/lib/config.test.d.ts.map +1 -0
  28. package/dist/lib/drain.d.ts +13 -0
  29. package/dist/lib/drain.d.ts.map +1 -0
  30. package/dist/lib/generate.d.ts +23 -0
  31. package/dist/lib/generate.d.ts.map +1 -0
  32. package/dist/lib/prompt.d.ts +39 -0
  33. package/dist/lib/prompt.d.ts.map +1 -0
  34. package/dist/lib/prompt.test.d.ts +2 -0
  35. package/dist/lib/prompt.test.d.ts.map +1 -0
  36. package/dist/lib/transport.d.ts +59 -0
  37. package/dist/lib/transport.d.ts.map +1 -0
  38. package/dist/locales/en.d.ts +2 -0
  39. package/dist/locales/en.d.ts.map +1 -0
  40. package/dist/locales/index.d.ts +2 -0
  41. package/dist/locales/index.d.ts.map +1 -0
  42. package/dist/locales/pt-BR.d.ts +2 -0
  43. package/dist/locales/pt-BR.d.ts.map +1 -0
  44. package/dist/migrations/index.d.ts +6 -0
  45. package/dist/migrations/index.d.ts.map +1 -0
  46. package/dist/runtime/capture.d.ts +84 -0
  47. package/dist/runtime/capture.d.ts.map +1 -0
  48. package/dist/runtime/idb.d.ts +121 -0
  49. package/dist/runtime/idb.d.ts.map +1 -0
  50. package/dist/runtime/index.d.ts +48 -0
  51. package/dist/runtime/index.d.ts.map +1 -0
  52. package/dist/runtime/pump.d.ts +11 -0
  53. package/dist/runtime/pump.d.ts.map +1 -0
  54. package/dist/store.d.ts +25 -0
  55. package/dist/store.d.ts.map +1 -0
  56. package/dist/types.d.ts +266 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/functions/scribe-generate/index.ts +255 -0
  59. package/functions/scribe-transcribe/index.ts +455 -0
  60. package/package.json +58 -0
package/dist/index.js ADDED
@@ -0,0 +1,2910 @@
1
+ import * as React6 from 'react';
2
+ import React6__default from 'react';
3
+ import { getActiveTenantId, getSupabaseClientOptional, registerTranslations } from '@fayz-ai/core';
4
+ import { create } from 'zustand';
5
+ import { createPortal } from 'react-dom';
6
+ import { useAgentSurface, useAuth, resolveFayzAgentConnection } from '@fayz-ai/admin';
7
+ import { Button, toast, Badge, Tabs, TabsList, TabsTrigger, TabsContent, Card, CardContent, MarkdownEditor, Modal, ModalContent, ModalHeader, ModalTitle, ModalBody, Checkbox, ModalFooter } from '@fayz-ai/ui/primitives';
8
+ import { Mic, Loader2, FileText, Sparkles, AlertTriangle, RefreshCw, Trash2, Check, Pause, Play, Square, ShieldCheck } from 'lucide-react';
9
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
10
+ import { SubpageHeader } from '@fayz-ai/ui/layout';
11
+
12
+ // src/index.ts
13
+
14
+ // src/locales/en.ts
15
+ var en = {
16
+ "scribe.nav.sessions": "Sessions",
17
+ "scribe.settings.title": "Session recording",
18
+ "scribe.action.start": "Start session",
19
+ "scribe.action.pause": "Pause",
20
+ "scribe.action.resume": "Resume",
21
+ "scribe.action.finish": "Finish",
22
+ "scribe.action.discard": "Discard",
23
+ "scribe.action.generate": "Generate document",
24
+ "scribe.transcript": "Transcript",
25
+ "scribe.recovery.title": "Recording interrupted",
26
+ "scribe.consent.title": "Start recording"
27
+ };
28
+
29
+ // src/locales/pt-BR.ts
30
+ var ptBR = {
31
+ "scribe.nav.sessions": "Atendimentos",
32
+ "scribe.settings.title": "Grava\xE7\xE3o de atendimentos",
33
+ "scribe.action.start": "Iniciar atendimento",
34
+ "scribe.action.pause": "Pausar",
35
+ "scribe.action.resume": "Retomar",
36
+ "scribe.action.finish": "Finalizar",
37
+ "scribe.action.discard": "Descartar",
38
+ "scribe.action.generate": "Gerar documento",
39
+ "scribe.transcript": "Transcri\xE7\xE3o",
40
+ "scribe.recovery.title": "Grava\xE7\xE3o interrompida",
41
+ "scribe.consent.title": "Iniciar grava\xE7\xE3o"
42
+ };
43
+
44
+ // src/locales/index.ts
45
+ var scribeLocales = {
46
+ en,
47
+ "pt-BR": ptBR
48
+ };
49
+
50
+ // src/runtime/idb.ts
51
+ var DB_NAME = "fayz.scribe";
52
+ var DB_VERSION = 1;
53
+ var STORE_SESSIONS = "sessions";
54
+ var STORE_PIECES = "pieces";
55
+ var STORE_SEGMENTS = "segments";
56
+ var STORE_TRANSCRIPT = "transcript";
57
+ var IDX_SEGMENT_UPLOAD = "by_upload";
58
+ var dbPromise = null;
59
+ function isIdbAvailable() {
60
+ return typeof indexedDB !== "undefined";
61
+ }
62
+ function openScribeDb() {
63
+ if (dbPromise) return dbPromise;
64
+ dbPromise = new Promise((resolve, reject) => {
65
+ if (!isIdbAvailable()) {
66
+ reject(new Error("IndexedDB indispon\xEDvel neste navegador"));
67
+ return;
68
+ }
69
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
70
+ req.onupgradeneeded = () => {
71
+ const db = req.result;
72
+ if (!db.objectStoreNames.contains(STORE_SESSIONS)) {
73
+ db.createObjectStore(STORE_SESSIONS, { keyPath: "id" });
74
+ }
75
+ if (!db.objectStoreNames.contains(STORE_PIECES)) {
76
+ db.createObjectStore(STORE_PIECES, { keyPath: ["sessionId", "segIndex", "pieceIndex"] });
77
+ }
78
+ if (!db.objectStoreNames.contains(STORE_SEGMENTS)) {
79
+ const store = db.createObjectStore(STORE_SEGMENTS, { keyPath: ["sessionId", "segIndex"] });
80
+ store.createIndex(IDX_SEGMENT_UPLOAD, ["uploadState", "nextAttemptAt"]);
81
+ }
82
+ if (!db.objectStoreNames.contains(STORE_TRANSCRIPT)) {
83
+ db.createObjectStore(STORE_TRANSCRIPT, { keyPath: ["sessionId", "segIndex"] });
84
+ }
85
+ };
86
+ req.onsuccess = () => resolve(req.result);
87
+ req.onerror = () => reject(req.error ?? new Error("falha ao abrir o IndexedDB"));
88
+ req.onblocked = () => reject(new Error("IndexedDB bloqueado por outra aba"));
89
+ });
90
+ return dbPromise;
91
+ }
92
+ function promisify(req) {
93
+ return new Promise((resolve, reject) => {
94
+ req.onsuccess = () => resolve(req.result);
95
+ req.onerror = () => reject(req.error ?? new Error("falha na opera\xE7\xE3o do IndexedDB"));
96
+ });
97
+ }
98
+ async function withStore(name, mode, fn) {
99
+ const db = await openScribeDb();
100
+ const tx = db.transaction(name, mode);
101
+ const store = tx.objectStore(name);
102
+ const out = fn(store);
103
+ const value = out instanceof Promise ? await out : await promisify(out);
104
+ await new Promise((resolve, reject) => {
105
+ tx.oncomplete = () => resolve();
106
+ tx.onerror = () => reject(tx.error ?? new Error("transa\xE7\xE3o abortada"));
107
+ tx.onabort = () => reject(tx.error ?? new Error("transa\xE7\xE3o abortada"));
108
+ });
109
+ return value;
110
+ }
111
+ function putSession(session) {
112
+ return withStore(STORE_SESSIONS, "readwrite", (s) => s.put(session));
113
+ }
114
+ function getSession(id) {
115
+ return withStore(STORE_SESSIONS, "readonly", (s) => s.get(id));
116
+ }
117
+ function getAllSessions() {
118
+ return withStore(STORE_SESSIONS, "readonly", (s) => s.getAll());
119
+ }
120
+ async function findOrphanSessions(staleAfterMs = 15e3) {
121
+ const now = Date.now();
122
+ const all = await getAllSessions();
123
+ return all.filter(
124
+ (s) => (s.state === "recording" || s.state === "paused" || s.state === "interrupted") && now - s.lastTickEpochMs > staleAfterMs
125
+ );
126
+ }
127
+ function putPiece(piece) {
128
+ return withStore(STORE_PIECES, "readwrite", (s) => s.put(piece));
129
+ }
130
+ function getPieces(sessionId, segIndex) {
131
+ const range = IDBKeyRange.bound(
132
+ [sessionId, segIndex, -Infinity],
133
+ [sessionId, segIndex, Infinity]
134
+ );
135
+ return withStore(STORE_PIECES, "readonly", (s) => s.getAll(range));
136
+ }
137
+ function deletePieces(sessionId, segIndex) {
138
+ const range = IDBKeyRange.bound(
139
+ [sessionId, segIndex, -Infinity],
140
+ [sessionId, segIndex, Infinity]
141
+ );
142
+ return withStore(STORE_PIECES, "readwrite", (s) => promisify(s.delete(range)).then(() => void 0));
143
+ }
144
+ function putSegment(segment) {
145
+ return withStore(STORE_SEGMENTS, "readwrite", (s) => s.put(segment));
146
+ }
147
+ function getSegment(sessionId, segIndex) {
148
+ return withStore(
149
+ STORE_SEGMENTS,
150
+ "readonly",
151
+ (s) => s.get([sessionId, segIndex])
152
+ );
153
+ }
154
+ async function claimUploadable(limit) {
155
+ const now = Date.now();
156
+ const out = [];
157
+ for (const state of ["pending", "failed"]) {
158
+ if (out.length >= limit) break;
159
+ const range = IDBKeyRange.bound([state, -Infinity], [state, now]);
160
+ const rows = await withStore(
161
+ STORE_SEGMENTS,
162
+ "readonly",
163
+ (s) => s.index(IDX_SEGMENT_UPLOAD).getAll(range, limit - out.length)
164
+ );
165
+ out.push(...rows);
166
+ }
167
+ return out.sort(
168
+ (a, b) => a.sessionId === b.sessionId ? a.segIndex - b.segIndex : a.sessionId.localeCompare(b.sessionId)
169
+ );
170
+ }
171
+ async function reclaimStuckUploads() {
172
+ const range = IDBKeyRange.bound(["uploading", -Infinity], ["uploading", Infinity]);
173
+ const stuck = await withStore(
174
+ STORE_SEGMENTS,
175
+ "readonly",
176
+ (s) => s.index(IDX_SEGMENT_UPLOAD).getAll(range)
177
+ );
178
+ for (const seg of stuck) {
179
+ await putSegment({ ...seg, uploadState: "pending", nextAttemptAt: 0 });
180
+ }
181
+ return stuck.length;
182
+ }
183
+ var MIN_FREE_BYTES = 50 * 1024 * 1024;
184
+ async function checkStorageHealth() {
185
+ if (!isIdbAvailable()) {
186
+ return { ok: false, persisted: false, freeBytes: null, reason: "IndexedDB indispon\xEDvel" };
187
+ }
188
+ let persisted = false;
189
+ try {
190
+ if (navigator.storage?.persist) persisted = await navigator.storage.persist();
191
+ } catch {
192
+ }
193
+ try {
194
+ if (!navigator.storage?.estimate) return { ok: true, persisted, freeBytes: null };
195
+ const { quota = 0, usage = 0 } = await navigator.storage.estimate();
196
+ const free = quota - usage;
197
+ if (free < MIN_FREE_BYTES) {
198
+ return {
199
+ ok: false,
200
+ persisted,
201
+ freeBytes: free,
202
+ reason: `Espa\xE7o insuficiente no navegador (${Math.round(free / 1024 / 1024)} MB livres, m\xEDnimo ${MIN_FREE_BYTES / 1024 / 1024} MB)`
203
+ };
204
+ }
205
+ return { ok: true, persisted, freeBytes: free };
206
+ } catch {
207
+ return { ok: true, persisted, freeBytes: null };
208
+ }
209
+ }
210
+
211
+ // src/runtime/capture.ts
212
+ var PIECE_MS = 5e3;
213
+ var SEGMENT_MS = 3e4;
214
+ var HEARTBEAT_MS = 2e3;
215
+ var MIME_CANDIDATES = [
216
+ "audio/webm;codecs=opus",
217
+ "audio/ogg;codecs=opus",
218
+ "audio/mp4",
219
+ "audio/webm"
220
+ ];
221
+ function pickMimeType() {
222
+ if (typeof MediaRecorder === "undefined") return null;
223
+ for (const mime of MIME_CANDIDATES) {
224
+ try {
225
+ if (MediaRecorder.isTypeSupported(mime)) return mime;
226
+ } catch {
227
+ }
228
+ }
229
+ return null;
230
+ }
231
+ function isCaptureSupported() {
232
+ return typeof navigator !== "undefined" && !!navigator.mediaDevices?.getUserMedia && typeof MediaRecorder !== "undefined" && pickMimeType() !== null;
233
+ }
234
+ var live = null;
235
+ var listeners = /* @__PURE__ */ new Set();
236
+ var lastError;
237
+ function getSnapshot() {
238
+ if (!live) return { state: "idle", sessionId: null, elapsedMs: 0, segIndex: 0, error: lastError };
239
+ return {
240
+ state: live.session.state,
241
+ sessionId: live.session.id,
242
+ subjectId: live.session.subjectId,
243
+ subjectName: live.session.subjectName,
244
+ elapsedMs: elapsedMs(live.session),
245
+ segIndex: live.session.segIndex,
246
+ error: lastError
247
+ };
248
+ }
249
+ function emit() {
250
+ const snap = getSnapshot();
251
+ for (const fn of listeners) {
252
+ try {
253
+ fn(snap);
254
+ } catch {
255
+ }
256
+ }
257
+ }
258
+ function subscribeCapture(fn) {
259
+ listeners.add(fn);
260
+ fn(getSnapshot());
261
+ return () => listeners.delete(fn);
262
+ }
263
+ function elapsedMs(session) {
264
+ const running2 = session.state === "recording" && session.resumedAtEpochMs !== null;
265
+ return session.accumulatedMs + (running2 ? Date.now() - session.resumedAtEpochMs : 0);
266
+ }
267
+ async function closeSegment(cap, partial) {
268
+ const { session, buffer, segStartOffsetMs } = cap;
269
+ if (buffer.length === 0) return;
270
+ const segIndex = session.segIndex;
271
+ const blob = new Blob(buffer, { type: session.mimeType });
272
+ const nominalDurationMs = buffer.length * PIECE_MS;
273
+ await putSegment({
274
+ sessionId: session.id,
275
+ segIndex,
276
+ blob,
277
+ bytes: blob.size,
278
+ startOffsetMs: segStartOffsetMs,
279
+ nominalDurationMs,
280
+ partial,
281
+ gap: false,
282
+ uploadState: "pending",
283
+ attempts: 0,
284
+ nextAttemptAt: 0,
285
+ storagePath: null
286
+ });
287
+ await deletePieces(session.id, segIndex);
288
+ cap.buffer = [];
289
+ cap.pieceIndex = 0;
290
+ cap.segStartOffsetMs = segStartOffsetMs + nominalDurationMs;
291
+ session.segIndex = segIndex + 1;
292
+ await putSession(session);
293
+ }
294
+ async function writeGapSegment(cap, durationMs) {
295
+ const { session } = cap;
296
+ const segIndex = session.segIndex;
297
+ await putSegment({
298
+ sessionId: session.id,
299
+ segIndex,
300
+ blob: null,
301
+ bytes: 0,
302
+ startOffsetMs: cap.segStartOffsetMs,
303
+ nominalDurationMs: durationMs,
304
+ partial: false,
305
+ gap: true,
306
+ uploadState: "pending",
307
+ attempts: 0,
308
+ nextAttemptAt: 0,
309
+ storagePath: null
310
+ });
311
+ cap.segStartOffsetMs += durationMs;
312
+ session.segIndex = segIndex + 1;
313
+ await putSession(session);
314
+ }
315
+ function startRecorder(cap) {
316
+ const recorder = new MediaRecorder(cap.stream, {
317
+ mimeType: cap.session.mimeType,
318
+ // 24 kbps mono é transparente para fala e mantém 40 min em ~7 MB — o número
319
+ // que torna toda a história de durabilidade barata.
320
+ audioBitsPerSecond: 24e3
321
+ });
322
+ recorder.ondataavailable = (ev) => {
323
+ if (!ev.data || ev.data.size === 0) return;
324
+ cap.buffer.push(ev.data);
325
+ const pieceIndex = cap.pieceIndex++;
326
+ void putPiece({
327
+ sessionId: cap.session.id,
328
+ segIndex: cap.session.segIndex,
329
+ pieceIndex,
330
+ blob: ev.data,
331
+ bytes: ev.data.size,
332
+ createdAt: Date.now()
333
+ }).catch(() => {
334
+ });
335
+ };
336
+ recorder.onstop = () => {
337
+ const wasRotating = cap.rotating;
338
+ cap.rotating = false;
339
+ void (async () => {
340
+ await closeSegment(cap, !wasRotating);
341
+ if (wasRotating && cap.session.state === "recording") {
342
+ startRecorder(cap);
343
+ armRotation(cap);
344
+ }
345
+ emit();
346
+ })();
347
+ };
348
+ recorder.onerror = () => {
349
+ void handleInterruption("erro do gravador");
350
+ };
351
+ cap.recorder = recorder;
352
+ recorder.start(PIECE_MS);
353
+ }
354
+ function armRotation(cap) {
355
+ if (cap.rotateTimer) clearTimeout(cap.rotateTimer);
356
+ cap.rotateTimer = setTimeout(() => {
357
+ if (!cap.recorder || cap.recorder.state !== "recording") return;
358
+ cap.rotating = true;
359
+ cap.recorder.stop();
360
+ }, SEGMENT_MS);
361
+ }
362
+ async function acquireWakeLock(cap) {
363
+ try {
364
+ if (!("wakeLock" in navigator)) return;
365
+ cap.wakeLock = await navigator.wakeLock.request("screen");
366
+ } catch {
367
+ }
368
+ }
369
+ async function releaseWakeLock(cap) {
370
+ try {
371
+ await cap.wakeLock?.release();
372
+ } catch {
373
+ }
374
+ cap.wakeLock = null;
375
+ }
376
+ function onBeforeUnload(ev) {
377
+ if (!live || live.session.state !== "recording") return;
378
+ ev.preventDefault();
379
+ ev.returnValue = "";
380
+ }
381
+ function onVisibilityChange() {
382
+ if (!live) return;
383
+ if (document.visibilityState === "visible" && live.session.state === "recording" && !live.wakeLock) {
384
+ void acquireWakeLock(live);
385
+ }
386
+ emit();
387
+ }
388
+ function onFreeze() {
389
+ if (!live) return;
390
+ live.interruptedAtMs = Date.now();
391
+ live.session.lastTickEpochMs = Date.now();
392
+ void putSession(live.session);
393
+ }
394
+ function onResume() {
395
+ if (!live) return;
396
+ const track = live.stream.getAudioTracks()[0];
397
+ if (!track || track.readyState === "ended") {
398
+ void handleInterruption("microfone perdido enquanto a aba estava suspensa");
399
+ }
400
+ }
401
+ function attachLifecycle() {
402
+ window.addEventListener("beforeunload", onBeforeUnload);
403
+ document.addEventListener("visibilitychange", onVisibilityChange);
404
+ document.addEventListener("freeze", onFreeze);
405
+ document.addEventListener("resume", onResume);
406
+ }
407
+ function detachLifecycle() {
408
+ window.removeEventListener("beforeunload", onBeforeUnload);
409
+ document.removeEventListener("visibilitychange", onVisibilityChange);
410
+ document.removeEventListener("freeze", onFreeze);
411
+ document.removeEventListener("resume", onResume);
412
+ }
413
+ function watchTrack(cap) {
414
+ const track = cap.stream.getAudioTracks()[0];
415
+ if (!track) return;
416
+ track.onended = () => void handleInterruption("microfone desconectado");
417
+ track.onmute = () => void handleInterruption("microfone silenciado pelo sistema");
418
+ }
419
+ async function handleInterruption(reason) {
420
+ if (!live || live.session.state !== "recording") return;
421
+ const cap = live;
422
+ cap.interruptedAtMs = Date.now();
423
+ cap.session.state = "interrupted";
424
+ cap.session.accumulatedMs = elapsedMs(cap.session);
425
+ cap.session.resumedAtEpochMs = null;
426
+ lastError = reason;
427
+ if (cap.rotateTimer) clearTimeout(cap.rotateTimer);
428
+ try {
429
+ if (cap.recorder?.state === "recording") cap.recorder.stop();
430
+ else await closeSegment(cap, true);
431
+ } catch {
432
+ await closeSegment(cap, true);
433
+ }
434
+ await releaseWakeLock(cap);
435
+ await putSession(cap.session);
436
+ emit();
437
+ }
438
+ async function startCapture(opts) {
439
+ if (live) return { ok: false, reason: "j\xE1 existe uma grava\xE7\xE3o em andamento" };
440
+ if (!isCaptureSupported()) return { ok: false, reason: "este navegador n\xE3o suporta grava\xE7\xE3o de \xE1udio" };
441
+ const health = await checkStorageHealth();
442
+ if (!health.ok) return { ok: false, reason: health.reason ?? "armazenamento local indispon\xEDvel" };
443
+ const mimeType = pickMimeType();
444
+ let stream;
445
+ try {
446
+ stream = await navigator.mediaDevices.getUserMedia({
447
+ audio: {
448
+ channelCount: 1,
449
+ echoCancellation: true,
450
+ noiseSuppression: true,
451
+ autoGainControl: true,
452
+ sampleRate: 16e3
453
+ }
454
+ });
455
+ } catch (err) {
456
+ const name = err?.name;
457
+ return {
458
+ ok: false,
459
+ reason: name === "NotAllowedError" ? "permiss\xE3o de microfone negada" : name === "NotFoundError" ? "nenhum microfone encontrado" : "n\xE3o foi poss\xEDvel acessar o microfone"
460
+ };
461
+ }
462
+ const now = Date.now();
463
+ const session = {
464
+ id: crypto.randomUUID(),
465
+ tenantId: opts.tenantId,
466
+ subjectId: opts.subjectId,
467
+ subjectName: opts.subjectName,
468
+ appointmentId: opts.appointmentId,
469
+ userId: opts.userId,
470
+ mimeType,
471
+ locale: opts.locale ?? "pt-BR",
472
+ startedAtEpochMs: now,
473
+ resumedAtEpochMs: now,
474
+ accumulatedMs: 0,
475
+ lastTickEpochMs: now,
476
+ state: "recording",
477
+ segIndex: 0,
478
+ templateKeys: opts.templateKeys ?? []
479
+ };
480
+ await putSession(session);
481
+ const cap = {
482
+ session,
483
+ stream,
484
+ recorder: null,
485
+ buffer: [],
486
+ pieceIndex: 0,
487
+ segStartOffsetMs: 0,
488
+ wakeLock: null,
489
+ heartbeat: null,
490
+ rotateTimer: null,
491
+ rotating: false,
492
+ interruptedAtMs: null
493
+ };
494
+ live = cap;
495
+ lastError = void 0;
496
+ startRecorder(cap);
497
+ armRotation(cap);
498
+ watchTrack(cap);
499
+ attachLifecycle();
500
+ void acquireWakeLock(cap);
501
+ cap.heartbeat = setInterval(() => {
502
+ if (!live) return;
503
+ live.session.lastTickEpochMs = Date.now();
504
+ void putSession(live.session);
505
+ emit();
506
+ }, HEARTBEAT_MS);
507
+ try {
508
+ await opts.onSessionCreated?.(session, opts.consentMode);
509
+ } catch {
510
+ }
511
+ emit();
512
+ return { ok: true, sessionId: session.id };
513
+ }
514
+ async function pauseCapture() {
515
+ if (!live || live.session.state !== "recording") return;
516
+ const cap = live;
517
+ cap.session.accumulatedMs = elapsedMs(cap.session);
518
+ cap.session.resumedAtEpochMs = null;
519
+ cap.session.state = "paused";
520
+ if (cap.rotateTimer) clearTimeout(cap.rotateTimer);
521
+ if (cap.recorder?.state === "recording") {
522
+ cap.rotating = false;
523
+ cap.recorder.stop();
524
+ }
525
+ await releaseWakeLock(cap);
526
+ await putSession(cap.session);
527
+ emit();
528
+ }
529
+ async function resumeCapture() {
530
+ if (!live || live.session.state !== "paused" && live.session.state !== "interrupted") return;
531
+ const cap = live;
532
+ if (cap.session.state === "interrupted" && cap.interruptedAtMs) {
533
+ await writeGapSegment(cap, Date.now() - cap.interruptedAtMs);
534
+ }
535
+ cap.interruptedAtMs = null;
536
+ const track = cap.stream.getAudioTracks()[0];
537
+ if (!track || track.readyState === "ended") {
538
+ try {
539
+ cap.stream = await navigator.mediaDevices.getUserMedia({
540
+ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }
541
+ });
542
+ watchTrack(cap);
543
+ } catch {
544
+ lastError = "n\xE3o foi poss\xEDvel reabrir o microfone";
545
+ emit();
546
+ return;
547
+ }
548
+ }
549
+ cap.session.state = "recording";
550
+ cap.session.resumedAtEpochMs = Date.now();
551
+ lastError = void 0;
552
+ await putSession(cap.session);
553
+ startRecorder(cap);
554
+ armRotation(cap);
555
+ void acquireWakeLock(cap);
556
+ emit();
557
+ }
558
+ async function stopCapture() {
559
+ if (!live) return null;
560
+ const cap = live;
561
+ const sessionId = cap.session.id;
562
+ cap.session.accumulatedMs = elapsedMs(cap.session);
563
+ cap.session.resumedAtEpochMs = null;
564
+ cap.session.state = "stopping";
565
+ await putSession(cap.session);
566
+ emit();
567
+ if (cap.rotateTimer) clearTimeout(cap.rotateTimer);
568
+ if (cap.heartbeat) clearInterval(cap.heartbeat);
569
+ if (cap.recorder?.state === "recording") {
570
+ cap.rotating = false;
571
+ await new Promise((resolve) => {
572
+ const rec = cap.recorder;
573
+ const prev = rec.onstop;
574
+ rec.onstop = (ev) => {
575
+ if (typeof prev === "function") prev.call(rec, ev);
576
+ setTimeout(resolve, 0);
577
+ };
578
+ rec.stop();
579
+ });
580
+ } else {
581
+ await closeSegment(cap, true);
582
+ }
583
+ for (const track of cap.stream.getTracks()) track.stop();
584
+ await releaseWakeLock(cap);
585
+ detachLifecycle();
586
+ const finished = await getSession(sessionId);
587
+ if (finished) {
588
+ finished.state = "idle";
589
+ await putSession(finished);
590
+ }
591
+ live = null;
592
+ emit();
593
+ return sessionId;
594
+ }
595
+ async function adoptOrphan(session) {
596
+ if (live) return;
597
+ const pieces = await getPieces(session.id, session.segIndex);
598
+ live = {
599
+ session: { ...session, state: "interrupted" },
600
+ stream: new MediaStream(),
601
+ recorder: null,
602
+ buffer: pieces.map((p) => p.blob),
603
+ pieceIndex: pieces.length,
604
+ segStartOffsetMs: 0,
605
+ wakeLock: null,
606
+ heartbeat: null,
607
+ rotateTimer: null,
608
+ rotating: false,
609
+ interruptedAtMs: session.lastTickEpochMs
610
+ };
611
+ if (pieces.length > 0) await closeSegment(live, true);
612
+ live.session.state = "interrupted";
613
+ await putSession(live.session);
614
+ lastError = "grava\xE7\xE3o interrompida";
615
+ emit();
616
+ }
617
+ async function releaseCapture() {
618
+ if (!live) return;
619
+ if (live.rotateTimer) clearTimeout(live.rotateTimer);
620
+ if (live.heartbeat) clearInterval(live.heartbeat);
621
+ for (const track of live.stream.getTracks()) track.stop();
622
+ await releaseWakeLock(live);
623
+ detachLifecycle();
624
+ live = null;
625
+ lastError = void 0;
626
+ emit();
627
+ }
628
+ function getLiveSessionId() {
629
+ return live?.session.id ?? null;
630
+ }
631
+
632
+ // src/data/tables.ts
633
+ var T = {
634
+ sessions: "plg_scribe_sessions",
635
+ segments: "plg_scribe_segments",
636
+ generations: "plg_scribe_generations"
637
+ };
638
+ var AUDIO_BUCKET = "scribe-audio";
639
+ function storagePath(args) {
640
+ const yyyy = String(args.startedAt.getUTCFullYear());
641
+ const mm = String(args.startedAt.getUTCMonth() + 1).padStart(2, "0");
642
+ const seg = String(args.segIndex).padStart(5, "0");
643
+ return `${args.tenantId}/${yyyy}/${mm}/${args.sessionId}/${seg}.${args.ext}`;
644
+ }
645
+ function sessionPrefix(args) {
646
+ const yyyy = String(args.startedAt.getUTCFullYear());
647
+ const mm = String(args.startedAt.getUTCMonth() + 1).padStart(2, "0");
648
+ return `${args.tenantId}/${yyyy}/${mm}/${args.sessionId}`;
649
+ }
650
+ function extForMime(mimeType) {
651
+ if (mimeType.includes("ogg")) return "ogg";
652
+ if (mimeType.includes("mp4")) return "m4a";
653
+ if (mimeType.includes("mpeg")) return "mp3";
654
+ return "webm";
655
+ }
656
+
657
+ // src/runtime/pump.ts
658
+ var CONCURRENCY = 2;
659
+ var IDLE_POLL_MS = 3e3;
660
+ var BACKOFF_BASE_MS = 500;
661
+ var BACKOFF_CEILING_MS = 3e4;
662
+ var CHANNEL_NAME = "fayz.scribe";
663
+ var running = false;
664
+ var timer = null;
665
+ var channel = null;
666
+ var tabId = typeof crypto !== "undefined" && crypto.randomUUID ? crypto.randomUUID() : String(Math.random());
667
+ var isLeader = true;
668
+ var peerIds = /* @__PURE__ */ new Set();
669
+ function setupChannel() {
670
+ if (typeof BroadcastChannel === "undefined" || channel) return;
671
+ channel = new BroadcastChannel(CHANNEL_NAME);
672
+ channel.onmessage = (ev) => {
673
+ const msg = ev.data;
674
+ if (msg?.type === "hello" && msg.tabId) {
675
+ peerIds.add(msg.tabId);
676
+ channel?.postMessage({ type: "ack", tabId });
677
+ recomputeLeader();
678
+ } else if (msg?.type === "ack" && msg.tabId) {
679
+ peerIds.add(msg.tabId);
680
+ recomputeLeader();
681
+ } else if (msg?.type === "bye" && msg.tabId) {
682
+ peerIds.delete(msg.tabId);
683
+ recomputeLeader();
684
+ }
685
+ };
686
+ channel.postMessage({ type: "hello", tabId });
687
+ window.addEventListener("pagehide", () => channel?.postMessage({ type: "bye", tabId }));
688
+ }
689
+ function recomputeLeader() {
690
+ const all = [tabId, ...peerIds].sort();
691
+ isLeader = all[0] === tabId;
692
+ }
693
+ function isDuplicateError(err) {
694
+ const e = err;
695
+ const status = Number(e?.statusCode ?? e?.status);
696
+ if (status === 409) return true;
697
+ const text = `${e?.message ?? ""} ${e?.error ?? ""}`.toLowerCase();
698
+ return text.includes("duplicate") || text.includes("already exists") || text.includes("resource already");
699
+ }
700
+ function nextBackoff(attempts) {
701
+ const base = Math.min(BACKOFF_CEILING_MS, BACKOFF_BASE_MS * 2 ** attempts);
702
+ return Date.now() + base + Math.floor(Math.random() * 500);
703
+ }
704
+ async function uploadOne(segment) {
705
+ const supabase = getSupabaseClientOptional();
706
+ if (!supabase) return;
707
+ const session = await getSession(segment.sessionId);
708
+ if (!session) {
709
+ await putSegment({ ...segment, uploadState: "failed", nextAttemptAt: nextBackoff(segment.attempts + 1), attempts: segment.attempts + 1 });
710
+ return;
711
+ }
712
+ await putSegment({ ...segment, uploadState: "uploading" });
713
+ if (segment.gap || !segment.blob) {
714
+ await recordSegment(supabase, session.id, segment, null);
715
+ await putSegment({ ...segment, blob: null, uploadState: "uploaded", storagePath: null });
716
+ return;
717
+ }
718
+ const path = storagePath({
719
+ tenantId: session.tenantId,
720
+ sessionId: session.id,
721
+ segIndex: segment.segIndex,
722
+ startedAt: new Date(session.startedAtEpochMs),
723
+ ext: extForMime(session.mimeType)
724
+ });
725
+ try {
726
+ const { error } = await supabase.storage.from(AUDIO_BUCKET).upload(path, segment.blob, {
727
+ contentType: session.mimeType,
728
+ // Sem upsert de propósito: combina com a ausência de policy de UPDATE no
729
+ // bucket. Um caminho gravado nunca é trocado por outro conteúdo.
730
+ upsert: false
731
+ });
732
+ if (error && !isDuplicateError(error)) throw error;
733
+ await recordSegment(supabase, session.id, segment, path);
734
+ await putSegment({ ...segment, blob: null, uploadState: "uploaded", storagePath: path });
735
+ } catch (err) {
736
+ const attempts = segment.attempts + 1;
737
+ await putSegment({
738
+ ...segment,
739
+ uploadState: "failed",
740
+ attempts,
741
+ nextAttemptAt: nextBackoff(attempts)
742
+ });
743
+ }
744
+ }
745
+ async function recordSegment(supabase, sessionId, segment, path) {
746
+ const { error } = await supabase.rpc("scribe_record_segment", {
747
+ p_payload: {
748
+ session_id: sessionId,
749
+ seg_index: segment.segIndex,
750
+ storage_path: path,
751
+ bytes: segment.bytes,
752
+ start_offset_ms: segment.startOffsetMs,
753
+ // Duração NOMINAL. O servidor sobrescreve com a medida do STT quando ela
754
+ // chega — o cliente conta tempo, o STT conta amostras.
755
+ duration_ms: segment.nominalDurationMs,
756
+ partial: segment.partial,
757
+ gap: segment.gap
758
+ }
759
+ });
760
+ if (error) throw error;
761
+ }
762
+ async function tick() {
763
+ if (!running) return;
764
+ try {
765
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return;
766
+ if (!isLeader) return;
767
+ if (!getSupabaseClientOptional()) return;
768
+ const batch = await claimUploadable(CONCURRENCY);
769
+ if (batch.length === 0) return;
770
+ await Promise.all(
771
+ batch.map(async (seg) => {
772
+ const fresh = await getSegment(seg.sessionId, seg.segIndex);
773
+ if (!fresh || fresh.uploadState === "uploaded") return;
774
+ await uploadOne(fresh);
775
+ })
776
+ );
777
+ } catch {
778
+ } finally {
779
+ if (running) timer = setTimeout(() => void tick(), IDLE_POLL_MS);
780
+ }
781
+ }
782
+ function onOnline() {
783
+ if (!running) return;
784
+ if (timer) clearTimeout(timer);
785
+ void tick();
786
+ }
787
+ function kickPump() {
788
+ if (!running) return;
789
+ if (timer) clearTimeout(timer);
790
+ timer = setTimeout(() => void tick(), 0);
791
+ }
792
+ function startPump() {
793
+ if (running) return;
794
+ running = true;
795
+ setupChannel();
796
+ if (typeof window !== "undefined") window.addEventListener("online", onOnline);
797
+ void reclaimStuckUploads().finally(() => void tick());
798
+ }
799
+ async function finalizeSession(sessionId, wallDurationMs) {
800
+ const supabase = getSupabaseClientOptional();
801
+ if (!supabase) return;
802
+ const local = await getSession(sessionId);
803
+ await supabase.from("plg_scribe_sessions").update({
804
+ status: "uploading",
805
+ ended_at: (/* @__PURE__ */ new Date()).toISOString(),
806
+ wall_duration_ms: wallDurationMs,
807
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
808
+ }).eq("id", sessionId);
809
+ if (local) {
810
+ local.state = "idle";
811
+ await putSession(local);
812
+ }
813
+ }
814
+
815
+ // src/runtime/index.ts
816
+ var initialized = false;
817
+ var retention = {
818
+ audioDays: 90,
819
+ requireConsent: true,
820
+ defaultConsentMode: "verbal"
821
+ };
822
+ var orphans = [];
823
+ var orphanListeners = /* @__PURE__ */ new Set();
824
+ function subscribeOrphans(fn) {
825
+ orphanListeners.add(fn);
826
+ fn(orphans);
827
+ return () => orphanListeners.delete(fn);
828
+ }
829
+ function emitOrphans() {
830
+ for (const fn of orphanListeners) {
831
+ try {
832
+ fn(orphans);
833
+ } catch {
834
+ }
835
+ }
836
+ }
837
+ function initScribeRuntime(options) {
838
+ if (initialized) return;
839
+ if (typeof window === "undefined" || !isIdbAvailable()) return;
840
+ initialized = true;
841
+ retention = { ...retention, ...options?.retention };
842
+ startPump();
843
+ void (async () => {
844
+ try {
845
+ orphans = await findOrphanSessions();
846
+ if (orphans.length > 0) {
847
+ const latest = orphans.reduce((a, b) => a.lastTickEpochMs > b.lastTickEpochMs ? a : b);
848
+ await adoptOrphan(latest);
849
+ kickPump();
850
+ }
851
+ emitOrphans();
852
+ } catch {
853
+ }
854
+ })();
855
+ }
856
+ async function beginSession(args) {
857
+ const tenantId = getActiveTenantId();
858
+ if (!tenantId) return { ok: false, reason: "nenhum tenant ativo" };
859
+ const opts = {
860
+ tenantId,
861
+ userId: args.userId,
862
+ subjectId: args.subjectId,
863
+ subjectName: args.subjectName,
864
+ appointmentId: args.appointmentId,
865
+ locale: args.locale,
866
+ templateKeys: args.templateKeys,
867
+ consentMode: args.consentMode ?? retention.defaultConsentMode,
868
+ onSessionCreated: async (session, consentMode) => {
869
+ const supabase = getSupabaseClientOptional();
870
+ if (!supabase) return;
871
+ const retentionUntil = new Date(Date.now() + retention.audioDays * 864e5);
872
+ await supabase.from("plg_scribe_sessions").insert({
873
+ id: session.id,
874
+ tenant_id: session.tenantId,
875
+ subject_id: session.subjectId ?? null,
876
+ appointment_id: session.appointmentId ?? null,
877
+ context_kind: session.appointmentId ? "appointment" : session.subjectId ? "person" : "standalone",
878
+ owner_user_id: session.userId,
879
+ status: "recording",
880
+ locale: session.locale,
881
+ mime_type: session.mimeType,
882
+ started_at: new Date(session.startedAtEpochMs).toISOString(),
883
+ // O consentimento é carimbado no INSERT, não depois: a linha nunca
884
+ // existe sem a base legal do que ela guarda.
885
+ consent_at: retention.requireConsent ? (/* @__PURE__ */ new Date()).toISOString() : null,
886
+ consent_mode: retention.requireConsent ? consentMode ?? retention.defaultConsentMode : null,
887
+ retention_until: retentionUntil.toISOString().slice(0, 10)
888
+ });
889
+ }
890
+ };
891
+ const result = await startCapture(opts);
892
+ if (result.ok) kickPump();
893
+ return result;
894
+ }
895
+ async function pauseSession() {
896
+ await pauseCapture();
897
+ const id = getLiveSessionId();
898
+ if (!id) return;
899
+ kickPump();
900
+ await patchStatus(id, "paused");
901
+ }
902
+ async function resumeSession() {
903
+ await resumeCapture();
904
+ const id = getLiveSessionId();
905
+ if (id) await patchStatus(id, "recording");
906
+ }
907
+ async function endSession() {
908
+ const snapshot = getSnapshot();
909
+ const wall = snapshot.elapsedMs;
910
+ const id = await stopCapture();
911
+ if (!id) return null;
912
+ kickPump();
913
+ await finalizeSession(id, wall);
914
+ orphans = orphans.filter((o) => o.id !== id);
915
+ emitOrphans();
916
+ return id;
917
+ }
918
+ async function discardSession(sessionId) {
919
+ const id = sessionId ?? getLiveSessionId();
920
+ if (!id) return;
921
+ await releaseCapture();
922
+ await patchStatus(id, "abandoned");
923
+ const local = await getSession(id);
924
+ if (local) {
925
+ local.state = "idle";
926
+ await putSession(local);
927
+ }
928
+ orphans = orphans.filter((o) => o.id !== id);
929
+ emitOrphans();
930
+ }
931
+ function dismissOrphan(sessionId) {
932
+ orphans = orphans.filter((o) => o.id !== sessionId);
933
+ emitOrphans();
934
+ }
935
+ async function patchStatus(sessionId, status) {
936
+ const supabase = getSupabaseClientOptional();
937
+ if (!supabase) return;
938
+ try {
939
+ await supabase.from("plg_scribe_sessions").update({ status, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", sessionId);
940
+ } catch {
941
+ }
942
+ }
943
+ var useScribeStore = create((set) => ({
944
+ ...getSnapshot(),
945
+ orphans: [],
946
+ viewingSessionId: null,
947
+ setViewing: (id) => set({ viewingSessionId: id })
948
+ }));
949
+ var wired = false;
950
+ function wireScribeStore() {
951
+ if (wired) return;
952
+ wired = true;
953
+ subscribeCapture((snap) => {
954
+ useScribeStore.setState({
955
+ state: snap.state,
956
+ sessionId: snap.sessionId,
957
+ subjectId: snap.subjectId,
958
+ subjectName: snap.subjectName,
959
+ elapsedMs: snap.elapsedMs,
960
+ segIndex: snap.segIndex,
961
+ error: snap.error
962
+ });
963
+ });
964
+ subscribeOrphans((orphans2) => useScribeStore.setState({ orphans: orphans2 }));
965
+ }
966
+ function formatElapsed(ms) {
967
+ const total = Math.max(0, Math.floor(ms / 1e3));
968
+ const h = Math.floor(total / 3600);
969
+ const m = Math.floor(total % 3600 / 60);
970
+ const s = total % 60;
971
+ const mm = String(m).padStart(2, "0");
972
+ const ss = String(s).padStart(2, "0");
973
+ return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
974
+ }
975
+ var MOBILE_BREAKPOINT = 768;
976
+ function useIsMobile() {
977
+ const [isMobile, setIsMobile] = React6.useState(
978
+ () => typeof window !== "undefined" && window.innerWidth < MOBILE_BREAKPOINT
979
+ );
980
+ React6.useEffect(() => {
981
+ const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
982
+ const onChange = (e) => setIsMobile(e.matches);
983
+ setIsMobile(mq.matches);
984
+ mq.addEventListener("change", onChange);
985
+ return () => mq.removeEventListener("change", onChange);
986
+ }, []);
987
+ return isMobile;
988
+ }
989
+ function useTicker(active) {
990
+ const [, force] = React6.useReducer((n) => n + 1, 0);
991
+ React6.useEffect(() => {
992
+ if (!active) return;
993
+ let raf = 0;
994
+ let last = 0;
995
+ const loop = (t) => {
996
+ if (t - last >= 250) {
997
+ last = t;
998
+ if (document.visibilityState === "visible") force();
999
+ }
1000
+ raf = requestAnimationFrame(loop);
1001
+ };
1002
+ raf = requestAnimationFrame(loop);
1003
+ const onVis = () => force();
1004
+ document.addEventListener("visibilitychange", onVis);
1005
+ return () => {
1006
+ cancelAnimationFrame(raf);
1007
+ document.removeEventListener("visibilitychange", onVis);
1008
+ };
1009
+ }, [active]);
1010
+ return 0;
1011
+ }
1012
+ function ScribeRecordingPill({ labels, onOpenSession }) {
1013
+ const state = useScribeStore((s) => s.state);
1014
+ const sessionId = useScribeStore((s) => s.sessionId);
1015
+ const subjectName = useScribeStore((s) => s.subjectName);
1016
+ const isMobile = useIsMobile();
1017
+ const [busy, setBusy] = React6.useState(false);
1018
+ const active = state === "recording" || state === "paused" || state === "interrupted";
1019
+ useTicker(state === "recording");
1020
+ const elapsedMs2 = active ? useScribeStore.getState().elapsedMs : 0;
1021
+ const handleToggle = React6.useCallback(async () => {
1022
+ setBusy(true);
1023
+ try {
1024
+ if (state === "recording") await pauseSession();
1025
+ else await resumeSession();
1026
+ } finally {
1027
+ setBusy(false);
1028
+ }
1029
+ }, [state]);
1030
+ const handleStop = React6.useCallback(async () => {
1031
+ setBusy(true);
1032
+ try {
1033
+ const id = await endSession();
1034
+ if (id) onOpenSession?.(id);
1035
+ } finally {
1036
+ setBusy(false);
1037
+ }
1038
+ }, [onOpenSession]);
1039
+ if (!active || !sessionId) return null;
1040
+ const tone = state === "recording" ? "bg-destructive/10 text-destructive border-destructive/30" : state === "interrupted" ? "bg-amber-500/10 text-amber-700 border-amber-500/30 dark:text-amber-400" : "bg-muted text-muted-foreground border-border";
1041
+ const content = /* @__PURE__ */ jsxs(
1042
+ "div",
1043
+ {
1044
+ className: `flex items-center gap-2 rounded-full border px-2.5 py-1 ${tone}`,
1045
+ role: "status",
1046
+ "aria-live": "polite",
1047
+ "aria-label": `${labels.sessionSingular}: ${formatElapsed(elapsedMs2)}`,
1048
+ children: [
1049
+ /* @__PURE__ */ jsxs(
1050
+ "button",
1051
+ {
1052
+ type: "button",
1053
+ onClick: () => onOpenSession?.(sessionId),
1054
+ className: "flex items-center gap-1.5 text-sm font-medium tabular-nums",
1055
+ title: subjectName ?? labels.sessionSingular,
1056
+ children: [
1057
+ /* @__PURE__ */ jsxs("span", { className: "relative flex h-2 w-2 shrink-0", children: [
1058
+ state === "recording" && /* @__PURE__ */ jsx("span", { className: "absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-60" }),
1059
+ /* @__PURE__ */ jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-current" })
1060
+ ] }),
1061
+ formatElapsed(elapsedMs2),
1062
+ subjectName && /* @__PURE__ */ jsxs("span", { className: "hidden max-w-[12ch] truncate sm:inline", children: [
1063
+ "\xB7 ",
1064
+ subjectName
1065
+ ] })
1066
+ ]
1067
+ }
1068
+ ),
1069
+ /* @__PURE__ */ jsx(
1070
+ Button,
1071
+ {
1072
+ variant: "ghost",
1073
+ size: "sm",
1074
+ className: "h-6 w-6 p-0",
1075
+ disabled: busy,
1076
+ onClick: handleToggle,
1077
+ title: state === "recording" ? labels.pause : labels.resume,
1078
+ "aria-label": state === "recording" ? labels.pause : labels.resume,
1079
+ children: state === "recording" ? /* @__PURE__ */ jsx(Pause, { className: "h-3.5 w-3.5" }) : /* @__PURE__ */ jsx(Play, { className: "h-3.5 w-3.5" })
1080
+ }
1081
+ ),
1082
+ /* @__PURE__ */ jsx(
1083
+ Button,
1084
+ {
1085
+ variant: "ghost",
1086
+ size: "sm",
1087
+ className: "h-6 w-6 p-0",
1088
+ disabled: busy,
1089
+ onClick: handleStop,
1090
+ title: labels.finish,
1091
+ "aria-label": labels.finish,
1092
+ children: /* @__PURE__ */ jsx(Square, { className: "h-3.5 w-3.5" })
1093
+ }
1094
+ )
1095
+ ]
1096
+ }
1097
+ );
1098
+ if (isMobile && typeof document !== "undefined") {
1099
+ return createPortal(
1100
+ /* @__PURE__ */ jsx("div", { className: "fixed inset-x-0 top-0 z-50 flex justify-center bg-background/80 p-1.5 backdrop-blur md:hidden", children: content }),
1101
+ document.body
1102
+ );
1103
+ }
1104
+ return content;
1105
+ }
1106
+ function ScribeStartButton({ labels, disabled, onStart, variant = "default", size = "sm" }) {
1107
+ const state = useScribeStore((s) => s.state);
1108
+ const busy = state === "recording" || state === "paused" || state === "interrupted" || state === "requesting";
1109
+ return /* @__PURE__ */ jsxs(Button, { variant, size, disabled: disabled || busy, onClick: onStart, title: labels.start, children: [
1110
+ /* @__PURE__ */ jsx(Mic, { className: "mr-1.5 h-4 w-4" }),
1111
+ labels.start
1112
+ ] });
1113
+ }
1114
+ var RESUMABLE_WINDOW_MS = 30 * 60 * 1e3;
1115
+ function ScribeRecoveryBanner({ labels, onOpenSession }) {
1116
+ const orphans2 = useScribeStore((s) => s.orphans);
1117
+ const [busy, setBusy] = React6.useState(null);
1118
+ if (orphans2.length === 0) return null;
1119
+ const session = orphans2[0];
1120
+ const interruptedAgoMs = Date.now() - session.lastTickEpochMs;
1121
+ const canResume = interruptedAgoMs < RESUMABLE_WINDOW_MS;
1122
+ const durationLabel = formatElapsed(session.accumulatedMs);
1123
+ const run = async (fn) => {
1124
+ setBusy(session.id);
1125
+ try {
1126
+ await fn();
1127
+ } finally {
1128
+ setBusy(null);
1129
+ }
1130
+ };
1131
+ return /* @__PURE__ */ jsxs("div", { className: "mx-3 mb-3 flex flex-col gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 md:mx-6 md:flex-row md:items-center md:justify-between", children: [
1132
+ /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-2", children: [
1133
+ /* @__PURE__ */ jsx(AlertTriangle, { className: "mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" }),
1134
+ /* @__PURE__ */ jsxs("div", { className: "text-sm", children: [
1135
+ /* @__PURE__ */ jsxs("p", { className: "font-medium", children: [
1136
+ labels.sessionSingular,
1137
+ " de ",
1138
+ durationLabel,
1139
+ " foi interrompido",
1140
+ session.subjectName ? ` \u2014 ${session.subjectName}` : "",
1141
+ "."
1142
+ ] }),
1143
+ /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: "O \xE1udio j\xE1 gravado est\xE1 seguro e continua sendo enviado." })
1144
+ ] })
1145
+ ] }),
1146
+ /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 flex-wrap gap-1.5", children: [
1147
+ canResume && /* @__PURE__ */ jsx(Button, { size: "sm", disabled: busy === session.id, onClick: () => void run(resumeSession), children: labels.resume }),
1148
+ /* @__PURE__ */ jsx(
1149
+ Button,
1150
+ {
1151
+ size: "sm",
1152
+ variant: "outline",
1153
+ disabled: busy === session.id,
1154
+ onClick: () => void run(async () => {
1155
+ const id = await endSession();
1156
+ dismissOrphan(session.id);
1157
+ if (id) onOpenSession?.(id);
1158
+ }),
1159
+ children: labels.finish
1160
+ }
1161
+ ),
1162
+ /* @__PURE__ */ jsx(
1163
+ Button,
1164
+ {
1165
+ size: "sm",
1166
+ variant: "ghost",
1167
+ disabled: busy === session.id,
1168
+ onClick: () => void run(() => discardSession(session.id)),
1169
+ children: labels.discard
1170
+ }
1171
+ )
1172
+ ] })
1173
+ ] });
1174
+ }
1175
+ function ScribeConsentDialog({
1176
+ open,
1177
+ labels,
1178
+ subjectName,
1179
+ defaultConsentMode,
1180
+ onCancel,
1181
+ onConfirm
1182
+ }) {
1183
+ const [acknowledged, setAcknowledged] = React6.useState(false);
1184
+ React6.useEffect(() => {
1185
+ if (open) setAcknowledged(false);
1186
+ }, [open]);
1187
+ return /* @__PURE__ */ jsx(Modal, { open, onOpenChange: (next) => !next && onCancel(), children: /* @__PURE__ */ jsxs(ModalContent, { size: "sm", children: [
1188
+ /* @__PURE__ */ jsx(ModalHeader, { children: /* @__PURE__ */ jsxs(ModalTitle, { className: "flex items-center gap-2", children: [
1189
+ /* @__PURE__ */ jsx(Mic, { className: "h-4 w-4" }),
1190
+ "Iniciar grava\xE7\xE3o"
1191
+ ] }) }),
1192
+ /* @__PURE__ */ jsxs(ModalBody, { className: "space-y-3 text-sm", children: [
1193
+ /* @__PURE__ */ jsxs("p", { className: "text-muted-foreground", children: [
1194
+ "O \xE1udio ",
1195
+ subjectName ? `de ${subjectName}` : `deste ${labels.sessionSingular.toLowerCase()}`,
1196
+ " ser\xE1 gravado e transcrito para gerar um documento. A grava\xE7\xE3o fica vis\xEDvel na barra superior enquanto durar."
1197
+ ] }),
1198
+ /* @__PURE__ */ jsxs("label", { className: "flex cursor-pointer items-start gap-2 rounded-md border p-3", children: [
1199
+ /* @__PURE__ */ jsx(Checkbox, { checked: acknowledged, onChange: setAcknowledged, className: "mt-0.5" }),
1200
+ /* @__PURE__ */ jsxs("span", { children: [
1201
+ "Informei ",
1202
+ subjectName ?? "o titular",
1203
+ " de que este atendimento ser\xE1 gravado e obtive o consentimento."
1204
+ ] })
1205
+ ] }),
1206
+ /* @__PURE__ */ jsxs("p", { className: "flex items-start gap-2 text-xs text-muted-foreground", children: [
1207
+ /* @__PURE__ */ jsx(ShieldCheck, { className: "mt-0.5 h-3.5 w-3.5 shrink-0" }),
1208
+ "O \xE1udio \xE9 armazenado de forma privada e pode ser apagado a qualquer momento \u2014 a transcri\xE7\xE3o e o documento assinado permanecem."
1209
+ ] })
1210
+ ] }),
1211
+ /* @__PURE__ */ jsxs(ModalFooter, { children: [
1212
+ /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: onCancel, children: "Cancelar" }),
1213
+ /* @__PURE__ */ jsx(Button, { size: "sm", disabled: !acknowledged, onClick: () => onConfirm(defaultConsentMode), children: labels.start })
1214
+ ] })
1215
+ ] }) });
1216
+ }
1217
+ var SCRIBE_START_EVENT = "scribe:start";
1218
+ function ScribeShellMount({ labels, retention: retention2, locale, onOpenSession }) {
1219
+ const { user } = useAuth();
1220
+ const [pending, setPending] = React6.useState(null);
1221
+ React6.useEffect(() => {
1222
+ wireScribeStore();
1223
+ }, []);
1224
+ React6.useEffect(() => {
1225
+ const onStart = (ev) => {
1226
+ const detail = ev.detail ?? {};
1227
+ if (retention2.requireConsent) setPending(detail);
1228
+ else void start(detail, retention2.defaultConsentMode);
1229
+ };
1230
+ window.addEventListener(SCRIBE_START_EVENT, onStart);
1231
+ return () => window.removeEventListener(SCRIBE_START_EVENT, onStart);
1232
+ }, [retention2.requireConsent, retention2.defaultConsentMode, user?.id, locale]);
1233
+ const start = React6.useCallback(
1234
+ async (detail, consentMode) => {
1235
+ if (!user?.id) {
1236
+ toast.error("Fa\xE7a login para gravar um atendimento");
1237
+ return;
1238
+ }
1239
+ const result = await beginSession({
1240
+ userId: user.id,
1241
+ subjectId: detail.subjectId,
1242
+ subjectName: detail.subjectName,
1243
+ appointmentId: detail.appointmentId,
1244
+ templateKeys: detail.templateKeys,
1245
+ locale,
1246
+ consentMode
1247
+ });
1248
+ if (!result.ok) {
1249
+ toast.error(`N\xE3o foi poss\xEDvel iniciar: ${result.reason}`);
1250
+ return;
1251
+ }
1252
+ toast.success(`${labels.sessionSingular} iniciado`);
1253
+ },
1254
+ [user?.id, locale, labels.sessionSingular]
1255
+ );
1256
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1257
+ /* @__PURE__ */ jsx(ScribeRecordingPill, { labels, onOpenSession }),
1258
+ /* @__PURE__ */ jsx(
1259
+ ScribeConsentDialog,
1260
+ {
1261
+ open: pending !== null,
1262
+ labels,
1263
+ subjectName: pending?.subjectName,
1264
+ defaultConsentMode: retention2.defaultConsentMode,
1265
+ onCancel: () => setPending(null),
1266
+ onConfirm: (mode) => {
1267
+ const detail = pending;
1268
+ setPending(null);
1269
+ if (detail) void start(detail, mode);
1270
+ }
1271
+ }
1272
+ ),
1273
+ typeof document !== "undefined" && createPortal(
1274
+ /* @__PURE__ */ jsx("div", { className: "pointer-events-auto fixed inset-x-0 top-14 z-40", children: /* @__PURE__ */ jsx(ScribeRecoveryBanner, { labels, onOpenSession }) }),
1275
+ document.body
1276
+ )
1277
+ ] });
1278
+ }
1279
+ function requestScribeStart(detail) {
1280
+ window.dispatchEvent(new CustomEvent(SCRIBE_START_EVENT, { detail }));
1281
+ }
1282
+
1283
+ // src/lib/prompt.ts
1284
+ var PROMPT_VERSION = "scribe/narrative@1";
1285
+ function formatOffset(ms) {
1286
+ const total = Math.floor(ms / 1e3);
1287
+ const mm = String(Math.floor(total / 60)).padStart(2, "0");
1288
+ const ss = String(total % 60).padStart(2, "0");
1289
+ return `${mm}:${ss}`;
1290
+ }
1291
+ function renderTranscript(lines) {
1292
+ return lines.filter((l) => l.gap || l.text?.trim()).map((l) => {
1293
+ const at = formatOffset(l.startOffsetMs);
1294
+ if (l.gap) return `[${at}] (((TRECHO AUSENTE \u2014 a grava\xE7\xE3o foi interrompida aqui)))`;
1295
+ const who = l.speaker !== void 0 ? `Falante ${l.speaker + 1}: ` : "";
1296
+ return `[${at}] ${who}${l.text.trim()}`;
1297
+ }).join("\n");
1298
+ }
1299
+ function shapeInstruction(section) {
1300
+ switch (section.shape) {
1301
+ case "bullets":
1302
+ return "Formato: lista com h\xEDfen. Itens curtos, um fato por item.";
1303
+ case "keyvalue":
1304
+ return "Formato: lista com h\xEDfen no padr\xE3o `- **R\xF3tulo:** valor`. Um par por linha.";
1305
+ default:
1306
+ return "Formato: prosa corrida, sem listas.";
1307
+ }
1308
+ }
1309
+ function buildSystemPrompt(schema) {
1310
+ const parts = [];
1311
+ parts.push(
1312
+ "Voc\xEA redige documentos a partir da transcri\xE7\xE3o de um atendimento gravado.",
1313
+ "Escreva SOMENTE o que a transcri\xE7\xE3o sustenta. Voc\xEA n\xE3o \xE9 um assistente conversacional: sua sa\xEDda \xE9 o documento, nada mais."
1314
+ );
1315
+ if (schema.style) parts.push(`Estilo: ${schema.style}`);
1316
+ if (schema.outputLocale) parts.push(`Escreva o documento em ${schema.outputLocale}, mesmo que a transcri\xE7\xE3o esteja em outro idioma.`);
1317
+ parts.push(
1318
+ "",
1319
+ "REGRAS INEGOCI\xC1VEIS:",
1320
+ "- Nunca invente fato, n\xFAmero, data, nome ou medida que n\xE3o esteja na transcri\xE7\xE3o.",
1321
+ '- Quando a transcri\xE7\xE3o n\xE3o cobre algo que a se\xE7\xE3o pede, escreva "n\xE3o informado". N\xE3o deduza a partir do contexto.',
1322
+ "- Onde a transcri\xE7\xE3o indicar `(((TRECHO AUSENTE)))`, N\xC3O costure os dois lados como se fossem cont\xEDnuos. Se aquilo afeta o que a se\xE7\xE3o diz, registre a lacuna.",
1323
+ "- N\xE3o dirija a palavra ao leitor, n\xE3o comente o pr\xF3prio trabalho, n\xE3o pe\xE7a confirma\xE7\xE3o."
1324
+ );
1325
+ if (schema.neverInfer?.length) {
1326
+ parts.push(
1327
+ `- Estes itens NUNCA podem ser inferidos, apenas transcritos quando ditos explicitamente: ${schema.neverInfer.join(", ")}.`
1328
+ );
1329
+ }
1330
+ parts.push(
1331
+ "",
1332
+ "FORMATO DA SA\xCDDA (markdown restrito):",
1333
+ "- Cada se\xE7\xE3o come\xE7a com `## ` seguido exatamente do t\xEDtulo fornecido.",
1334
+ "- Use no m\xE1ximo `###` para subt\xEDtulo. Nunca `####` ou mais fundo.",
1335
+ "- PROIBIDO: tabela, link, imagem, bloco de c\xF3digo cercado por crases, HTML.",
1336
+ "- Permitido: par\xE1grafo, lista com h\xEDfen, lista numerada, **negrito**, *it\xE1lico*.",
1337
+ "- N\xE3o escreva pre\xE2mbulo nem fecho. A primeira linha da resposta \xE9 o primeiro `## `."
1338
+ );
1339
+ return parts.join("\n");
1340
+ }
1341
+ function buildUserPrompt(args) {
1342
+ const { schema, transcript, context } = args;
1343
+ const parts = [];
1344
+ const ctx = Object.entries(context ?? {}).filter(([, v]) => v);
1345
+ if (ctx.length > 0) {
1346
+ parts.push("DADOS DO ATENDIMENTO (refer\xEAncia; n\xE3o repita como se\xE7\xE3o):");
1347
+ for (const [k, v] of ctx) parts.push(`- ${k}: ${v}`);
1348
+ parts.push("");
1349
+ }
1350
+ parts.push("TRANSCRI\xC7\xC3O:", '"""', transcript, '"""', "");
1351
+ parts.push("SE\xC7\xD5ES A ESCREVER, nesta ordem:");
1352
+ schema.sections.forEach((section, i) => {
1353
+ parts.push("");
1354
+ parts.push(`${i + 1}. ## ${section.heading}`);
1355
+ parts.push(` O que entra: ${section.guidance}`);
1356
+ parts.push(` ${shapeInstruction(section)}`);
1357
+ if (section.maxWords) parts.push(` Limite: cerca de ${section.maxWords} palavras.`);
1358
+ if (section.required) parts.push(' Obrigat\xF3ria: escreva-a mesmo que s\xF3 para registrar "n\xE3o informado".');
1359
+ else if (section.omitWhenEmpty) parts.push(" Se a transcri\xE7\xE3o n\xE3o disser nada sobre isto, OMITA a se\xE7\xE3o inteira, t\xEDtulo incluso.");
1360
+ });
1361
+ return parts.join("\n");
1362
+ }
1363
+ function parseSections(markdown, schema) {
1364
+ const out = {};
1365
+ if (!markdown) return out;
1366
+ const norm = (s) => s.trim().toLowerCase().replace(/\s+/g, " ").replace(/[:.]+$/, "");
1367
+ const byHeading = new Map(schema.sections.map((s) => [norm(s.heading), s.id]));
1368
+ const lines = markdown.split("\n");
1369
+ let currentId = null;
1370
+ let buffer = [];
1371
+ const flush = () => {
1372
+ if (currentId) out[currentId] = buffer.join("\n").trim();
1373
+ buffer = [];
1374
+ };
1375
+ for (const line of lines) {
1376
+ const match = /^##\s+(.+?)\s*$/.exec(line);
1377
+ if (match && !line.startsWith("###")) {
1378
+ flush();
1379
+ currentId = byHeading.get(norm(match[1])) ?? null;
1380
+ continue;
1381
+ }
1382
+ if (currentId) buffer.push(line);
1383
+ }
1384
+ flush();
1385
+ return out;
1386
+ }
1387
+ function deriveTitle(markdown, fallback, maxLen = 80) {
1388
+ const firstBody = markdown.split("\n").find((l) => l.trim() && !l.startsWith("#") && !l.startsWith("-") && !l.startsWith(">"));
1389
+ if (!firstBody) return fallback;
1390
+ const clean = firstBody.replace(/[*_`]/g, "").trim();
1391
+ return clean.length > maxLen ? `${clean.slice(0, maxLen - 1)}\u2026` : clean;
1392
+ }
1393
+ function TranscriptPanel({ segments, pending, emptyLabel }) {
1394
+ const visible = segments.filter((s) => s.gap || s.text?.trim());
1395
+ if (visible.length === 0) {
1396
+ return /* @__PURE__ */ jsx("div", { className: "flex flex-col items-center justify-center gap-2 py-12 text-center text-sm text-muted-foreground", children: pending ? /* @__PURE__ */ jsxs(Fragment, { children: [
1397
+ /* @__PURE__ */ jsx(Loader2, { className: "h-5 w-5 animate-spin" }),
1398
+ /* @__PURE__ */ jsx("p", { children: "Transcrevendo\u2026" }),
1399
+ /* @__PURE__ */ jsx("p", { className: "text-xs", children: "O texto aparece conforme os trechos s\xE3o processados." })
1400
+ ] }) : /* @__PURE__ */ jsx("p", { children: emptyLabel ?? "Nenhuma transcri\xE7\xE3o ainda." }) });
1401
+ }
1402
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
1403
+ visible.map((seg) => {
1404
+ if (seg.gap) {
1405
+ return /* @__PURE__ */ jsxs(
1406
+ "div",
1407
+ {
1408
+ className: "flex items-center gap-2 rounded-md border border-dashed border-amber-500/40 bg-amber-500/5 px-3 py-2 text-xs text-amber-700 dark:text-amber-400",
1409
+ children: [
1410
+ /* @__PURE__ */ jsx(AlertTriangle, { className: "h-3.5 w-3.5 shrink-0" }),
1411
+ /* @__PURE__ */ jsx("span", { className: "font-mono", children: formatOffset(seg.startOffsetMs) }),
1412
+ /* @__PURE__ */ jsxs("span", { children: [
1413
+ "Grava\xE7\xE3o interrompida",
1414
+ seg.durationMs ? ` por ${Math.round(seg.durationMs / 1e3)}s` : ""
1415
+ ] })
1416
+ ]
1417
+ },
1418
+ seg.segIndex
1419
+ );
1420
+ }
1421
+ const speaker = seg.words?.[0]?.sp;
1422
+ return /* @__PURE__ */ jsxs("div", { className: "flex gap-3", children: [
1423
+ /* @__PURE__ */ jsx("span", { className: "w-12 shrink-0 pt-0.5 font-mono text-xs text-muted-foreground", children: formatOffset(seg.startOffsetMs) }),
1424
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
1425
+ speaker !== void 0 && /* @__PURE__ */ jsxs(Badge, { variant: "secondary", className: "mb-1 text-[10px]", children: [
1426
+ "Falante ",
1427
+ speaker + 1
1428
+ ] }),
1429
+ /* @__PURE__ */ jsxs("p", { className: "text-sm leading-relaxed", children: [
1430
+ seg.text,
1431
+ seg.partial && /* @__PURE__ */ jsx("span", { className: "ml-1 text-xs text-muted-foreground", children: "(trecho parcial)" })
1432
+ ] })
1433
+ ] })
1434
+ ] }, seg.segIndex);
1435
+ }),
1436
+ pending && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 pl-15 text-xs text-muted-foreground", children: [
1437
+ /* @__PURE__ */ jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }),
1438
+ "Transcrevendo o restante\u2026"
1439
+ ] })
1440
+ ] });
1441
+ }
1442
+ function client() {
1443
+ const supabase = getSupabaseClientOptional();
1444
+ if (!supabase) throw new Error("Supabase n\xE3o inicializado");
1445
+ return supabase;
1446
+ }
1447
+ function mapSession(r) {
1448
+ return {
1449
+ id: r.id,
1450
+ tenantId: r.tenant_id,
1451
+ subjectId: r.subject_id ?? void 0,
1452
+ contextKind: r.context_kind,
1453
+ appointmentId: r.appointment_id ?? void 0,
1454
+ ownerUserId: r.owner_user_id,
1455
+ status: r.status,
1456
+ locale: r.locale,
1457
+ mimeType: r.mime_type ?? void 0,
1458
+ startedAt: r.started_at,
1459
+ endedAt: r.ended_at ?? void 0,
1460
+ wallDurationMs: r.wall_duration_ms ?? 0,
1461
+ audioDurationMs: r.audio_duration_ms ?? 0,
1462
+ segmentCount: r.segment_count ?? 0,
1463
+ uploadedSegmentCount: r.uploaded_segment_count ?? 0,
1464
+ transcribedSegmentCount: r.transcribed_segment_count ?? 0,
1465
+ transcriptChars: r.transcript_chars ?? 0,
1466
+ consentAt: r.consent_at ?? void 0,
1467
+ consentMode: r.consent_mode ?? void 0,
1468
+ retentionUntil: r.retention_until ?? void 0,
1469
+ audioDeletedAt: r.audio_deleted_at ?? void 0,
1470
+ error: r.error ?? void 0,
1471
+ metadata: r.metadata ?? {}
1472
+ };
1473
+ }
1474
+ function mapSegment(r) {
1475
+ return {
1476
+ sessionId: r.session_id,
1477
+ segIndex: r.seg_index,
1478
+ tenantId: r.tenant_id,
1479
+ storagePath: r.storage_path ?? void 0,
1480
+ bytes: r.bytes ?? void 0,
1481
+ startOffsetMs: r.start_offset_ms ?? 0,
1482
+ durationMs: r.duration_ms ?? void 0,
1483
+ partial: !!r.partial,
1484
+ gap: !!r.gap,
1485
+ uploadState: r.upload_state,
1486
+ uploadedAt: r.uploaded_at ?? void 0,
1487
+ sttState: r.stt_state,
1488
+ sttProvider: r.stt_provider ?? void 0,
1489
+ sttAttempts: r.stt_attempts ?? 0,
1490
+ sttError: r.stt_error ?? void 0,
1491
+ text: r.text ?? void 0,
1492
+ words: r.words ?? void 0,
1493
+ confidence: r.confidence ?? void 0
1494
+ };
1495
+ }
1496
+ function mapGeneration(r) {
1497
+ return {
1498
+ id: r.id,
1499
+ tenantId: r.tenant_id,
1500
+ sessionId: r.session_id,
1501
+ templateId: r.template_id ?? void 0,
1502
+ templateKey: r.template_key ?? void 0,
1503
+ tabOrder: r.tab_order ?? 0,
1504
+ title: r.title ?? void 0,
1505
+ status: r.status,
1506
+ markdown: r.markdown ?? void 0,
1507
+ markdownEdited: r.markdown_edited ?? void 0,
1508
+ facts: r.facts ?? void 0,
1509
+ model: r.model ?? void 0,
1510
+ promptVersion: r.prompt_version ?? void 0,
1511
+ inputTokens: r.input_tokens ?? void 0,
1512
+ outputTokens: r.output_tokens ?? void 0,
1513
+ error: r.error ?? void 0,
1514
+ documentId: r.document_id ?? void 0,
1515
+ createdBy: r.created_by ?? void 0,
1516
+ createdAt: r.created_at
1517
+ };
1518
+ }
1519
+ async function fetchSession(sessionId) {
1520
+ const { data, error } = await client().from(T.sessions).select("*").eq("id", sessionId).maybeSingle();
1521
+ if (error) throw error;
1522
+ return data ? mapSession(data) : null;
1523
+ }
1524
+ async function fetchSegments(sessionId) {
1525
+ const { data, error } = await client().from(T.segments).select("*").eq("session_id", sessionId).order("seg_index", { ascending: true });
1526
+ if (error) throw error;
1527
+ return (data ?? []).map(mapSegment);
1528
+ }
1529
+ async function fetchGenerations(sessionId) {
1530
+ const { data, error } = await client().from(T.generations).select("*").eq("session_id", sessionId).neq("status", "discarded").order("tab_order", { ascending: true });
1531
+ if (error) throw error;
1532
+ return (data ?? []).map(mapGeneration);
1533
+ }
1534
+ async function fetchOpenSessions(userId) {
1535
+ const { data, error } = await client().from(T.sessions).select("*").eq("owner_user_id", userId).in("status", ["recording", "paused", "interrupted", "uploading", "transcribing", "ready", "generating"]).order("started_at", { ascending: false }).limit(20);
1536
+ if (error) throw error;
1537
+ return (data ?? []).map(mapSession);
1538
+ }
1539
+ async function fetchSessionsForSubject(subjectId) {
1540
+ const { data, error } = await client().from(T.sessions).select("*").eq("subject_id", subjectId).order("started_at", { ascending: false }).limit(50);
1541
+ if (error) throw error;
1542
+ return (data ?? []).map(mapSession);
1543
+ }
1544
+ async function saveDraft(generationId, markdownEdited) {
1545
+ const { error } = await client().from(T.generations).update({ markdown_edited: markdownEdited, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", generationId);
1546
+ if (error) throw error;
1547
+ }
1548
+ async function discardGeneration(generationId) {
1549
+ const { error } = await client().from(T.generations).update({ status: "discarded", updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", generationId);
1550
+ if (error) throw error;
1551
+ }
1552
+ async function commitGeneration(args) {
1553
+ const supabase = client();
1554
+ const { generation, session, markdown, title, sections, edited } = args;
1555
+ const { data: doc, error: docErr } = await supabase.from("documents").insert({
1556
+ tenant_id: session.tenantId,
1557
+ kind: "form",
1558
+ person_id: session.subjectId ?? null,
1559
+ title,
1560
+ status: "completed",
1561
+ metadata: {
1562
+ scribe: {
1563
+ sessionId: session.id,
1564
+ generationId: generation.id,
1565
+ model: generation.model,
1566
+ promptVersion: generation.promptVersion,
1567
+ edited,
1568
+ audioDurationMs: session.audioDurationMs
1569
+ }
1570
+ }
1571
+ }).select("id").single();
1572
+ if (docErr || !doc) throw docErr ?? new Error("falha ao criar o documento");
1573
+ const { error: extErr } = await supabase.from("plg_forms_documents").insert({
1574
+ document_id: doc.id,
1575
+ tenant_id: session.tenantId,
1576
+ template_id: generation.templateId ?? null,
1577
+ data: {
1578
+ markdown,
1579
+ sections,
1580
+ sourceSessionId: session.id,
1581
+ generationId: generation.id
1582
+ }
1583
+ });
1584
+ if (extErr) throw extErr;
1585
+ const { error: genErr } = await supabase.from(T.generations).update({ status: "committed", document_id: doc.id, updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", generation.id);
1586
+ if (genErr) throw genErr;
1587
+ await maybeCompleteSession(session.id);
1588
+ return doc.id;
1589
+ }
1590
+ async function maybeCompleteSession(sessionId) {
1591
+ const supabase = client();
1592
+ const { data } = await supabase.from(T.generations).select("status").eq("session_id", sessionId);
1593
+ if (!data || data.length === 0) return;
1594
+ const open = data.filter((g) => !["committed", "discarded"].includes(g.status));
1595
+ if (open.length > 0) return;
1596
+ await supabase.from(T.sessions).update({ status: "completed", updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", sessionId);
1597
+ }
1598
+ async function deleteSessionAudio(session) {
1599
+ const supabase = client();
1600
+ const prefix = sessionPrefix({
1601
+ tenantId: session.tenantId,
1602
+ sessionId: session.id,
1603
+ startedAt: new Date(session.startedAt)
1604
+ });
1605
+ const { data: files } = await supabase.storage.from(AUDIO_BUCKET).list(prefix);
1606
+ const paths = (files ?? []).map((f) => `${prefix}/${f.name}`);
1607
+ if (paths.length > 0) await supabase.storage.from(AUDIO_BUCKET).remove(paths);
1608
+ await supabase.from(T.sessions).update({ audio_deleted_at: (/* @__PURE__ */ new Date()).toISOString(), updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", session.id);
1609
+ await supabase.from(T.segments).update({ storage_path: null, upload_state: "missing" }).eq("session_id", session.id);
1610
+ return paths.length;
1611
+ }
1612
+ async function invokeTranscribe(args) {
1613
+ const { data, error } = await client().functions.invoke(args.endpoint, {
1614
+ body: {
1615
+ action: "drain",
1616
+ sessionId: args.sessionId,
1617
+ provider: args.provider,
1618
+ model: args.model,
1619
+ diarize: args.diarize
1620
+ }
1621
+ });
1622
+ if (error) throw error;
1623
+ if (data?.error) throw new Error(data.error);
1624
+ return { transcribed: data?.transcribed ?? 0, failed: data?.failed ?? 0, remaining: data?.remaining ?? 0 };
1625
+ }
1626
+ async function invokeGenerate(args) {
1627
+ const { data, error } = await client().functions.invoke(args.endpoint, {
1628
+ body: {
1629
+ sessionId: args.sessionId,
1630
+ templateId: args.templateId,
1631
+ templateKey: args.templateKey,
1632
+ schema: args.schema,
1633
+ tabOrder: args.tabOrder ?? 0
1634
+ }
1635
+ });
1636
+ if (error) throw error;
1637
+ if (data?.error) throw new Error(data.error);
1638
+ return { generationId: data.generationId, markdown: data.markdown };
1639
+ }
1640
+ var AUTOSAVE_DEBOUNCE_MS = 2e3;
1641
+ function DraftPanel({
1642
+ generation,
1643
+ session,
1644
+ schema,
1645
+ templateName,
1646
+ labels,
1647
+ onRegenerate,
1648
+ onCommitted,
1649
+ onDiscarded
1650
+ }) {
1651
+ const original = generation.markdown ?? "";
1652
+ const [value, setValue] = React6.useState(generation.markdownEdited ?? original);
1653
+ const [saving, setSaving] = React6.useState(false);
1654
+ const [committing, setCommitting] = React6.useState(false);
1655
+ const timer2 = React6.useRef(null);
1656
+ React6.useEffect(() => {
1657
+ setValue(generation.markdownEdited ?? generation.markdown ?? "");
1658
+ }, [generation.id, generation.markdown, generation.markdownEdited]);
1659
+ const edited = value.trim() !== original.trim();
1660
+ const handleChange = React6.useCallback(
1661
+ (next) => {
1662
+ setValue(next);
1663
+ if (timer2.current) clearTimeout(timer2.current);
1664
+ timer2.current = setTimeout(() => {
1665
+ setSaving(true);
1666
+ saveDraft(generation.id, next).catch(() => toast.error("N\xE3o foi poss\xEDvel salvar o rascunho")).finally(() => setSaving(false));
1667
+ }, AUTOSAVE_DEBOUNCE_MS);
1668
+ },
1669
+ [generation.id]
1670
+ );
1671
+ React6.useEffect(() => {
1672
+ return () => {
1673
+ if (timer2.current) {
1674
+ clearTimeout(timer2.current);
1675
+ void saveDraft(generation.id, value).catch(() => void 0);
1676
+ }
1677
+ };
1678
+ }, [generation.id, value]);
1679
+ const handleCommit = React6.useCallback(async () => {
1680
+ setCommitting(true);
1681
+ try {
1682
+ if (timer2.current) clearTimeout(timer2.current);
1683
+ await saveDraft(generation.id, value);
1684
+ const documentId = await commitGeneration({
1685
+ generation,
1686
+ session,
1687
+ markdown: value,
1688
+ title: generation.title ?? deriveTitle(value, templateName),
1689
+ sections: parseSections(value, schema),
1690
+ edited
1691
+ });
1692
+ toast.success("Documento salvo na ficha");
1693
+ onCommitted(documentId);
1694
+ } catch (err) {
1695
+ toast.error(`N\xE3o foi poss\xEDvel salvar: ${err?.message ?? err}`);
1696
+ } finally {
1697
+ setCommitting(false);
1698
+ }
1699
+ }, [generation, session, value, schema, templateName, edited, onCommitted]);
1700
+ if (generation.status === "running" || generation.status === "pending") {
1701
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center gap-2 py-16 text-sm text-muted-foreground", children: [
1702
+ /* @__PURE__ */ jsx(Loader2, { className: "h-5 w-5 animate-spin" }),
1703
+ /* @__PURE__ */ jsxs("p", { children: [
1704
+ "Gerando ",
1705
+ templateName.toLowerCase(),
1706
+ "\u2026"
1707
+ ] })
1708
+ ] });
1709
+ }
1710
+ if (generation.status === "failed") {
1711
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col items-center justify-center gap-3 py-16 text-sm", children: [
1712
+ /* @__PURE__ */ jsx("p", { className: "text-destructive", children: generation.error ?? "A gera\xE7\xE3o falhou." }),
1713
+ /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "outline", onClick: onRegenerate, children: [
1714
+ /* @__PURE__ */ jsx(RefreshCw, { className: "mr-1.5 h-4 w-4" }),
1715
+ "Tentar novamente"
1716
+ ] })
1717
+ ] });
1718
+ }
1719
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
1720
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [
1721
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
1722
+ edited ? /* @__PURE__ */ jsx(Badge, { variant: "secondary", children: "Editado" }) : /* @__PURE__ */ jsx(Badge, { variant: "outline", children: "Gerado por IA" }),
1723
+ saving && /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-1", children: [
1724
+ /* @__PURE__ */ jsx(Loader2, { className: "h-3 w-3 animate-spin" }),
1725
+ "salvando\u2026"
1726
+ ] }),
1727
+ generation.model && /* @__PURE__ */ jsxs("span", { className: "hidden sm:inline", children: [
1728
+ "\xB7 ",
1729
+ generation.model
1730
+ ] })
1731
+ ] }),
1732
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-1.5", children: [
1733
+ /* @__PURE__ */ jsxs(Button, { size: "sm", variant: "ghost", onClick: onRegenerate, disabled: committing, children: [
1734
+ /* @__PURE__ */ jsx(RefreshCw, { className: "mr-1.5 h-3.5 w-3.5" }),
1735
+ "Regerar"
1736
+ ] }),
1737
+ /* @__PURE__ */ jsxs(
1738
+ Button,
1739
+ {
1740
+ size: "sm",
1741
+ variant: "ghost",
1742
+ disabled: committing,
1743
+ onClick: () => void discardGeneration(generation.id).then(onDiscarded).catch(() => toast.error("Falha ao descartar")),
1744
+ children: [
1745
+ /* @__PURE__ */ jsx(Trash2, { className: "mr-1.5 h-3.5 w-3.5" }),
1746
+ labels.discard
1747
+ ]
1748
+ }
1749
+ ),
1750
+ /* @__PURE__ */ jsxs(Button, { size: "sm", onClick: () => void handleCommit(), disabled: committing || !value.trim(), children: [
1751
+ committing ? /* @__PURE__ */ jsx(Loader2, { className: "mr-1.5 h-3.5 w-3.5 animate-spin" }) : /* @__PURE__ */ jsx(Check, { className: "mr-1.5 h-3.5 w-3.5" }),
1752
+ "Salvar na ficha"
1753
+ ] })
1754
+ ] })
1755
+ ] }),
1756
+ /* @__PURE__ */ jsx(
1757
+ MarkdownEditor,
1758
+ {
1759
+ value,
1760
+ onChange: handleChange,
1761
+ minRows: 20,
1762
+ defaultMode: "preview",
1763
+ editLabel: "Editar",
1764
+ previewLabel: "Visualizar"
1765
+ }
1766
+ ),
1767
+ schema.disclaimer && /* @__PURE__ */ jsx("p", { className: "text-xs italic text-muted-foreground", children: schema.disclaimer })
1768
+ ] });
1769
+ }
1770
+
1771
+ // src/lib/config.ts
1772
+ function usesFayzTransport(stt) {
1773
+ return (stt.transport ?? "fayz") === "fayz";
1774
+ }
1775
+ function assertSttConfig(stt) {
1776
+ if (!stt) {
1777
+ throw new Error("[plugin-scribe] a op\xE7\xE3o `stt` \xE9 obrigat\xF3ria.");
1778
+ }
1779
+ if (!usesFayzTransport(stt)) {
1780
+ if (!stt.endpoint) {
1781
+ throw new Error(
1782
+ '[plugin-scribe] com transport: "edge" o stt.endpoint \xE9 obrigat\xF3rio. Sem um endpoint pr\xF3prio de transcri\xE7\xE3o o \xE1udio do atendimento sairia para um terceiro n\xE3o declarado \u2014 o plugin recusa a subir.'
1783
+ );
1784
+ }
1785
+ return;
1786
+ }
1787
+ if (stt.provider !== "openai") {
1788
+ throw new Error(
1789
+ `[plugin-scribe] transport: "fayz" s\xF3 fala com provider: "openai" (recebido: "${stt.provider}"). Para Deepgram use transport: "edge" com uma edge function pr\xF3pria.`
1790
+ );
1791
+ }
1792
+ }
1793
+
1794
+ // src/lib/transport.ts
1795
+ var KEY_HEADER = "x-fayz-agent-key";
1796
+ function createFayzTransport() {
1797
+ const connection = resolveFayzAgentConnection();
1798
+ if (!connection) return null;
1799
+ const base = `${connection.apiBaseUrl.replace(/\/+$/, "")}/public/projects/${connection.projectId}/agents`;
1800
+ async function readError(res) {
1801
+ let detail = "";
1802
+ try {
1803
+ const body = await res.json();
1804
+ detail = body?.error ?? "";
1805
+ } catch {
1806
+ detail = await res.text().catch(() => "");
1807
+ }
1808
+ throw new Error(`fayz ${res.status}${detail ? `: ${detail}` : ""}`);
1809
+ }
1810
+ return {
1811
+ kind: "fayz",
1812
+ async transcribeClip({ audio, mimeType, locale, model, context }) {
1813
+ const form = new FormData();
1814
+ form.append("file", audio, `clip.${extOf(mimeType)}`);
1815
+ if (model) form.append("model", model);
1816
+ form.append("language", locale);
1817
+ if (context) form.append("prompt", context);
1818
+ const res = await fetch(`${base}/transcribe`, {
1819
+ method: "POST",
1820
+ headers: { [KEY_HEADER]: connection.publishableKey },
1821
+ body: form
1822
+ });
1823
+ if (!res.ok) await readError(res);
1824
+ const json = await res.json();
1825
+ return {
1826
+ text: json.text ?? "",
1827
+ words: json.words ?? [],
1828
+ durationMs: json.durationMs ?? 0,
1829
+ // The broker returns no aggregate confidence; 0 means "not reported",
1830
+ // same as durationMs.
1831
+ confidence: 0,
1832
+ provider: json.provider ?? "fayz"
1833
+ };
1834
+ },
1835
+ async complete({ system, user, model, temperature, maxTokens }) {
1836
+ const res = await fetch(`${base}/complete`, {
1837
+ method: "POST",
1838
+ headers: { [KEY_HEADER]: connection.publishableKey, "Content-Type": "application/json" },
1839
+ body: JSON.stringify({ system, user, model, temperature, maxTokens })
1840
+ });
1841
+ if (!res.ok) await readError(res);
1842
+ const json = await res.json();
1843
+ return {
1844
+ content: json.content ?? "",
1845
+ model: json.model ?? (model ?? "auto"),
1846
+ tokensIn: json.tokensIn,
1847
+ tokensOut: json.tokensOut
1848
+ };
1849
+ }
1850
+ };
1851
+ }
1852
+ function extOf(mimeType) {
1853
+ const type = mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
1854
+ if (type === "audio/ogg") return "ogg";
1855
+ if (type === "audio/mp4" || type === "audio/mpeg") return "m4a";
1856
+ if (type === "audio/mpga" || type === "audio/mp3") return "mp3";
1857
+ if (type === "audio/wav" || type === "audio/x-wav") return "wav";
1858
+ return "webm";
1859
+ }
1860
+ function resolveScribeTransport(stt) {
1861
+ if (!usesFayzTransport(stt)) return null;
1862
+ return createFayzTransport();
1863
+ }
1864
+
1865
+ // src/lib/drain.ts
1866
+ var CONCURRENCY2 = 3;
1867
+ var MAX_ATTEMPTS = 4;
1868
+ function client2() {
1869
+ const supabase = getSupabaseClientOptional();
1870
+ if (!supabase) throw new Error("Supabase not initialized");
1871
+ return supabase;
1872
+ }
1873
+ async function drainSessionViaTransport(sessionId, stt) {
1874
+ const transport = resolveScribeTransport(stt);
1875
+ if (!transport) return null;
1876
+ const supabase = client2();
1877
+ const { data: session } = await supabase.from(T.sessions).select("id, locale, mime_type, status, ended_at").eq("id", sessionId).maybeSingle();
1878
+ if (!session) throw new Error("session not found");
1879
+ const { data: pending } = await supabase.from(T.segments).select("session_id, seg_index, storage_path, stt_attempts").eq("session_id", sessionId).eq("upload_state", "uploaded").in("stt_state", ["pending", "failed"]).lt("stt_attempts", MAX_ATTEMPTS).order("seg_index", { ascending: true });
1880
+ if (!pending || pending.length === 0) {
1881
+ await recomputeCounters(supabase, sessionId);
1882
+ await maybeMarkReady(supabase, sessionId);
1883
+ return { transcribed: 0, failed: 0, remaining: 0 };
1884
+ }
1885
+ let transcribed = 0;
1886
+ let failed = 0;
1887
+ for (let i = 0; i < pending.length; i += CONCURRENCY2) {
1888
+ const batch = pending.slice(i, i + CONCURRENCY2);
1889
+ const results = await Promise.all(
1890
+ batch.map(
1891
+ (seg) => transcribeOne(supabase, transport, seg, {
1892
+ locale: session.locale ?? stt.locale ?? "pt-BR",
1893
+ mimeType: session.mime_type ?? "audio/webm",
1894
+ model: stt.model
1895
+ })
1896
+ )
1897
+ );
1898
+ transcribed += results.filter(Boolean).length;
1899
+ failed += results.filter((ok) => !ok).length;
1900
+ }
1901
+ await recomputeCounters(supabase, sessionId);
1902
+ await maybeMarkReady(supabase, sessionId);
1903
+ const { count } = await supabase.from(T.segments).select("seg_index", { count: "exact", head: true }).eq("session_id", sessionId).eq("upload_state", "uploaded").in("stt_state", ["pending", "failed"]).lt("stt_attempts", MAX_ATTEMPTS);
1904
+ return { transcribed, failed, remaining: count ?? 0 };
1905
+ }
1906
+ async function transcribeOne(supabase, transport, seg, opts) {
1907
+ if (!seg.storage_path) return false;
1908
+ const where = (q) => q.eq("session_id", seg.session_id).eq("seg_index", seg.seg_index);
1909
+ try {
1910
+ await where(
1911
+ supabase.from(T.segments).update({
1912
+ stt_state: "running",
1913
+ stt_attempts: (seg.stt_attempts ?? 0) + 1,
1914
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1915
+ })
1916
+ );
1917
+ const { data: file, error: dlErr } = await supabase.storage.from(AUDIO_BUCKET).download(seg.storage_path);
1918
+ if (dlErr || !file) throw new Error(`download failed: ${dlErr?.message ?? "no body"}`);
1919
+ const context = await previousTail(supabase, seg.session_id, seg.seg_index);
1920
+ const result = await transport.transcribeClip({
1921
+ audio: file,
1922
+ mimeType: opts.mimeType,
1923
+ locale: opts.locale,
1924
+ model: opts.model,
1925
+ context
1926
+ });
1927
+ await where(
1928
+ supabase.from(T.segments).update({
1929
+ stt_state: "done",
1930
+ stt_provider: result.provider,
1931
+ stt_error: null,
1932
+ text: result.text,
1933
+ words: result.words,
1934
+ confidence: result.confidence,
1935
+ // AUTHORITATIVE duration: measured in the audio, never counted in JS.
1936
+ // A provider that does not report it returns 0, and 0 becomes NULL
1937
+ // rather than a zero-minute session.
1938
+ duration_ms: result.durationMs > 0 ? result.durationMs : null,
1939
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1940
+ })
1941
+ );
1942
+ return true;
1943
+ } catch (err) {
1944
+ await where(
1945
+ supabase.from(T.segments).update({
1946
+ stt_state: "failed",
1947
+ stt_error: String(err?.message ?? err).slice(0, 500),
1948
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1949
+ })
1950
+ );
1951
+ return false;
1952
+ }
1953
+ }
1954
+ async function previousTail(supabase, sessionId, segIndex) {
1955
+ if (segIndex === 0) return void 0;
1956
+ const { data } = await supabase.from(T.segments).select("text").eq("session_id", sessionId).eq("seg_index", segIndex - 1).maybeSingle();
1957
+ const text = data?.text;
1958
+ return text ? text.slice(-200) : void 0;
1959
+ }
1960
+ async function recomputeCounters(supabase, sessionId) {
1961
+ const { data: segs } = await supabase.from(T.segments).select("stt_state, duration_ms, text, gap").eq("session_id", sessionId);
1962
+ if (!segs) return;
1963
+ await supabase.from(T.sessions).update({
1964
+ transcribed_segment_count: segs.filter((s) => s.stt_state === "done").length,
1965
+ audio_duration_ms: segs.filter((s) => !s.gap).reduce((acc, s) => acc + (s.duration_ms ?? 0), 0),
1966
+ transcript_chars: segs.reduce((acc, s) => acc + (s.text?.length ?? 0), 0),
1967
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1968
+ }).eq("id", sessionId);
1969
+ }
1970
+ async function maybeMarkReady(supabase, sessionId) {
1971
+ const { data: session } = await supabase.from(T.sessions).select("status, ended_at").eq("id", sessionId).maybeSingle();
1972
+ if (!session?.ended_at) return;
1973
+ if (!["uploading", "transcribing", "recording", "paused", "interrupted"].includes(session.status)) return;
1974
+ const { data: segs } = await supabase.from(T.segments).select("stt_state, stt_attempts, upload_state").eq("session_id", sessionId);
1975
+ if (!segs || segs.length === 0) return;
1976
+ const outstanding = segs.filter(
1977
+ (s) => s.upload_state === "uploaded" && ["pending", "running", "failed"].includes(s.stt_state) && (s.stt_attempts ?? 0) < MAX_ATTEMPTS
1978
+ );
1979
+ await supabase.from(T.sessions).update({
1980
+ status: outstanding.length === 0 ? "ready" : "transcribing",
1981
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
1982
+ }).eq("id", sessionId);
1983
+ }
1984
+ var TEMPERATURE = 0.2;
1985
+ var MAX_TOKENS = 4e3;
1986
+ function client3() {
1987
+ const supabase = getSupabaseClientOptional();
1988
+ if (!supabase) throw new Error("Supabase n\xE3o inicializado");
1989
+ return supabase;
1990
+ }
1991
+ async function generateViaTransport(args) {
1992
+ const transport = resolveScribeTransport(args.stt);
1993
+ if (!transport) return null;
1994
+ const supabase = client3();
1995
+ const { data: userData } = await supabase.auth.getUser();
1996
+ const userId = userData?.user?.id;
1997
+ if (!userId) throw new Error("n\xE3o autenticado");
1998
+ const { data: session } = await supabase.from(T.sessions).select("id, tenant_id, subject_id, started_at, audio_duration_ms, locale").eq("id", args.sessionId).maybeSingle();
1999
+ if (!session) throw new Error("sess\xE3o n\xE3o encontrada");
2000
+ const lines = await loadTranscript(supabase, args.sessionId);
2001
+ if (!lines.some((l) => !l.gap && l.text.trim())) {
2002
+ throw new Error("n\xE3o h\xE1 transcri\xE7\xE3o para gerar o documento");
2003
+ }
2004
+ const { data: created, error: insErr } = await supabase.from(T.generations).insert({
2005
+ tenant_id: session.tenant_id,
2006
+ session_id: args.sessionId,
2007
+ template_id: args.templateId ?? null,
2008
+ template_key: args.templateKey ?? null,
2009
+ tab_order: args.tabOrder ?? 0,
2010
+ status: "running",
2011
+ prompt_version: PROMPT_VERSION,
2012
+ created_by: userId
2013
+ }).select("id").single();
2014
+ if (insErr || !created) throw new Error(`falha ao criar a gera\xE7\xE3o: ${insErr?.message}`);
2015
+ const generationId = created.id;
2016
+ try {
2017
+ let subjectName;
2018
+ if (session.subject_id) {
2019
+ const { data: person } = await supabase.from("people").select("name").eq("id", session.subject_id).maybeSingle();
2020
+ subjectName = person?.name;
2021
+ }
2022
+ const result = await transport.complete({
2023
+ system: buildSystemPrompt(args.schema),
2024
+ user: buildUserPrompt({
2025
+ schema: args.schema,
2026
+ transcript: renderTranscript(lines),
2027
+ context: {
2028
+ Titular: subjectName,
2029
+ Data: new Date(session.started_at).toLocaleDateString(session.locale ?? "pt-BR"),
2030
+ "Dura\xE7\xE3o (min)": String(Math.round((session.audio_duration_ms ?? 0) / 6e4))
2031
+ }
2032
+ }),
2033
+ model: args.model,
2034
+ temperature: TEMPERATURE,
2035
+ maxTokens: MAX_TOKENS
2036
+ });
2037
+ const markdown = result.content.trim();
2038
+ if (!markdown) throw new Error("o modelo devolveu um documento vazio");
2039
+ await supabase.from(T.generations).update({
2040
+ status: "ready",
2041
+ markdown,
2042
+ title: deriveTitle(markdown, args.templateName),
2043
+ model: result.model,
2044
+ input_tokens: result.tokensIn ?? null,
2045
+ output_tokens: result.tokensOut ?? null,
2046
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
2047
+ }).eq("id", generationId);
2048
+ await supabase.from(T.sessions).update({ status: "generating", updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", args.sessionId);
2049
+ return { generationId, markdown, model: result.model };
2050
+ } catch (err) {
2051
+ const message = String(err?.message ?? err);
2052
+ await supabase.from(T.generations).update({ status: "failed", error: message.slice(0, 500), updated_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", generationId);
2053
+ throw err;
2054
+ }
2055
+ }
2056
+ async function loadTranscript(supabase, sessionId) {
2057
+ const { data } = await supabase.from(T.segments).select("seg_index, start_offset_ms, text, gap, words").eq("session_id", sessionId).order("seg_index", { ascending: true });
2058
+ if (!data) return [];
2059
+ return data.map((s) => ({
2060
+ startOffsetMs: s.start_offset_ms ?? 0,
2061
+ text: s.text ?? "",
2062
+ gap: !!s.gap,
2063
+ speaker: Array.isArray(s.words) && s.words.length > 0 ? s.words[0]?.sp : void 0
2064
+ }));
2065
+ }
2066
+ var POLL_MS = 5e3;
2067
+ function ScribeSessionPage({
2068
+ sessionId,
2069
+ labels,
2070
+ presets,
2071
+ stt,
2072
+ generateEndpoint,
2073
+ onOpenDocument,
2074
+ onBack
2075
+ }) {
2076
+ const [session, setSession] = React6.useState(null);
2077
+ const [segments, setSegments] = React6.useState([]);
2078
+ const [generations, setGenerations] = React6.useState([]);
2079
+ const [loading, setLoading] = React6.useState(true);
2080
+ const [generating, setGenerating] = React6.useState(false);
2081
+ const [tab, setTab] = React6.useState("transcript");
2082
+ const draining = React6.useRef(false);
2083
+ const refresh = React6.useCallback(async () => {
2084
+ const [s, segs, gens] = await Promise.all([
2085
+ fetchSession(sessionId),
2086
+ fetchSegments(sessionId),
2087
+ fetchGenerations(sessionId)
2088
+ ]);
2089
+ setSession(s);
2090
+ setSegments(segs);
2091
+ setGenerations(gens);
2092
+ return { session: s, segments: segs };
2093
+ }, [sessionId]);
2094
+ React6.useEffect(() => {
2095
+ setLoading(true);
2096
+ void refresh().finally(() => setLoading(false));
2097
+ }, [refresh]);
2098
+ const pendingStt = React6.useMemo(
2099
+ () => segments.filter((s) => s.uploadState === "uploaded" && ["pending", "failed"].includes(s.sttState) && s.sttAttempts < 4),
2100
+ [segments]
2101
+ );
2102
+ const busy = pendingStt.length > 0 || segments.some((s) => s.sttState === "running");
2103
+ React6.useEffect(() => {
2104
+ if (!busy) return;
2105
+ const id = setInterval(() => {
2106
+ void (async () => {
2107
+ if (draining.current) return;
2108
+ draining.current = true;
2109
+ try {
2110
+ if (pendingStt.length > 0) {
2111
+ const drained = await drainSessionViaTransport(sessionId, stt);
2112
+ if (drained === null) {
2113
+ await invokeTranscribe({
2114
+ endpoint: stt.endpoint,
2115
+ sessionId,
2116
+ provider: stt.provider,
2117
+ model: stt.model,
2118
+ diarize: stt.diarize
2119
+ });
2120
+ }
2121
+ }
2122
+ await refresh();
2123
+ } catch {
2124
+ } finally {
2125
+ draining.current = false;
2126
+ }
2127
+ })();
2128
+ }, POLL_MS);
2129
+ return () => clearInterval(id);
2130
+ }, [busy, pendingStt.length, sessionId, stt, refresh]);
2131
+ const hasTranscript = segments.some((s) => !s.gap && s.text?.trim());
2132
+ const handleGenerate = React6.useCallback(
2133
+ async (preset) => {
2134
+ setGenerating(true);
2135
+ try {
2136
+ const viaTransport = await generateViaTransport({
2137
+ sessionId,
2138
+ schema: preset.schema,
2139
+ templateName: typeof preset.name === "string" ? preset.name : "Documento",
2140
+ templateKey: preset.key,
2141
+ tabOrder: generations.length,
2142
+ stt
2143
+ });
2144
+ const generationId = viaTransport?.generationId ?? (await invokeGenerate({
2145
+ endpoint: generateEndpoint,
2146
+ sessionId,
2147
+ templateKey: preset.key,
2148
+ schema: preset.schema,
2149
+ tabOrder: generations.length
2150
+ })).generationId;
2151
+ await refresh();
2152
+ setTab(generationId);
2153
+ } catch (err) {
2154
+ toast.error(`N\xE3o foi poss\xEDvel gerar: ${err?.message ?? err}`);
2155
+ } finally {
2156
+ setGenerating(false);
2157
+ }
2158
+ },
2159
+ [generateEndpoint, sessionId, generations.length, refresh, stt]
2160
+ );
2161
+ const activeGeneration = generations.find((g) => g.id === tab);
2162
+ useAgentSurface(
2163
+ React6.useMemo(
2164
+ () => ({
2165
+ id: "scribe.session",
2166
+ describe: () => ({
2167
+ title: `${labels.sessionSingular} \u2014 ${session?.startedAt ? new Date(session.startedAt).toLocaleDateString() : ""}`,
2168
+ state: {
2169
+ durationMinutes: Math.round((session?.audioDurationMs ?? 0) / 6e4),
2170
+ transcriptExcerpt: segments.filter((s) => s.text).map((s) => s.text).join(" ").slice(0, 4e3),
2171
+ documentMarkdown: activeGeneration?.markdownEdited ?? activeGeneration?.markdown,
2172
+ documentStatus: activeGeneration?.status
2173
+ },
2174
+ record: session?.subjectId ? { entity: "person", id: session.subjectId, label: labels.subject } : void 0
2175
+ }),
2176
+ instructions: [
2177
+ `O usu\xE1rio est\xE1 revisando um ${labels.sessionSingular.toLowerCase()} gravado e transcrito.`,
2178
+ 'Responda "o que foi dito sobre X" citando o trecho da transcri\xE7\xE3o, sempre com o minuto.',
2179
+ "Nunca afirme algo que a transcri\xE7\xE3o n\xE3o sustente \u2014 diga que n\xE3o foi mencionado."
2180
+ ].join(" ")
2181
+ }),
2182
+ [session, segments, activeGeneration, labels]
2183
+ )
2184
+ );
2185
+ if (loading) {
2186
+ return /* @__PURE__ */ jsx("div", { className: "flex justify-center py-16", children: /* @__PURE__ */ jsx(Loader2, { className: "h-6 w-6 animate-spin text-muted-foreground" }) });
2187
+ }
2188
+ if (!session) {
2189
+ return /* @__PURE__ */ jsx("p", { className: "py-16 text-center text-sm text-muted-foreground", children: "Sess\xE3o n\xE3o encontrada." });
2190
+ }
2191
+ const uploadPending = session.segmentCount - session.uploadedSegmentCount;
2192
+ return /* @__PURE__ */ jsxs("div", { className: "space-y-4", children: [
2193
+ /* @__PURE__ */ jsx(
2194
+ SubpageHeader,
2195
+ {
2196
+ title: labels.sessionSingular,
2197
+ parentLabel: onBack ? labels.sessionPlural : void 0,
2198
+ onBack
2199
+ }
2200
+ ),
2201
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm text-muted-foreground", children: [
2202
+ /* @__PURE__ */ jsx(Badge, { variant: "outline", children: new Date(session.startedAt).toLocaleString() }),
2203
+ /* @__PURE__ */ jsxs(Badge, { variant: "outline", children: [
2204
+ formatElapsed(session.audioDurationMs),
2205
+ " de \xE1udio"
2206
+ ] }),
2207
+ /* @__PURE__ */ jsx(Badge, { variant: session.status === "ready" || session.status === "completed" ? "secondary" : "outline", children: statusLabel(session.status) }),
2208
+ uploadPending > 0 && /* @__PURE__ */ jsxs(Badge, { variant: "outline", className: "text-amber-600", children: [
2209
+ uploadPending,
2210
+ " trecho",
2211
+ uploadPending > 1 ? "s" : "",
2212
+ " enviando"
2213
+ ] })
2214
+ ] }),
2215
+ /* @__PURE__ */ jsxs(Tabs, { value: tab, onValueChange: setTab, children: [
2216
+ /* @__PURE__ */ jsxs(TabsList, { className: "max-w-full justify-start overflow-x-auto scrollbar-none", children: [
2217
+ /* @__PURE__ */ jsxs(TabsTrigger, { value: "transcript", className: "gap-1.5", children: [
2218
+ /* @__PURE__ */ jsx(FileText, { className: "h-3.5 w-3.5" }),
2219
+ labels.transcript
2220
+ ] }),
2221
+ generations.map((g) => /* @__PURE__ */ jsxs(TabsTrigger, { value: g.id, className: "gap-1.5", children: [
2222
+ /* @__PURE__ */ jsx(Sparkles, { className: "h-3.5 w-3.5" }),
2223
+ g.title ?? presets.find((p) => p.key === g.templateKey)?.name ?? "Documento"
2224
+ ] }, g.id))
2225
+ ] }),
2226
+ /* @__PURE__ */ jsxs(TabsContent, { value: "transcript", className: "mt-4", children: [
2227
+ /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { className: "pt-6", children: /* @__PURE__ */ jsx(TranscriptPanel, { segments, pending: busy }) }) }),
2228
+ hasTranscript && /* @__PURE__ */ jsx("div", { className: "mt-4 flex flex-wrap gap-2", children: presets.map((preset) => /* @__PURE__ */ jsxs(
2229
+ Button,
2230
+ {
2231
+ size: "sm",
2232
+ variant: generations.length === 0 ? "default" : "outline",
2233
+ disabled: generating,
2234
+ onClick: () => void handleGenerate(preset),
2235
+ children: [
2236
+ generating ? /* @__PURE__ */ jsx(Loader2, { className: "mr-1.5 h-4 w-4 animate-spin" }) : /* @__PURE__ */ jsx(Sparkles, { className: "mr-1.5 h-4 w-4" }),
2237
+ labels.generate,
2238
+ ": ",
2239
+ preset.name
2240
+ ]
2241
+ },
2242
+ preset.key
2243
+ )) })
2244
+ ] }),
2245
+ generations.map((g) => {
2246
+ const preset = presets.find((p) => p.key === g.templateKey);
2247
+ if (!preset) return null;
2248
+ return /* @__PURE__ */ jsx(TabsContent, { value: g.id, className: "mt-4", children: /* @__PURE__ */ jsx(
2249
+ DraftPanel,
2250
+ {
2251
+ generation: g,
2252
+ session,
2253
+ schema: preset.schema,
2254
+ templateName: preset.name,
2255
+ labels,
2256
+ onRegenerate: () => void handleGenerate(preset),
2257
+ onCommitted: (documentId) => {
2258
+ void refresh();
2259
+ onOpenDocument?.(documentId);
2260
+ },
2261
+ onDiscarded: () => {
2262
+ setTab("transcript");
2263
+ void refresh();
2264
+ }
2265
+ }
2266
+ ) }, g.id);
2267
+ })
2268
+ ] })
2269
+ ] });
2270
+ }
2271
+ function statusLabel(status) {
2272
+ const map = {
2273
+ recording: "Gravando",
2274
+ paused: "Pausado",
2275
+ interrupted: "Interrompido",
2276
+ uploading: "Enviando",
2277
+ transcribing: "Transcrevendo",
2278
+ ready: "Pronto",
2279
+ generating: "Rascunho",
2280
+ completed: "Conclu\xEDdo",
2281
+ abandoned: "Descartado",
2282
+ failed: "Falhou"
2283
+ };
2284
+ return map[status] ?? status;
2285
+ }
2286
+
2287
+ // src/migrations/index.ts
2288
+ var MIGRATION_001_SCRIBE_BASE = `-- plugin-scribe 001: gravar um atendimento inteiro sem poder perd\xEA-lo.
2289
+ --
2290
+ -- O problema que este schema resolve n\xE3o \xE9 "guardar \xE1udio". \xC9 que o profissional
2291
+ -- aperta gravar, navega o app por 40 minutos, e QUALQUER coisa pode acontecer no
2292
+ -- meio: reload, aba morta, wifi caindo, tampa fechando. Perder a consulta \xE9 uma
2293
+ -- falha irrecuper\xE1vel \u2014 n\xE3o d\xE1 para pedir para o paciente repetir.
2294
+ --
2295
+ -- Por isso o desenho n\xE3o tem "o arquivo da sess\xE3o". Tem um MANIFESTO: a sess\xE3o
2296
+ -- \xE9 uma linha, e cada 30 segundos de \xE1udio \xE9 OUTRA linha que sobe sozinha e \xE9
2297
+ -- transcrita sozinha. O que chegou, chegou. O cliente pode morrer entre dois
2298
+ -- segmentos e o que j\xE1 subiu continua \xEDntegro e j\xE1 transcrito.
2299
+ --
2300
+ -- ---------------------------------------------------------------------------
2301
+ -- Duas dura\xE7\xF5es, e elas discordam de prop\xF3sito
2302
+ -- ---------------------------------------------------------------------------
2303
+ -- \`wall_duration_ms\` \xE9 o rel\xF3gio de parede que o browser reportou. Serve para o
2304
+ -- cron\xF4metro na tela e para mais nada.
2305
+ --
2306
+ -- \`audio_duration_ms\` \xE9 SUM(segments.duration_ms) \u2014 soma do \xE1udio que de fato
2307
+ -- existe, com a dura\xE7\xE3o vinda da resposta do STT, n\xE3o de um \`setInterval\`.
2308
+ --
2309
+ -- Elas divergem sempre que a aba congela: notebook dormindo por 10 minutos faz
2310
+ -- o rel\xF3gio de parede contar a soneca inteira enquanto o MediaRecorder n\xE3o
2311
+ -- emitiu um byte. Registro cl\xEDnico, medidor de cota e fatura usam a SEGUNDA.
2312
+ -- Confundir as duas \xE9 cobrar do cliente pelo cochilo do notebook.
2313
+ --
2314
+ -- ---------------------------------------------------------------------------
2315
+ -- O contador nunca \xE9 escrito pelo browser
2316
+ -- ---------------------------------------------------------------------------
2317
+ -- \`scribe_record_segment\` (se\xE7\xE3o 5) faz o upsert do segmento e RECALCULA os
2318
+ -- contadores da sess\xE3o a partir da tabela de segmentos. Um cliente que sobe o
2319
+ -- mesmo segmento duas vezes (replay depois de queda de rede) converge para o
2320
+ -- mesmo n\xFAmero em vez de somar duas vezes.
2321
+
2322
+ -- ---------------------------------------------------------------------------
2323
+ -- 1. sessions \u2014 o encontro
2324
+ -- ---------------------------------------------------------------------------
2325
+ -- \`id\` N\xC3O tem DEFAULT gen_random_uuid(). \xC9 gerado no cliente, antes do primeiro
2326
+ -- byte, e usado como chave no IndexedDB e no caminho do Storage. Sem isso a
2327
+ -- grava\xE7\xE3o teria que esperar um round-trip para saber onde escrever \u2014 e uma
2328
+ -- grava\xE7\xE3o que depende da rede para COME\xC7AR j\xE1 nasce fr\xE1gil.
2329
+ CREATE TABLE IF NOT EXISTS public.plg_scribe_sessions (
2330
+ id uuid PRIMARY KEY,
2331
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
2332
+
2333
+ -- O titular. SET NULL e n\xE3o CASCADE: apagar a pessoa n\xE3o pode apagar em
2334
+ -- sil\xEAncio o registro do atendimento que aconteceu.
2335
+ subject_id uuid REFERENCES public.people(id) ON DELETE SET NULL,
2336
+ context_kind text NOT NULL DEFAULT 'person'
2337
+ CHECK (context_kind IN ('person', 'appointment', 'standalone')),
2338
+ appointment_id uuid REFERENCES public.appointments(id) ON DELETE SET NULL,
2339
+ owner_user_id uuid NOT NULL,
2340
+
2341
+ status text NOT NULL DEFAULT 'recording'
2342
+ CHECK (status IN ('recording','paused','interrupted','uploading','transcribing',
2343
+ 'ready','generating','completed','abandoned','failed')),
2344
+ locale text NOT NULL DEFAULT 'pt-BR',
2345
+ mime_type text,
2346
+
2347
+ started_at timestamptz NOT NULL DEFAULT now(),
2348
+ ended_at timestamptz,
2349
+ wall_duration_ms integer NOT NULL DEFAULT 0,
2350
+ audio_duration_ms integer NOT NULL DEFAULT 0,
2351
+
2352
+ segment_count integer NOT NULL DEFAULT 0,
2353
+ uploaded_segment_count integer NOT NULL DEFAULT 0,
2354
+ transcribed_segment_count integer NOT NULL DEFAULT 0,
2355
+ transcript_chars integer NOT NULL DEFAULT 0,
2356
+
2357
+ -- Base legal, carimbada antes do primeiro byte. "Quando" e "como" \xE9 o que se
2358
+ -- prova depois; guardar isso na mesma linha da grava\xE7\xE3o \xE9 o que torna a prova
2359
+ -- barata.
2360
+ consent_at timestamptz,
2361
+ consent_mode text CHECK (consent_mode IN ('verbal','written','implied_contract')),
2362
+
2363
+ -- Reten\xE7\xE3o: o \xE1udio some, a transcri\xE7\xE3o e o documento assinado ficam.
2364
+ retention_until date,
2365
+ audio_deleted_at timestamptz,
2366
+
2367
+ error text,
2368
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
2369
+ created_at timestamptz NOT NULL DEFAULT now(),
2370
+ updated_at timestamptz NOT NULL DEFAULT now()
2371
+ );
2372
+ ALTER TABLE public.plg_scribe_sessions ENABLE ROW LEVEL SECURITY;
2373
+
2374
+ CREATE INDEX IF NOT EXISTS idx_plg_scribe_sessions_tenant
2375
+ ON public.plg_scribe_sessions(tenant_id);
2376
+ CREATE INDEX IF NOT EXISTS idx_plg_scribe_sessions_subject
2377
+ ON public.plg_scribe_sessions(subject_id) WHERE subject_id IS NOT NULL;
2378
+ CREATE INDEX IF NOT EXISTS idx_plg_scribe_sessions_appointment
2379
+ ON public.plg_scribe_sessions(appointment_id) WHERE appointment_id IS NOT NULL;
2380
+
2381
+ -- A consulta da recupera\xE7\xE3o: "este usu\xE1rio tem alguma sess\xE3o aberta?". Roda em
2382
+ -- todo boot do app, ent\xE3o \xE9 \xEDndice parcial e n\xE3o varredura.
2383
+ CREATE INDEX IF NOT EXISTS idx_plg_scribe_sessions_open
2384
+ ON public.plg_scribe_sessions(tenant_id, owner_user_id, status)
2385
+ WHERE status IN ('recording','paused','interrupted','uploading','transcribing','ready','generating');
2386
+
2387
+ -- A consulta do job de reten\xE7\xE3o.
2388
+ CREATE INDEX IF NOT EXISTS idx_plg_scribe_sessions_retention
2389
+ ON public.plg_scribe_sessions(retention_until)
2390
+ WHERE audio_deleted_at IS NULL AND retention_until IS NOT NULL;
2391
+
2392
+ -- ---------------------------------------------------------------------------
2393
+ -- 2. segments \u2014 o manifesto dos peda\xE7os
2394
+ -- ---------------------------------------------------------------------------
2395
+ -- A PK \xE9 (session_id, seg_index) e n\xE3o um uuid: o \xEDndice do segmento j\xE1 \xE9
2396
+ -- \xFAnico dentro da sess\xE3o, e usar isso como chave faz o re-upload do mesmo
2397
+ -- peda\xE7o ser um ON CONFLICT em vez de uma linha duplicada. Idempot\xEAncia de
2398
+ -- gra\xE7a, que \xE9 exatamente o que um pump com retry infinito precisa.
2399
+ CREATE TABLE IF NOT EXISTS public.plg_scribe_segments (
2400
+ session_id uuid NOT NULL REFERENCES public.plg_scribe_sessions(id) ON DELETE CASCADE,
2401
+ seg_index integer NOT NULL,
2402
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
2403
+
2404
+ storage_path text,
2405
+ bytes integer,
2406
+ start_offset_ms integer NOT NULL,
2407
+ -- Vem da resposta do STT. O rel\xF3gio do JS n\xE3o \xE9 testemunha confi\xE1vel de
2408
+ -- quanto \xE1udio existe: ele conta tempo, n\xE3o amostras.
2409
+ duration_ms integer,
2410
+
2411
+ -- Montado a partir de pieces de um segmento cortado no meio (crash). Vale
2412
+ -- transcrever mesmo assim: 22 segundos de consulta \xE9 melhor que zero.
2413
+ partial boolean NOT NULL DEFAULT false,
2414
+
2415
+ -- Linha SINT\xC9TICA: n\xE3o tem \xE1udio, marca um buraco (sono, mic perdido,
2416
+ -- permiss\xE3o revogada). Existe para a transcri\xE7\xE3o ADMITIR o buraco em vez de
2417
+ -- emendar dois momentos como se fossem cont\xEDnuos. Isso \xE9 requisito
2418
+ -- m\xE9dico-legal: um registro que esconde a costura \xE9 pior que um com furo.
2419
+ gap boolean NOT NULL DEFAULT false,
2420
+
2421
+ upload_state text NOT NULL DEFAULT 'pending'
2422
+ CHECK (upload_state IN ('pending','uploaded','missing')),
2423
+ uploaded_at timestamptz,
2424
+
2425
+ stt_state text NOT NULL DEFAULT 'pending'
2426
+ CHECK (stt_state IN ('pending','running','done','failed','skipped')),
2427
+ stt_provider text,
2428
+ stt_attempts smallint NOT NULL DEFAULT 0,
2429
+ stt_error text,
2430
+
2431
+ text text,
2432
+ -- [{w,s,e,c,sp}] com offsets RELATIVOS a este segmento. Absoluto se calcula
2433
+ -- somando start_offset_ms; guardar relativo \xE9 o que deixa reprocessar um
2434
+ -- segmento sozinho sem reescrever os vizinhos.
2435
+ words jsonb,
2436
+ confidence real,
2437
+
2438
+ created_at timestamptz NOT NULL DEFAULT now(),
2439
+ updated_at timestamptz NOT NULL DEFAULT now(),
2440
+ PRIMARY KEY (session_id, seg_index)
2441
+ );
2442
+ ALTER TABLE public.plg_scribe_segments ENABLE ROW LEVEL SECURITY;
2443
+
2444
+ CREATE INDEX IF NOT EXISTS idx_plg_scribe_segments_tenant
2445
+ ON public.plg_scribe_segments(tenant_id);
2446
+
2447
+ -- A fila do STT. Parcial porque s\xF3 interessa o que j\xE1 subiu e ainda n\xE3o foi
2448
+ -- transcrito \u2014 que \xE9 uma fra\xE7\xE3o min\xFAscula da tabela em regime permanente.
2449
+ CREATE INDEX IF NOT EXISTS idx_plg_scribe_segments_stt_queue
2450
+ ON public.plg_scribe_segments(tenant_id, stt_state, created_at)
2451
+ WHERE upload_state = 'uploaded' AND stt_state IN ('pending','failed');
2452
+
2453
+ -- ---------------------------------------------------------------------------
2454
+ -- 3. generations \u2014 uma tentativa de documento
2455
+ -- ---------------------------------------------------------------------------
2456
+ -- DUAS colunas de markdown, e isso n\xE3o \xE9 redund\xE2ncia.
2457
+ --
2458
+ -- \`markdown\` sa\xEDda do modelo, escrita uma vez, nunca mutada
2459
+ -- \`markdown_edited\` a c\xF3pia de trabalho do humano
2460
+ --
2461
+ -- A diferen\xE7a entre elas \xE9 a \xFAnica prova de que algu\xE9m revisou. Ela sustenta
2462
+ -- tr\xEAs coisas: o selo "revisado pelo profissional", o diff que diz se o prompt
2463
+ -- est\xE1 bom (documento muito editado = prompt ruim), e a cadeia de proveni\xEAncia
2464
+ -- que um registro m\xE9dico-legal precisa ter. Guardar s\xF3 o texto final apaga tudo
2465
+ -- isso para economizar uma coluna de texto.
2466
+ CREATE TABLE IF NOT EXISTS public.plg_scribe_generations (
2467
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
2468
+ tenant_id uuid NOT NULL REFERENCES public.tenants(id) ON DELETE CASCADE,
2469
+ session_id uuid NOT NULL REFERENCES public.plg_scribe_sessions(id) ON DELETE CASCADE,
2470
+
2471
+ -- Um dos dois. \`template_id\` quando o tenant forkou e tem linha pr\xF3pria;
2472
+ -- \`template_key\` quando \xE9 preset de c\xF3digo (que de prop\xF3sito n\xE3o tem linha,
2473
+ -- para n\xE3o consumir a cota de templates do plano).
2474
+ template_id uuid,
2475
+ template_key text,
2476
+
2477
+ tab_order integer NOT NULL DEFAULT 0,
2478
+ title text,
2479
+ status text NOT NULL DEFAULT 'pending'
2480
+ CHECK (status IN ('pending','running','ready','failed','committed','discarded')),
2481
+
2482
+ markdown text,
2483
+ markdown_edited text,
2484
+
2485
+ -- Extra\xE7\xE3o stage-1 do modo two-stage. A coluna entra agora, vazia, porque a
2486
+ -- interface j\xE1 nasce com esse formato \u2014 quando as abas de multi-documento
2487
+ -- chegarem, \xE9 preencher, n\xE3o migrar.
2488
+ facts jsonb,
2489
+
2490
+ model text,
2491
+ prompt_version text,
2492
+ input_tokens integer,
2493
+ output_tokens integer,
2494
+ error text,
2495
+
2496
+ document_id uuid,
2497
+ created_by uuid,
2498
+ created_at timestamptz NOT NULL DEFAULT now(),
2499
+ updated_at timestamptz NOT NULL DEFAULT now()
2500
+ );
2501
+ ALTER TABLE public.plg_scribe_generations ENABLE ROW LEVEL SECURITY;
2502
+
2503
+ CREATE INDEX IF NOT EXISTS idx_plg_scribe_generations_session
2504
+ ON public.plg_scribe_generations(session_id, tab_order);
2505
+ CREATE INDEX IF NOT EXISTS idx_plg_scribe_generations_tenant
2506
+ ON public.plg_scribe_generations(tenant_id);
2507
+
2508
+ -- FKs para plugin-forms e para o arqu\xE9tipo de documentos s\xE3o condicionais: o
2509
+ -- scribe declara \`dependencies: ['custom_forms']\`, mas a ordem de aplica\xE7\xE3o
2510
+ -- entre plugins n\xE3o \xE9 garantida e um pool pode aplicar este arquivo antes.
2511
+ -- Guardado, o re-apply converge; sem guarda, quebraria o passo inteiro.
2512
+ DO $$
2513
+ BEGIN
2514
+ IF to_regclass('public.plg_forms_templates') IS NOT NULL
2515
+ AND NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_scribe_generations_template_fk') THEN
2516
+ ALTER TABLE public.plg_scribe_generations
2517
+ ADD CONSTRAINT plg_scribe_generations_template_fk
2518
+ FOREIGN KEY (template_id) REFERENCES public.plg_forms_templates(id) ON DELETE SET NULL;
2519
+ END IF;
2520
+
2521
+ IF to_regclass('public.documents') IS NOT NULL
2522
+ AND NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'plg_scribe_generations_document_fk') THEN
2523
+ ALTER TABLE public.plg_scribe_generations
2524
+ ADD CONSTRAINT plg_scribe_generations_document_fk
2525
+ FOREIGN KEY (document_id) REFERENCES public.documents(id) ON DELETE SET NULL;
2526
+ END IF;
2527
+ END $$;
2528
+
2529
+ -- ---------------------------------------------------------------------------
2530
+ -- 4. RLS \u2014 a forma can\xF4nica deste repo, tr\xEAs tabelas x quatro verbos
2531
+ -- ---------------------------------------------------------------------------
2532
+ DROP POLICY IF EXISTS plg_scribe_sessions_select ON public.plg_scribe_sessions;
2533
+ CREATE POLICY plg_scribe_sessions_select ON public.plg_scribe_sessions FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
2534
+ DROP POLICY IF EXISTS plg_scribe_sessions_insert ON public.plg_scribe_sessions;
2535
+ CREATE POLICY plg_scribe_sessions_insert ON public.plg_scribe_sessions FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
2536
+ DROP POLICY IF EXISTS plg_scribe_sessions_update ON public.plg_scribe_sessions;
2537
+ CREATE POLICY plg_scribe_sessions_update ON public.plg_scribe_sessions FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
2538
+ DROP POLICY IF EXISTS plg_scribe_sessions_delete ON public.plg_scribe_sessions;
2539
+ CREATE POLICY plg_scribe_sessions_delete ON public.plg_scribe_sessions FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
2540
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_scribe_sessions TO authenticated;
2541
+
2542
+ DROP POLICY IF EXISTS plg_scribe_segments_select ON public.plg_scribe_segments;
2543
+ CREATE POLICY plg_scribe_segments_select ON public.plg_scribe_segments FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
2544
+ DROP POLICY IF EXISTS plg_scribe_segments_insert ON public.plg_scribe_segments;
2545
+ CREATE POLICY plg_scribe_segments_insert ON public.plg_scribe_segments FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
2546
+ DROP POLICY IF EXISTS plg_scribe_segments_update ON public.plg_scribe_segments;
2547
+ CREATE POLICY plg_scribe_segments_update ON public.plg_scribe_segments FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
2548
+ DROP POLICY IF EXISTS plg_scribe_segments_delete ON public.plg_scribe_segments;
2549
+ CREATE POLICY plg_scribe_segments_delete ON public.plg_scribe_segments FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
2550
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_scribe_segments TO authenticated;
2551
+
2552
+ DROP POLICY IF EXISTS plg_scribe_generations_select ON public.plg_scribe_generations;
2553
+ CREATE POLICY plg_scribe_generations_select ON public.plg_scribe_generations FOR SELECT TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
2554
+ DROP POLICY IF EXISTS plg_scribe_generations_insert ON public.plg_scribe_generations;
2555
+ CREATE POLICY plg_scribe_generations_insert ON public.plg_scribe_generations FOR INSERT TO authenticated WITH CHECK (tenant_id IN (SELECT public.user_tenant_ids()));
2556
+ DROP POLICY IF EXISTS plg_scribe_generations_update ON public.plg_scribe_generations;
2557
+ CREATE POLICY plg_scribe_generations_update ON public.plg_scribe_generations FOR UPDATE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
2558
+ DROP POLICY IF EXISTS plg_scribe_generations_delete ON public.plg_scribe_generations;
2559
+ CREATE POLICY plg_scribe_generations_delete ON public.plg_scribe_generations FOR DELETE TO authenticated USING (tenant_id IN (SELECT public.user_tenant_ids()));
2560
+ GRANT SELECT, INSERT, UPDATE, DELETE ON public.plg_scribe_generations TO authenticated;
2561
+
2562
+ -- ---------------------------------------------------------------------------
2563
+ -- 5. scribe_record_segment \u2014 o browser registra, o banco conta
2564
+ -- ---------------------------------------------------------------------------
2565
+ -- SECURITY INVOKER: a RLS acima j\xE1 amarra o tenant. N\xE3o h\xE1 nada aqui que
2566
+ -- precise de privil\xE9gio elevado, e uma fun\xE7\xE3o SECURITY DEFINER que n\xE3o precisa
2567
+ -- ser \xE9 s\xF3 superf\xEDcie de ataque a mais.
2568
+ --
2569
+ -- O ponto da fun\xE7\xE3o \xE9 a segunda metade: recalcular os contadores da sess\xE3o a
2570
+ -- partir de um SELECT sobre os segmentos, nunca de um \`+1\` vindo do cliente.
2571
+ -- Um pump com retry infinito vai reenviar o mesmo segmento, e um contador
2572
+ -- incremental transformaria isso em n\xFAmeros que crescem sem o \xE1udio crescer.
2573
+ CREATE OR REPLACE FUNCTION public.scribe_record_segment(p_payload jsonb)
2574
+ RETURNS jsonb
2575
+ LANGUAGE plpgsql
2576
+ SECURITY INVOKER
2577
+ SET search_path TO 'public'
2578
+ AS $function$
2579
+ DECLARE
2580
+ v_session_id uuid;
2581
+ v_seg_index integer;
2582
+ v_tenant_id uuid;
2583
+ v_result jsonb;
2584
+ BEGIN
2585
+ v_session_id := (p_payload->>'session_id')::uuid;
2586
+ v_seg_index := (p_payload->>'seg_index')::integer;
2587
+
2588
+ IF v_session_id IS NULL OR v_seg_index IS NULL THEN
2589
+ RAISE EXCEPTION 'session_id and seg_index are required';
2590
+ END IF;
2591
+
2592
+ -- O tenant vem da SESS\xC3O, nunca do payload. Se o cliente pudesse informar o
2593
+ -- tenant, ele escolheria em que pasta escrever.
2594
+ SELECT s.tenant_id INTO v_tenant_id
2595
+ FROM plg_scribe_sessions s WHERE s.id = v_session_id;
2596
+
2597
+ IF v_tenant_id IS NULL THEN
2598
+ -- Sess\xE3o inexistente OU invis\xEDvel pela RLS: a mesma resposta nos dois
2599
+ -- casos, de prop\xF3sito \u2014 n\xE3o vaza a exist\xEAncia de sess\xE3o de outro tenant.
2600
+ RAISE EXCEPTION 'unknown session';
2601
+ END IF;
2602
+
2603
+ INSERT INTO plg_scribe_segments (
2604
+ session_id, seg_index, tenant_id, storage_path, bytes,
2605
+ start_offset_ms, duration_ms, partial, gap, upload_state, uploaded_at, stt_state
2606
+ ) VALUES (
2607
+ v_session_id, v_seg_index, v_tenant_id,
2608
+ p_payload->>'storage_path',
2609
+ (p_payload->>'bytes')::integer,
2610
+ COALESCE((p_payload->>'start_offset_ms')::integer, 0),
2611
+ (p_payload->>'duration_ms')::integer,
2612
+ COALESCE((p_payload->>'partial')::boolean, false),
2613
+ COALESCE((p_payload->>'gap')::boolean, false),
2614
+ CASE WHEN COALESCE((p_payload->>'gap')::boolean, false) THEN 'missing' ELSE 'uploaded' END,
2615
+ now(),
2616
+ -- Buraco n\xE3o vai para o STT: n\xE3o h\xE1 \xE1udio para transcrever, e mand\xE1-lo
2617
+ -- queimaria uma chamada para receber string vazia.
2618
+ CASE WHEN COALESCE((p_payload->>'gap')::boolean, false) THEN 'skipped' ELSE 'pending' END
2619
+ )
2620
+ ON CONFLICT (session_id, seg_index) DO UPDATE SET
2621
+ storage_path = COALESCE(EXCLUDED.storage_path, plg_scribe_segments.storage_path),
2622
+ bytes = COALESCE(EXCLUDED.bytes, plg_scribe_segments.bytes),
2623
+ duration_ms = COALESCE(EXCLUDED.duration_ms, plg_scribe_segments.duration_ms),
2624
+ partial = EXCLUDED.partial,
2625
+ upload_state = EXCLUDED.upload_state,
2626
+ uploaded_at = COALESCE(plg_scribe_segments.uploaded_at, EXCLUDED.uploaded_at),
2627
+ updated_at = now();
2628
+
2629
+ -- Contadores derivados. \`audio_duration_ms\` soma s\xF3 o que tem dura\xE7\xE3o medida,
2630
+ -- ent\xE3o um segmento que ainda n\xE3o voltou do STT n\xE3o infla o total.
2631
+ UPDATE plg_scribe_sessions s SET
2632
+ segment_count = agg.total,
2633
+ uploaded_segment_count = agg.uploaded,
2634
+ transcribed_segment_count = agg.transcribed,
2635
+ audio_duration_ms = agg.audio_ms,
2636
+ transcript_chars = agg.chars,
2637
+ updated_at = now()
2638
+ FROM (
2639
+ SELECT
2640
+ count(*)::int AS total,
2641
+ count(*) FILTER (WHERE upload_state = 'uploaded')::int AS uploaded,
2642
+ count(*) FILTER (WHERE stt_state = 'done')::int AS transcribed,
2643
+ COALESCE(sum(duration_ms) FILTER (WHERE NOT gap), 0)::int AS audio_ms,
2644
+ COALESCE(sum(length(text)), 0)::int AS chars
2645
+ FROM plg_scribe_segments WHERE session_id = v_session_id
2646
+ ) agg
2647
+ WHERE s.id = v_session_id;
2648
+
2649
+ SELECT to_jsonb(x) INTO v_result FROM (
2650
+ SELECT s.segment_count, s.uploaded_segment_count,
2651
+ s.transcribed_segment_count, s.audio_duration_ms, s.status
2652
+ FROM plg_scribe_sessions s WHERE s.id = v_session_id
2653
+ ) x;
2654
+
2655
+ RETURN v_result;
2656
+ END;
2657
+ $function$;
2658
+
2659
+ COMMENT ON FUNCTION public.scribe_record_segment(jsonb) IS
2660
+ 'Idempotent upsert of one audio segment + recomputation of the parent session counters from the segments table. Called by the browser upload pump after each successful Storage write.';
2661
+
2662
+ GRANT EXECUTE ON FUNCTION public.scribe_record_segment(jsonb) TO authenticated;
2663
+
2664
+ -- ---------------------------------------------------------------------------
2665
+ -- 6. o bucket
2666
+ -- ---------------------------------------------------------------------------
2667
+ -- PRIVADO, sem discuss\xE3o: \xE9 a voz de uma pessoa falando da pr\xF3pria sa\xFAde. URL
2668
+ -- p\xFAblica seria um endere\xE7o eterno para isso. O backoffice l\xEA por URL assinada
2669
+ -- de curta dura\xE7\xE3o; a edge function de transcri\xE7\xE3o l\xEA com service key.
2670
+ --
2671
+ -- 5 MB por SEGMENTO \xE9 folgado por design: 30 s de Opus mono a 24 kbps d\xE1 ~90 KB.
2672
+ -- O limite existe para barrar um cliente com bug mandando um arquivo inteiro,
2673
+ -- n\xE3o para apertar o caso normal.
2674
+ INSERT INTO storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
2675
+ VALUES (
2676
+ 'scribe-audio',
2677
+ 'scribe-audio',
2678
+ false,
2679
+ 5242880,
2680
+ ARRAY['audio/webm','audio/ogg','audio/mp4','audio/mpeg','audio/m4a','audio/x-m4a']
2681
+ )
2682
+ ON CONFLICT (id) DO UPDATE SET
2683
+ public = EXCLUDED.public,
2684
+ file_size_limit = EXCLUDED.file_size_limit,
2685
+ allowed_mime_types = EXCLUDED.allowed_mime_types;
2686
+
2687
+ -- ---------------------------------------------------------------------------
2688
+ -- 7. o caminho \xE9 v\xE1lido?
2689
+ -- ---------------------------------------------------------------------------
2690
+ -- Gram\xE1tica (o SDK constr\xF3i, o banco confere):
2691
+ --
2692
+ -- {tenant_id}/{yyyy}/{mm}/{session_id}/{seg_index:05d}.{ext}
2693
+ -- 1 2 3 4 5
2694
+ --
2695
+ -- 1 tenant_id chave do isolamento \u2014 toda policy pendura aqui
2696
+ -- 2,3 yyyy/mm varredura de reten\xE7\xE3o por prefixo (LGPD), pasta list\xE1vel
2697
+ -- 4 session_id uuid do cliente: agrupa a sess\xE3o e \xE9 imprevis\xEDvel
2698
+ -- 5 seg_index zero-padded, ent\xE3o list() j\xE1 volta em ordem cronol\xF3gica
2699
+ --
2700
+ -- SECURITY DEFINER porque o EXISTS olha \`tenants\`, que tem RLS.
2701
+ CREATE OR REPLACE FUNCTION public.scribe_audio_path_ok(p_name text)
2702
+ RETURNS boolean
2703
+ LANGUAGE sql
2704
+ STABLE
2705
+ SECURITY DEFINER
2706
+ SET search_path TO 'public'
2707
+ AS $function$
2708
+ SELECT
2709
+ p_name IS NOT NULL
2710
+ AND length(p_name) <= 200
2711
+ AND p_name ~ ('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
2712
+ || '/[0-9]{4}/[0-9]{2}'
2713
+ || '/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
2714
+ || '/[0-9]{5}\\.(webm|ogg|m4a|mp3)$')
2715
+ -- compara\xE7\xE3o em texto (nunca \`::uuid\`): entrada malformada devolve falso em
2716
+ -- vez de derrubar a policy com erro de cast.
2717
+ AND EXISTS (
2718
+ SELECT 1 FROM public.tenants t WHERE t.id::text = split_part(p_name, '/', 1)
2719
+ );
2720
+ $function$;
2721
+
2722
+ COMMENT ON FUNCTION public.scribe_audio_path_ok(text) IS
2723
+ 'True when a storage object name follows the scribe-audio grammar {tenant}/{yyyy}/{mm}/{session}/{segIndex}.{ext} and segment 1 is a real tenant. Used by the INSERT policy on that bucket.';
2724
+
2725
+ GRANT EXECUTE ON FUNCTION public.scribe_audio_path_ok(text) TO authenticated;
2726
+
2727
+ -- ---------------------------------------------------------------------------
2728
+ -- 8. pol\xEDticas do bucket
2729
+ -- ---------------------------------------------------------------------------
2730
+ DROP POLICY IF EXISTS plg_scribe_audio_insert ON storage.objects;
2731
+ DROP POLICY IF EXISTS plg_scribe_audio_read ON storage.objects;
2732
+ DROP POLICY IF EXISTS plg_scribe_audio_delete ON storage.objects;
2733
+
2734
+ -- Escrever: s\xF3 logado (diferente do bucket do CRM, que aceita visitante an\xF4nimo
2735
+ -- \u2014 aqui quem grava \xE9 sempre um profissional autenticado), dentro da gram\xE1tica
2736
+ -- e da pr\xF3pria pasta.
2737
+ --
2738
+ -- N\xC3O existe policy de UPDATE, e isso \xE9 a trava mais importante das tr\xEAs: sem
2739
+ -- ela o \`upsert\` falha, e um caminho j\xE1 gravado NUNCA pode ser trocado por
2740
+ -- outro conte\xFAdo. \xC9 o que torna o \xE1udio uma testemunha e n\xE3o um rascunho.
2741
+ CREATE POLICY plg_scribe_audio_insert ON storage.objects
2742
+ FOR INSERT TO authenticated
2743
+ WITH CHECK (
2744
+ bucket_id = 'scribe-audio'
2745
+ AND public.scribe_audio_path_ok(name)
2746
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
2747
+ );
2748
+
2749
+ CREATE POLICY plg_scribe_audio_read ON storage.objects
2750
+ FOR SELECT TO authenticated
2751
+ USING (
2752
+ bucket_id = 'scribe-audio'
2753
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
2754
+ );
2755
+
2756
+ -- Apagar: mesma regra da leitura. \xC9 o que torna "apagar o \xE1udio desta sess\xE3o"
2757
+ -- execut\xE1vel por quem \xE9 dono dele \u2014 o direito de elimina\xE7\xE3o do art. 18 VI n\xE3o
2758
+ -- vale nada se ningu\xE9m no tenant consegue exerc\xEA-lo.
2759
+ CREATE POLICY plg_scribe_audio_delete ON storage.objects
2760
+ FOR DELETE TO authenticated
2761
+ USING (
2762
+ bucket_id = 'scribe-audio'
2763
+ AND (storage.foldername(name))[1] IN (SELECT public.user_tenant_ids()::text)
2764
+ );
2765
+ `;
2766
+
2767
+ // src/types.ts
2768
+ function isNarrativeSchema(schema) {
2769
+ return !!schema && typeof schema === "object" && schema.kind === "narrative";
2770
+ }
2771
+
2772
+ // src/index.ts
2773
+ var DEFAULT_LABELS = {
2774
+ sessionSingular: "Atendimento",
2775
+ sessionPlural: "Atendimentos",
2776
+ start: "Iniciar atendimento",
2777
+ pause: "Pausar",
2778
+ resume: "Retomar",
2779
+ finish: "Finalizar",
2780
+ discard: "Descartar",
2781
+ generate: "Gerar documento",
2782
+ subject: "Cliente",
2783
+ transcript: "Transcri\xE7\xE3o",
2784
+ settingsTitle: "Grava\xE7\xE3o de atendimentos"
2785
+ };
2786
+ var DEFAULT_RETENTION = {
2787
+ audioDays: 90,
2788
+ requireConsent: true,
2789
+ defaultConsentMode: "verbal"
2790
+ };
2791
+ function createScribePlugin(options) {
2792
+ assertSttConfig(options?.stt);
2793
+ const labels = { ...DEFAULT_LABELS, ...options.labels };
2794
+ const retention2 = { ...DEFAULT_RETENTION, ...options.retention };
2795
+ const presets = options.templatePresets ?? [];
2796
+ const generateEndpoint = options.generateEndpoint ?? "scribe-generate";
2797
+ const locale = options.stt.locale ?? "pt-BR";
2798
+ registerTranslations(scribeLocales);
2799
+ initScribeRuntime({ retention: retention2 });
2800
+ wireScribeStore();
2801
+ const openSession = (sessionId) => {
2802
+ window.location.hash = `/scribe/${sessionId}`;
2803
+ };
2804
+ const ShellMount = () => React6__default.createElement(ScribeShellMount, { labels, retention: retention2, locale, onOpenSession: openSession });
2805
+ ShellMount.displayName = "ScribeShellMount";
2806
+ const SessionRoute = () => {
2807
+ const hash = typeof window !== "undefined" ? window.location.hash : "";
2808
+ const sessionId = hash.split("/scribe/")[1]?.split("/")[0] ?? "";
2809
+ if (!sessionId) return null;
2810
+ return React6__default.createElement(ScribeSessionPage, {
2811
+ sessionId,
2812
+ labels,
2813
+ presets,
2814
+ stt: options.stt,
2815
+ generateEndpoint,
2816
+ onBack: () => {
2817
+ window.location.hash = "/clients";
2818
+ },
2819
+ onOpenDocument: () => {
2820
+ window.location.hash = "/clients";
2821
+ }
2822
+ });
2823
+ };
2824
+ SessionRoute.displayName = "ScribeSessionRoute";
2825
+ return {
2826
+ id: "scribe",
2827
+ name: labels.sessionPlural,
2828
+ icon: "Mic",
2829
+ version: "0.1.0",
2830
+ scope: options.scope ?? "universal",
2831
+ verticalId: options.verticalId,
2832
+ defaultEnabled: false,
2833
+ // O scribe EMITE documento mas não é dono do conceito. plugin-forms é a
2834
+ // engine de documento; este é uma engine de aquisição. A dependência aponta
2835
+ // nessa direção e não na inversa — senão todo app que só quer uma anamnese
2836
+ // impressa passaria a depender de bucket, duas edge functions e chave de
2837
+ // modelo.
2838
+ dependencies: ["custom_forms"],
2839
+ navigation: [],
2840
+ routes: [
2841
+ {
2842
+ path: "/scribe/*",
2843
+ component: SessionRoute
2844
+ }
2845
+ ],
2846
+ widgets: [
2847
+ {
2848
+ id: "scribe-shell-mount",
2849
+ // A única zona global verificadamente renderizada (AdminShell.tsx:979-980)
2850
+ // e que fica FORA do switch de rota. Navegar não a desmonta.
2851
+ zone: "shell.topbar.end",
2852
+ component: ShellMount,
2853
+ order: -10
2854
+ }
2855
+ ],
2856
+ declaredFeatures: [
2857
+ { id: "scribe", label: labels.settingsTitle, actions: ["read", "create", "edit", "delete"] }
2858
+ ],
2859
+ declaredLimits: [
2860
+ // Cota entra no dia um, não depois: a 1000 atendimentos/mês o STT bruto
2861
+ // já é a maior linha de custo variável do produto.
2862
+ {
2863
+ key: options.limits?.minutesMonth ?? "scribe_minutes_month",
2864
+ label: "Minutos de grava\xE7\xE3o por m\xEAs",
2865
+ table: "plg_scribe_sessions",
2866
+ period: "month"
2867
+ },
2868
+ {
2869
+ key: options.limits?.documentsMonth ?? "scribe_documents_month",
2870
+ label: "Documentos gerados por m\xEAs",
2871
+ table: "plg_scribe_generations",
2872
+ period: "month"
2873
+ }
2874
+ ],
2875
+ aiTools: [
2876
+ {
2877
+ id: "scribe.list-sessions",
2878
+ name: "listScribeSessions",
2879
+ description: `Lista ${labels.sessionPlural.toLowerCase()} gravados, com data, dura\xE7\xE3o e status.`,
2880
+ icon: "Mic",
2881
+ mode: "read",
2882
+ category: labels.sessionPlural,
2883
+ permission: { feature: "scribe", action: "read" },
2884
+ parameters: {
2885
+ type: "object",
2886
+ properties: {
2887
+ subjectId: { type: "string", description: "Filtra pelo id do titular" },
2888
+ status: { type: "string", description: "Filtra pelo status da sess\xE3o" }
2889
+ }
2890
+ },
2891
+ suggestions: [
2892
+ { label: `Quais ${labels.sessionPlural.toLowerCase()} ainda n\xE3o viraram documento?` }
2893
+ ]
2894
+ }
2895
+ ],
2896
+ migrations: [
2897
+ {
2898
+ id: "scribe-001-base",
2899
+ version: "0.1.0",
2900
+ sql: MIGRATION_001_SCRIBE_BASE,
2901
+ description: "Cria plg_scribe_sessions/_segments/_generations, o bucket privado scribe-audio com guard de caminho, e o RPC scribe_record_segment"
2902
+ }
2903
+ ],
2904
+ locales: scribeLocales
2905
+ };
2906
+ }
2907
+
2908
+ export { PROMPT_VERSION, SCRIBE_START_EVENT, ScribeSessionPage, ScribeStartButton, beginSession, createScribePlugin, deleteSessionAudio, discardSession, endSession, fetchGenerations, fetchOpenSessions, fetchSegments, fetchSession, fetchSessionsForSubject, formatElapsed, formatOffset, isCaptureSupported, isNarrativeSchema, parseSections, pauseSession, renderTranscript, requestScribeStart, resumeSession, useScribeStore };
2909
+ //# sourceMappingURL=index.js.map
2910
+ //# sourceMappingURL=index.js.map