Merge pull request #470 from fsolla/B173-sollinha-consulta-por-voz-no-chat

B173 — Sollinha: consulta por voz no chat
This commit is contained in:
Francisco Solla 2026-08-09 02:16:53 -03:00 committed by GitHub
commit 177803a54a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 971 additions and 7 deletions

View file

@ -0,0 +1,97 @@
# Impl: B173 — Sollinha: consulta por voz no chat
Status: aprovado
Atualizado em: 2026-08-08
Issue: #448
Intenção: docs/plans/sollinha-consulta-por-voz.md
Appetite restante: ~0,5–1 dia (herdado)
Impeccable: C — encaixe no input do chat (superfície que o usuário toca)
## Leitura da intenção
- **Outcome:** O assessor/coordenador fala a pergunta no chat da Sollinha; a transcrição vira **rascunho editável** no campo de texto; ele confere e envia pelo fluxo normal (mesma auth, rate limit, tools, streaming). Áudio nunca é armazenado. Sem mic/permissão → mensagem clara, chat por texto intacto.
- **O que NÃO negociar:** rascunho editável (sem auto-send); **sem TTS**; áudio processado e descartado (sem collection, sem Blob/media, sem Consent — nada persiste); STT servidor via **Deep Infra Whisper large-v3**, mesma chave `DEEPINFRA_API_KEY` do LLM (converge com B174); perdas/falha de transcrição nunca quebram o chat de texto; respostas de voz passam pelos mesmos guards (auth + rate limit + RBAC das tools).
- **O que reavaliar:** a "Direção no codebase" da intenção previa "botão no `InputGroup` + injeta transcrição como `sendMessage`" — a **decisão de produto confirmada** (2026-08-08) é **rascunho editável no input** (`setInput`), não auto-send. O provider STT é **Deep Infra** (REST direto no `route`), não Assembly AI do roadmap original. A hipótese de "uma nova rota irmã em `campanha/api/`" está correta.
## Abordagem recomendada
```mermaid
flowchart LR
subgraph client [CampaignAIChat]
useMicTranscript -- FormData(file) --> transcribe[POST /campanha/api/ai-transcribe]
mic[botão mic no InputGroup]
textarea[textarea rascunho editável]
useChat -- sendMessage --> chat[/campanha/api/ai-chat]
end
subgraph server [Rota transcribe]
auth[getCampaignUserRaw]
rl[checkRateLimit user.id]
origin[isSameOriginRequest]
fwd[fetch Deep Infra /v1/openai/audio/transcriptions]
end
subgraph dl [Deep Infra]
wh[whisper-large-v3]
end
mic --> useMicTranscript --> transcribe --> auth --> rl --> origin --> fwd --> wh
transcribe --> textarea --> useChat --> chat
```
**Opções consideradas:**
- **A — Nova rota `POST /campanha/api/ai-transcribe`** (irmã de `ai-chat`, mesma pasta `campanha/api/`), auth cookie + rate limit mesmo balde + mesma-origem; recebe o blob em `FormData(file)` e faz `fetch` à Deep Infra. Retorna `{ text }`.
- **B — Server action** do lado do cliente chamando DirectDeep Infra. Rejeitada: body size limit de server actions no Next (clips longos batem no limite), e foge do padrão irmão de `ai-chat` (rota explícita com `maxDuration`, debugável/smokcável em prod).
- **C — STT direto do browser** (Web Speech / on-device / fetch direto com a chave). Rejeitada pela intenção (gate 2026-08-08): Web Speech inconsistente/sem contrato com o provedor; Whisper on-device só até `small` com 590 MB+ no celular; chave seria vazada no cliente. A Deep Infra em servidor é o desfecho decidido.
**Recomendação: A** — porque reusa o padrão canônico das rotas autenticadas de `/campanha` (`ai-chat` é a precedente), mantém a chave no servidor, permite `maxDuration` explícito e é a superfície mais fácil de smoke em produção (a mesma família que B174 toca, sem conflitar em arquivo).
**Decisões de engenharia (caras, com rejeitadas):**
1. **Rate limit no transcribe.** Opções: A) `checkRateLimit(user.id)` também no transcribe (pergunta por voz = 1 unidade no transcribe + 1 no send = **2 unidades** do balde 50/15min) | B) não limitar o transcribe (só o send conta; STT é ~US$0,00008/consulta). **Recomendação: A** — a intenção é explícita ("contar no mesmo balde para não virar bypass") e fail-closed; ~25 perguntas de voz/15min é folgado para assessor de campo. B rejeitada: endpoint de STT viraria superfície de custo sem freio (autenticado, mas spamável).
2. **Formato do áudio enviado.** o `MediaRecorder` do Chrome/Android (Field Desk — superfície principal) produz `webm/opus`; Safari produz `mp4/aac`. **Opções:** A) mandar o blob como está no `file` (OpenAI-compat aceita webm/aac via ffmpeg) | B) converter client-side para WAV 16kHz mono (~50 linhas, módulo puro testável) no hook. **Recomendação: A** — menos código, e Whisper lê webm/aac; o smoke em prod valida. **Gatilho de revisita:** se o smoke devolver 4xx de formato, adicionar o conversor WAV (opção B, módulo puro `lib/`, unit-testado) — registrado como débito/fix no impl, não bloqueia.
3. **Onde vive a lógica de captura.** Opções: A) hook `useMicTranscript` (`src/components/campaign/shell/ai/`) | B) inline em `CampaignAIChat`. **Recomendação: A** — encapsula getUserMedia/MediaRecorder/fetch/decode dos erros uma vez e deixa o componente render-only; é o caso "encapsulate once" do engineering-brief (conhecimento que vazaria no componente senão). Componente continua o dono da superfície (edita o owner, não cria twin).
4. **Same-origin no transcribe.** Embora a rota entre no allowlist do `campaignJsonMutationRoute` (multipart, não JSON), aplicar `isSameOriginRequest` explicitamente (403) como a rota membro de `/campanha` — barato, defense-in-depth.
### Componentes / mudanças
- **`src/app/(campaign)/campanha/api/ai-transcribe/route.ts`** (nova): `export const maxDuration = 60`; `getCampaignUserRaw()` → 401; `checkRateLimit(user.id)` → 429 (mesma mensagem do `ai-chat`); `isSameOriginRequest(request)` → 403; lê `FormData.file`; delega a transcrição; retorna `{ text }` (200) ou `{ error }` com status. **Não armazena nada** (áudio fica só na memória da função).
- **`src/utilities/ai/deepInfraTranscribe.ts`** (nova, sob `utilities/ai/` — subpasta, fora do pin top-level): função pura-ish `deepInfraTranscribe(file: Blob): Promise<string>` que monta o `FormData` (`file`, `model: 'openai/whisper-large-v3'`, `language: 'pt'`), `fetch('https://api.deepinfra.com/v1/openai/audio/transcriptions', { method: 'POST', headers: { Authorization: Bearer DEEPINFRA_API_KEY }, body })`, e devolve `response.text` (OpenAI JSON `{ text }`) — **única costura de rede**, 100% testável com `global.fetch` mockado. A chave vem de `process.env.DEEPINFRA_API_KEY`.
- **`src/components/campaign/shell/ai/useMicTranscript.ts`** (nova): estados `idle | recording | transcribing | error`; `transcript`, `errorMessage`, `elapsed` (timer), `start()`, `stop()`, `dismissError()`. Gerencia `getUserMedia({ audio: true })` + `MediaRecorder` (chunks → `Blob`), `URL.createObjectURL` não necessário; envia `FormData` ao transcribe; mapeia 401/429/502/'chave ausente' para pt-BR; faz cleanup (para tracks, revoga streams) em `stop`/unmount. Cap de segurança ~60 s de gravação (timer) para evitar clip infinito.
- **`src/components/campaign/shell/ai/CampaignAIChat.tsx`** (editar): botão mic (`InputGroupButton`, ícone `Mic`/`Square`/`Spinner`) antes do Send no `InputGroup`; `aria-label`/`aria-pressed`; estados visuais ("Ouvindo…" + timer pulsante, spinner em transcrição); ao obter `transcript` → `setInput(transcript)` (draft editável) + focus no textarea; erro → linha inline (`Marker`/status) com retry e **chat por texto funcional** (não desabilita o input). `disabled={busy}` (chat respondendo) e sem suporte a `MediaRecorder`. Cobre **as duas superfícies** de uma vez (sidebar desktop + drawer mobile renderizam o mesmo `CampaignAIChat`).
- **`codebaseConventions.unit.spec.ts`** (editar): adicionar `src/app/(campaign)/campanha/api/ai-transcribe/route.ts` ao allowlist do sweep "POST routes sob src/app" com justificativa ("multipart não-JSON irmão do ai-chat, auth cookie + same-origin").
- **`.env.example`** (editar): adicionar bloco `DEEPINFRA_API_KEY` (converge com B174; mantém `DEEPSEEK_API_KEY` atual enquanto o chat estiver no provider antigo em `main`).
- **Migration:** sem migration (nenhum schema/collection; áudio transiente).
- **Access / Consent:** intocados — auth/session do chat existente; **sem** nova chave Consent (nada persiste); RBAC das tools reusado no fluxo de envio final.
- **UI:** Impeccable C — shape→craft→polish no encaixe do input; tokens `data-theme='campaign'`; shells existentes (`InputGroup`, `Marker`); sem novo componente de página.
### Dados → forma
N/A — não apresenta métrica nova (intenção: "novo meio de entrada para o mesmo fluxo"). A única "forma" é o rascunho editável + estados do mic, descritos acima.
## Fases verificáveis
1. **Server / STT** — `deepInfraTranscribe.ts` + unit (mock `global.fetch`: header Bearer, model `openai/whisper-large-v3`, language `pt`, parse de `{ text }`, tratamento de erro HTTP) → rota `ai-transcribe` (auth/rate/origin/FormData). Gate: `pnpm gate:fast`.
2. **Client** — `useMicTranscript` + unit (stub `getUserMedia`/`MediaRecorder` no jsdom: transcribe ok -> `transcript`; permissão negada -> `errorMessage` pt-BR; stop/dispose) → `CampaignAIChat` (mic no InputGroup, estados, draft no textarea) → allowlist + `.env.example`.
3. **E2E + gates** — `tests/e2e/aiTranscribe.e2e.spec.ts`: `addInitScript` stub de `getUserMedia`/`MediaRecorder` + `page.route('**/campanha/api/ai-transcribe')` retornando `{ text: 'Quantos votos em Ilhéus?' }`; asserta rascunho no textarea → send → `ai-chat` mockado responde; caminho de erro (permissão negada → mensagem + chat texto segue usável). `pnpm gate:fast` na iteração; entrega via `pnpm push`.
## Rabbit holes / Não escopo (engenharia)
- VAD/detecção de silêncio — corte: tap-to-stop (o "confirmar" do fluxo da intenção); Whisper lida com pausas.
- TTS / responder por voz — item sucessor separado (fora de escopo da intenção).
- Converter webm→wav no cliente **por fazer** — só se o smoke reprovar o formato (ver decisão 2).
- Persistir histórico/áudio, telemetria de "quantas perguntas por voz", múltiplos provedores STT selecionáveis.
- Mexer no `ai-chat/route.ts` (LLM/tools) — não toca este item; B174 é chore separado.
## Riscos e mitigação
- **Deep Infra rejeita webm/opus/aac** → smoke em prod com 1 clip real; fallback documentado = conversor WAV em `lib/` (decisão 2, gatilho explícito).
- **Pré-condição operacional:** `DEEPINFRA_API_KEY` precisa existir na env de produção Vercel (mesma chave do B174); no env local teste/e2e **mockam** a rota → nenhuma chave real no CI (mesmo padrão do `ai-chat`).
- **`.env.example` converge com B174** (mesma var) → conflito trivial de merge, resolvido no rebase; ambos apontam para o mesmo name.
- **Chave ausente em produção** → rota responde 502/500 com mensagem pt-BR "Transcrição indisponível" e o chat por texto continua; nunca auto-send.
- **Rate limit 2-unidades por pergunta de voz** → intencional (decisão 1); reavaliar subindo `MAX_MESSAGES` se a telemetria de campo mostrar aperto (débito observável, não inflar agora).
## Aceite de engenharia
- [ ] Aceite de produto da intenção coberto: mic → ouvir → transcrição como rascunho editável → envio pelo fluxo normal; áudio não persiste; sem TTS.
- [ ] Invariantes AGENTS/engineering-standards: auth cookie `campaign-token`; rate limit mesmo balde; `server-only` onde for preciso; `lib/` nunca importa `utilities`; zero dead code (knip); `pnpm gate:fast` + `pnpm push`.
- [ ] Testes de domínio previstos: unit `deepInfraTranscribe` (rede mockada) + `useMicTranscript` (stubs browser) + e2e do fluxo completo com a rota mockada.
- [ ] Conversão WAV adiada só com gatilho de smoke (decisão 2) — registrado como débito observável, não bloqueia.

