mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Collections speech/speechSegment no grupo Comunicação, papel communicator (leitura restrita) e índice trigram normalizado para a busca por segmento. Import idempotente da Câmara (discurso→evento→trecho→VOD→ASR) com facetas gazetteer+LLM, proveniência e preservação de curadoria manual; parser de quem presidia; reusa a lógica pura do piloto C152 via scripts/lib. Related #955
136 lines
4.6 KiB
TypeScript
136 lines
4.6 KiB
TypeScript
import 'server-only'
|
|
|
|
import { z } from 'zod'
|
|
|
|
import {
|
|
mergeFacetClassification,
|
|
parseLlmFacetResponse,
|
|
SPEECH_SCOPES,
|
|
SPEECH_TOPICS,
|
|
type MergedSpeechFacets,
|
|
} from '@/lib/speechFacets'
|
|
import { classifySpeechByGazetteer, type SpeechFacetInput } from '@/lib/speechGazetteer'
|
|
|
|
/**
|
|
* Facet classification for one speech (C153): the offline gazetteer pass plus
|
|
* one LLM refinement on Deep Infra (same key/provider as the ASR). The LLM
|
|
* output is validated against the taxonomy; any failure — missing key, HTTP
|
|
* error, timeout, invalid JSON — degrades to the gazetteer provenance and
|
|
* never throws, so the import continues.
|
|
*/
|
|
|
|
const DEEPINFRA_CHAT_URL = 'https://api.deepinfra.com/v1/openai/chat/completions'
|
|
const DEEPINFRA_FACET_MODEL = 'deepseek-ai/DeepSeek-V4-Flash'
|
|
const FACET_TIMEOUT_MS = 60_000
|
|
const TRANSCRIPT_PROMPT_LIMIT = 12_000
|
|
|
|
export type SpeechClassificationResult = {
|
|
facets: MergedSpeechFacets
|
|
llm: {
|
|
used: boolean
|
|
totalTokens: number | null
|
|
estimatedCostUsd: number | null
|
|
error: string | null
|
|
}
|
|
}
|
|
|
|
const TOPIC_PROMPT_VALUES = SPEECH_TOPICS.map(({ value, label }) => `${value} (${label})`).join(
|
|
'; ',
|
|
)
|
|
const SCOPE_PROMPT_VALUES = SPEECH_SCOPES.map(({ value, label }) => `${value} (${label})`).join(
|
|
'; ',
|
|
)
|
|
|
|
const FACET_SYSTEM_PROMPT = [
|
|
'Você classifica discursos do deputado federal Jorge Solla (PT-BA) para um catálogo de falas.',
|
|
'Responda SOMENTE com um JSON válido, sem markdown, no formato:',
|
|
'{"topics": [], "scopes": [], "people": [], "programs": [], "projects": []}',
|
|
`topics: subconjunto de [${TOPIC_PROMPT_VALUES}] — use exatamente esses valores.`,
|
|
`scopes: subconjunto de [${SCOPE_PROMPT_VALUES}] — use exatamente esses valores.`,
|
|
'people: pessoas citadas, nome completo quando houver (máx. 12).',
|
|
'programs: programas de governo citados (ex.: "Minha Casa, Minha Vida").',
|
|
'projects: proposições citadas (PL, PEC, medida provisória), com o número quando houver.',
|
|
'Não invente: liste apenas o que aparece no sumário, nas palavras-chave ou na transcrição.',
|
|
'Máximo 4 topics. Sem conteúdo em uma lista, devolva [].',
|
|
].join('\n')
|
|
|
|
const llmEnvelopeSchema = z.object({
|
|
choices: z.array(z.object({ message: z.object({ content: z.string() }).passthrough() })).min(1),
|
|
usage: z
|
|
.object({
|
|
total_tokens: z.number().optional(),
|
|
estimated_cost: z.number().optional(),
|
|
})
|
|
.optional(),
|
|
})
|
|
|
|
const buildUserPrompt = (input: SpeechFacetInput): string => {
|
|
const transcript = input.transcript.slice(0, TRANSCRIPT_PROMPT_LIMIT)
|
|
return [
|
|
`SUMÁRIO: ${input.summary ?? '—'}`,
|
|
`PALAVRAS-CHAVE OFICIAIS: ${input.keywords?.join('; ') ?? '—'}`,
|
|
`TRANSCRIÇÃO (trecho): ${transcript}`,
|
|
].join('\n')
|
|
}
|
|
|
|
export const classifySpeech = async (
|
|
input: SpeechFacetInput,
|
|
): Promise<SpeechClassificationResult> => {
|
|
const gazetteer = classifySpeechByGazetteer(input)
|
|
const apiKey = process.env.DEEPINFRA_API_KEY
|
|
if (!apiKey) {
|
|
return {
|
|
facets: mergeFacetClassification(gazetteer, null),
|
|
llm: { used: false, totalTokens: null, estimatedCostUsd: null, error: 'chave ausente' },
|
|
}
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(DEEPINFRA_CHAT_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
model: DEEPINFRA_FACET_MODEL,
|
|
messages: [
|
|
{ role: 'system', content: FACET_SYSTEM_PROMPT },
|
|
{ role: 'user', content: buildUserPrompt(input) },
|
|
],
|
|
response_format: { type: 'json_object' },
|
|
temperature: 0,
|
|
max_tokens: 500,
|
|
}),
|
|
signal: AbortSignal.timeout(FACET_TIMEOUT_MS),
|
|
})
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`)
|
|
}
|
|
|
|
const envelope = llmEnvelopeSchema.safeParse(await response.json())
|
|
if (!envelope.success) throw new Error('resposta do provedor fora do formato')
|
|
const parsed = parseLlmFacetResponse(JSON.parse(envelope.data.choices[0].message.content))
|
|
if (!parsed) throw new Error('resposta fora da taxonomia')
|
|
|
|
return {
|
|
facets: mergeFacetClassification(gazetteer, parsed),
|
|
llm: {
|
|
used: true,
|
|
totalTokens: envelope.data.usage?.total_tokens ?? null,
|
|
estimatedCostUsd: envelope.data.usage?.estimated_cost ?? null,
|
|
error: null,
|
|
},
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
facets: mergeFacetClassification(gazetteer, null),
|
|
llm: {
|
|
used: false,
|
|
totalTokens: null,
|
|
estimatedCostUsd: null,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
},
|
|
}
|
|
}
|
|
}
|