teqo-backup/src/lib/speechFacets.ts
Francisco Solla e5e1ae7ee8 feat(C190): dossiê Solla por tema/área + boletim modelo (skill + builder)
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
2026-09-18 17:02:56 -03:00

178 lines
6.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { z } from 'zod'
import type { MunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import { normalizeForSearch, uniqueByNormalizedForm } from '@/lib/speechSearch'
/**
* Facet contract for the speech catalog (C153). The topic and scope values are
* persisted data (facet filters in C154); labels are the pt-BR product names.
* The offline gazetteer lives in `speechGazetteer.ts` and the LLM refinement in
* `src/utilities/speech/speechClassifier.ts`; this module validates the LLM
* response and merges both provenances. `manual` edits win over both.
*/
export const SPEECH_TOPICS = [
{ value: 'saude', label: 'Saúde' },
{ value: 'educacao', label: 'Educação' },
{ value: 'cultura', label: 'Cultura' },
{ value: 'esporte', label: 'Esporte' },
{ value: 'seguranca-publica', label: 'Segurança Pública' },
{ value: 'meio-ambiente', label: 'Meio Ambiente' },
{ value: 'economia-trabalho', label: 'Economia e Trabalho' },
{ value: 'direitos-humanos', label: 'Direitos Humanos e Assistência Social' },
{ value: 'infraestrutura', label: 'Infraestrutura e Transporte' },
{ value: 'ciencia-tecnologia', label: 'Ciência e Tecnologia' },
{ value: 'politica-instituicoes', label: 'Política e Instituições' },
{ value: 'agricultura', label: 'Agricultura e Agropecuária' },
{ value: 'habitacao-cidades', label: 'Habitação e Cidades' },
{ value: 'comunicacao-midia', label: 'Comunicação e Mídia' },
{ value: 'igualdade-racial', label: 'Igualdade Racial' },
{ value: 'mulheres-genero', label: 'Mulheres e Gênero' },
{ value: 'juventude', label: 'Juventude' },
{ value: 'pessoa-deficiencia', label: 'Pessoa com Deficiência' },
] as const
export type SpeechTopic = (typeof SPEECH_TOPICS)[number]['value']
export const SPEECH_SCOPES = [
{ value: 'bahia', label: 'Bahia' },
{ value: 'brasil', label: 'Brasil' },
{ value: 'internacional', label: 'Internacional' },
] as const
export type SpeechScope = (typeof SPEECH_SCOPES)[number]['value']
export const SPEECH_CLASSIFICATION_SOURCES = ['gazetteer', 'llm', 'manual'] as const
type SpeechClassificationSource = (typeof SPEECH_CLASSIFICATION_SOURCES)[number]
export type SpeechFacetClassification = {
topics: SpeechTopic[]
scopes: SpeechScope[]
municipalities: MunicipalityCatalogEntry[]
people: string[]
programs: string[]
projects: string[]
}
export type MergedSpeechFacets = SpeechFacetClassification & {
classifiedBy: SpeechClassificationSource
}
const facetKey = (value: string): string => normalizeForSearch(value).replace(/-/g, ' ')
const topicByKey = new Map(
SPEECH_TOPICS.flatMap(({ value, label }) => [
[facetKey(value), value],
[facetKey(label), value],
]),
)
const scopeByKey = new Map(
SPEECH_SCOPES.flatMap(({ value, label }) => [
[facetKey(value), value],
[facetKey(label), value],
]),
)
/**
* Resolves a canonical topic token (value or pt-BR label, accent/case
* insensitive) to its taxonomy entry. Fail-closed: unknown tokens return null —
* callers never invent a slug outside `SPEECH_TOPICS` (C190).
*/
export const resolveSpeechTopic = (token: string): (typeof SPEECH_TOPICS)[number] | null => {
const key = facetKey(token ?? '')
if (!key) return null
const value = topicByKey.get(key)
return SPEECH_TOPICS.find((topic) => topic.value === value) ?? null
}
// ---------------------------------------------------------------------------
// LLM response parsing and merge
// ---------------------------------------------------------------------------
const MAX_MENTION_ITEMS = 12
const MAX_MENTION_LENGTH = 120
const llmFacetResponseSchema = z.object({
topics: z.array(z.unknown()).optional(),
scopes: z.array(z.unknown()).optional(),
people: z.array(z.unknown()).optional(),
programs: z.array(z.unknown()).optional(),
projects: z.array(z.unknown()).optional(),
})
export type SpeechLlmFacets = {
topics: SpeechTopic[]
scopes: SpeechScope[]
people: string[]
programs: string[]
projects: string[]
}
const stringMentions = (value: unknown): string[] => {
if (!Array.isArray(value)) return []
return uniqueByNormalizedForm(
value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim().slice(0, MAX_MENTION_LENGTH)),
).slice(0, MAX_MENTION_ITEMS)
}
/**
* Validates the LLM JSON response against the known taxonomy. Unknown topics
* and scopes are dropped (never persisted as free text); an object without any
* known key returns null so the caller falls back to the gazetteer provenance.
*/
export const parseLlmFacetResponse = (json: unknown): SpeechLlmFacets | null => {
const parsed = llmFacetResponseSchema.safeParse(json)
if (!parsed.success || Object.keys(parsed.data).length === 0) return null
const data = parsed.data
const topics = uniqueByNormalizedForm(
(Array.isArray(data.topics) ? data.topics : []).filter(
(item): item is string => typeof item === 'string',
),
)
.map((item) => topicByKey.get(facetKey(item)))
.filter((value): value is SpeechTopic => value !== undefined)
const scopes = uniqueByNormalizedForm(
(Array.isArray(data.scopes) ? data.scopes : []).filter(
(item): item is string => typeof item === 'string',
),
)
.map((item) => scopeByKey.get(facetKey(item)))
.filter((value): value is SpeechScope => value !== undefined)
return {
topics: [...new Set(topics)],
scopes: [...new Set(scopes)],
people: stringMentions(data.people),
programs: stringMentions(data.programs),
projects: stringMentions(data.projects),
}
}
/**
* Gazetteer ∪ LLM. Topics are unioned (the lexicon is evidence too); scopes
* prefer the validated LLM output when it produced any; people/programs/
* projects come from the LLM only. Provenance is `llm` whenever a valid
* response refined the result, `gazetteer` otherwise.
*/
export const mergeFacetClassification = (
gazetteer: SpeechFacetClassification,
llm: SpeechLlmFacets | null,
): MergedSpeechFacets => {
if (!llm) return { ...gazetteer, classifiedBy: 'gazetteer' }
return {
topics: [...new Set([...gazetteer.topics, ...llm.topics])],
scopes: llm.scopes.length > 0 ? llm.scopes : gazetteer.scopes,
municipalities: gazetteer.municipalities,
people: llm.people,
programs: llm.programs,
projects: llm.projects,
classifiedBy: 'llm',
}
}