View file

@ -0,0 +1,52 @@
import { deepInfraTranscribe } from '@/utilities/ai/deepInfraTranscribe'
import { checkRateLimit, RATE_LIMIT_EXCEEDED_MESSAGE } from '@/utilities/ai/rateLimit'
import { getCampaignUserRaw } from '@/utilities/campaignAuth'
import { isSameOriginRequest } from '@/utilities/sameOriginRequest'
export const maxDuration = 60
/**
* B173: voice transcription for the Sollinha chat. The client records audio
* (MediaRecorder) and POSTs the clip as multipart `file`; this route guards it
* exactly like the chat send (cookie auth, same rate-limit bucket, same-origin)
* and forwards to Deep Infra Whisper large-v3. The audio is never persisted —
* it exists only in memory for the duration of the provider call, then the
* transcript text becomes an editable draft in the chat input.
*
* A deliberate sibling of `/campanha/api/ai-chat`: multipart (not JSON), so it
* cannot ride `campaignJsonMutationRoute`; both origin-check and rate-limit by
* the same per-user bucket so voice cannot become a bypass of the text limit.
*/
export async function POST(request: Request) {
if (!isSameOriginRequest(request)) {
return Response.json({ error: 'Requisição inválida.' }, { status: 403 })
}
const user = await getCampaignUserRaw()
if (!user) {
return Response.json({ error: 'Não autenticado.' }, { status: 401 })
}
if (!checkRateLimit(user.id)) {
return Response.json({ error: RATE_LIMIT_EXCEEDED_MESSAGE }, { status: 429 })
}
let file: Blob
try {
const form = await request.formData()
const entry = form.get('file')
if (!(entry instanceof File)) {
return Response.json({ error: 'Arquivo de áudio ausente.' }, { status: 400 })
}
file = entry
} catch {
return Response.json({ error: 'Corpo da requisição inválido.' }, { status: 400 })
}
const result = await deepInfraTranscribe(file)
if (!result.ok) {
return Response.json({ error: result.error }, { status: result.status })
}
return Response.json({ text: result.text })
}

