mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Terceira variante da família de dossiês: recorte pela área canônica do acervo (SPEECH_TOPICS), com o pipeline C186/C187 generalizado para a forma subject via THEME_UNIT (generalização aditiva; C186/C187 byte-idênticos). - resolveSpeechTopic no dono speechFacets (fail-closed, sem slug inventado) - themeSnapshot + extract-theme-snapshot (THEME_REPORT_CONFIRM=1, read-only) - build-dossie-solla-tema (pack medido, PDF A4 + .md + boletim 1 página) - skill/comando/2 subagentes (researcher por era + redator) + specs - cenas theme-only no descritor (acervo universo/amostra, abrangência, lacunas, hierarquia de fontes, pills do boletim) certificadas pelo designer Closes #1143
209 lines
7.6 KiB
TypeScript
209 lines
7.6 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { describe, expect, it } from 'vitest'
|
|
|
|
import {
|
|
mergeFacetClassification,
|
|
parseLlmFacetResponse,
|
|
resolveSpeechTopic,
|
|
} from '@/lib/speechFacets'
|
|
import { classifySpeechByGazetteer, matchMunicipalityMentions } from '@/lib/speechGazetteer'
|
|
|
|
describe('resolveSpeechTopic (C190)', () => {
|
|
it('resolves the canonical value and the pt-BR label (accent/case insensitive)', () => {
|
|
expect(resolveSpeechTopic('educacao')?.value).toBe('educacao')
|
|
expect(resolveSpeechTopic('Educação')?.value).toBe('educacao')
|
|
expect(resolveSpeechTopic(' EDUCAÇÃO ')?.value).toBe('educacao')
|
|
expect(resolveSpeechTopic('Seguranca Publica')?.value).toBe('seguranca-publica')
|
|
expect(resolveSpeechTopic('Direitos Humanos e Assistência Social')?.value).toBe(
|
|
'direitos-humanos',
|
|
)
|
|
})
|
|
|
|
it('fails closed on an unknown or empty token (never an invented slug)', () => {
|
|
expect(resolveSpeechTopic('educacao-infantil')).toBeNull()
|
|
expect(resolveSpeechTopic('')).toBeNull()
|
|
expect(resolveSpeechTopic(' ')).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('classifySpeechByGazetteer', () => {
|
|
it('detects topics, explicit scopes and municipality mentions', () => {
|
|
const result = classifySpeechByGazetteer({
|
|
summary: 'Investimentos em saúde e educação na Bahia.',
|
|
keywords: ['SUS', 'Escola'],
|
|
transcript: 'Defendemos o SUS, a escola pública e a universidade em Feira de Santana.',
|
|
})
|
|
|
|
expect(result.topics).toContain('saude')
|
|
expect(result.topics).toContain('educacao')
|
|
expect(result.scopes).toContain('bahia')
|
|
expect(result.municipalities.map((entry) => entry.slug)).toContain('feira-de-santana')
|
|
expect(result.people).toEqual([])
|
|
expect(result.programs).toEqual([])
|
|
expect(result.projects).toEqual([])
|
|
})
|
|
|
|
it('matches whole words only (no "sus" inside "suspeito")', () => {
|
|
const result = classifySpeechByGazetteer({
|
|
transcript: 'O suspeito foi ouvido e liberado.',
|
|
})
|
|
|
|
expect(result.topics).not.toContain('saude')
|
|
})
|
|
|
|
it('infers the Bahia scope from a municipality mention alone', () => {
|
|
const result = classifySpeechByGazetteer({
|
|
transcript: 'A cidade de Vitória da Conquista recebeu o investimento.',
|
|
})
|
|
|
|
expect(result.scopes).toEqual(['bahia'])
|
|
})
|
|
|
|
it('ignores official keywords for place mentions (controlled topic vocabulary)', () => {
|
|
const result = classifySpeechByGazetteer({
|
|
summary: 'Debate sobre financiamento da educação.',
|
|
keywords: ['Saúde', 'Educação', 'Governo federal'],
|
|
transcript: 'Precisamos investir na educação pública.',
|
|
})
|
|
|
|
expect(result.topics).toContain('saude')
|
|
expect(result.municipalities).toEqual([])
|
|
|
|
const withPlace = classifySpeechByGazetteer({
|
|
summary: 'Investimento em Saúde, no interior da Bahia.',
|
|
transcript: 'A comitiva esteve em Saúde.',
|
|
})
|
|
expect(withPlace.municipalities.map((entry) => entry.slug)).toEqual(['saude'])
|
|
})
|
|
})
|
|
|
|
describe('matchMunicipalityMentions', () => {
|
|
it('consumes the longest name (São Félix do Coribe never also yields Coribe)', () => {
|
|
const slugs = matchMunicipalityMentions('Ele passou por São Félix do Coribe.').map(
|
|
(entry) => entry.slug,
|
|
)
|
|
|
|
expect(slugs).toEqual(['sao-felix-do-coribe'])
|
|
})
|
|
|
|
it('does not split a longer name into a shorter one (Condeúba is not Conde)', () => {
|
|
const slugs = matchMunicipalityMentions('A comitiva esteve em Condeúba.').map(
|
|
(entry) => entry.slug,
|
|
)
|
|
|
|
expect(slugs).toEqual(['condeuba'])
|
|
})
|
|
|
|
it('requires a capitalized proper noun (prose "saúde" is not the municipality Saúde)', () => {
|
|
expect(matchMunicipalityMentions('A saúde pública é prioridade.')).toEqual([])
|
|
expect(
|
|
matchMunicipalityMentions('A agenda passou em Saúde, no interior da Bahia.').map(
|
|
(entry) => entry.slug,
|
|
),
|
|
).toEqual(['saude'])
|
|
})
|
|
|
|
it('requires a place context for ambiguous names (Saúde, Wagner, Santana)', () => {
|
|
expect(matchMunicipalityMentions('A visita da Ministra da Saúde foi destaque.')).toEqual([])
|
|
expect(matchMunicipalityMentions('O senador Jaques Wagner discursou.')).toEqual([])
|
|
expect(matchMunicipalityMentions('O deputado Santana votou com o governo.')).toEqual([])
|
|
expect(
|
|
matchMunicipalityMentions('Ele esteve em Saúde, no interior.').map((entry) => entry.slug),
|
|
).toEqual(['saude'])
|
|
expect(
|
|
matchMunicipalityMentions('A comitiva passou em Wagner.').map((entry) => entry.slug),
|
|
).toEqual(['wagner'])
|
|
})
|
|
|
|
it('ignores institutional phrases that contain a municipality name', () => {
|
|
expect(matchMunicipalityMentions('O Sistema Único de Saúde atende a população.')).toEqual([])
|
|
expect(matchMunicipalityMentions('O Banco Central elevou os juros.')).toEqual([])
|
|
expect(matchMunicipalityMentions('O Palácio do Planalto confirmou a agenda.')).toEqual([])
|
|
expect(
|
|
matchMunicipalityMentions('Ele esteve em Central, no interior da Bahia.').map(
|
|
(entry) => entry.slug,
|
|
),
|
|
).toEqual(['central'])
|
|
})
|
|
|
|
it('maps a Salvador mention to its 19 zone entries', () => {
|
|
const slugs = matchMunicipalityMentions('O comício foi em Salvador.').map((entry) => entry.slug)
|
|
|
|
expect(slugs).toHaveLength(19)
|
|
expect(slugs.every((slug) => slug.startsWith('salvador-ze-'))).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('parseLlmFacetResponse', () => {
|
|
it('maps labels and values to the taxonomy, dropping unknown values', () => {
|
|
expect(
|
|
parseLlmFacetResponse({
|
|
topics: ['Saúde', 'seguranca-publica', 'inexistente'],
|
|
scopes: ['Bahia', 'Mundo'],
|
|
people: ['Dilma Rousseff', 'dilma rousseff', ''],
|
|
programs: ['Minha Casa, Minha Vida'],
|
|
projects: ['PEC 45'],
|
|
}),
|
|
).toEqual({
|
|
topics: ['saude', 'seguranca-publica'],
|
|
scopes: ['bahia'],
|
|
people: ['Dilma Rousseff'],
|
|
programs: ['Minha Casa, Minha Vida'],
|
|
projects: ['PEC 45'],
|
|
})
|
|
})
|
|
|
|
it('maps the long labels too', () => {
|
|
const parsed = parseLlmFacetResponse({
|
|
topics: ['Direitos Humanos e Assistência Social', 'Infraestrutura e Transporte'],
|
|
})
|
|
|
|
expect(parsed?.topics).toEqual(['direitos-humanos', 'infraestrutura'])
|
|
})
|
|
|
|
it('returns null for a response without any known key', () => {
|
|
expect(parseLlmFacetResponse(null)).toBeNull()
|
|
expect(parseLlmFacetResponse('texto')).toBeNull()
|
|
expect(parseLlmFacetResponse({ foo: 1 })).toBeNull()
|
|
})
|
|
|
|
it('caps and truncates the free-text mentions', () => {
|
|
const parsed = parseLlmFacetResponse({
|
|
people: Array.from({ length: 20 }, (_, index) => `Pessoa ${index}`),
|
|
programs: ['x'.repeat(200)],
|
|
})
|
|
|
|
expect(parsed?.people).toHaveLength(12)
|
|
expect(parsed?.programs[0]).toHaveLength(120)
|
|
})
|
|
})
|
|
|
|
describe('mergeFacetClassification', () => {
|
|
const gazetteer = classifySpeechByGazetteer({
|
|
transcript: 'O SUS e a escola pública em Feira de Santana.',
|
|
})
|
|
|
|
it('unions topics and prefers the validated LLM scopes', () => {
|
|
const merged = mergeFacetClassification(gazetteer, {
|
|
topics: ['cultura'],
|
|
scopes: ['brasil'],
|
|
people: ['Lula'],
|
|
programs: [],
|
|
projects: [],
|
|
})
|
|
|
|
expect(merged.topics).toEqual([...gazetteer.topics, 'cultura'])
|
|
expect(merged.scopes).toEqual(['brasil'])
|
|
expect(merged.people).toEqual(['Lula'])
|
|
expect(merged.classifiedBy).toBe('llm')
|
|
})
|
|
|
|
it('keeps the gazetteer provenance without an LLM response', () => {
|
|
const merged = mergeFacetClassification(gazetteer, null)
|
|
|
|
expect(merged.classifiedBy).toBe('gazetteer')
|
|
expect(merged.scopes).toEqual(gazetteer.scopes)
|
|
expect(merged.municipalities).toEqual(gazetteer.municipalities)
|
|
})
|
|
})
|