teqo-backup/src/utilities/speech/speechCutMetadata.ts
Francisco Solla 04ac01d0f3 feat(C167): corta o trecho [início,fim] do acervo e publica o arquivo numa página compartilhável
Seleção do C166 ganha "Cortar vídeo": título/descrição por IA (editáveis, com fallback determinístico), job assíncrono com ffmpeg por re-encode exato e polling dos quatro passos, e página pública unlisted /corte/<id> (noindex, OG, crédito CC BY) servindo o mesmo MP4. Collection speechCut com access canReadSpeechCut (advisor/leader negados) e kill switch por status; ffmpeg entra só no runner da imagem com FFMPEG_PATH para teste.
2026-09-15 10:05:59 -03:00

114 lines
3.7 KiB
TypeScript

import 'server-only'
import { deepSeek } from '@ai-sdk/deepseek'
import { generateObject } from 'ai'
import { z } from 'zod'
import { buildSpeechCutFallbackMetadata, clipSpeechCutText } from '@/lib/speechCut'
const SUGGESTION_TIMEOUT_MS = 6000
const MAX_EXCERPT_CHARS = 1200
const MAX_TITLE_LENGTH = 120
const MAX_DESCRIPTION_LENGTH = 600
const MAX_OUTPUT_TOKENS = 400
type SpeechCutMetadataSource = 'ai' | 'fallback'
export type SpeechCutMetadataSuggestion = {
title: string
description: string
source: SpeechCutMetadataSource
}
export type SpeechCutMetadataSegment = {
startSeconds: number
endSeconds: number
text: string
}
const suggestionSchema = z.object({
title: z
.string()
.describe('Título curto em português (no máximo 120 caracteres), sem aspas nem prefixos.'),
description: z
.string()
.describe(
'Descrição factual em português (no máximo 600 caracteres) do que o trecho diz, sem inventar fatos.',
),
})
const SYSTEM_PROMPT =
'Você prepara cortes de falas do deputado federal Jorge Solla no acervo da Câmara dos Deputados. ' +
'Receberá tipo, data, resumo oficial e a transcrição automática (ASR) de um trecho de 5 a 180 segundos. ' +
'Escreva um título curto e concreto (o assunto do trecho, não o nome do deputado) e uma descrição de uma ' +
'a duas frases, em português do Brasil, adequadas para compartilhar o vídeo no WhatsApp. ' +
'Use apenas o que está no trecho e no resumo; nunca invente números, acordos ou citações, e a transcrição ' +
'pode ter ruído.'
const excerptOf = (
segments: readonly SpeechCutMetadataSegment[],
startSeconds: number,
endSeconds: number,
): string =>
clipSpeechCutText(
segments
.filter((segment) => segment.endSeconds > startSeconds && segment.startSeconds < endSeconds)
.map((segment) => segment.text.trim())
.filter((text) => text !== '')
.join(' '),
MAX_EXCERPT_CHARS,
)
/**
* C167 — one-shot AI title/description for the picked window, on the same
* DeepSeek path as the other acervo AI (C158/B195): never throws and never
* blocks the cut. Missing key, empty excerpt, timeout or unusable output all
* resolve to the deterministic fallback.
*/
export const suggestSpeechCutMetadata = async ({
speechType,
dateLabel,
summary,
segments,
startSeconds,
endSeconds,
}: {
speechType: string | null
dateLabel: string
summary: string | null
segments: readonly SpeechCutMetadataSegment[]
startSeconds: number
endSeconds: number
}): Promise<SpeechCutMetadataSuggestion> => {
const fallback = buildSpeechCutFallbackMetadata({ speechType, dateLabel, summary })
if (!process.env.DEEPSEEK_API_KEY) return { ...fallback, source: 'fallback' }
const excerpt = excerptOf(segments, startSeconds, endSeconds)
if (excerpt === '') return { ...fallback, source: 'fallback' }
try {
const { object } = await generateObject({
model: deepSeek('deepseek-flash'),
schema: suggestionSchema,
system: SYSTEM_PROMPT,
prompt: [
`Tipo: ${speechType?.trim() || 'fala'}`,
`Data: ${dateLabel}`,
summary?.trim() ? `Resumo oficial: ${summary.trim()}` : null,
`Trecho (transcrição automática): "${excerpt}"`,
]
.filter((line): line is string => line !== null)
.join('\n'),
temperature: 0.3,
maxOutputTokens: MAX_OUTPUT_TOKENS,
abortSignal: AbortSignal.timeout(SUGGESTION_TIMEOUT_MS),
})
const title = clipSpeechCutText(object.title, MAX_TITLE_LENGTH)
const description = clipSpeechCutText(object.description, MAX_DESCRIPTION_LENGTH)
if (title === '' || description === '') return { ...fallback, source: 'fallback' }
return { title, description, source: 'ai' }
} catch {
return { ...fallback, source: 'fallback' }
}
}