View file

@ -1,6 +1,6 @@
'use client'
import { Bot, Send, User } from 'lucide-react'
import { Bot, Mic, Send, Square, User } from 'lucide-react'
import { useEffect, useMemo, useRef, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@ -13,12 +13,16 @@ import { Spinner } from '@/components/ui/Spinner'
import { cn } from '@/lib/utils'
import { useAISidebar } from '@/components/campaign/shell/ai/CampaignAISidebarContext'
import { useMicTranscript } from '@/components/campaign/shell/ai/useMicTranscript'
export const CampaignAIChat = ({ className }: { className?: string }) => {
const ctx = useAISidebar()
const [input, setInput] = useState('')
const bottomRef = useRef<HTMLDivElement>(null)
const mic = useMicTranscript()
const textareaRef = useRef<HTMLTextAreaElement>(null)
const messages = useMemo(() => ctx?.messages ?? [], [ctx])
// Auto-scroll to bottom on new messages
@ -26,6 +30,14 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
// A completed transcript becomes an editable draft in the input — never an
// auto-sent message (voices mishear proper nouns; the person reviews first).
useEffect(() => {
if (!mic.transcript) return
setInput(mic.transcript)
textareaRef.current?.focus()
}, [mic.transcript])
if (!ctx) return null
const { sendMessage, status } = ctx
@ -38,6 +50,10 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
setInput('')
}
const isRecording = mic.status === 'recording'
const isTranscribing = mic.status === 'transcribing'
const micDisabled = busy || isTranscribing
return (
<div className={cn('grid min-h-0 grid-rows-[1fr_auto]', className)}>
{/* Messages area */}
@ -127,6 +143,7 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
<form onSubmit={handleSubmit}>
<InputGroup className="h-auto min-h-10">
<textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
@ -135,11 +152,29 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
handleSubmit(e)
}
}}
placeholder="Pergunte para a Sollinha..."
placeholder={isRecording ? 'Ouvindo...' : 'Pergunte para a Sollinha...'}
disabled={busy}
rows={1}
className="min-h-0 flex-1 resize-none border-0 bg-transparent px-3 py-2 text-sm outline-none placeholder:text-muted-foreground disabled:opacity-50"
/>
<InputGroupButton
type="button"
variant="ghost"
size="icon-sm"
aria-label={isRecording ? 'Parar gravação' : 'Falar pergunta (voz)'}
aria-pressed={isRecording}
disabled={micDisabled}
onClick={() => (isRecording ? mic.stop() : void mic.start())}
>
{isTranscribing ? (
<Spinner className="size-4" />
) : isRecording ? (
<Square className="size-4 fill-current" />
) : (
<Mic className="size-4" />
)}
<span className="sr-only">{isRecording ? 'Parar gravação' : 'Falar'}</span>
</InputGroupButton>
<InputGroupButton
type="submit"
variant="default"
@ -151,11 +186,42 @@ export const CampaignAIChat = ({ className }: { className?: string }) => {
</InputGroupButton>
</InputGroup>
</form>
<Marker className="mt-2">
<MarkerContent>
Sollinha pode cometer erros. Verifique informações importantes.
</MarkerContent>
</Marker>
<div className="mt-2">
{isRecording && (
<Marker variant="default">
<MarkerContent>
<span className="inline-flex items-center gap-1.5">
<span
className="size-1.5 animate-pulse rounded-full bg-destructive"
aria-hidden
/>
Ouvindo... {mic.elapsed}s — toque no quadrado para finalizar.
</span>
</MarkerContent>
</Marker>
)}
{mic.status === 'error' && mic.errorMessage && (
<Marker variant="default">
<MarkerContent>
{mic.errorMessage}{' '}
<button
type="button"
className="underline underline-offset-2"
onClick={mic.dismissError}
>
Dispensar
</button>
</MarkerContent>
</Marker>
)}
{!isRecording && mic.status !== 'error' && (
<Marker>
<MarkerContent>
Sollinha pode cometer erros. Verifique informações importantes.
</MarkerContent>
</Marker>
)}
</div>
</div>
</div>
)

