D-1b — Normalização
Parte da D-1 — Ingestão de Demanda
Propósito
Seção intitulada “Propósito”Consome demanda.recebida e transforma o input bruto do cidadão em um registro estruturado com campos padronizados. É aqui que texto livre vira campos semânticos, áudio vira transcrição, imagem vira descrição textual em campo próprio e coordenadas passam por validação básica de consistência.
A autoria do texto é separada. A descricao_limpa publicada carrega exclusivamente o texto do cidadão e aceita string vazia. A descrição automática de cada imagem fica em midias_descritas, com a legenda original, a tradução quando aplicada, a marca de tradução e o idioma. Sem texto do cidadão, o título usa a primeira legenda exibível truncada em TITULO_MAX. A extração de entidades e o cálculo de confiança usam um texto de análise interno que compõe o texto do cidadão e as legendas exibíveis.
A IA entra como redução de atrito, nunca como decisão. Todo output automático é marcado como origem_normalizacao: 'automatico' e mantém referência ao conteúdo bruto, que nunca é descartado. A normalização é uma visão derivada, não substituta. A publicação é automática: o resultado do processamento é publicado com marcação de origem e score de confiança, e o caminho de contestação fica visível na timeline pública para qualquer cidadão.
Não categoriza, não prioriza, não valida vínculo. Sua única responsabilidade é garantir que toda demanda recebida tenha uma versão normalizada com campos estruturados, score de confiança calculado e referência ao dado bruto preservado.
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”A D-1b é uma colônia puramente reativa. Não expõe controllers REST e não possui BFF acoplado. Consome demanda.recebida do barramento e publica demanda.normalizada. É a segunda colônia do pipeline de ingestão, disparada em paralelo com a D-1c.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”src/demanda/d-1b-normalizacao/├── d1b.module.ts # Module definition├── d1b.service.ts # Orquestração: iniciar, consumir evento → processar → persistir → publicar├── d1b.constants.ts # Constantes: bounding box, limites, timeouts, pesos de confiança├── services/│ ├── limpeza-texto.service.ts # Limpeza: normalização Unicode, remoção de HTML/ruído, detecção de idioma│ ├── extracao-entidades.service.ts # Extração: NER básico (endereço, CEP, nome de rua)│ ├── processamento-midia.service.ts # Orquestra transcrição e descrição; acumula midias_descritas e expõe a primeira legenda exibível│ ├── transcricao-audio.service.ts # Transcrição: Whisper (local) para áudio → texto│ ├── descricao-imagem.service.ts # Descrição: Florence-2 (local) para foto → texto│ ├── traducao.service.ts # Tradução: OPUS-MT/Marian en→pt da legenda, opcional│ ├── validacao-geo.service.ts # Validação geo: bounding box + anti-patterns│ ├── calculo-confianca.service.ts # Score: heurística de confiança da normalização│ ├── moderacao-texto.service.ts # Delega ao helper compartilhado src/shared/moderacao/denylist-texto.ts│ └── temporizador-ia.service.ts # Wrapper: timeout com AbortController para os modelos└── repositories/ ├── normalizacao.repository.ts # Acesso a d1b.normalizacoes ├── evento-processado.repository.ts # Acesso a d1b.eventos_processados (idempotência) └── consumer-offset.repository.ts # Acesso a d1b.consumer_offset (cursor de replay)1.2 Module definition
Seção intitulada “1.2 Module definition”@Module({ imports: [], controllers: [], providers: [ D1bService, NormalizacaoRepository, EventoProcessadoRepository, ConsumerOffsetRepository, LimpezaTextoService, ExtracaoEntidadesService, ValidacaoGeoService, CalculoConfiancaService, TranscricaoAudioService, DescricaoImagemService, TraducaoService, TemporizadorIaService, ModeracaoTextoService, ], exports: [],})export class D1bModule implements OnModuleInit { constructor( private readonly d1bService: D1bService, ) {}
async onModuleInit(): Promise<void> { await this.d1bService.iniciar(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- O módulo não é
@Global(). A D-1b não é dependência de nenhuma outra colônia. - O módulo não importa
EventBusModuleexplicitamente.EventBusModuleé@Global(), e oEventBusServiceé injetável sem import. - O
OnModuleInitchamainiciar(). O método seeda o cursor comobterMaiorSequence(), refaz o replay dos eventos perdidos e só então registra os handlers dedemanda.recebidaeconselheiro.atualização_registradanoEventBusServiceviainscrever(). O handler é registrado programaticamente, não via decorator, para controle explícito do ciclo de vida. - A D-1b é consumidora e produtora. Mantém consumer offset próprio (
d1b.consumer_offset) para replay seletivo e idempotência de consumo (d1b.eventos_processados). - Nenhum controller exposto. A D-1b é acessada exclusivamente via eventos no barramento.
- O
TemporizadorIaServiceé injetado noD1bServicee envolve as chamadas de transcrição e descrição. Garante timeout com cancelamento real e converte o abort emTimeoutError. - O
ModeracaoTextoServicelê a denylist no construtor, a partir deMODERACAO_DENYLIST_TEXTO, e delega a normalização e o casamento ao helper compartilhadosrc/shared/moderacao/denylist-texto.ts. O mesmo helper atende a D-6b na publicação do relato, sem import cruzado entre colônias.
1.4 Serviços — responsabilidades e contratos
Seção intitulada “1.4 Serviços — responsabilidades e contratos”Os contratos dos serviços, com as assinaturas reais:
| Serviço | Métodos |
|---|---|
D1bService |
iniciar(), registrarConsumidores(), processarDemandaRecebida(evento), processarAtualizacaoConselheiro(evento) |
ProcessamentoMidiaService |
processar(entrada) — instanciado pelo D1bService; devolve a descrição do cidadão, midiasDescritas, primeiraLegendaExibivel, o tipo processado, as flags de transcrição/descrição e as confianças |
LimpezaTextoService |
limpar(textoBruto), extrairTitulo(textoLimpo) |
ExtracaoEntidadesService |
extrair(textoLimpo) |
TranscricaoAudioService |
transcrever(midiaUrl, sinal?), estaDisponivel() |
DescricaoImagemService |
descrever(imagemUrl, sinal?), estaDisponivel() |
TraducaoService |
traduzir(texto), estaEmPortugues(texto), estaDisponivel() |
ValidacaoGeoService |
validar(lat, lng) |
CalculoConfiancaService |
calcular(resultado) |
ModeracaoTextoService |
verificar(titulo, descricao) |
TemporizadorIaService |
executarComTimeout(fn, timeoutMs) |
Tipos de retorno exportados:
interface ResultadoLimpeza { textoLimpo: string; idioma: string; alteracoesAplicadas: string[];}
interface EntidadesExtraidas { endereco?: string; cep?: string; nome_rua?: string;}
interface ResultadoValidacaoGeo { valido: boolean; problemas: string[]; lat: number; lng: number;}
interface ResultadoNormalizacao { textoOriginal: string; textoLimpo: string; entidadesDetectadas: boolean; geoValida: boolean; tipoMidiaOriginal: string; audioTranscrito: boolean; imagemDescrita: boolean; confiancaAudio: number; confiancaImagem: number; idiomaReconhecido: boolean;}
interface ResultadoTranscricao { texto: string; confianca: number;}
interface ResultadoDescricao { descricao: string; confianca: number;}
interface MidiaDescrita { object_key: string; descricao_original: string; descricao_traduzida?: string; traducao_aplicada: boolean; descricao_idioma: string;}
interface ResultadoProcessamentoMidia { descricaoLimpa: string; tipoMidiaProcessada: string; confiancaAudio: number; confiancaImagem: number; audioTranscrito: boolean; imagemDescrita: boolean; midiasDescritas: MidiaDescrita[]; primeiraLegendaExibivel: string | null; detalhes: Record<string, unknown>;}
interface ResultadoTraducaoTexto { texto: string; motor: string; // 'opus-mt'}
interface ResultadoModeracaoTexto { suspeito: boolean; termos: string[];}2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema d1b
Seção intitulada “2.1 Schema d1b”Todas as tabelas da D-1b residem no schema d1b do PostgreSQL. Este schema é de uso exclusivo do módulo D-1b. Nenhuma outra colônia lê ou escreve nestas tabelas.
2.2 Tabela d1b.normalizacoes
Seção intitulada “2.2 Tabela d1b.normalizacoes”Registro de toda normalização realizada. Cada linha corresponde a uma versão normalizada de uma demanda. No MVP cada demanda é processada uma vez e recebe versao = 1; o reprocessamento e a correção manual da Fase 2 preservam a versão anterior e inserem uma nova linha com a versão incrementada.
CREATE SCHEMA IF NOT EXISTS d1b;
CREATE TABLE d1b.normalizacoes ( id UUID PRIMARY KEY, demanda_id UUID NOT NULL, titulo VARCHAR(200) NOT NULL, descricao_limpa TEXT NOT NULL, tipo_midia_processada VARCHAR(10) NOT NULL, coordenadas_lat DOUBLE PRECISION NOT NULL, coordenadas_lng DOUBLE PRECISION NOT NULL, confianca_normalizacao DOUBLE PRECISION NOT NULL, entidades_endereco VARCHAR(500), entidades_cep VARCHAR(10), entidades_nome_rua VARCHAR(300), idioma_detectado VARCHAR(5) NOT NULL DEFAULT 'pt', origem_processamento VARCHAR(20) NOT NULL, processamento_detalhes JSONB NOT NULL DEFAULT '{}', conteudo_suspeito BOOLEAN NOT NULL DEFAULT false, termos_suspeitos JSONB NOT NULL DEFAULT '[]', versao INT NOT NULL DEFAULT 1, criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX normalizacoes_demanda_id_idx ON d1b.normalizacoes (demanda_id);CREATE INDEX normalizacoes_confianca_normalizacao_idx ON d1b.normalizacoes (confianca_normalizacao);CREATE INDEX normalizacoes_idioma_detectado_idx ON d1b.normalizacoes (idioma_detectado);Os CHECKs de tipo_midia_processada e origem_processamento não existem no banco. Prisma não gera CHECK. A validação dos enums ocorre na aplicação, no pipeline do serviço.
Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
id |
UUID PK | Identificador único da normalização. Gerado pelo Prisma (uuid v4). |
demanda_id |
UUID | Demanda correspondente. Origem: payload de demanda.recebida. |
titulo |
VARCHAR(200) | Título derivado do texto do cidadão (primeiros tokens significativos). Se a extração falhar, usa os primeiros 200 caracteres da descricao_limpa; sem texto do cidadão, usa a primeira legenda exibível truncada em TITULO_MAX; se ainda vazio, 'Sem título'. |
descricao_limpa |
TEXT | Descrição com o ruído do texto do cidadão removido, Unicode normalizado, HTML removido, idioma detectado. Max 5000 caracteres (truncado se exceder). Contém exclusivamente texto do cidadão e aceita string vazia. |
tipo_midia_processada |
VARCHAR(10) | texto, foto ou audio. Pode diferir do tipo_midia original. Áudio transcrito ou imagem descrita com sucesso viram texto. |
coordenadas_lat |
DOUBLE PRECISION | Latitude validada. Igual à bruta se passou na validação de bounding box. |
coordenadas_lng |
DOUBLE PRECISION | Longitude validada. Igual à bruta se passou na validação. |
confianca_normalizacao |
DOUBLE PRECISION | Score 0-1 da qualidade da normalização. Calculado por heurística: campos presentes, qualidade da transcrição/descrição, idioma reconhecível. |
entidades_endereco |
VARCHAR(500) | Endereço extraído do texto. Nulo se não detectado. |
entidades_cep |
VARCHAR(10) | CEP extraído do texto (formato 00000-000). Nulo se não detectado. |
entidades_nome_rua |
VARCHAR(300) | Nome de rua/avenida extraído do texto. Nulo se não detectado. |
idioma_detectado |
VARCHAR(5) | Código ISO 639-1 (ex: pt, en, es). Default pt quando a detecção falha. |
origem_processamento |
VARCHAR(20) | automatico (IA processou sozinha), manual (humano editou), hibrido (IA processou, humano revisou). No MVP sempre automatico. |
processamento_detalhes |
JSONB | Metadados do processamento: serviços utilizados, scores parciais, timeouts, falhas e problemas de geo. Na imagem, guarda descricao_original, descricao_traduzida, traducao_motor, traducao_aplicada e descricao_idioma. Formato livre para auditoria. |
conteudo_suspeito |
BOOLEAN | true quando a denylist de texto encontra termo ou frase no título ou na descrição do cidadão. As legendas automáticas não entram na verificação. Default false. |
termos_suspeitos |
JSONB | Lista dos termos da denylist que dispararam a sinalização. Default []. |
versao |
INT | Número da versão desta normalização. No MVP sempre 1. Incrementa com o reprocessamento e a correção manual da Fase 2. |
criado_em |
TIMESTAMPTZ(2) | Timestamp de criação do registro. |
2.3 Tabela d1b.eventos_processados
Seção intitulada “2.3 Tabela d1b.eventos_processados”Registro de idempotência para consumo de eventos. Garante que cada demanda.recebida seja processada exatamente uma vez.
CREATE TABLE d1b.eventos_processados ( event_id UUID PRIMARY KEY, demanda_id UUID NOT NULL, processado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP, criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE INDEX eventos_processados_demanda_id_idx ON d1b.eventos_processados (demanda_id);Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
event_id |
UUID PK | event_id do evento demanda.recebida processado. Chave de idempotência. |
demanda_id |
UUID | demanda_id correspondente. Índice para consultas de reconciliação. |
processado_em |
TIMESTAMPTZ(2) | Timestamp de conclusão do processamento (normalização persistida e evento publicado). |
criado_em |
TIMESTAMPTZ(2) | Timestamp de criação do registro. |
2.4 Tabela d1b.consumer_offset
Seção intitulada “2.4 Tabela d1b.consumer_offset”Cursor de replay para o barramento de eventos. Permite que a D-1b recupere eventos perdidos durante inatividade.
CREATE TABLE d1b.consumer_offset ( tipo_evento VARCHAR(255) PRIMARY KEY, last_sequence BIGINT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP);Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
tipo_evento |
VARCHAR(255) PK | Tipo de evento monitorado. Para a D-1b: demanda.recebida e conselheiro.atualização_registrada. |
last_sequence |
BIGINT | Último sequence_number do event_log processado com sucesso. |
updated_at |
TIMESTAMPTZ(2) | Timestamp da última atualização do cursor. |
2.5 Migrations
Seção intitulada “2.5 Migrations”Três migrations:
20260809103344_add_d1b_schema— Cria o schemad1b, as tabelasnormalizacoeseeventos_processadose os índices.20260813112406_add_consumer_offset_d1b_d2_d4— Criad1b.consumer_offsetcom PK emtipo_evento(também cria as tabelas equivalentes de D-2 e D-4).20260912150000_d1b_moderacao_texto— Adicionaconteudo_suspeitoetermos_suspeitosad1b.normalizacoes.
O seed do cursor ocorre em runtime, com obterMaiorSequence(). A migration não insere linha com last_sequence = 0.
2.6 Relações internas
Seção intitulada “2.6 Relações internas”Não há foreign keys entre as tabelas do schema d1b. A relação entre normalizacoes e eventos_processados é lógica via demanda_id. O demanda_id referencia a tabela d1a.demandas_recebidas, mas sem constraint formal. O dado bruto pode existir sem normalização (erro de processamento), e a normalização pode ser refeita sem afetar o registro bruto.
2.7 Decisões de schema
Seção intitulada “2.7 Decisões de schema”Uma linha por versão, não update in-place.
Cada normalização gera uma linha nova. No MVP cada demanda é processada uma vez (idempotência) e grava versao = 1. Com o reprocessamento e a correção manual da Fase 2, a versão anterior permanece preservada para auditoria. A versão mais recente (versao DESC LIMIT 1) é a ativa e o histórico completo fica acessível.
processamento_detalhes como JSONB, não colunas dedicadas.
Cada tipo de processamento (texto, áudio, imagem) gera metadados diferentes: modelo usado, tempo de execução, timeout aplicado, fallback ativado. Uma coluna flexível acomoda todos os cenários sem migrações frequentes. A estrutura é livre e documentada por versão do código, não por schema.
eventos_processados com PK no event_id, não sequence.
O event_id do barramento é UUID v4 único por publicação. Usá-lo como PK garante idempotência natural: se o mesmo evento for entregue duas vezes, o segundo INSERT viola a PK e é ignorado. Padrão consistente com a D-1c.
Índice em confianca_normalizacao.
Consultas de auditoria e monitoramento precisam identificar demandas com baixa confiança para revisão. O índice cobre WHERE confianca_normalizacao < :limiar ORDER BY criado_em DESC.
Denylist em colunas próprias.
conteudo_suspeito e termos_suspeitos ficam em colunas dedicadas, gravadas na mesma inserção da normalização. A D-1d consulta a sinalização pelo evento publicado e a D-7 libera ou limpa o conteúdo depois da decisão de moderação.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”A D-1b consome demanda.recebida e produz demanda.normalizada. Os schemas completos (JSON Schema draft-2020-12) estão definidos no Registry (N-0b).
3.1 Evento consumido: demanda.recebida
Seção intitulada “3.1 Evento consumido: demanda.recebida”| Propriedade | Valor |
|---|---|
| Tipo | demanda.recebida |
| Schema version | 1.1.0 (a 1.0.0 permanece aceita) |
| Produtor | D-1a (Captura) |
| Consumidores | D-1b (Normalização), D-1c (Anexos) |
| Descrição | Input bruto do cidadão aceito pelo sistema. |
Payload consumido:
interface DemandaRecebidaPayload { demanda_id: string; texto_bruto: string; tipo_midia?: string; // 'texto' | 'foto' | 'audio' localizacao_bruta: { lat: number; lng: number; }; midia_urls: Array<{ // campo adicional publicado pela D-1a, fora do schema declarado tipo: string; // 'imagem' | 'audio' url: string; object_key: string; }>; cidadao_id: string; canal: string; // 'app' | 'web' | 'sms' | '156' timestamp_criacao: string; termos_versao?: string; // v1.1.0 consentimentos?: Array<{ // v1.1.0 finalidade: string; versao: string; aceito_em: string; }>; categoria_id?: string; // v1.1.0 subcategoria_id?: string; // v1.1.0}Critério de processamento:
- Se
texto_brutoé string vazia etipo_midiaétexto: a D-1b usa'Sem título'no título, mantém adescricao_limpavazia e calcula a confiança pela heurística normal. Não bloqueia o pipeline. A partir da 1.4.0 o schema aceita a descrição vazia. - Se
tipo_midiaéaudioe o serviço de transcrição está indisponível:tipo_midia_processadapermaneceaudio,descricao_limpaé preenchida apenas com o texto bruto (se houver),confianca_normalizacaoé penalizada. - Se
tipo_midiaéfotoe o serviço de descrição está indisponível:tipo_midia_processadapermanecefoto,descricao_limpaé preenchida apenas com o texto bruto (se houver),confianca_normalizacaoé penalizada. - O campo
midia_urlsacompanha o payload da D-1a mas não faz parte do schema declarado do evento. Se ausente ou vazio, não causa erro: a D-1b segue com otexto_brutoe alocalizacao_bruta. tipo_midiadecide o pipeline de processamento (texto, áudio ou foto).midia_urlsalimenta a transcrição e a descrição: a D-1b assina uma URL interna a partir doobject_key. O áudio usa o primeiro item; a descrição percorre todos os itens de imagem. O campourlpublicado pela D-1a não é usado na leitura.
3.1.1 Evento consumido: conselheiro.atualização_registrada
Seção intitulada “3.1.1 Evento consumido: conselheiro.atualização_registrada”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.atualização_registrada |
| Schema version | 1.1.0 (a 1.0.0 permanece aceita) |
| Produtor | BFF da D-1a |
| Consumidor | D-1b (esta colônia) |
| Descrição | Relato do conselheiro registrado no workspace. Quando o payload traz audio_object_key, a D-1b transcreve o áudio em segundo plano e publica conselheiro.atualização_transcrita. |
Payload consumido (campos usados):
interface ConselheiroAtualizacaoRegistradaPayload { conselheiro_id: string; demanda_id: string; unidade_civica_id: string; tipo: string; texto_bruto: string; texto_estruturado: string; audio_object_key?: string; // chave do relato em áudio timestamp: string;}Critério de processamento:
- Sem
audio_object_key, o evento é descartado pela D-1b: o relato digitado não passa por transcrição. Oevent_idé registrado emd1b.eventos_processadose o cursor avança. - Com
audio_object_key, a D-1b assina uma URL interna a partir da chave, com TTL de 300 segundos, e transcreve peloTranscricaoAudioServicesob o timeout de 60 segundos doTemporizadorIaService. A URL do payload não é usada. - A transcrição não bloqueia o envio. O BFF já respondeu ao conselheiro e a D-1b processa o evento de forma assíncrona.
- O áudio não é copiado nem publicado. A D-1b só lê o objeto privado e publica o texto resultante.
3.2 Evento produzido: demanda.normalizada
Seção intitulada “3.2 Evento produzido: demanda.normalizada”| Propriedade | Valor |
|---|---|
| Tipo | demanda.normalizada |
| Schema version | 1.4.0 (o catálogo mantém 1.0.0, 1.1.0, 1.2.0 e 1.3.0) |
| Produtor | D-1b |
| Consumidores | D-2 (Georreferenciamento), D-3 (Categorização), D-1d (Moderação, quando conteudo_suspeito=true), D-12 (Detecção de Duplicidade), D-7 (Transparência) |
| Descrição | Demanda com texto limpo, entidades extraídas e campos estruturados. Derivada do dado bruto. O original permanece preservado. A 1.3.0 adicionou midias_descritas com a descrição e a tradução automáticas de cada imagem da captura. A 1.4.0 separa a autoria: a descricao_limpa passa a conter exclusivamente o texto do cidadão e aceita string vazia. |
Payload publicado:
interface DemandaNormalizadaPayload { demanda_id: string; titulo: string; // max 200 chars, do texto do cidadão ou da primeira legenda exibível descricao_limpa: string; // max 5000 chars, exclusivamente texto do cidadão; aceita vazio tipo_midia_processada: string; // 'texto' | 'foto' | 'audio' coordenadas_validadas: { lat: number; lng: number; }; confianca_normalizacao: number; // 0-1 origem_normalizacao: string; // 'automatico' | 'hibrido' conteudo_suspeito: boolean; // termo da denylist encontrado entidades_extraidas?: { endereco?: string; cep?: string; nome_rua?: string; }; idioma_detectado?: string; // ISO 639-1, ex: 'pt' termos_suspeitos?: string[]; // presente quando conteudo_suspeito = true categoria_id?: string; // repassado de demanda.recebida v1.1.0 subcategoria_id?: string; // repassado de demanda.recebida v1.1.0 midias_descritas?: Array<{ // campo da versão 1.3.0; até 10 itens, um por imagem object_key: string; // chave temporária da captura, max 1024 descricao_original: string; // legenda do Florence, max 2000 descricao_traduzida?: string; // tradução, presente quando aplicada, max 2000 traducao_aplicada: boolean; descricao_idioma: string; // idioma da legenda original }>;}Detalhamento do payload:
| Campo | Descrição |
|---|---|
demanda_id |
ID original da demanda. Referência imutável. |
titulo |
Título do cidadão. Se o texto bruto não tem conteúdo suficiente, usa os primeiros 200 caracteres da descricao_limpa; sem texto do cidadão, usa a primeira legenda exibível truncada em 200; se ainda vazio, 'Sem título'. |
descricao_limpa |
Texto do cidadão sem ruído. Encoding normalizado, HTML removido, caracteres de controle removidos, espaços múltiplos colapsados. A transcrição de áudio só preenche o campo quando não há texto do cidadão. A legenda de imagem não compõe o campo em nenhum caso. Aceita string vazia. |
tipo_midia_processada |
Pode diferir do tipo_midia original. Áudio transcrito com sucesso vira texto. Imagem descrita com sucesso vira texto. Se a transcrição/descrição falhar, mantém o tipo original. |
coordenadas_validadas |
Cópia das brutas se passaram na validação de bounding box e anti-patterns. Idênticas às brutas se válidas. |
confianca_normalizacao |
Score agregado da qualidade do processamento. |
origem_normalizacao |
automatico (MVP). hibrido entra na Fase 2 quando houver correção manual. |
conteudo_suspeito |
true quando a denylist encontra termo ou frase no título ou na descrição do cidadão. Sempre presente no payload. |
entidades_extraidas |
Objeto opcional. Presente apenas se ao menos uma entidade foi detectada. |
idioma_detectado |
Código ISO 639-1. Default pt se a detecção falhar. |
termos_suspeitos |
Lista dos termos que dispararam a sinalização. Presente apenas quando conteudo_suspeito = true. |
categoria_id |
Categoria escolhida pelo cidadão na captura, repassada de demanda.recebida v1.1.0. Omitido quando ausente. |
subcategoria_id |
Subcategoria escolhida pelo cidadão, mesmo tratamento de categoria_id. Omitido quando ausente. |
midias_descritas |
Lista com a descrição automática de cada imagem da captura, com o object_key temporário, a legenda original, a tradução quando aplicada, a marca de tradução e o idioma original. Omitida quando nenhuma imagem foi descrita. A descrição exibida é a tradução quando aplicada, senão o original. |
Denylist de texto. O ModeracaoTextoService delega ao helper compartilhado src/shared/moderacao/denylist-texto.ts: lerDenylistTextoDoAmbiente() lê MODERACAO_DENYLIST_TEXTO (lista separada por vírgula), normalizarTexto tira acento e caixa e verificarDenylist casa termo e frase no título e na descrição do cidadão já limpos. As legendas automáticas ficam fora da verificação: a imagem segue pela classificação própria da D-1c e o título derivado da legenda continua sinalizável. Lista vazia ou ausente mantém o comportamento sem filtro. A lista fica restrita ao secret de ambiente e não é publicada. O resultado vira conteudo_suspeito e termos_suspeitos em d1b.normalizacoes e no payload. O conteúdo suspeito entra na fila da D-1d e fica fora da vitrine até a aprovação. A D-6b usa o mesmo helper na publicação do relato do conselheiro, digitado ou transcrito.
3.2.1 Evento produzido: conselheiro.atualização_transcrita
Seção intitulada “3.2.1 Evento produzido: conselheiro.atualização_transcrita”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.atualização_transcrita |
| Schema version | 1.0.0 |
| Produtor | D-1b |
| Consumidor | D-6b (Relatoria e Acompanhamento) |
| Descrição | Resultado da transcrição do relato em áudio do conselheiro. O atualizacao_event_id é o event_id do conselheiro.atualização_registrada correspondente e é a chave de correlação entre as duas colônias. |
Payload publicado:
interface ConselheiroAtualizacaoTranscritaPayload { atualizacao_event_id: string; // event_id do atualização_registrada conselheiro_id: string; demanda_id: string; unidade_civica_id: string; sucesso: boolean; texto_transcrito: string; // vazio quando sucesso = false confianca: number; // CONFIANCA_TRANSCRICAO (0,75) motivo_falha?: string; // presente na falha; removido na redação timestamp: string;}Regras de publicação:
- Com sucesso, o payload leva
sucesso = true, o texto transcrito e a confiança deCONFIANCA_TRANSCRICAO. - Sem sucesso, o payload leva
sucesso = false,texto_transcritovazio,confianca = 0emotivo_falhacom o erro outranscricao_indisponivelquando o serviço está desabilitado. A D-6b marca a atualização pendente com falha e nada é publicado na timeline. - A publicação usa
publicarComRetry. Se a publicação falhar de forma definitiva, o cursor não avança, o evento de entrada vai para a DLQ e o replay do boot seguinte retenta. A linha pendente da D-6b é encontrada porevent_id_publicacao, então a reentrega não duplica a publicação. - O
motivo_falhaé removido na redação docore.event_log; replay e DLQ entregam o payload sem ele.
3.3 Ordem de operações
Seção intitulada “3.3 Ordem de operações”O fluxo no D1bService.processarDemandaRecebida() segue esta ordem:
1. Verificar idempotência de consumo → consultar d1b.eventos_processados por event_id → se encontrado: atualizar o consumer offset e retornar sem processar → se não encontrado: continuar
2. Extrair dados do evento → demanda_id = evento.payload.demanda_id → texto_bruto = evento.payload.texto_bruto ?? '' → tipo_midia = evento.payload.tipo_midia ?? 'texto' → lat = evento.payload.localizacao_bruta.lat → lng = evento.payload.localizacao_bruta.lng → midia_urls = evento.payload.midia_urls ?? []
3. Validar coordenadas → resultadoGeo = validacaoGeoService.validar(lat, lng) → problemas registrados em processamento_detalhes.problemas_geo → coordenadas_validadas = { lat, lng }, sem rejeitar a demanda
4. Limpar o texto (quando texto_bruto não está vazio) → resultadoLimpeza = limpezaTextoService.limpar(texto_bruto) → descricao_limpa = resultadoLimpeza.textoLimpo → idioma_detectado = resultadoLimpeza.idioma → alterações registradas em processamento_detalhes.limpeza_alteracoes → titulo = limpezaTextoService.extrairTitulo(descricao_limpa) → se titulo vazio e descricao_limpa não vazia: titulo = primeiros 200 caracteres
5. Processar a mídia (na sequência da limpeza)
SE tipo_midia == 'audio' E midia_urls não está vazio: → se transcricaoAudioService.estaDisponivel(): → urlInterna = assinarUrlGetInterna(midia_urls[0].object_key, 300) → resultadoAudio = temporizadorIaService.executarComTimeout( (sinal) => transcricaoAudioService.transcrever(urlInterna, sinal), 60000) → confianca_audio = resultadoAudio.confianca → se resultadoAudio.texto não vazio E texto_bruto vazio: descricao_limpa = resultadoAudio.texto → tipo_midia_processada = 'texto' → audioTranscrito = true → se indisponível: tipo_midia_processada = 'audio' → se falha ou timeout: mantém o tipo original, registra a falha e penaliza a confiança
SE tipo_midia == 'foto' E midia_urls não está vazio: → se descricaoImagemService.estaDisponivel(): → percorrer cada item de imagem de midia_urls, em sequência, com o modelo já carregado: → urlInterna = assinarUrlGetInterna(item.object_key, 300) → resultadoImagem = temporizadorIaService.executarComTimeout( (sinal) => descricaoImagemService.descrever(urlInterna, sinal), 180000) → legenda = traducaoLegenda(resultadoImagem.descricao) → acumular em midias_descritas: object_key, descricao_original, traducao_aplicada, descricao_idioma e descricao_traduzida quando aplicada → na primeira imagem descrita: primeiraLegendaExibivel recebe a legenda exibível (tradução quando aplicada, senão o original) e confianca_imagem = resultadoImagem.confianca → a falha de uma imagem é isolada e não impede as demais → a legenda não compõe a descricao_limpa em nenhum caso → tipo_midia_processada = 'texto' → imagemDescrita = true → se indisponível: tipo_midia_processada = 'foto' → se falha ou timeout: mantém o tipo original, registra a falha e penaliza a confiança
6. Fechar os campos de texto → truncar descricao_limpa em 5000 caracteres, cortando na última palavra quando o último espaço passa de 4900 → titulo final = titulo do cidadão ou a primeira legenda exibível truncada em TITULO_MAX; se vazio, 'Sem título' → a descricao_limpa segue possivelmente vazia, string aceita pela versão 1.4.0
7. Verificar a denylist de texto → resultadoModeracao = moderacaoTextoService.verificar(titulo, descricao_limpa) → suspeição registrada em processamento_detalhes.moderacao_texto_suspeito
8. Extrair entidades (quando o texto de análise não está vazio) → textoAnalise = descricao_limpa e legendas exibíveis das imagens, uma por linha → entidades = extracaoEntidadesService.extrair(textoAnalise) → se null: entidades_extraidas não entra no payload
9. Calcular o score de confiança → calculoConfiancaService.calcular({ textoOriginal: texto_bruto, textoLimpo: textoAnalise, entidadesDetectadas: entidades !== null, geoValida: resultadoGeo.valido, tipoMidiaOriginal: tipo_midia, audioTranscrito, imagemDescrita, confiancaAudio, confiancaImagem, idiomaReconhecido: idioma_detectado !== 'unknown', })
10. Persistir a normalização → INSERT em d1b.normalizacoes com os campos, conteudo_suspeito, termos_suspeitos e versao = 1
11. Publicar demanda.normalizada → eventBus.publicar({ tipo: 'demanda.normalizada', origem: 'D-1b', versao_schema: '1.4.0', event_id: uuidv4(), correlacao_id: evento.correlacao_id ?? demanda_id, payload: { demanda_id, titulo, descricao_limpa, tipo_midia_processada, coordenadas_validadas, confianca_normalizacao, origem_normalizacao: 'automatico', conteudo_suspeito, termos_suspeitos, // omitido quando não há suspeição entidades_extraidas, // omitido quando vazio idioma_detectado, categoria_id, // repassado quando presente subcategoria_id, // repassado quando presente midias_descritas, // omitido quando nenhuma imagem foi descrita } }) → falha de publicação propaga a exceção; o evento de entrada vai para a DLQ
12. Registrar idempotência de consumo → INSERT em d1b.eventos_processados (event_id, demanda_id) → falha de gravação é ignorada
13. Atualizar o consumer offset → UPSERT em d1b.consumer_offset (tipo_evento, last_sequence, updated_at) com a sequência do evento3.4 Idempotência — duas camadas
Seção intitulada “3.4 Idempotência — duas camadas”Camada 1 — Consumo de evento (eventos_processados):
O event_id do demanda.recebida e do conselheiro.atualização_registrada é a chave de idempotência. Se o barramento reentregar o mesmo evento, a PK event_id em d1b.eventos_processados detecta e o handler retorna sem processar. Garante at-most-once para eventos completos.
Camada 2 — Publicação de evento (event_id):
O EventBusService.publicar() usa event_id como chave de idempotência. Se a D-1b chamar publicar() com o mesmo event_id duas vezes, o barramento retorna o registro existente sem reemitir. O event_id publicado pela D-1b é um UUID v4 novo (não o event_id do evento consumido), para distinguir o evento de origem do evento derivado.
3.5 Tratamento de erro e reentrega
Seção intitulada “3.5 Tratamento de erro e reentrega”| Cenário | Comportamento |
|---|---|
demanda.recebida com texto_bruto vazio e tipo_midia texto |
Normalização prossegue. titulo = 'Sem título', descricao_limpa vazia, confiança calculada pela heurística. Pipeline não é bloqueado. |
tipo_midia = foto com imagens descritas e sem texto do cidadão |
titulo usa a primeira legenda exibível truncada em TITULO_MAX, descricao_limpa fica vazia e midias_descritas leva um item por imagem. A versão publicada é a 1.4.0. |
tipo_midia = audio sem midia_urls |
tipo_midia_processada = audio. Transcrição não ocorre. descricao_limpa usa apenas texto_bruto. confianca_normalizacao penalizada. |
tipo_midia = foto sem midia_urls |
tipo_midia_processada = foto. Descrição não ocorre. descricao_limpa usa apenas texto_bruto. confianca_normalizacao penalizada. |
| Whisper indisponível (processo não iniciado, crash, timeout) | transcricaoAudioService.estaDisponivel() retorna false. Transcrição pulada. Pipeline continua sem áudio transcrito. |
| Modelo de visão indisponível | descricaoImagemService.estaDisponivel() retorna false. Descrição pulada. Pipeline continua sem descrição de imagem. |
| Timeout de transcrição (60s) | TemporizadorIaService lança TimeoutError com cancelamento real (AbortController). Transcrição abortada. Pipeline continua com texto_bruto. |
| Timeout de descrição (180s) | Idem para imagem. |
INSERT em d1b.normalizacoes falha |
Erro de infraestrutura. Exceção propagada para o wrapper do barramento. Evento vai para DLQ e será reprocessado. |
INSERT em d1b.eventos_processados falha |
Qualquer falha de gravação é ignorada. O evento já foi persistido e publicado; a proteção de idempotência fica registrada quando a gravação funciona. |
eventBus.publicar() falha |
O registro no banco existe e o evento não é publicado. A exceção propaga para o wrapper do barramento e o evento de entrada vai para a DLQ. No MVP a republicação é manual, pelo operador; a reconciliação automática de órfãos é Fase 2. |
| Handler lança exceção não tratada | Barramento captura via wrapper, registra na DLQ. Na reentrega, idempotência por event_id evita duplicação. |
Evento demanda.recebida na versão 1.1.0 (com categoria_id e subcategoria_id) |
Backward-compatible. A D-1b ignora os campos que não usa e repassa categoria e subcategoria no payload de saída. |
| Coordenadas (0, 0) ou claramente inválidas | validacaoGeoService detecta e registra em processamento_detalhes. coordenadas_validadas mantém os valores brutos. confianca_normalizacao é penalizada. Pipeline não é bloqueado. A D-2 lidará com a resolução geo. |
conselheiro.atualização_registrada sem audio_object_key |
A D-1b registra o event_id em d1b.eventos_processados e segue sem transcrever. Nenhum evento de transcrição é publicado. |
| Whisper indisponível no relato do conselheiro | A D-1b publica conselheiro.atualização_transcrita com sucesso = false e motivo_falha = 'transcricao_indisponivel'. A D-6b marca a atualização pendente com falha. |
| Falha ou timeout (60s) na transcrição do relato | O TemporizadorIaService aborta a operação e a D-1b publica o evento de transcrição com sucesso = false e o motivo do erro. A atualização não é publicada na timeline. |
eventBus.publicar() da transcrição falha de forma definitiva |
A exceção propaga, o cursor não avança, o evento de entrada vai para a DLQ e o replay do boot seguinte retenta. |
conselheiro.atualização_transcrita reentregue no replay |
A D-6b encontra a linha pendente por event_id_publicacao e não duplica a publicação. |
3.6 Decisões de design com justificativa
Seção intitulada “3.6 Decisões de design com justificativa”Publicar sempre, mesmo com baixa confiança.
A D-1b publica demanda.normalizada em todos os casos, inclusive quando confianca_normalizacao está próxima de 0. O campo confianca_normalizacao é metadado informativo para as colônias a jusante (D-2, D-3) e para a transparência (D-7). Bloquear a publicação travaria o pipeline inteiro atrás de um gargalo de qualidade que não compete à D-1b resolver. A D-3, por exemplo, já tem seu próprio mecanismo de confiança e revisão. Ela pode decidir tratar diferentemente demandas com baixa confiança de normalização. A D-7 exibe o score publicamente, permitindo escrutínio.
Não consumir anexo.processado no MVP.
A D-1b processa a partir do texto_bruto e dos midia_urls já presentes no demanda.recebida. Consumir anexo.processado exigiria aguardar o processamento assíncrono da D-1c antes de publicar demanda.normalizada, criando um acoplamento temporal que atrasa o pipeline principal. Na Fase 2, a D-1b pode consumir anexo.processado para enriquecer metadados ou disparar reprocessamento quando novos anexos são adicionados a uma demanda existente.
Tipo de mídia processada pode diferir do original.
Áudio transcrito com sucesso vira texto. Imagem descrita com sucesso vira texto. Isso é intencional: a D-3 categoriza a partir de texto. Se a D-1b convertesse áudio em texto mas mantivesse tipo_midia_processada = audio, a D-3 precisaria saber interpretar essa combinação. Ao reportar texto, a D-1b sinaliza que o conteúdo já está em formato processável pelas colônias a jusante. O dado bruto original (áudio, foto) permanece acessível via demanda_id e via D-1c.
Precedência entre texto bruto, transcrição e descrição.
A descricao_limpa é exclusiva do cidadão. Com texto e áudio, o texto prevalece e a transcrição alimenta apenas o score de confiança. Sem texto do cidadão, a transcrição de áudio pode preencher a descrição, porque o áudio é uma forma de fala do próprio cidadão. A legenda de imagem, que é inferência sobre o conteúdo, nunca compõe a descrição: ela fica em midias_descritas, alimenta o título quando não há texto do cidadão e entra no texto de análise interno junto com a descrição. Essa separação preserva a autoria do texto publicado.
Coordenadas validadas = coordenadas brutas quando passam na validação. A D-1b não altera coordenadas. Apenas verifica consistência e registra problemas. Se as coordenadas são (0, 0), elas são publicadas como (0, 0) com confiança reduzida. A D-2 (Georreferenciamento) é a colônia responsável por resolver coordenadas para unidades cívicas. Se a D-1b “corrigisse” coordenadas, estaria tomando uma decisão de negócio que compete à D-2 (e potencialmente introduzindo erro). A separação é clara: D-1b valida consistência, D-2 resolve território.
4. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”4.1 LimpezaTextoService — pseudocódigo
Seção intitulada “4.1 LimpezaTextoService — pseudocódigo”função limpar(textoBruto: string) → { textoLimpo, idioma, alteracoesAplicadas }:
alteracoes = []
// 1. Remover tags HTML texto = removerTagsHtml(textoBruto) se texto != textoBruto: alteracoes.push('html_removido')
// 2. Decodificar entidades HTML texto = decodificarEntidadesHtml(texto)
// 3. Normalizar Unicode (NFC) normalizado = texto.normalize('NFC') se normalizado != texto: alteracoes.push('unicode_normalizado') texto = normalizado
// 4. Remover caracteres de controle (exceto \n e \t) texto = removerCaracteresControle(texto)
// 5. Colapsar espaços múltiplos e linhas em branco texto = texto.replace(/[ \t]+/g, ' ') texto = texto.replace(/\n{3,}/g, '\n\n') texto = texto.trim()
// 6. Detectar idioma idioma = detectarIdioma(texto) se idioma == 'unknown': alteracoes.push('idioma_default')
// 7. Truncar em 5000 caracteres mantendo palavras inteiras se texto.length > 5000: texto = texto.substring(0, 5000) ultimoEspaco = texto.lastIndexOf(' ') se ultimoEspaco > 4000: texto = texto.substring(0, ultimoEspaco) alteracoes.push('truncado')
retornar { textoLimpo: texto, idioma: idioma == 'unknown' ? 'pt' : idioma, alteracoesAplicadas: alteracoes, }A detecção de idioma é heurística própria, sem biblioteca. O serviço pontua padrões regex de português, inglês e espanhol; empate favorece pt. Texto vazio já retorna pt. Texto sem nenhum acerto retorna unknown, a limpeza grava pt e registra a alteração idioma_default.
4.2 LimpezaTextoService.extrairTitulo() — pseudocódigo
Seção intitulada “4.2 LimpezaTextoService.extrairTitulo() — pseudocódigo”função extrairTitulo(textoLimpo: string) → string:
se textoLimpo vazio: retornar ''
// 1. Extrair primeira linha ou primeira sentença (até primeiro .!?\n) primeiraLinha = textoLimpo.split('\n')[0].trim() primeiraSentenca = primeiraLinha.split(/[.!?]/)[0].trim()
// 2. Usar a primeira sentença se tiver pelo menos 10 caracteres candidato = primeiraLinha se primeiraSentenca.length >= 10 E primeiraSentenca.length < primeiraLinha.length: candidato = primeiraSentenca
// 3. Remover palavras de preenchimento comuns no início padroesInicio = [ /^(ola|oi|bom dia|boa tarde|boa noite)[,:\s]+/i, /^(gostaria de|queria|preciso de|estou precisando de)\s+/i, /^(por favor|pfvr|pfv)[,:\s]+/i, /^(obrigado|obrigada|valeu|brigado)[,:\s]+/i, ] para cada padrao em padroesInicio: candidato = candidato.replace(padrao, '').trim()
// 4. Capitalizar primeira letra se candidato.length > 0: candidato = candidato[0].toUpperCase() + candidato.substring(1)
// 5. Truncar em 200 caracteres mantendo palavra inteira se candidato.length > 200: candidato = candidato.substring(0, 200) ultimoEspaco = candidato.lastIndexOf(' ') se ultimoEspaco > 150: candidato = candidato.substring(0, ultimoEspaco)
retornar candidato4.3 ExtracaoEntidadesService — pseudocódigo
Seção intitulada “4.3 ExtracaoEntidadesService — pseudocódigo”função extrair(textoLimpo: string) → EntidadesExtraidas | null:
entidades = {}
// 1. Extrair CEP (padrão brasileiro: 00000-000 ou 00000000) matchCep = textoLimpo.match(/\b(\d{5})-?(\d{3})\b/) se matchCep: entidades.cep = matchCep[1] + '-' + matchCep[2]
// 2. Endereço declarado no formato "endereço: X" tem precedência e encerra a extração matchEnderecoLiteral = textoLimpo.match(/(endereço|end\.?|local|localização)\s*[:;]\s*(.+?)(\n|$|\.)/i) se matchEnderecoLiteral: entidades.endereco = matchEnderecoLiteral[2].trim() retornar entidades
// 3. Endereço com prefixo de logradouro (rua, avenida, travessa, praça, alameda, estrada, rodovia, br-) padraoEndereco = /(rua|avenida|av\.?|travessa|tv\.?|praça|pça\.?|alameda|estrada|rodovia|br-\d{3})\s+([^\n,;.]+)/i matchEndereco = textoLimpo.match(padraoEndereco) se matchEndereco: entidades.endereco = matchEndereco[0].trim() entidades.nome_rua = (matchEndereco[1] + ' ' + matchEndereco[2]).trim()
retornar Object.keys(entidades).length > 0 ? entidades : nullQuando o texto traz o padrão “endereço: X”, a extração para no passo 2 e o nome_rua não é preenchido. O CEP do passo 1 permanece no resultado.
A extração de entidades no MVP usa regex estruturado. spaCy com modelo pt-BR é referência para evolução na Fase 2, quando o volume e a variedade de padrões textuais justificarem uma dependência de NLP. O regex cobre os casos mais comuns de endereçamento brasileiro e CEP, que são as entidades relevantes para o pipeline a jusante (D-2 precisa saber se há endereço textual para geocodificação).
4.4 TranscricaoAudioService — pseudocódigo
Seção intitulada “4.4 TranscricaoAudioService — pseudocódigo”função transcrever(midiaUrl: string, sinal?: AbortSignal) → { texto, confianca }:
// midiaUrl é a URL interna assinada pelo D1bService a partir do object_key (TTL de 300 s) se NÃO validarUrlMidia(midiaUrl, obterHostsMidiaPermitidos()): lançar Error('URL de mídia fora dos hosts permitidos')
// 1. Baixar o áudio da URL assinada resposta = await fetch(midiaUrl, { signal: sinal }) se !resposta.ok: lançar Error('Falha ao baixar áudio: HTTP ...') se content-length declarado > 5 MB: lançar Error antes de consumir o corpo buffer = await resposta.arrayBuffer()
// 2. Decodificar e reamostrar para 16 kHz decodificar = await carregarAudioDecode() decodificado = await decodificar(new Uint8Array(buffer)) reamostrado = reamostrar(decodificado.channelData[0], decodificado.sampleRate, 16000)
// 3. Chamar o Whisper local via @huggingface/transformers funcao = await carregarFuncao() // carregamento preguiçoso, cacheado no processo resultado = await funcao(reamostrado, { language: 'portuguese', task: 'transcribe' }) texto = trechos do resultado unidos por espaço
retornar { texto, confianca: CONFIANCA_TRANSCRICAO } // 0,75estaDisponivel() devolve D1B_TRANSCRICAO_HABILITADA (default true). O D1bService só chama transcrever() quando o serviço está disponível.
Referência de implementação: whisper-tiny (Xenova/whisper-tiny) via @huggingface/transformers v4 no mesmo processo Node.js do monolito. Pesos de ~75 MB, inferência ~1x tempo real em CPU. Carregamento preguiçoso no primeiro uso. O download do modelo é cacheado no diretório de D1B_CACHE_MODELOS, apontado para o volume modelos_d1b no docker-compose. Fallback: se o modelo não está disponível, a D-1b funciona sem ele e penaliza a confiança.
4.5 DescricaoImagemService — pseudocódigo
Seção intitulada “4.5 DescricaoImagemService — pseudocódigo”função descrever(imagemUrl: string, sinal?: AbortSignal) → { descricao, confianca }:
se sinal?.aborted: lançar Error('Descrição de imagem cancelada antes de iniciar')
se NÃO validarUrlMidia(imagemUrl, obterHostsMidiaPermitidos()): lançar Error('URL de mídia fora dos hosts permitidos')
// 1. Carregar o modelo e o processador (preguiçoso, cacheado no processo) modulo = await carregarTransformers() { modelo, processador } = await obterModelo(modulo)
// 2. Descrever a imagem imagem = await modulo.load_image(imagemUrl) prompts = processador.construct_prompts(tarefa) // padrão <MORE_DETAILED_CAPTION> entradas = await processador(imagem, prompts) ids = await modelo.generate({ ...entradas, max_new_tokens: 256 }) texto = processador.batch_decode(ids, { skip_special_tokens: false })[0] ?? '' resultado = processador.post_process_generation(texto, tarefa, imagem.size)
descricao = (resultado[tarefa] ?? '').trim() confianca = CONFIANCA_DESCRICAO_IMAGEM // 0,70
retornar { descricao, confianca }estaDisponivel() devolve D1B_DESCRICAO_IMAGEM_HABILITADA (default true). O D1bService só chama descrever() quando o serviço está disponível.
Referência de implementação: Florence-2-base (onnx-community/Florence-2-base) via @huggingface/transformers v4 (JavaScript, mesmo processo Node.js do monolito). Modelo de visão multimodal com legendas por tarefa (<CAPTION>, <DETAILED_CAPTION>, <MORE_DETAILED_CAPTION>) e capacidades futuras de detecção de objetos e OCR para outras colônias. Pesos fp32 de ~1,06 GB, carregamento preguiçoso no primeiro uso e reutilização nas chamadas seguintes. Inferência em CPU de 15 a 45 segundos por legenda detalhada em 2 vCPU. O processamento é assíncrono e não bloqueia o cidadão. A tarefa padrão é <MORE_DETAILED_CAPTION>, configurável via D1B_TAREFA_DESCRICAO. O dtype dos pesos é configurável via D1B_DTYPE_MODELOS (fp32 padrão, fp16 e q8 como emergência de memória). O download do modelo ocorre no primeiro uso e é cacheado no diretório de D1B_CACHE_MODELOS, apontado para o volume modelos_d1b no docker-compose. Fallback: se o modelo não está disponível, a D-1b funciona sem ele e penaliza a confiança, usando apenas o texto_bruto.
Tradução da legenda (TraducaoService).
função traduzir(texto: string) → ResultadoTraducaoTexto | null:
se NÃO estaDisponivel(): retornar null se texto.trim() vazio: retornar null se estaEmPortugues(texto): retornar null // acentos ou dois marcadores do português carregar o pipeline de tradução (preguiçoso, cacheado no processo) resultado = pipeline(texto) // OPUS-MT/Marian dedicado en→pt retornar { texto: trechos traduzidos unidos, motor: 'opus-mt' } // falha de carga ou de inferência: log de warning e retorna nullO serviço é habilitado por D1B_TRADUCAO_HABILITADA (default true). O modelo vem de D1B_MODELO_TRADUCAO, com default TigreGotico/opus-mt-en-pt-onnx (OPUS-MT/Marian dedicado en→pt, pesos int8), com o tokenizer de R4kSo1997/opus-mt-en-pt-onnx-int8 e o cache em D1B_CACHE_MODELOS, no mesmo volume modelos_d1b. A tradução acontece depois do Florence, para cada imagem descrita, e alimenta a legenda exibível de midias_descritas; ela não compõe a descricao_limpa. Quando aplica, o processamento_detalhes grava descricao_original, descricao_traduzida, traducao_motor e traducao_aplicada: true, com descricao_idioma: 'pt' para a primeira legenda. Quando retorna nulo, o original é mantido e os detalhes gravam traducao_aplicada: false e descricao_idioma (pt quando a legenda já parece português, en quando não parece). A confiança da descrição permanece a do Florence. O custo de memória do modelo fica no mesmo orçamento dos demais modelos da D-1b.
4.6 ValidacaoGeoService — pseudocódigo
Seção intitulada “4.6 ValidacaoGeoService — pseudocódigo”função validar(lat: number, lng: number) → { valido, problemas, lat, lng }:
problemas = []
// Bounding box do Brasil (valores aproximados) LAT_MIN = -33.75 LAT_MAX = 5.27 LNG_MIN = -73.99 LNG_MAX = -28.84 // caixa única do Brasil, inclui Fernando de Noronha
se lat < LAT_MIN ou lat > LAT_MAX: problemas.push('latitude_fora_brasil') se lng < LNG_MIN ou lng > LNG_MAX: problemas.push('longitude_fora_brasil')
// Anti-patterns comuns se lat == 0 E lng == 0: problemas.push('coordenadas_zero') // GPS não inicializado se lat == lng: problemas.push('coordenadas_identicas') // erro de preenchimento se Math.abs(lat) < 0.01 E Math.abs(lng) < 0.01: problemas.push('proximo_zero') // GPS com precisão degradada
retornar { valido: problemas.length == 0, problemas, lat, lng }A validação geo da D-1b é a bounding box única do Brasil, com o limite leste em -28,84 para incluir Fernando de Noronha. Não usa polígonos, não consulta base de unidades cívicas, não faz point-in-polygon. O objetivo é capturar erros grosseiros (GPS desligado, coordenadas 0,0, ponto fora do território nacional) que poluiriam o pipeline. A resolução territorial fina é responsabilidade exclusiva da D-2. Pontos fora de todos os polígonos ficam a cargo da marcação de revisão da D-2.
4.7 CalculoConfiancaService — pseudocódigo
Seção intitulada “4.7 CalculoConfiancaService — pseudocódigo”função calcular(resultado: ResultadoNormalizacao) → number:
score = 1.0 pesos = { texto_presente: 0.30, texto_limpo: 0.20, geo_valida: 0.15, audio_transcrito: 0.10, // só se tipo_midia original for audio imagem_descrita: 0.10, // só se tipo_midia original for foto idioma_reconhecido: 0.05, } bonus_entidades = 0.05
// 1. Texto presente com conteúdo mínimo se resultado.textoLimpo.length < 10: score -= pesos.texto_presente
// 2. Limpeza produziu alterações (indica que havia ruído) // O texto bruto tinha o que limpar // Só penaliza se o resultado final ficou vazio após limpeza se resultado.textoOriginal.length > 0 E resultado.textoLimpo.length == 0: score -= pesos.texto_limpo
// 3. Entidades detectadas se resultado.entidadesDetectadas: // Bônus: não penaliza se não detectou (texto pode não ter endereço) score = Math.min(1.0, score + bonus_entidades)
// 4. Coordenadas válidas se NÃO resultado.geoValida: score -= pesos.geo_valida
// 5. Áudio transcrito com sucesso (se aplicável) se resultado.tipoMidiaOriginal == 'audio': se resultado.audioTranscrito: // Confiança da transcrição afeta o score penalidadeAudio = (1 - resultado.confiancaAudio) * pesos.audio_transcrito score -= penalidadeAudio senão: score -= pesos.audio_transcrito
// 6. Imagem descrita com sucesso (se aplicável) se resultado.tipoMidiaOriginal == 'foto': se resultado.imagemDescrita: penalidadeImagem = (1 - resultado.confiancaImagem) * pesos.imagem_descrita score -= penalidadeImagem senão: score -= pesos.imagem_descrita
// 7. Idioma reconhecido se NÃO resultado.idiomaReconhecido: score -= pesos.idioma_reconhecido
retornar Math.max(0, Math.min(1, Math.round(score * 100) / 100))Os pesos ficam em PESOS_CONFIANCA e o bônus por entidades detectadas em BONUS_ENTIDADES_DETECTADAS (0,05), ambos em d1b.constants.ts. O bônus apenas soma ao score; texto sem endereço não é penalizado. A parametrização via env fica para a calibração futura. A heurística é simples e explicável: cada dimensão do processamento contribui com uma fração do score total. O breakdown do score é registrado em processamento_detalhes para auditoria e calibração futura.
4.8 TemporizadorIaService — contrato
Seção intitulada “4.8 TemporizadorIaService — contrato”O timeout usa AbortController com cancelamento real. O AbortSignal é passado à função e o abort converte a chamada em TimeoutError. Assinatura: executarComTimeout<T>(fn: (sinal: AbortSignal) => Promise<T>, timeoutMs: number).
Garante que chamadas aos modelos locais não bloqueiem o pipeline indefinidamente. Se o Whisper travar, após 60s o timeout dispara e o pipeline continua com fallback. Para descrição de imagem, o timeout é de 180s. O timeout é registrado em processamento_detalhes.
5. Integração com o Barramento e Outras Colônias
Seção intitulada “5. Integração com o Barramento e Outras Colônias”5.1 Registro de consumidores — ciclo de vida
Seção intitulada “5.1 Registro de consumidores — ciclo de vida”const TIPOS_EVENTO_CONSUMIDOS = [ 'demanda.recebida', 'conselheiro.atualização_registrada',] as const;
@Injectable()export class D1bService { private iniciado = false;
async iniciar(): Promise<void> { if (this.iniciado) return; this.iniciado = true;
await this.protocolo.iniciar({ colonia: 'D-1b', tipos: TIPOS_EVENTO_CONSUMIDOS, offset: this.offset, manipulador: (evento) => this.despacharConsumo(evento), }); }
private despacharConsumo( evento: EventoDe<(typeof TIPOS_EVENTO_CONSUMIDOS)[number]>, ): Promise<void> { if (evento.tipo === 'conselheiro.atualização_registrada') { return this.processarAtualizacaoConselheiro( evento as EventoDe<'conselheiro.atualização_registrada'>, ); } return this.processarDemandaRecebida(evento as EventoDe<'demanda.recebida'>); }}O D1bModule chama iniciar() no OnModuleInit. O ProtocoloConsumoService centraliza o ciclo: semeia o cursor de cada tipo com obterMaiorSequence() sem sobrescrever, refaz o replay por tipo pelo caminho protegido e registra o manipulador ao vivo no fim. O replay cobre o gap entre o último evento processado e o momento atual. O despacharConsumo roteia pelo evento.tipo entre a normalização da demanda e a transcrição do relato do conselheiro. Padrão consistente com a seção 5.2 (Consumo — como uma colônia consome) do N-0a - Event Bus.md.
5.2 Publicação no barramento
Seção intitulada “5.2 Publicação no barramento”// Dentro de D1bService.processarDemandaRecebida()// Após persistir a normalização
const novoEventId = uuidv4();
await this.eventBus.publicar({ tipo: 'demanda.normalizada', origem: 'D-1b', versao_schema: '1.4.0', event_id: novoEventId, correlacao_id: evento.correlacao_id ?? demandaId, payload: { demanda_id, titulo, descricao_limpa, tipo_midia_processada, coordenadas_validadas: { lat, lng }, confianca_normalizacao, origem_normalizacao: 'automatico', conteudo_suspeito: resultadoModeracao.suspeito, ...(resultadoModeracao.suspeito ? { termos_suspeitos: resultadoModeracao.termos } : {}), ...(entidades && Object.keys(entidades).length > 0 ? { entidades_extraidas: entidades } : {}), idioma_detectado, ...(categoriaId !== undefined ? { categoria_id: categoriaId } : {}), ...(subcategoriaId !== undefined ? { subcategoria_id: subcategoriaId } : {}), ...(midiasDescritas.length > 0 ? { midias_descritas: midiasDescritas } : {}), },});5.3 Chamadas síncronas via BFF
Seção intitulada “5.3 Chamadas síncronas via BFF”A D-1b não possui BFF e não realiza chamadas síncronas. Toda comunicação é via barramento de eventos.
5.4 Dependências de projeções de leitura
Seção intitulada “5.4 Dependências de projeções de leitura”A D-1b não consome projeções de leitura de outras colônias. O estado necessário para o processamento está inteiramente contido no payload do evento demanda.recebida e no estado próprio (d1b.normalizacoes).
Na Fase 2, quando a D-1b consumir anexo.processado, poderá manter uma projeção local em d1b.anexos_demanda com os metadados de anexos relevantes para reprocessamento. No MVP, essa dependência não existe.
5.5 Integração com o front-end — caminho de contestação
Seção intitulada “5.5 Integração com o front-end — caminho de contestação”A D-1b não expõe endpoints. O caminho de contestação da normalização é implementado via D-7 (Transparência) e D-6b (Relatoria). A timeline pública exibe a normalização com seu confianca_normalizacao e link para o dado bruto. Um cidadão pode contestar a normalização via interface da D-7. A contestação gera um evento (Fase 2, colônia de correção) que dispara reprocessamento ou revisão manual. No MVP, a contestação é sinalizada como feedback e tratada manualmente pelo operador.
5.6 Mapa de fluxo de eventos
Seção intitulada “5.6 Mapa de fluxo de eventos”D-1a (Captura) │ ├── demanda.recebida │ │ │ ├── D-1b (Normalização) ← esta colônia │ │ │ │ │ └── demanda.normalizada │ │ │ │ │ ├── D-2 (Georreferenciamento) │ │ └── D-3 (Categorização) │ │ │ └── D-1c (Anexos) ← colônia irmã, paralela │ │ │ └── anexo.processado │ │ │ └── D-7 (Transparência) │ └── D-7 (Transparência) ← consome todos os eventos
D-1a (BFF do Conselheiro) │ └── conselheiro.atualização_registrada │ └── D-1b (Normalização) ← esta colônia │ └── conselheiro.atualização_transcrita │ └── D-6b (Relatoria e Acompanhamento)5.7 LGPD — limpeza do texto derivado pela N-0d
Seção intitulada “5.7 LGPD — limpeza do texto derivado pela N-0d”O texto derivado da normalização é alcançado por dois fluxos da N-0d, dentro da exceção transversal do núcleo:
- Eliminação a pedido do titular:
titulo,descricao_limpae as entidades (entidades_endereco,entidades_cep,entidades_nome_rua) são sobrescritos com o marcador da eliminação. - Remoção por moderação de conteúdo:
tituloedescricao_limpasão sobrescritos, as entidades são anuladas,termos_suspeitosfica vazio eprocessamento_detalhesvira objeto vazio na demanda atingida.
A operação é idempotente em core.remocoes_conteudo e ocorre no handler da N-0d, sem participação da D-1b. Nenhuma outra colônia escreve em d1b.normalizacoes.
6. Performance e Limites
Seção intitulada “6. Performance e Limites”6.1 Rate limiting
Seção intitulada “6.1 Rate limiting”A D-1b não aplica rate limiting próprio. Herda o fluxo de eventos do barramento, que já é regulado pelo rate limiting da D-1a (5 demandas por minuto por IP no MVP). O volume de entrada é o limitador natural.
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| Limite | Valor MVP | Justificativa |
|---|---|---|
texto_bruto máximo |
5000 caracteres | Já limitado pelo schema de demanda.recebida. A D-1b aplica truncamento adicional como safety net. |
titulo máximo |
200 caracteres | Definido no schema do Registry demanda.normalizada/1.4.0. |
descricao_limpa máxima |
5000 caracteres | Definido no schema. Truncado com preservação de palavra. |
| Imagens descritas por demanda | 10 | Um item de midias_descritas por imagem da captura. O teto é o de mídias por demanda da D-1c. |
| Tempo máximo de transcrição (timeout) | 60 segundos | Áudios típicos de demanda são < 60s. whisper-tiny processa ~1x tempo real em CPU. 60s cobre download, decode, reamostragem e inferência. |
| Tempo máximo de descrição de imagem (timeout) | 180 segundos | Florence-2-base gera legendas detalhadas em 15 a 45s em 2 vCPU. O processamento é assíncrono. 180s cobre download, pré-processamento e geração. |
| Tamanho máximo de áudio para download | 5 MB | Checado via content-length ANTES do download. Limite próprio da D-1b. |
| Tamanho máximo de imagem para download | Sem limite próprio na D-1b | A validação de 10 MB ocorre na D-1c. |
processamento_detalhes JSONB |
Sem limite rígido | Contém apenas metadados textuais (scores, nomes de serviço, flags). Não armazena binário. |
Consumer offset last_sequence |
BIGINT | Mesmo tipo do sequence_number do event_log. Escala até 9.2 × 10^18 eventos. |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”| Índice | Padrão de query |
|---|---|
normalizacoes_demanda_id_idx (demanda_id) |
Normalizações de uma demanda. WHERE demanda_id = ? ORDER BY versao DESC. |
normalizacoes_confianca_normalizacao_idx (confianca_normalizacao) |
Auditoria de baixa confiança. WHERE confianca_normalizacao < 0.5 ORDER BY criado_em DESC LIMIT 100. |
normalizacoes_idioma_detectado_idx (idioma_detectado) |
Análise de distribuição de idiomas. SELECT idioma_detectado, COUNT(*) ... GROUP BY idioma_detectado. |
eventos_processados_demanda_id_idx (demanda_id) |
Reconciliação: verificar se um demanda_id já foi processado. WHERE demanda_id = ?. |
Não existe índice dedicado por (demanda_id, versao). A ordenação por versão é feita na consulta, sobre o índice de demanda_id. No caminho quente, a idempotência usa a PK event_id de eventos_processados. Consultas por demanda_id nessa tabela são raras (reconciliação offline).
6.4 Estratégia de cache
Seção intitulada “6.4 Estratégia de cache”In-memory cache para bounding box e anti-patterns geo.
Os valores de bounding box são constantes de d1b.constants.ts, importadas pelo ValidacaoGeoService. Não mudam em runtime. Sem Redis.
Sem cache para resultados de normalização.
Cada demanda.recebida é processada exatamente uma vez (idempotência). O resultado é persistido e acessível via query ao banco. Não há cenário de reuso de normalização entre demandas diferentes. Cada demanda tem seu próprio texto.
Sem cache para modelos de IA. Whisper e Florence-2 são stateless por requisição. O modelo é carregado em memória uma vez, de forma preguiçosa, e reutilizado para todas as chamadas. Não há cache de resultados de transcrição/descrição porque o mesmo áudio/imagem não é processado duas vezes (idempotência).
7. Testabilidade
Seção intitulada “7. Testabilidade”7.1 Teste isolado do módulo
Seção intitulada “7.1 Teste isolado do módulo”// d1b.service.spec.ts — estrutura de testedescribe('D1bService', () => { let service: D1bService; let offsetRepo: OffsetRepoEmMemoria; // repositório de offset em memória
// Mock do Event Bus: o barramento é a única dependência externa do módulo const mockEventBus = { publicar: jest.fn(), inscrever: jest.fn(), replayDeSequence: jest.fn().mockResolvedValue([]), obterMaiorSequence: jest.fn().mockResolvedValue(0), };
// A assinatura da URL de mídia é mockada: // jest.mock('../../shared/midia/assinatura-midia') // Os serviços de IA (TranscricaoAudio, DescricaoImagem e Traducao) são sempre mockados, // junto com os repositórios e o ModeracaoTextoService.
beforeEach(async () => { const module = await Test.createTestingModule({ providers: [ D1bService, { provide: EventBusService, useValue: mockEventBus }, { provide: NormalizacaoRepository, useValue: mockNormalizacaoRepo }, { provide: EventoProcessadoRepository, useValue: mockEventoProcRepo }, { provide: ConsumerOffsetRepository, useValue: offsetRepo }, { provide: LimpezaTextoService, useValue: mockLimpeza }, { provide: ExtracaoEntidadesService, useValue: mockExtracao }, { provide: ValidacaoGeoService, useValue: mockValidacaoGeo }, { provide: CalculoConfiancaService, useValue: mockCalculo }, { provide: TranscricaoAudioService, useValue: mockTranscricao }, { provide: DescricaoImagemService, useValue: mockDescricao }, { provide: TraducaoService, useValue: mockTraducao }, { provide: TemporizadorIaService, useValue: mockTemporizador }, { provide: ModeracaoTextoService, useValue: mockModeracao }, ], }).compile();
service = module.get(D1bService); });});7.2 Cenários de teste críticos
Seção intitulada “7.2 Cenários de teste críticos”| ID | Cenário | Verificação |
|---|---|---|
| T1 | demanda.recebida com texto simples e coordenadas válidas |
demanda.normalizada publicado. 1 linha em d1b.normalizacoes. confianca_normalizacao ≥ 0.7. titulo extraído. descricao_limpa sem ruído. |
| T2 | demanda.recebida com texto contendo HTML e caracteres especiais |
descricao_limpa sem tags HTML. Unicode NFC. processamento_detalhes registra html_removido. |
| T3 | demanda.recebida com endereço e CEP no texto |
entidades_extraidas contém cep formatado e endereco. |
| T4 | demanda.recebida sem endereço no texto |
entidades_extraidas ausente do payload (não null, campo omitido). |
| T5 | demanda.recebida com texto em inglês |
idioma_detectado = en. Pipeline continua normalmente. |
| T6 | demanda.recebida com coordenadas (0, 0) |
coordenadas_validadas = (0, 0). confianca_normalizacao < 0.7. processamento_detalhes registra coordenadas_zero. Pipeline não bloqueado. |
| T7 | demanda.recebida com coordenadas fora do bounding box do Brasil |
confianca_normalizacao reduzida. processamento_detalhes registra latitude_fora_brasil/longitude_fora_brasil. Pipeline continua. |
| T8 | demanda.recebida com tipo_midia = audio e áudio disponível |
Transcrição chamada. tipo_midia_processada = texto. descricao_limpa contém transcrição. |
| T9 | demanda.recebida com tipo_midia = audio e Whisper indisponível |
tipo_midia_processada = audio. confianca_normalizacao penalizada. processamento_detalhes registra transcricao_indisponivel. |
| T10 | demanda.recebida com tipo_midia = foto e modelo de visão disponível, sem texto do cidadão |
Descrição chamada. tipo_midia_processada = texto. descricao_limpa vazia, midias_descritas com um item por imagem e titulo pela primeira legenda exibível. |
| T11 | demanda.recebida com tipo_midia = foto e modelo indisponível |
tipo_midia_processada = foto. Pipeline continua. |
| T12 | Timeout de transcrição (60s) | TemporizadorIaService lança TimeoutError. Pipeline continua sem transcrição. processamento_detalhes registra transcricao_falha. |
| T13 | Timeout de descrição de imagem (180s) | Idem para imagem, com descricao_imagem_falha. |
| T14 | Foto + texto: cidadão enviou foto COM descrição textual | descricao_limpa contém apenas o texto do cidadão, sem marcador de foto. A legenda automática fica em midias_descritas e o texto de análise interno compõe os dois. |
| T15 | Áudio + texto: cidadão enviou áudio COM texto complementar | descricao_limpa contém o texto do cidadão. A transcrição é descartada da descrição e a confiança dela entra no score. |
| T16 | Evento demanda.recebida reentregue (mesmo event_id) |
eventos_processados impede o reprocessamento e o cursor avança. Nenhum efeito colateral. |
| T17 | eventBus.publicar() falha após persistência |
normalizacoes tem a linha. demanda.normalizada não publicado. A exceção propaga e o evento de entrada vai para a DLQ. A reconciliação automática de órfãos é Fase 2. |
| T18 | texto_bruto com emojis e caracteres Unicode exóticos |
Normalização NFC preserva caracteres válidos. Emojis são mantidos (parte do conteúdo). Caracteres de controle removidos. |
| T19 | texto_bruto com 10000 caracteres |
Truncado para 5000 mantendo última palavra inteira. processamento_detalhes registra truncado. |
| T20 | Duas demandas com textos completamente diferentes em concorrência | Processamento paralelo independente. Cada uma gera sua própria normalização. Sem interferência. |
| T21 | Segunda normalização para a mesma demanda (Fase 2) | Nova linha com versao incrementada. Nenhum update in-place. No MVP cada demanda é processada uma vez e grava versao = 1. |
| T22 | demanda.recebida com tipo_midia = foto e duas imagens disponíveis |
As duas imagens são descritas e traduzidas em sequência. midias_descritas leva um item por imagem, com object_key, legenda original, tradução quando aplicada e idioma. A falha de uma imagem não impede a outra. |
7.3 Dados de seed para desenvolvimento local
Seção intitulada “7.3 Dados de seed para desenvolvimento local”-- Seed: demanda de texto simples (buraco na rua)INSERT INTO d1b.normalizacoes ( id, demanda_id, titulo, descricao_limpa, tipo_midia_processada, coordenadas_lat, coordenadas_lng, confianca_normalizacao, entidades_endereco, entidades_cep, entidades_nome_rua, idioma_detectado, origem_processamento, processamento_detalhes, versao) VALUES ( 'a1111111-1111-1111-1111-111111111111', 'd1111111-1111-1111-1111-111111111111', 'Buraco na Rua das Flores', 'Tem um buraco grande na Rua das Flores, esquina com a Avenida Brasil, número 123. Já causou acidentes com motos.', 'texto', -23.5505, -46.6333, 0.92, 'Rua das Flores, esquina com Avenida Brasil, número 123', '01310-000', 'Rua das Flores', 'pt', 'automatico', '{"servicos": ["limpeza_texto", "extracao_entidades", "validacao_geo"], "alteracoes": []}', 1);
-- Seed: demanda de áudio (transcrição simulada)INSERT INTO d1b.normalizacoes ( id, demanda_id, titulo, descricao_limpa, tipo_midia_processada, coordenadas_lat, coordenadas_lng, confianca_normalizacao, idioma_detectado, origem_processamento, processamento_detalhes, versao) VALUES ( 'a2222222-2222-2222-2222-222222222222', 'd2222222-2222-2222-2222-222222222222', 'Falta de água no bairro', 'Estou sem água há três dias no bairro Jardim das Oliveiras. A companhia de saneamento não atende as ligações.', 'texto', -22.9068, -43.1729, 0.78, 'pt', 'automatico', '{"servicos": ["transcricao_audio", "limpeza_texto", "extracao_entidades", "validacao_geo"], "transcricao": {"modelo": "Xenova/whisper-tiny", "confianca": 0.75, "tempo_s": 12.3}}', 1);
-- Seed: demanda com coordenadas problemáticasINSERT INTO d1b.normalizacoes ( id, demanda_id, titulo, descricao_limpa, tipo_midia_processada, coordenadas_lat, coordenadas_lng, confianca_normalizacao, idioma_detectado, origem_processamento, processamento_detalhes, versao) VALUES ( 'a3333333-3333-3333-3333-333333333333', 'd3333333-3333-3333-3333-333333333333', 'Iluminação pública apagada', 'Poste apagado na praça principal há duas semanas. Perigoso à noite.', 'texto', 0, 0, 0.55, 'pt', 'automatico', '{"servicos": ["limpeza_texto", "validacao_geo"], "geo": {"problemas": ["coordenadas_zero"]}}', 1);
-- Seed: consumer offsetINSERT INTO d1b.consumer_offset (tipo_evento, last_sequence) VALUES ('demanda.recebida', 42);8. Alinhamento com o MVP
Seção intitulada “8. Alinhamento com o MVP”8.1 MVP obrigatório (Fase 1)
Seção intitulada “8.1 MVP obrigatório (Fase 1)”| Funcionalidade | Status | Justificativa |
|---|---|---|
Consumo de demanda.recebida e publicação de demanda.normalizada |
MVP obrigatório | Sem normalização, a D-2 e a D-3 não recebem dados estruturados. É o elo entre captura e processamento inteligente. |
| Limpeza de texto (Unicode, HTML, caracteres de controle) | MVP obrigatório | Operação determinística, barata, essencial para o pipeline. |
| Extração de título | MVP obrigatório | Necessário para exibição em listas e dashboards pela D-7. |
| Extração de entidades (regex) | MVP obrigatório | D-2 usa entidades para geocodificação de endereços textuais. |
| Detecção de idioma | MVP obrigatório | Informação relevante para a D-3 (classificador pode ter comportamento diferente por idioma). |
| Validação de coordenadas (bounding box + anti-patterns) | MVP obrigatório | Captura erros grosseiros antes da D-2. Operação barata (comparações numéricas). |
| Cálculo de confiança da normalização | MVP obrigatório | Metadado essencial para transparência (D-7) e para a D-3 decidir tratamento. |
| Idempotência de consumo | MVP obrigatório | Requisito do barramento (at-least-once). Sem idempotência, eventos duplicados geram linhas duplicadas. |
| Consumer offset para replay | MVP obrigatório | Recuperação de quedas sem perda de eventos. |
8.2 Simplificações aceitáveis no MVP
Seção intitulada “8.2 Simplificações aceitáveis no MVP”| Funcionalidade | MVP | Fase 2 |
|---|---|---|
| Transcrição de áudio | whisper-tiny local via @huggingface/transformers, timeout 60s. Se indisponível, pipeline continua sem transcrição. |
Whisper medium/large, GPU, multi-idioma, diarização (identificar falantes). |
| Descrição de imagem | Florence-2-base local via @huggingface/transformers, legenda detalhada, timeout 180s, uma descrição por imagem da demanda em fluxo sequencial com o modelo já carregado. Se indisponível, pipeline continua sem descrição. |
Detecção de objetos e OCR no pipeline. Modelos maiores se houver GPU. |
| Tradução da legenda | OPUS-MT/Marian en→pt local via @huggingface/transformers, depois do Florence, por imagem. Motor desligado, falha ou legenda já em português mantém o original e registra descricao_idioma e traducao_aplicada nos detalhes. |
Tradução de outros idiomas e legendas de áudio. |
| Extração de entidades | Regex estruturado para CEP e endereço brasileiro. | spaCy com modelo pt-BR, NER completo (pessoas, organizações, datas, valores). |
| Detecção de idioma | Heurística própria por score de padrões regex (pt/en/es), sem biblioteca; empate favorece pt; zero ocorrências → unknown → pt. |
Modelo treinado para variantes do português (PT-BR, PT-PT, dialetos africanos). |
| Cálculo de confiança | Heurística com pesos fixos. | Modelo de regressão treinado sobre correlação entre scores e avaliações humanas. |
| Correção manual / contestação | Não implementado. Contestação é feedback manual do operador. | Evento normalizacao.contestada, fila de revisão, interface de moderação. |
Consumo de anexo.processado |
Não implementado. | D-1b consome evento para enriquecer normalização com metadados de anexos. |
| Reprocessamento automático | Não implementado. Se o modelo melhorar, reprocessamento é manual. | Trigger por nova versão de modelo ou parâmetro. |
8.3 O que vai para a Fase 2 (MLP)
Seção intitulada “8.3 O que vai para a Fase 2 (MLP)”- Modelos de IA mais potentes: Whisper medium/large, LLaVA ou similar, spaCy pt-BR.
- Pipeline de correção colaborativa: cidadãos e moderadores podem corrigir normalizações. Cada correção gera nova versão e alimenta dataset de treino.
- Reprocessamento em lote: quando um modelo é atualizado, todas as demandas antigas podem ser reprocessadas sob demanda.
- Extração de entidades avançada: pessoas mencionadas, organizações, referências legais, datas de ocorrência.
- Estruturação semântica: separar “problema” de “sugestão de solução”, identificar causa e efeito, extrair cronologia de eventos.
- Consumo de
anexo.processado: quando a D-1c termina de processar um anexo, a D-1b pode reprocessar a normalização incluindo metadados do anexo (tipo MIME, hash, descrição adicional). - Métricas de qualidade: dashboards de taxa de correção manual, evolução da confiança média, distribuição de idiomas.
- Scheduled job de reconciliação: detecta normalizações persistidas sem evento publicado e republica.
8.4 Verificação de conflitos com as demais colônias
Seção intitulada “8.4 Verificação de conflitos com as demais colônias”Sem conflitos com a D-1a. A D-1a publica demanda.recebida com texto_bruto e localizacao_bruta. A D-1b consome esses campos. O campo midia_urls acompanha o payload como campo adicional, fora do schema declarado. A versão 1.1.0 do schema, com categoria_id e subcategoria_id, não afeta a D-1b: os dois campos são repassados no evento de saída.
Sem conflitos com a D-1c. Ambas consomem demanda.recebida em paralelo. Operam sobre aspectos diferentes do mesmo evento (texto vs. binário). Seus eventos de saída (demanda.normalizada e anexo.processado) são independentes e consumidos por colônias diferentes. A ordem relativa de publicação não é garantida e não precisa ser. A D-7 consome ambos e monta a timeline eventualmente consistente.
Sem conflitos com a D-2. A D-2 consome demanda.normalizada e usa coordenadas_validadas e entidades_extraidas para resolver a unidade cívica. A validação leve da D-1b complementa, não compete, com o point-in-polygon da D-2.
Sem conflitos com a D-3. A D-3 consome demanda.normalizada e classifica pelo título, pela descrição do cidadão e pelas legendas automáticas das imagens (midias_descritas). A D-3 pode optar por tratar demandas com baixa confiança de normalização de forma diferente (ex: enfileirar para revisão manual com mais urgência).
Sem conflitos com o schema do Registry. O payload publicado pela D-1b respeita o schema demanda.normalizada/1.4.0 definido em N-0b. Campos obrigatórios sempre presentes. Campos opcionais omitidos quando vazios. A descricao_limpa aceita string vazia.
Referências
Seção intitulada “Referências”- Ficha técnica: Apêndice B - Colônias.md, seção “D-1b — Normalização”
- Schema do evento: N-0b - Registry.md, seção 3.4.2 (
demanda.normalizada) - Barramento: N-0a - Event Bus.md (contratos de
publicar/inscrever, idempotência, DLQ, replay) - Colônia irmã: D-1c - Gestão de Anexos.md (processamento paralelo, consumo do mesmo evento)
- Colônias a jusante: D-2 - Georreferenciamento.md, D-3 - Categorização.md
- Contexto IA: contexto_IA.md, seções 7 (Gestão), 10 (Infraestrutura), 12 (Uso de IA), 22 (Formigueiro)