mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:53:05 +00:00
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.
114 lines
3.7 KiB
TypeScript
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' }
|
|
}
|
|
}
|