View file

@ -0,0 +1,178 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
export type MicTranscriptStatus = 'idle' | 'recording' | 'transcribing' | 'error'
type UseMicTranscriptOptions = {
/** Recording cap (seconds): stops an accidental infinite clip automatically. */
maxDurationSeconds?: number
}
const TRANSCRIBE_ENDPOINT = '/campanha/api/ai-transcribe'
const DEFAULT_MAX_DURATION_SECONDS = 60
const MESSAGE_VOICE_UNSUPPORTED = 'Este navegador não suporta gravação de voz por aqui.'
const MESSAGE_PERMISSION_DENIED =
'Não foi possível acessar o microfone. Verifique a permissão no navegador e tente novamente.'
const MESSAGE_TRANSCRIBE_FAILED = 'Não foi possível transcrever o áudio. Tente novamente.'
const stopStreamTracks = (stream: MediaStream) => {
stream.getTracks().forEach((track) => track.stop())
}
/**
* B173: the voice-input state machine behind the Sollinha chat mic button.
* Owns the browser audio capture (getUserMedia + MediaRecorder), the elapsed
* timer, the multipart POST to `/campanha/api/ai-transcribe` and the pt-BR
* mapping of every failure — so `CampaignAIChat` stays render-only and the
* whole flow is unit-testable with stubbed browser APIs.
*
* Product contract it enforces: the transcript is delivered as a draft string
* (the caller puts it in the chat input), never auto-sent; any failure degrades
* to an `error` state with a message, keeping the text chat fully usable.
*/
export const useMicTranscript = ({
maxDurationSeconds = DEFAULT_MAX_DURATION_SECONDS,
}: UseMicTranscriptOptions = {}) => {
const [status, setStatus] = useState<MicTranscriptStatus>('idle')
const [transcript, setTranscript] = useState('')
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [elapsed, setElapsed] = useState(0)
const recorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const chunksRef = useRef<Blob[]>([])
const timerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined)
const elapsedRef = useRef(0)
// True from unmount cleanup until the next `start()`: blocks an in-flight
// transcription from reporting through a dead component (StrictMode remounts
// are covered because `start()` resets it).
const cancelledRef = useRef(false)
const clearElapsedTimer = useCallback(() => {
if (timerRef.current !== undefined) {
clearInterval(timerRef.current)
timerRef.current = undefined
}
}, [])
const sendForTranscription = useCallback(async (clip: Blob) => {
if (cancelledRef.current) return
setStatus('transcribing')
setErrorMessage(null)
const form = new FormData()
form.append('file', clip, 'voice.webm')
try {
const response = await fetch(TRANSCRIBE_ENDPOINT, { method: 'POST', body: form })
const payload = (await response.json()) as { text?: string; error?: string }
if (cancelledRef.current) return
if (!response.ok || typeof payload.text !== 'string') {
setStatus('error')
setErrorMessage(payload.error ?? MESSAGE_TRANSCRIBE_FAILED)
return
}
setTranscript(payload.text)
setStatus('idle')
} catch {
if (cancelledRef.current) return
setStatus('error')
setErrorMessage(MESSAGE_TRANSCRIBE_FAILED)
}
}, [])
/** Stops an active capture; the recorder's `stop` event fires the transcription. */
const stop = useCallback(() => {
clearElapsedTimer()
const recorder = recorderRef.current
if (!recorder || recorder.state === 'inactive') return
try {
recorder.stop()
} catch {
// Already stopped (e.g. the platform stopped it): nothing to finalize.
}
}, [clearElapsedTimer])
const start = useCallback(async () => {
cancelledRef.current = false
setErrorMessage(null)
setTranscript('')
if (typeof MediaRecorder === 'undefined') {
setStatus('error')
setErrorMessage(MESSAGE_VOICE_UNSUPPORTED)
return
}
let stream: MediaStream
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch {
setStatus('error')
setErrorMessage(MESSAGE_PERMISSION_DENIED)
return
}
if (cancelledRef.current) {
stopStreamTracks(stream)
return
}
streamRef.current = stream
const recorder = new MediaRecorder(stream)
recorderRef.current = recorder
chunksRef.current = []
recorder.addEventListener('dataavailable', (event) => {
if (event.data.size > 0) chunksRef.current.push(event.data)
})
recorder.addEventListener('stop', () => {
const clip = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm' })
chunksRef.current = []
if (clip.size > 0) void sendForTranscription(clip)
else if (!cancelledRef.current) {
setStatus('error')
setErrorMessage(MESSAGE_TRANSCRIBE_FAILED)
}
})
recorder.start()
setStatus('recording')
elapsedRef.current = 0
setElapsed(0)
timerRef.current = setInterval(() => {
elapsedRef.current += 1
setElapsed(elapsedRef.current)
if (elapsedRef.current >= maxDurationSeconds) stop()
}, 1000)
}, [maxDurationSeconds, sendForTranscription, stop])
// Unmount (or closing the chat) abandons the capture: tracks stop, recorder
// stops, and nothing in flight reports through a dead component.
useEffect(
() => () => {
cancelledRef.current = true
clearElapsedTimer()
const recorder = recorderRef.current
if (recorder && recorder.state !== 'inactive') {
try {
recorder.stop()
} catch {
// Already stopped by the stop button.
}
}
if (streamRef.current) stopStreamTracks(streamRef.current)
streamRef.current = null
recorderRef.current = null
},
[clearElapsedTimer],
)
const dismissError = useCallback(() => {
setErrorMessage(null)
setStatus('idle')
}, [])
return { status, transcript, errorMessage, elapsed, start, stop, dismissError }
}

