Pular para o conteúdo

D-1b — Normalização

Parte da D-1 — Ingestão de Demanda


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.


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.

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)
@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();
}
}
  • O módulo não é @Global(). A D-1b não é dependência de nenhuma outra colônia.
  • O módulo não importa EventBusModule explicitamente. EventBusModule é @Global(), e o EventBusService é injetável sem import.
  • O OnModuleInit chama iniciar(). O método seeda o cursor com obterMaiorSequence(), refaz o replay dos eventos perdidos e só então registra os handlers de demanda.recebida e conselheiro.atualização_registrada no EventBusService via inscrever(). 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 no D1bService e envolve as chamadas de transcrição e descrição. Garante timeout com cancelamento real e converte o abort em TimeoutError.
  • O ModeracaoTextoService lê a denylist no construtor, a partir de MODERACAO_DENYLIST_TEXTO, e delega a normalização e o casamento ao helper compartilhado src/shared/moderacao/denylist-texto.ts. O mesmo helper atende a D-6b na publicação do relato, sem import cruzado entre colônias.

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[];
}

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.

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.

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.

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);
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.

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
);
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.

Três migrations:

  1. 20260809103344_add_d1b_schema — Cria o schema d1b, as tabelas normalizacoes e eventos_processados e os índices.
  2. 20260813112406_add_consumer_offset_d1b_d2_d4 — Cria d1b.consumer_offset com PK em tipo_evento (também cria as tabelas equivalentes de D-2 e D-4).
  3. 20260912150000_d1b_moderacao_texto — Adiciona conteudo_suspeito e termos_suspeitos a d1b.normalizacoes.

O seed do cursor ocorre em runtime, com obterMaiorSequence(). A migration não insere linha com last_sequence = 0.

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.

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.


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).

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 e tipo_midia é texto: a D-1b usa 'Sem título' no título, mantém a descricao_limpa vazia 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 é audio e o serviço de transcrição está indisponível: tipo_midia_processada permanece audio, descricao_limpa é preenchida apenas com o texto bruto (se houver), confianca_normalizacao é penalizada.
  • Se tipo_midia é foto e o serviço de descrição está indisponível: tipo_midia_processada permanece foto, descricao_limpa é preenchida apenas com o texto bruto (se houver), confianca_normalizacao é penalizada.
  • O campo midia_urls acompanha 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 o texto_bruto e a localizacao_bruta.
  • tipo_midia decide o pipeline de processamento (texto, áudio ou foto). midia_urls alimenta a transcrição e a descrição: a D-1b assina uma URL interna a partir do object_key. O áudio usa o primeiro item; a descrição percorre todos os itens de imagem. O campo url publicado 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. O event_id é registrado em d1b.eventos_processados e 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 pelo TranscricaoAudioService sob o timeout de 60 segundos do TemporizadorIaService. 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.
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()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 de CONFIANCA_TRANSCRICAO.
  • Sem sucesso, o payload leva sucesso = false, texto_transcrito vazio, confianca = 0 e motivo_falha com o erro ou transcricao_indisponivel quando 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 por event_id_publicacao, então a reentrega não duplica a publicação.
  • O motivo_falha é removido na redação do core.event_log; replay e DLQ entregam o payload sem ele.

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 evento

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.

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.

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.


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 candidato
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 : null

Quando 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).

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,75

estaDisponivel() 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.

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 null

O 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.

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.

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.

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”
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.

// 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 } : {}),
},
});

A D-1b não possui BFF e não realiza chamadas síncronas. Toda comunicação é via barramento de eventos.

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.

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)

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_limpa e 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: titulo e descricao_limpa são sobrescritos, as entidades são anuladas, termos_suspeitos fica vazio e processamento_detalhes vira 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.


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.

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.
Í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).

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).


// d1b.service.spec.ts — estrutura de teste
describe('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);
});
});
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.
-- 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áticas
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 (
'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 offset
INSERT INTO d1b.consumer_offset (tipo_evento, last_sequence) VALUES ('demanda.recebida', 42);

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.
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 → unknownpt. 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.
  • 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.