@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.
- package/LICENSE +21 -0
- package/dist/components/DraftPanel.d.ts +14 -0
- package/dist/components/DraftPanel.d.ts.map +1 -0
- package/dist/components/ScribeConsentDialog.d.ts +12 -0
- package/dist/components/ScribeConsentDialog.d.ts.map +1 -0
- package/dist/components/ScribeRecordingPill.d.ts +16 -0
- package/dist/components/ScribeRecordingPill.d.ts.map +1 -0
- package/dist/components/ScribeRecoveryBanner.d.ts +8 -0
- package/dist/components/ScribeRecoveryBanner.d.ts.map +1 -0
- package/dist/components/ScribeSessionPage.d.ts +13 -0
- package/dist/components/ScribeSessionPage.d.ts.map +1 -0
- package/dist/components/ScribeShellMount.d.ts +19 -0
- package/dist/components/ScribeShellMount.d.ts.map +1 -0
- package/dist/components/TranscriptPanel.d.ts +10 -0
- package/dist/components/TranscriptPanel.d.ts.map +1 -0
- package/dist/data/supabase.d.ts +63 -0
- package/dist/data/supabase.d.ts.map +1 -0
- package/dist/data/tables.d.ts +31 -0
- package/dist/data/tables.d.ts.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2910 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/config.d.ts +19 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.test.d.ts +2 -0
- package/dist/lib/config.test.d.ts.map +1 -0
- package/dist/lib/drain.d.ts +13 -0
- package/dist/lib/drain.d.ts.map +1 -0
- package/dist/lib/generate.d.ts +23 -0
- package/dist/lib/generate.d.ts.map +1 -0
- package/dist/lib/prompt.d.ts +39 -0
- package/dist/lib/prompt.d.ts.map +1 -0
- package/dist/lib/prompt.test.d.ts +2 -0
- package/dist/lib/prompt.test.d.ts.map +1 -0
- package/dist/lib/transport.d.ts +59 -0
- package/dist/lib/transport.d.ts.map +1 -0
- package/dist/locales/en.d.ts +2 -0
- package/dist/locales/en.d.ts.map +1 -0
- package/dist/locales/index.d.ts +2 -0
- package/dist/locales/index.d.ts.map +1 -0
- package/dist/locales/pt-BR.d.ts +2 -0
- package/dist/locales/pt-BR.d.ts.map +1 -0
- package/dist/migrations/index.d.ts +6 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/runtime/capture.d.ts +84 -0
- package/dist/runtime/capture.d.ts.map +1 -0
- package/dist/runtime/idb.d.ts +121 -0
- package/dist/runtime/idb.d.ts.map +1 -0
- package/dist/runtime/index.d.ts +48 -0
- package/dist/runtime/index.d.ts.map +1 -0
- package/dist/runtime/pump.d.ts +11 -0
- package/dist/runtime/pump.d.ts.map +1 -0
- package/dist/store.d.ts +25 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/types.d.ts +266 -0
- package/dist/types.d.ts.map +1 -0
- package/functions/scribe-generate/index.ts +255 -0
- package/functions/scribe-transcribe/index.ts +455 -0
- package/package.json +58 -0
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
// scribe-transcribe — transcreve os segmentos de áudio de uma sessão.
|
|
2
|
+
//
|
|
3
|
+
// Ações:
|
|
4
|
+
// drain → transcreve tudo o que está pendente numa sessão (o caso normal)
|
|
5
|
+
// segment → transcreve um segmento específico (retry manual / debug)
|
|
6
|
+
//
|
|
7
|
+
// Env: DEEPGRAM_API_KEY (ou OPENAI_API_KEY), SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY.
|
|
8
|
+
//
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// Por que a credencial mora aqui e não no browser
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Duas razões, e a segunda é a que importa. A primeira é óbvia: uma chave de STT
|
|
13
|
+
// no bundle é uma chave pública. A segunda é que o áudio deste bucket é privado
|
|
14
|
+
// e o browser não tem — nem deve ter — permissão para mandá-lo a um terceiro.
|
|
15
|
+
// A função lê com service key e fala com o provedor; a fronteira do dado
|
|
16
|
+
// sensível fica em um lugar só, auditável.
|
|
17
|
+
//
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Por que por SEGMENTO e não o arquivo inteiro
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Custo é IGUAL nos dois provedores (cobram por minuto de áudio, não por
|
|
22
|
+
// requisição), então custo não decide. Decide o modo de falha:
|
|
23
|
+
//
|
|
24
|
+
// - o texto aparece DURANTE a consulta, que é o requisito de produto
|
|
25
|
+
// - o request tem tamanho limitado (2 h de sessão estoura o teto de 25 MB do
|
|
26
|
+
// Whisper num arquivo só; segmentado, o teto some do mapa)
|
|
27
|
+
// - um segmento envenenado perde 30 s, não 40 minutos
|
|
28
|
+
// - retry é uma coluna, não um reprocessamento inteiro
|
|
29
|
+
// - quem para no minuto 12 tem 12 minutos de transcrição
|
|
30
|
+
//
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Contexto de fronteira
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Rotacionar o gravador a cada 30 s corta palavras na emenda. Mitigação barata:
|
|
35
|
+
// semear cada request com a CAUDA do texto do segmento anterior (`prompt` no
|
|
36
|
+
// Whisper, `keyterm` no Deepgram). O provedor usa isso como pista de contexto e
|
|
37
|
+
// a emenda para de comer nome próprio e termo técnico.
|
|
38
|
+
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.45.0'
|
|
39
|
+
|
|
40
|
+
const corsHeaders = {
|
|
41
|
+
'Access-Control-Allow-Origin': '*',
|
|
42
|
+
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const AUDIO_BUCKET = 'scribe-audio'
|
|
46
|
+
/** Quantos segmentos transcrevem em paralelo por invocação. */
|
|
47
|
+
const CONCURRENCY = 3
|
|
48
|
+
/** Teto de tentativas antes de desistir de um segmento e seguir com o resto. */
|
|
49
|
+
const MAX_ATTEMPTS = 4
|
|
50
|
+
/** Quantos caracteres do segmento anterior viram pista de contexto. */
|
|
51
|
+
const CONTEXT_TAIL_CHARS = 200
|
|
52
|
+
|
|
53
|
+
interface TranscriptWord {
|
|
54
|
+
w: string
|
|
55
|
+
s: number
|
|
56
|
+
e: number
|
|
57
|
+
c?: number
|
|
58
|
+
sp?: number
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface SttResult {
|
|
62
|
+
text: string
|
|
63
|
+
words: TranscriptWord[]
|
|
64
|
+
durationMs: number
|
|
65
|
+
confidence: number
|
|
66
|
+
provider: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Deepgram
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
async function transcribeDeepgram(
|
|
74
|
+
audio: ArrayBuffer,
|
|
75
|
+
mimeType: string,
|
|
76
|
+
opts: { locale: string; diarize: boolean; model: string; context?: string },
|
|
77
|
+
): Promise<SttResult> {
|
|
78
|
+
const key = Deno.env.get('DEEPGRAM_API_KEY')
|
|
79
|
+
if (!key) throw new Error('DEEPGRAM_API_KEY não configurada')
|
|
80
|
+
|
|
81
|
+
const params = new URLSearchParams({
|
|
82
|
+
model: opts.model,
|
|
83
|
+
language: opts.locale,
|
|
84
|
+
punctuate: 'true',
|
|
85
|
+
smart_format: 'true',
|
|
86
|
+
diarize: String(opts.diarize),
|
|
87
|
+
})
|
|
88
|
+
// `keyterm` só existe no Nova-3; mandar em outro modelo é erro 400.
|
|
89
|
+
if (opts.context && opts.model.startsWith('nova-3')) {
|
|
90
|
+
for (const term of extractKeyterms(opts.context)) params.append('keyterm', term)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const res = await fetch(`https://api.deepgram.com/v1/listen?${params}`, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { Authorization: `Token ${key}`, 'Content-Type': mimeType },
|
|
96
|
+
body: audio,
|
|
97
|
+
})
|
|
98
|
+
if (!res.ok) throw new Error(`Deepgram ${res.status}: ${await res.text()}`)
|
|
99
|
+
|
|
100
|
+
const json = await res.json()
|
|
101
|
+
const alt = json?.results?.channels?.[0]?.alternatives?.[0]
|
|
102
|
+
const words: TranscriptWord[] = (alt?.words ?? []).map((w: any) => ({
|
|
103
|
+
w: w.punctuated_word ?? w.word,
|
|
104
|
+
s: Math.round((w.start ?? 0) * 1000),
|
|
105
|
+
e: Math.round((w.end ?? 0) * 1000),
|
|
106
|
+
c: w.confidence,
|
|
107
|
+
sp: w.speaker,
|
|
108
|
+
}))
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
text: alt?.transcript ?? '',
|
|
112
|
+
words,
|
|
113
|
+
durationMs: Math.round((json?.metadata?.duration ?? 0) * 1000),
|
|
114
|
+
confidence: alt?.confidence ?? 0,
|
|
115
|
+
provider: `deepgram:${opts.model}`,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Termos que valem como pista: palavras longas o bastante para serem nome
|
|
121
|
+
* próprio ou jargão. Artigo e preposição não ajudam o reconhecedor e só gastam
|
|
122
|
+
* espaço no limite de keyterms.
|
|
123
|
+
*/
|
|
124
|
+
function extractKeyterms(context: string): string[] {
|
|
125
|
+
const seen = new Set<string>()
|
|
126
|
+
for (const word of context.split(/\s+/)) {
|
|
127
|
+
const clean = word.replace(/[^\p{L}\p{N}-]/gu, '')
|
|
128
|
+
if (clean.length >= 6) seen.add(clean)
|
|
129
|
+
if (seen.size >= 10) break
|
|
130
|
+
}
|
|
131
|
+
return [...seen]
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// OpenAI (o segundo provedor atrás do mesmo seam)
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
async function transcribeOpenAI(
|
|
139
|
+
audio: ArrayBuffer,
|
|
140
|
+
mimeType: string,
|
|
141
|
+
opts: { locale: string; model: string; context?: string },
|
|
142
|
+
): Promise<SttResult> {
|
|
143
|
+
const key = Deno.env.get('OPENAI_API_KEY')
|
|
144
|
+
if (!key) throw new Error('OPENAI_API_KEY não configurada')
|
|
145
|
+
|
|
146
|
+
const form = new FormData()
|
|
147
|
+
form.append('file', new Blob([audio], { type: mimeType }), 'segment.webm')
|
|
148
|
+
form.append('model', opts.model)
|
|
149
|
+
form.append('language', opts.locale.slice(0, 2))
|
|
150
|
+
form.append('response_format', 'verbose_json')
|
|
151
|
+
form.append('timestamp_granularities[]', 'word')
|
|
152
|
+
if (opts.context) form.append('prompt', opts.context)
|
|
153
|
+
|
|
154
|
+
const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
157
|
+
body: form,
|
|
158
|
+
})
|
|
159
|
+
if (!res.ok) throw new Error(`OpenAI ${res.status}: ${await res.text()}`)
|
|
160
|
+
|
|
161
|
+
const json = await res.json()
|
|
162
|
+
const words: TranscriptWord[] = (json?.words ?? []).map((w: any) => ({
|
|
163
|
+
w: w.word,
|
|
164
|
+
s: Math.round((w.start ?? 0) * 1000),
|
|
165
|
+
e: Math.round((w.end ?? 0) * 1000),
|
|
166
|
+
}))
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
text: json?.text ?? '',
|
|
170
|
+
words,
|
|
171
|
+
durationMs: Math.round((json?.duration ?? 0) * 1000),
|
|
172
|
+
confidence: 0,
|
|
173
|
+
provider: `openai:${opts.model}`,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// O seam de UM método
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
function transcribe(
|
|
182
|
+
audio: ArrayBuffer,
|
|
183
|
+
mimeType: string,
|
|
184
|
+
opts: { provider: string; model?: string; locale: string; diarize: boolean; context?: string },
|
|
185
|
+
): Promise<SttResult> {
|
|
186
|
+
if (opts.provider === 'openai') {
|
|
187
|
+
return transcribeOpenAI(audio, mimeType, {
|
|
188
|
+
locale: opts.locale,
|
|
189
|
+
model: opts.model ?? 'whisper-1',
|
|
190
|
+
context: opts.context,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
return transcribeDeepgram(audio, mimeType, {
|
|
194
|
+
locale: opts.locale,
|
|
195
|
+
diarize: opts.diarize,
|
|
196
|
+
model: opts.model ?? 'nova-3',
|
|
197
|
+
context: opts.context,
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// Drenagem
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
async function drainSession(
|
|
206
|
+
supabase: any,
|
|
207
|
+
sessionId: string,
|
|
208
|
+
provider: string,
|
|
209
|
+
model: string | undefined,
|
|
210
|
+
diarize: boolean,
|
|
211
|
+
): Promise<{ transcribed: number; failed: number; remaining: number }> {
|
|
212
|
+
const { data: session, error: sessionErr } = await supabase
|
|
213
|
+
.from('plg_scribe_sessions')
|
|
214
|
+
.select('id, tenant_id, locale, mime_type, status')
|
|
215
|
+
.eq('id', sessionId)
|
|
216
|
+
.single()
|
|
217
|
+
if (sessionErr || !session) throw new Error('sessão não encontrada')
|
|
218
|
+
|
|
219
|
+
const { data: pending } = await supabase
|
|
220
|
+
.from('plg_scribe_segments')
|
|
221
|
+
.select('session_id, seg_index, storage_path, stt_attempts')
|
|
222
|
+
.eq('session_id', sessionId)
|
|
223
|
+
.eq('upload_state', 'uploaded')
|
|
224
|
+
.in('stt_state', ['pending', 'failed'])
|
|
225
|
+
.lt('stt_attempts', MAX_ATTEMPTS)
|
|
226
|
+
.order('seg_index', { ascending: true })
|
|
227
|
+
|
|
228
|
+
if (!pending || pending.length === 0) {
|
|
229
|
+
await maybeMarkReady(supabase, sessionId)
|
|
230
|
+
return { transcribed: 0, failed: 0, remaining: 0 }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
let transcribed = 0
|
|
234
|
+
let failed = 0
|
|
235
|
+
|
|
236
|
+
for (let i = 0; i < pending.length; i += CONCURRENCY) {
|
|
237
|
+
const batch = pending.slice(i, i + CONCURRENCY)
|
|
238
|
+
const results = await Promise.all(
|
|
239
|
+
batch.map(async (seg: any) => {
|
|
240
|
+
if (!seg.storage_path) return { ok: false }
|
|
241
|
+
try {
|
|
242
|
+
// Marcar `running` ANTES de baixar: duas invocações concorrentes
|
|
243
|
+
// (browser + cron) veriam o mesmo segmento pendente, e transcrever
|
|
244
|
+
// duas vezes é dinheiro jogado fora.
|
|
245
|
+
await supabase
|
|
246
|
+
.from('plg_scribe_segments')
|
|
247
|
+
.update({ stt_state: 'running', stt_attempts: (seg.stt_attempts ?? 0) + 1, updated_at: new Date().toISOString() })
|
|
248
|
+
.eq('session_id', seg.session_id)
|
|
249
|
+
.eq('seg_index', seg.seg_index)
|
|
250
|
+
|
|
251
|
+
const { data: file, error: dlErr } = await supabase.storage
|
|
252
|
+
.from(AUDIO_BUCKET)
|
|
253
|
+
.download(seg.storage_path)
|
|
254
|
+
if (dlErr || !file) throw new Error(`download falhou: ${dlErr?.message ?? 'sem corpo'}`)
|
|
255
|
+
|
|
256
|
+
const context = await previousTail(supabase, sessionId, seg.seg_index)
|
|
257
|
+
const audio = await file.arrayBuffer()
|
|
258
|
+
const result = await transcribe(audio, session.mime_type ?? 'audio/webm', {
|
|
259
|
+
provider,
|
|
260
|
+
model,
|
|
261
|
+
locale: session.locale ?? 'pt-BR',
|
|
262
|
+
diarize,
|
|
263
|
+
context,
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
await supabase
|
|
267
|
+
.from('plg_scribe_segments')
|
|
268
|
+
.update({
|
|
269
|
+
stt_state: 'done',
|
|
270
|
+
stt_provider: result.provider,
|
|
271
|
+
stt_error: null,
|
|
272
|
+
text: result.text,
|
|
273
|
+
words: result.words,
|
|
274
|
+
confidence: result.confidence,
|
|
275
|
+
// A duração AUTORITATIVA: medida no áudio, não contada no JS.
|
|
276
|
+
duration_ms: result.durationMs > 0 ? result.durationMs : null,
|
|
277
|
+
updated_at: new Date().toISOString(),
|
|
278
|
+
})
|
|
279
|
+
.eq('session_id', seg.session_id)
|
|
280
|
+
.eq('seg_index', seg.seg_index)
|
|
281
|
+
|
|
282
|
+
return { ok: true }
|
|
283
|
+
} catch (err) {
|
|
284
|
+
await supabase
|
|
285
|
+
.from('plg_scribe_segments')
|
|
286
|
+
.update({
|
|
287
|
+
stt_state: 'failed',
|
|
288
|
+
stt_error: String((err as Error)?.message ?? err).slice(0, 500),
|
|
289
|
+
updated_at: new Date().toISOString(),
|
|
290
|
+
})
|
|
291
|
+
.eq('session_id', seg.session_id)
|
|
292
|
+
.eq('seg_index', seg.seg_index)
|
|
293
|
+
return { ok: false }
|
|
294
|
+
}
|
|
295
|
+
}),
|
|
296
|
+
)
|
|
297
|
+
transcribed += results.filter((r) => r.ok).length
|
|
298
|
+
failed += results.filter((r) => !r.ok).length
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
await recomputeCounters(supabase, sessionId)
|
|
302
|
+
await maybeMarkReady(supabase, sessionId)
|
|
303
|
+
|
|
304
|
+
const { count } = await supabase
|
|
305
|
+
.from('plg_scribe_segments')
|
|
306
|
+
.select('seg_index', { count: 'exact', head: true })
|
|
307
|
+
.eq('session_id', sessionId)
|
|
308
|
+
.eq('upload_state', 'uploaded')
|
|
309
|
+
.in('stt_state', ['pending', 'failed'])
|
|
310
|
+
.lt('stt_attempts', MAX_ATTEMPTS)
|
|
311
|
+
|
|
312
|
+
return { transcribed, failed, remaining: count ?? 0 }
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Cauda do segmento anterior — a pista de contexto que salva a palavra da emenda. */
|
|
316
|
+
async function previousTail(supabase: any, sessionId: string, segIndex: number): Promise<string | undefined> {
|
|
317
|
+
if (segIndex === 0) return undefined
|
|
318
|
+
const { data } = await supabase
|
|
319
|
+
.from('plg_scribe_segments')
|
|
320
|
+
.select('text')
|
|
321
|
+
.eq('session_id', sessionId)
|
|
322
|
+
.eq('seg_index', segIndex - 1)
|
|
323
|
+
.maybeSingle()
|
|
324
|
+
const text = data?.text as string | undefined
|
|
325
|
+
return text ? text.slice(-CONTEXT_TAIL_CHARS) : undefined
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Recalcula os contadores a partir dos segmentos — mesma razão do RPC do
|
|
330
|
+
* cliente: derivar, nunca incrementar.
|
|
331
|
+
*/
|
|
332
|
+
async function recomputeCounters(supabase: any, sessionId: string): Promise<void> {
|
|
333
|
+
const { data: segs } = await supabase
|
|
334
|
+
.from('plg_scribe_segments')
|
|
335
|
+
.select('stt_state, duration_ms, text, gap')
|
|
336
|
+
.eq('session_id', sessionId)
|
|
337
|
+
if (!segs) return
|
|
338
|
+
|
|
339
|
+
const transcribed = segs.filter((s: any) => s.stt_state === 'done').length
|
|
340
|
+
const audioMs = segs.filter((s: any) => !s.gap).reduce((acc: number, s: any) => acc + (s.duration_ms ?? 0), 0)
|
|
341
|
+
const chars = segs.reduce((acc: number, s: any) => acc + (s.text?.length ?? 0), 0)
|
|
342
|
+
|
|
343
|
+
await supabase
|
|
344
|
+
.from('plg_scribe_sessions')
|
|
345
|
+
.update({
|
|
346
|
+
transcribed_segment_count: transcribed,
|
|
347
|
+
audio_duration_ms: audioMs,
|
|
348
|
+
transcript_chars: chars,
|
|
349
|
+
updated_at: new Date().toISOString(),
|
|
350
|
+
})
|
|
351
|
+
.eq('id', sessionId)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* `ready` é decidido no SERVIDOR, a partir do manifesto — assim a transição
|
|
356
|
+
* acontece com ou sem browser aberto.
|
|
357
|
+
*
|
|
358
|
+
* `ready` inclui o caso em que alguns segmentos esgotaram as tentativas: uma
|
|
359
|
+
* sessão com 78 de 80 segmentos transcritos é utilizável, e travá-la em
|
|
360
|
+
* `transcribing` para sempre por causa de dois seria o pior dos dois mundos.
|
|
361
|
+
*/
|
|
362
|
+
async function maybeMarkReady(supabase: any, sessionId: string): Promise<void> {
|
|
363
|
+
const { data: session } = await supabase
|
|
364
|
+
.from('plg_scribe_sessions')
|
|
365
|
+
.select('status, ended_at')
|
|
366
|
+
.eq('id', sessionId)
|
|
367
|
+
.single()
|
|
368
|
+
if (!session?.ended_at) return
|
|
369
|
+
if (!['uploading', 'transcribing', 'recording', 'paused', 'interrupted'].includes(session.status)) return
|
|
370
|
+
|
|
371
|
+
const { data: segs } = await supabase
|
|
372
|
+
.from('plg_scribe_segments')
|
|
373
|
+
.select('stt_state, stt_attempts, upload_state')
|
|
374
|
+
.eq('session_id', sessionId)
|
|
375
|
+
if (!segs || segs.length === 0) return
|
|
376
|
+
|
|
377
|
+
const outstanding = segs.filter(
|
|
378
|
+
(s: any) =>
|
|
379
|
+
s.upload_state === 'uploaded' &&
|
|
380
|
+
['pending', 'running', 'failed'].includes(s.stt_state) &&
|
|
381
|
+
(s.stt_attempts ?? 0) < MAX_ATTEMPTS,
|
|
382
|
+
)
|
|
383
|
+
const nextStatus = outstanding.length === 0 ? 'ready' : 'transcribing'
|
|
384
|
+
await supabase
|
|
385
|
+
.from('plg_scribe_sessions')
|
|
386
|
+
.update({ status: nextStatus, updated_at: new Date().toISOString() })
|
|
387
|
+
.eq('id', sessionId)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
// Handler
|
|
392
|
+
// ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
Deno.serve(async (req: Request) => {
|
|
395
|
+
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
const body = await req.json()
|
|
399
|
+
const action = body.action ?? 'drain'
|
|
400
|
+
const sessionId = body.sessionId as string
|
|
401
|
+
if (!sessionId) throw new Error('sessionId é obrigatório')
|
|
402
|
+
|
|
403
|
+
const provider = (body.provider as string) ?? 'deepgram'
|
|
404
|
+
const model = body.model as string | undefined
|
|
405
|
+
const diarize = body.diarize !== false
|
|
406
|
+
|
|
407
|
+
const supabase = createClient(
|
|
408
|
+
Deno.env.get('SUPABASE_URL') ?? '',
|
|
409
|
+
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? '',
|
|
410
|
+
{ auth: { persistSession: false } },
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
// A service key contorna a RLS, então a autorização é feita aqui: o JWT do
|
|
414
|
+
// chamador precisa pertencer a um membro do tenant DESTA sessão. Sem esta
|
|
415
|
+
// checagem qualquer usuário logado leria o áudio de qualquer clínica.
|
|
416
|
+
const authHeader = req.headers.get('Authorization') ?? ''
|
|
417
|
+
const jwt = authHeader.replace(/^Bearer\s+/i, '')
|
|
418
|
+
if (!jwt) return json({ error: 'não autenticado' }, 401)
|
|
419
|
+
|
|
420
|
+
const { data: caller } = await supabase.auth.getUser(jwt)
|
|
421
|
+
if (!caller?.user) return json({ error: 'não autenticado' }, 401)
|
|
422
|
+
|
|
423
|
+
const { data: session } = await supabase
|
|
424
|
+
.from('plg_scribe_sessions')
|
|
425
|
+
.select('tenant_id')
|
|
426
|
+
.eq('id', sessionId)
|
|
427
|
+
.single()
|
|
428
|
+
if (!session) return json({ error: 'sessão não encontrada' }, 404)
|
|
429
|
+
|
|
430
|
+
const { data: membership } = await supabase
|
|
431
|
+
.schema('saas_core')
|
|
432
|
+
.from('tenant_members')
|
|
433
|
+
.select('user_id')
|
|
434
|
+
.eq('tenant_id', session.tenant_id)
|
|
435
|
+
.eq('user_id', caller.user.id)
|
|
436
|
+
.maybeSingle()
|
|
437
|
+
if (!membership) return json({ error: 'acesso negado' }, 403)
|
|
438
|
+
|
|
439
|
+
if (action === 'drain') {
|
|
440
|
+
const result = await drainSession(supabase, sessionId, provider, model, diarize)
|
|
441
|
+
return json({ ok: true, ...result })
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return json({ error: `ação desconhecida: ${action}` }, 400)
|
|
445
|
+
} catch (err) {
|
|
446
|
+
return json({ error: String((err as Error)?.message ?? err) }, 500)
|
|
447
|
+
}
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
function json(payload: unknown, status = 200): Response {
|
|
451
|
+
return new Response(JSON.stringify(payload), {
|
|
452
|
+
status,
|
|
453
|
+
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
|
454
|
+
})
|
|
455
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fayz-ai/plugin-scribe",
|
|
3
|
+
"fayz": {
|
|
4
|
+
"status": "experimental"
|
|
5
|
+
},
|
|
6
|
+
"version": "0.10.0",
|
|
7
|
+
"description": "Fayz SDK — ambient capture: record a session, transcribe it, generate a narrative document",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"module": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"functions"
|
|
21
|
+
],
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
24
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"lucide-react": "^0.400.0",
|
|
28
|
+
"zustand": "^4.5.0",
|
|
29
|
+
"@fayz-ai/core": "^0.10.0",
|
|
30
|
+
"@fayz-ai/admin": "^0.10.0",
|
|
31
|
+
"@fayz-ai/ui": "^0.10.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/react": "^18.3.0",
|
|
35
|
+
"@types/react-dom": "^18.3.0",
|
|
36
|
+
"react": "^18.3.0",
|
|
37
|
+
"react-dom": "^18.3.0",
|
|
38
|
+
"tsup": "^8.2.0",
|
|
39
|
+
"typescript": "^5.5.0",
|
|
40
|
+
"vitest": "^4.1.5"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"keywords": [
|
|
44
|
+
"fayz",
|
|
45
|
+
"fayz-plugin",
|
|
46
|
+
"fayz-sdk"
|
|
47
|
+
],
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsup && tsc --emitDeclarationOnly --declaration --declarationMap --noEmit false",
|
|
53
|
+
"dev": "tsup --watch",
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"clean": "rm -rf dist"
|
|
57
|
+
}
|
|
58
|
+
}
|