View file

@ -0,0 +1,71 @@
import 'server-only'
/**
* Transcribes a recorded voice clip to text via Deep Infra's OpenAI-compatible
* transcription endpoint (B173). Same provider/key as the Sollinha LLM
* (`DEEPINFRA_API_KEY`), so one account, one key, one invoice.
*
* The audio is forwarded as a multipart `file` and never persisted anywhere —
* it lives only in this function's memory for the duration of the provider
* call (product requirement: B173 processes and discards the recording).
*/
export const DEEPINFRA_TRANSCRIBE_URL = 'https://api.deepinfra.com/v1/openai/audio/transcriptions'
const DEEPINFRA_WHISPER_MODEL = 'openai/whisper-large-v3'
const DEEPINFRA_LANGUAGE = 'pt'
export type TranscribeAudioResult =
| { ok: true; text: string }
| { ok: false; error: string; status: number }
/**
* Sends the clip to Deep Infra Whisper large-v3 and returns the transcript.
* Never throws: every failure becomes `{ ok: false }` with a pt-BR message the
* route can return verbatim, so a provider outage or a missing key degrades to
* "sem voz" instead of breaking the text chat.
*/
export const deepInfraTranscribe = async (file: Blob): Promise<TranscribeAudioResult> => {
const apiKey = process.env.DEEPINFRA_API_KEY
if (!apiKey) {
return { ok: false, error: 'Transcrição de voz está indisponível no momento.', status: 503 }
}
const form = new FormData()
form.append('file', file, 'voice.webm')
form.append('model', DEEPINFRA_WHISPER_MODEL)
form.append('language', DEEPINFRA_LANGUAGE)
try {
const response = await fetch(DEEPINFRA_TRANSCRIBE_URL, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: form,
})
if (!response.ok) {
return {
ok: false,
error: 'Não foi possível transcrever o áudio. Tente novamente.',
status: 502,
}
}
const data = (await response.json()) as { text?: unknown }
const text = typeof data.text === 'string' ? data.text.trim() : ''
if (!text) {
return {
ok: false,
error: 'Não foi possível transcrever o áudio. Tente novamente.',
status: 502,
}
}
return { ok: true, text }
} catch {
return {
ok: false,
error: 'Não foi possível transcrever o áudio. Tente novamente.',
status: 502,
}
}
}

View file

@ -13,6 +13,10 @@ const store = new Map<number, RateLimitWindow>()
const MAX_MESSAGES = 50
const WINDOW_MS = 15 * 60 * 1000 // 15 minutes
/** Shared message returned by every rate-limited AI surface (chat send, voice transcribe). */
export const RATE_LIMIT_EXCEEDED_MESSAGE =
'Você atingiu o limite de mensagens. Aguarde alguns minutos e tente novamente.'
/** Cleans up expired windows. Called on every check to prevent memory leaks. */
const cleanup = (now: number) => {
for (const [userId, window] of store) {

View file

@ -0,0 +1,133 @@
import type { Page } from '@playwright/test'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* B173 — voice transcription for the Sollinha chat.
*
* The real browser media stack (getUserMedia/MediaRecorder) is stubbed with an
* init script: `getUserMedia` resolves a fake track and `MediaRecorder` pushes
* one data chunk then stops on the next tick, so the app's own record→transcribe
* flow runs for real while the two network surfaces are mocked:
* - `/campanha/api/ai-transcribe` returns a fixed transcript (editable draft).
* - `/campanha/api/ai-chat` returns an SSE stream (normal send).
*/
const fakeMediaInit = `
const makeTrack = () => ({ stop: () => {} })
const fakeStream = { getTracks: () => [makeTrack()] }
navigator.mediaDevices.getUserMedia = async () => fakeStream
class FakeMediaRecorder {
static isTypeSupported = () => true
state = 'inactive'
mimeType = 'audio/webm'
listeners = {}
constructor(stream) { this.stream = stream }
addEventListener(type, cb) { (this.listeners[type] ??= []).push(cb) }
start() { this.state = 'recording' }
stop() {
if (this.state === 'inactive') return
this.listeners.dataavailable?.forEach((cb) =>
cb({ data: new Blob(['fake'], { type: 'audio/webm' }) }))
this.state = 'inactive'
this.listeners.stop?.forEach((cb) => cb({}))
}
}
window.MediaRecorder = FakeMediaRecorder
`
const mockTranscribe = (page: Page, text: string) =>
page.route('**/campanha/api/ai-transcribe', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ text }),
})
})
const mockAiChat = (page: Page) =>
page.route('**/campanha/api/ai-chat', async (route) => {
await route.fulfill({
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
body: 'data: {"type":"text","text":"Resposta mockada da Sollinha."}\n\n',
})
})
const openChat = async (page: Page) => {
await expect(page.getByText('Olá! Eu sou o Sollinha')).toBeVisible({ timeout: 20_000 })
}
test.describe('B173 — consulta por voz no chat da Sollinha', () => {
test.beforeEach(async ({ page }) => {
test.slow()
await page.addInitScript(fakeMediaInit)
await page.setViewportSize({ width: 1280, height: 800 })
})
test('falar transcreve para um rascunho editável que roda no fluxo normal do chat', async ({
page,
campaign,
}) => {
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Voz Coordenador'),
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha')
await mockTranscribe(page, 'Quantos votos tivemos em Ilhéus?')
await mockAiChat(page)
await openChat(page)
const input = page.getByRole('textbox', { name: 'Pergunte para a Sollinha...' })
// Tap the mic → the bar enters "Ouvindo..." while we "speak".
await page.getByRole('button', { name: 'Falar pergunta (voz)' }).click()
await expect(page.getByText(/Ouvindo\.\.\./)).toBeVisible({ timeout: 20_000 })
// Tap again to finish → the fake recorder emits its chunk → transcript draft.
await page.getByRole('button', { name: 'Parar gravação' }).click()
await expect(input).toHaveValue('Quantos votos tivemos em Ilhéus?', { timeout: 20_000 })
// It stays an editable draft (never auto-sent): no user bubble yet.
await expect(page.getByText('Quantos votos tivemos em Ilhéus?').first()).toBeVisible()
// Edit the draft, then send through the regular flow. The send is what must
// happen — the mock ai-chat stream stops the chat from hanging, and the
// user bubble proves the transcribed draft rode the same path as text.
await input.fill('Quantos votos tivemos em Ilhéus em 2022?')
await input.press('Enter')
await expect(page.getByText('Quantos votos tivemos em Ilhéus em 2022?')).toBeVisible({
timeout: 20_000,
})
})
test('permissão negada mostra mensagem e o chat por texto segue usável', async ({
page,
campaign,
}) => {
await page.addInitScript(
`navigator.mediaDevices.getUserMedia = async () => { throw new Error('Permission denied') }`,
)
const user = await campaign.fixtures.createCampaignUser('coordinator', {
name: campaign.fixtures.value('Voz Sem Permissão'),
})
await campaign.login(page, user.email!, user.password)
await page.goto('/campanha')
await mockAiChat(page)
await openChat(page)
await page.getByRole('button', { name: 'Falar pergunta (voz)' }).click()
await expect(page.getByText(/Não foi possível acessar o microfone/)).toBeVisible({
timeout: 20_000,
})
// Text chat still works.
const input = page.getByRole('textbox', { name: 'Pergunte para a Sollinha...' })
await input.fill('Foco no texto')
await input.press('Enter')
await expect(page.getByText('Foco no texto')).toBeVisible({ timeout: 20_000 })
})
})

View file

@ -0,0 +1,117 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DEEPINFRA_TRANSCRIBE_URL, deepInfraTranscribe } from '@/utilities/ai/deepInfraTranscribe'
/**
* B173: the transcription network seam is deliberately the only place the chat
* talks to Deep Infra for voice. Here, with `fetch` stubbed, the contract is
* pinned: the multipart body carries the clip/model/language, the key is sent
* as a Bearer header and never inside the body, and every failure degrades to
* `{ ok: false }` with a pt-BR message instead of throwing.
*/
const fetchMock = vi.fn()
const jsonResponse = (payload: unknown, status = 200) =>
({ ok: status >= 200 && status < 300, status, json: async () => payload }) as Response
const smallClip = () => new Blob(['fake-webaudio-bytes'], { type: 'audio/webm' })
const stubFetch = (response: Response) => {
fetchMock.mockReset()
fetchMock.mockResolvedValue(response)
vi.stubGlobal('fetch', fetchMock)
}
beforeEach(() => {
process.env.DEEPINFRA_API_KEY = 'test-deepinfra-key'
fetchMock.mockReset()
fetchMock.mockResolvedValue(jsonResponse({ text: 'Quais os votos em Ilhéus?' }))
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
delete process.env.DEEPINFRA_API_KEY
vi.unstubAllGlobals()
})
describe('deepInfraTranscribe', () => {
it('posts the clip as multipart with the whisper model and pt language', async () => {
stubFetch(jsonResponse({ text: 'Quais os votos em Ilhéus?' }))
const result = await deepInfraTranscribe(smallClip())
expect(result).toEqual({ ok: true, text: 'Quais os votos em Ilhéus?' })
expect(fetchMock).toHaveBeenCalledTimes(1)
const [url, init] = fetchMock.mock.calls[0] as [
string,
{ method: string; headers: HeadersInit; body: FormData },
]
expect(url).toBe(DEEPINFRA_TRANSCRIBE_URL)
expect(url).not.toContain('test-deepinfra-key') // the key never rides the URL
expect(init.method).toBe('POST')
const auth = new Headers(init.headers)
expect(auth.get('Authorization')).toBe('Bearer test-deepinfra-key')
const form = init.body
expect(form.get('model')).toBe('openai/whisper-large-v3')
expect(form.get('language')).toBe('pt')
const file = form.get('file') as File
expect(file.name).toBe('voice.webm')
})
it('trims the transcript text', async () => {
stubFetch(jsonResponse({ text: ' Votos em Feira? ' }))
await expect(deepInfraTranscribe(smallClip())).resolves.toEqual({
ok: true,
text: 'Votos em Feira?',
})
})
it('fails closed when the key is missing', async () => {
delete process.env.DEEPINFRA_API_KEY
fetchMock.mockReset()
const result = await deepInfraTranscribe(smallClip())
expect(result).toEqual({
ok: false,
error: 'Transcrição de voz está indisponível no momento.',
status: 503,
})
expect(fetchMock).not.toHaveBeenCalled()
})
it('maps a provider error to a 502 without throwing', async () => {
stubFetch(jsonResponse({ error: 'server error' }, 500))
await expect(deepInfraTranscribe(smallClip())).resolves.toEqual({
ok: false,
error: 'Não foi possível transcrever o áudio. Tente novamente.',
status: 502,
})
})
it('maps an empty transcript to a 502', async () => {
stubFetch(jsonResponse({ text: '' }))
await expect(deepInfraTranscribe(smallClip())).resolves.toEqual({
ok: false,
error: 'Não foi possível transcrever o áudio. Tente novamente.',
status: 502,
})
})
it('degrades to a 502 when the provider call throws', async () => {
fetchMock.mockReset()
fetchMock.mockRejectedValue(new Error('network down'))
vi.stubGlobal('fetch', fetchMock)
await expect(deepInfraTranscribe(smallClip())).resolves.toEqual({
ok: false,
error: 'Não foi possível transcrever o áudio. Tente novamente.',
status: 502,
})
})
})

View file

@ -205,6 +205,10 @@ describe('campaign JSON mutation route convention', () => {
'src/app/(campaign)/campanha/api/ai-chat/route.ts',
'streaming AI endpoint (ReadableStream, not JSON) — cookie-authenticated via campaign-token, origin-checked by cookie path',
],
[
'src/app/(campaign)/campanha/api/ai-transcribe/route.ts',
'multipart STT endpoint (audio FormData, not JSON) — sibling of ai-chat, cookie-authenticated via campaign-token + explicit same-origin check',
],
])
it('builds every POST route under src/app with campaignJsonMutationRoute', () => {

View file

@ -0,0 +1,242 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useMicTranscript } from '@/components/campaign/shell/ai/useMicTranscript'
/**
* B173: the voice-input state machine must be testable with stubbed browser
* APIs — getUserMedia, MediaRecorder and fetch are all faked here. These cases
* pin the product contract: recording→transcribe→draft, permission-denied
* message, unsupported-browser message, provider-error pass-through, and the
* automatic stop when the recording cap is reached.
*/
const defineMediaDevices = (
getUserMedia: (constraints: MediaStreamConstraints) => Promise<unknown>,
) => {
Object.defineProperty(navigator, 'mediaDevices', {
value: { getUserMedia },
configurable: true,
writable: true,
})
}
const removeMediaDevices = () => {
Reflect.deleteProperty(navigator, 'mediaDevices')
}
const makeStream = (): MediaStream =>
({ getTracks: () => [{ stop: vi.fn() }] }) as unknown as MediaStream
/** Minimal MediaRecorder: queues a chunk on `start`, dispatches `stop` on `stop`. */
class FakeMediaRecorder {
static instances: FakeMediaRecorder[] = []
state: 'inactive' | 'recording' = 'inactive'
mimeType = 'audio/webm'
private listeners: Record<string, Array<(event: unknown) => void>> = {}
constructor(public readonly stream: MediaStream) {
FakeMediaRecorder.instances.push(this)
}
addEventListener(type: string, callback: (event: unknown) => void) {
;(this.listeners[type] ??= []).push(callback)
}
start() {
this.state = 'recording'
queueMicrotask(() => {
this.dispatch('dataavailable', { data: new Blob(['fake-audio'], { type: 'audio/webm' }) })
})
}
stop() {
if (this.state !== 'recording') return
this.state = 'inactive'
this.dispatch('stop', {})
}
private dispatch(type: string, event: unknown) {
for (const callback of this.listeners[type] ?? []) callback(event)
}
}
const installFakeMediaRecorder = () => {
FakeMediaRecorder.instances = []
Object.defineProperty(globalThis, 'MediaRecorder', {
value: FakeMediaRecorder,
configurable: true,
writable: true,
})
}
const removeFakeMediaRecorder = () => {
Reflect.deleteProperty(globalThis, 'MediaRecorder')
}
const fetchMock = vi.fn()
const stubTranscribe = (payload: { text?: string; error?: string } | Error, status = 200) => {
fetchMock.mockReset()
if (payload instanceof Error) {
fetchMock.mockRejectedValue(payload)
return
}
fetchMock.mockResolvedValue({ ok: status >= 200 && status < 300, json: async () => payload })
}
const resultOf = () => {
const { result } = renderHook(() => useMicTranscript())
return result
}
beforeEach(() => {
vi.useFakeTimers()
installFakeMediaRecorder()
fetchMock.mockReset()
fetchMock.mockResolvedValue({ ok: true, json: async () => ({ text: 'padrão' }) })
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.useRealTimers()
removeFakeMediaRecorder()
removeMediaDevices()
vi.unstubAllGlobals()
})
describe('useMicTranscript', () => {
it('records and delivers the transcript as a draft (never auto-sent)', async () => {
stubTranscribe({ text: 'Quais os votos em Ilhéus?' })
const getUserMedia = vi.fn(() => Promise.resolve(makeStream()))
defineMediaDevices(getUserMedia)
const result = resultOf()
await act(async () => {
await result.current.start()
})
expect(result.current.status).toBe('recording')
await act(async () => {
result.current.stop()
await vi.runAllTicks()
await Promise.resolve()
})
expect(result.current.status).toBe('idle')
expect(result.current.transcript).toBe('Quais os votos em Ilhéus?')
expect(result.current.errorMessage).toBeNull()
expect(getUserMedia).toHaveBeenCalledWith({ audio: true })
const [url, init] = fetchMock.mock.calls[0] as [string, { method: string; body: FormData }]
expect(url).toBe('/campanha/api/ai-transcribe')
expect(init.method).toBe('POST')
const file = (init.body as FormData).get('file') as File
expect(file.name).toBe('voice.webm')
})
it('maps a permission denial to a pt-BR message and keeps the text chat usable', async () => {
const getUserMedia = vi.fn(() => Promise.reject(new Error('Permission denied')))
defineMediaDevices(getUserMedia)
const result = resultOf()
await act(async () => {
await result.current.start()
})
expect(result.current.status).toBe('error')
expect(result.current.errorMessage).toContain('microfone')
expect(result.current.transcript).toBe('')
expect(fetchMock).not.toHaveBeenCalled()
})
it('fails with a clear message when MediaRecorder is unsupported', async () => {
removeFakeMediaRecorder()
const result = resultOf()
await act(async () => {
await result.current.start()
})
expect(result.current.status).toBe('error')
expect(result.current.errorMessage).toContain('não suporta')
})
it('passes the provider error message through (rate limit, outage)', async () => {
stubTranscribe(
{ error: 'Você atingiu o limite de mensagens. Aguarde alguns minutos e tente novamente.' },
429,
)
const getUserMedia = vi.fn(() => Promise.resolve(makeStream()))
defineMediaDevices(getUserMedia)
const result = resultOf()
await act(async () => {
await result.current.start()
})
await act(async () => {
result.current.stop()
await vi.runAllTicks()
await Promise.resolve()
})
expect(result.current.status).toBe('error')
expect(result.current.errorMessage).toBe(
'Você atingiu o limite de mensagens. Aguarde alguns minutos e tente novamente.',
)
expect(result.current.transcript).toBe('')
})
it('degrades to a generic message when transcribe itself throws', async () => {
stubTranscribe(new Error('network down'))
const getUserMedia = vi.fn(() => Promise.resolve(makeStream()))
defineMediaDevices(getUserMedia)
const result = resultOf()
await act(async () => {
await result.current.start()
})
await act(async () => {
result.current.stop()
await vi.runAllTicks()
await Promise.resolve()
})
expect(result.current.status).toBe('error')
expect(result.current.errorMessage).toContain('transcrever')
})
it('auto-stops and transcribes when the recording cap is reached', async () => {
stubTranscribe({ text: 'cap' })
const getUserMedia = vi.fn(() => Promise.resolve(makeStream()))
defineMediaDevices(getUserMedia)
const { result } = renderHook(() => useMicTranscript({ maxDurationSeconds: 2 }))
await act(async () => {
await result.current.start()
vi.advanceTimersByTime(2000)
await vi.runAllTicks()
await Promise.resolve()
})
expect(result.current.status).toBe('idle')
expect(result.current.transcript).toBe('cap')
})
it('cleans up tracks on unmount mid-recording', async () => {
stubTranscribe({ text: 'ok' })
const trackStop = vi.fn()
const getUserMedia = vi.fn(() =>
Promise.resolve({ getTracks: () => [{ stop: trackStop }] } as unknown as MediaStream),
)
defineMediaDevices(getUserMedia)
const { result, unmount } = renderHook(() => useMicTranscript())
await act(async () => {
await result.current.start()
unmount()
})
expect(trackStop).toHaveBeenCalled()
})
})