Pular para o conteúdo

D-3 — Categorização

Parte do Ciclo de Demandas — Fase 1


A D-3 classifica a demanda dentro da taxonomia de categorias do sistema. Consome demanda.normalizada da D-1b como gatilho de processamento e demanda.georreferenciada da D-2 para obter o unidade_cívica_id — que a D-3 transporta no evento de saída para as colônias a jusante. Produz demanda.categorizada.

A taxonomia é estática na Fase 1 — definida nos parâmetros públicos, não gerada automaticamente. A colônia aplica a taxonomia, não a cria. O classificador do MVP opera por palavras-chave e regras: um dicionário de termos mapeados para categoria_id. Rápido, explicável, zero dependência de modelo externo.

A D-3 implementa três faixas de confiança: acima do limiar automático (0.85), a categorização é publicada imediatamente com caminho de contestação visível; entre o limiar mínimo (0.7) e o automático, é publicada automaticamente mas também enfileirada para revisão por amostragem; abaixo do limiar mínimo, a publicação é bloqueada até revisão manual.

Não define prioridade. Não altera o conteúdo da demanda. Não valida vínculo. O resultado da categorização é o que permite à D-4 aplicar a fórmula de priorização — sem categoria, não há ranqueamento.

A colônia é pura no processamento de eventos. A única superfície REST é o D3Controller, restrito à moderação da fila de revisão em /admin/d3/review-queue. Não faz chamadas síncronas a outras colônias.


A D-3 é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. Consome eventos do barramento via EventBusService (N-0a) e publica eventos ao final do processamento. Expõe o D3Controller com GET/POST /admin/d3/review-queue para moderação da fila de revisão, sob o PapelModeradorGuard.

src/demanda/d-3-categorizacao/
├── d3.module.ts # Module definition
├── d3.service.ts # Lógica de negócio: classificar, publicar, moderar
├── d3.constants.ts # Limiares, retry e limites de texto
├── controllers/
│ └── d3.controller.ts # GET/POST /admin/d3/review-queue (moderador)
├── dto/
│ ├── moderar-categorizacao.dto.ts # Entrada da decisão do moderador
│ └── review-queue.dto.ts # Saída da fila e da decisão
├── repositories/
│ ├── categorizacao.repository.ts # Acesso a d3.categorizacoes
│ ├── projecao-geo.repository.ts # Acesso a d3.projecao_geo
│ ├── consumer-offset.repository.ts # Acesso a d3.consumer_offset
│ ├── review-queue.repository.ts # Acesso a d3.review_queue
│ └── taxonomia.repository.ts # Leitura de core.taxonomia_categorias
├── services/
│ ├── review-queue.service.ts # Fila de revisão (enfileirar, listar, remover)
│ └── taxonomia-config.service.ts # Carga da taxonomia e montagem do dicionário
└── taxonomy/
├── keyword-classifier.ts # Classificador por palavras-chave e regras (MVP)
├── confidence-calculator.ts # Cálculo do score de confiança da categorização
├── taxonomy-config.ts # Dicionário de termos e reforço por subcategoria
└── criar-taxonomia-config.test-helper.ts

Os testes ficam ao lado dos arquivos testados: d3.service.spec.ts, d3.controller.spec.ts, keyword-classifier.spec.ts, confidence-calculator.spec.ts, taxonomy-config.spec.ts, review-queue.service.spec.ts e taxonomia.repository.spec.ts.

@Module({
imports: [],
controllers: [D3Controller],
providers: [
D3Service,
CategorizacaoRepository,
ProjecaoGeoRepository,
ConsumerOffsetRepository,
ReviewQueueRepository,
ReviewQueueService,
TaxonomiaRepository,
TaxonomiaConfigService,
KeywordClassifier,
ConfidenceCalculator,
PapelModeradorGuard,
],
exports: [],
})
export class D3Module implements OnModuleInit {
constructor(private readonly d3Service: D3Service) {}
async onModuleInit(): Promise<void> {
await this.d3Service.iniciar();
}
}
  • O módulo não é @Global(). A D-3 não é dependência de nenhuma outra colônia. Outras colônias consomem seu evento (demanda.categorizada), não seu código.
  • O módulo não importa EventBusModule explicitamente. EventBusModule é @Global(), e o EventBusService é injetável sem import.
  • O módulo não importa RegistryModule. A validação de schema dos eventos publicados é feita pelo próprio Event Bus (N-0a) no momento do publicar().
  • O OnModuleInit dispara o protocolo de inicialização: carga da taxonomia, seed dos cursores, replay de eventos perdidos e registro de handlers para os dois tipos consumidos.
  • O módulo não registra ThrottlerModule. O D3Controller aplica throttle de 60/min por rota, além do PapelModeradorGuard.
  • A taxonomia é carregada de core.taxonomia_categorias no iniciar(), pelo TaxonomiaConfigService; o dicionário de termos permanece em código, em taxonomy/taxonomy-config.ts. As tabelas core.taxonomia_* são populadas pela migration 20260819100000_create_core_taxonomia, gerada a partir da fixture src/shared/taxonomia/taxonomia-seed.json, e atualizadas manualmente via SQL no MVP. Na Fase 2, a D-19 passa a escrever as tabelas e a D-3 recarrega no handler de parâmetros.atualizados.
// D3Service consome 'demanda.normalizada' (gatilho) e 'demanda.georreferenciada' (contexto territorial)
// Publica 'demanda.categorizada'
iniciar(): Promise<void>;
registrarConsumidores(): void;
processarDemandaNormalizada(evento): Promise<void>;
processarDemandaGeorreferenciada(evento): Promise<void>;
moderar(demandaId: string, categoriaId: string): Promise<...>;
onModuleDestroy(): void;

O service não expõe interface formal. Nenhuma outra colônia injeta D3Service. A comunicação com o exterior é via barramento.

A D-3 não tem BFF acoplado. É uma colônia de processamento puro: escuta, classifica, publica. O input é sempre via barramento:

  • demanda.normalizada da D-1b — gatilho de processamento
  • demanda.georreferenciada da D-2 — fornece unidade_cívica_id, armazenado em projeção local

1.6 Consumo duplo de eventos e projeção local de geo

Seção intitulada “1.6 Consumo duplo de eventos e projeção local de geo”

A D-3 consome dois tipos de evento e mantém uma projeção local (d3.projecao_geo) que mapeia demanda_id -> unidade_civica_id. Essa projeção é alimentada por demanda.georreferenciada e consultada quando demanda.normalizada chega. A abordagem resolve o problema de ordenação sem depender de projeções de leitura de outras colônias:

  • Se demanda.georreferenciada chegar primeiro: a projeção é populada. Quando demanda.normalizada chegar, o UC id está disponível. Processamento imediato.
  • Se demanda.normalizada chegar primeiro: a projeção não tem o UC id. O processamento é diferido com retry (backoff exponencial de 1s a 32s, 6 tentativas, configurável em d3.constants.ts). Esgotadas as tentativas, o retry continua em backoff máximo de 60s e o esgotamento gera um único log de erro, sem enviar o evento para a DLQ: a ausência de contexto geo é estado esperado para demanda pendente de geo, não falha transiente.

Esta é uma simplificação de MVP que será removida na Fase 2, quando a D-3 poderá consumir um único evento enriquecido que já contenha ambos os conjuntos de dados.


Todas as tabelas da D-3 residem no schema d3 do PostgreSQL. Este schema é de uso exclusivo do módulo D-3. Nenhuma outra colônia lê ou escreve nestas tabelas.

Registro do resultado da categorização para cada demanda processada. Mantém histórico de versões: se a categoria for corrigida manualmente, uma nova linha é inserida com versao incrementado — a versão anterior é preservada.

CREATE SCHEMA IF NOT EXISTS d3;
CREATE TABLE d3.categorizacoes (
id UUID NOT NULL,
demanda_id UUID NOT NULL,
categoria_id VARCHAR(10) NOT NULL,
nivel_precedencia INTEGER NOT NULL,
score_horizontal INTEGER NOT NULL,
confianca_categorizacao DOUBLE PRECISION NOT NULL,
metodo VARCHAR(20) NOT NULL,
sugestoes_alternativas JSONB NOT NULL DEFAULT '[]',
unidade_civica_id UUID,
nivel_minimo_resolvido INTEGER,
versao INTEGER NOT NULL DEFAULT 1,
event_id UUID NOT NULL,
correlacao_id UUID NOT NULL,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT categorizacoes_pkey PRIMARY KEY (id)
);
CREATE UNIQUE INDEX categorizacoes_demanda_id_versao_key
ON d3.categorizacoes (demanda_id, versao);
CREATE INDEX categorizacoes_demanda_id_versao_idx
ON d3.categorizacoes (demanda_id, versao DESC);
CREATE INDEX categorizacoes_unidade_civica_id_idx ON d3.categorizacoes (unidade_civica_id);
CREATE INDEX categorizacoes_event_id_idx ON d3.categorizacoes (event_id);
CREATE INDEX categorizacoes_metodo_idx ON d3.categorizacoes (metodo);
CREATE INDEX categorizacoes_categoria_id_idx ON d3.categorizacoes (categoria_id);

O banco não tem CHECKs para categoria_id, nivel_precedencia, score_horizontal, confianca_categorizacao ou metodo. Os valores são garantidos pelo código. O caminho de texto sem match grava categoria_id vazio, nivel_precedencia e score_horizontal zerados, confianca_categorizacao zero e metodo = 'revisao_pendente'.

Coluna Tipo Descrição
id UUID PK Identificador interno da categorização. Gerado pelo Prisma.
demanda_id UUID FK lógica para o registro de demanda na D-1a/D-1b. Sem constraint formal — regra de isolamento.
categoria_id VARCHAR(10) Identificador da categoria na taxonomia. Formato N.M (ex: 1.1, 3.4). Vazio no caminho de revisão pendente sem candidatas.
nivel_precedencia INTEGER Nível de precedência estrutural da categoria. Derivado da taxonomia. Zero no caminho de revisão pendente sem candidatas.
score_horizontal INTEGER Score horizontal da categoria atribuída. Proveniente da taxonomia. Zero no caminho de revisão pendente sem candidatas.
confianca_categorizacao DOUBLE PRECISION 0-1 Score de confiança do classificador para a categoria atribuída. 1.0 nas categorizações manual e do cidadão.
metodo VARCHAR(20) automatico (confiança ≥ limiar mínimo), manual (categoria escolhida pelo cidadão ou por moderador) e revisao_pendente (abaixo do limiar mínimo, aguardando).
sugestoes_alternativas JSONB Array de {categoria_id, score_confianca} com até 5 categorias candidatas além da principal.
unidade_civica_id UUID UC de menor nível resolvida. Transportado de demanda.georreferenciada e repassado à D-4 e à D-7.
nivel_minimo_resolvido INTEGER Nível da UC resolvida. Transportado de demanda.georreferenciada.
versao INTEGER Número de versão da categorização. Incrementado a cada correção manual.
event_id UUID event_id do evento demanda.normalizada que originou esta categorização. Na correção manual, reusa o event_id da última versão ou gera um novo quando não há versão anterior.
correlacao_id UUID correlacao_id do evento de origem. Na correção manual sem versão anterior, o demanda_id assume.
criado_em TIMESTAMPTZ(2) Timestamp de criação da categorização.

Cache local que mapeia demanda_id ao seu unidade_cívica_id. Alimentada pelo evento demanda.georreferenciada da D-2. Permite que a D-3 obtenha o contexto territorial sem depender de projeções de leitura de outras colônias.

CREATE TABLE d3.projecao_geo (
demanda_id UUID NOT NULL,
unidade_civica_id UUID NOT NULL,
nivel_minimo_resolvido INTEGER NOT NULL,
event_id UUID NOT NULL,
criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT projecao_geo_pkey PRIMARY KEY (demanda_id)
);
CREATE INDEX projecao_geo_unidade_civica_id_idx ON d3.projecao_geo (unidade_civica_id);

Controla o cursor de processamento para replay seletivo após falha ou reinicialização. Segue o padrão definido pela N-0a (Event Bus).

CREATE TABLE d3.consumer_offset (
tipo_evento VARCHAR(255) PRIMARY KEY,
last_sequence BIGINT NOT NULL DEFAULT 0,
updated_at TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP
);

No MVP, a tabela tem duas linhas: tipo_evento = 'demanda.normalizada' e tipo_evento = 'demanda.georreferenciada'.

Fila de revisão manual. Uma linha por demanda, com a categoria sugerida em espera e o texto original para o moderador. A PK é um id próprio; o demanda_id tem unique para impedir duplicidade na fila.

CREATE TABLE d3.review_queue (
id UUID NOT NULL,
demanda_id UUID NOT NULL,
texto_original TEXT NOT NULL,
sugestoes JSONB NOT NULL DEFAULT '[]',
uc_id UUID,
enfileirada_em TIMESTAMPTZ(2) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT review_queue_pkey PRIMARY KEY (id)
);
CREATE UNIQUE INDEX review_queue_demanda_id_key ON d3.review_queue (demanda_id);
CREATE INDEX review_queue_enfileirada_em_idx ON d3.review_queue (enfileirada_em);

O enfileirar é idempotente pelo demanda_id: reinserir uma demanda já na fila mantém o registro existente. O GET /admin/d3/review-queue devolve a fila ordenada por enfileirada_em.

Migrations reais do schema d3 e da taxonomia:

  1. 20260809200029_d3_categorizacao — Cria o schema d3, as tabelas d3.categorizacoes, d3.projecao_geo e d3.consumer_offset.
  2. 20260814142948_add_d3_review_queue — Cria d3.review_queue com unique em demanda_id.
  3. 20260819100000_create_core_taxonomia — Cria core.taxonomia_areas, core.taxonomia_categorias e core.taxonomia_subcategorias, com FKs, CHECKs de faixa e o seed inicial (17 áreas, 38 categorias e 47 subcategorias), gerado a partir de src/shared/taxonomia/taxonomia-seed.json.

Seed inicial do consumer_offset: no boot, offsetRepo.seed(obterMaiorSequence()) cria as duas linhas com a maior sequência do log sem sobrescrever cursor existente.

Migrations futuras (Fase 2): adição de coluna modelo_classificador (identificador do modelo de ML usado), coluna embedding_vetor para busca de similares e migração do dicionário de termos para tabela versionada.

Não há foreign keys entre as tabelas do schema d3. d3.projecao_geo.demanda_id referencia o identificador de demanda nos schemas da D-1a/D-1b e D-2, mas sem FK formal — regra de isolamento.

Tabela d3.projecao_geo separada de d3.categorizacoes. A projeção de geo é populada pelo handler de demanda.georreferenciada, independente do handler de categorização. Manter tabelas separadas evita acoplamento: a projeção pode ser populada a qualquer momento, antes ou depois da categorização. O join ocorre em memória no service, não no banco.

sugestoes_alternativas como JSONB. O classificador produz uma lista ranqueada de categorias candidatas. Armazenar como JSONB preserva a estrutura (array de objetos) sem exigir tabela de junção. O volume é pequeno: no máximo 5 sugestões por demanda.

versao com unique composto (demanda_id, versao). Uma demanda pode ter múltiplas versões de categorização: a original automática e correções manuais subsequentes. O unique garante a numeração; o índice descendente (demanda_id, versao DESC) otimiza a query mais comum: “última versão da categorização para a demanda X”.

unidade_civica_id e nivel_minimo_resolvido nullable em d3.categorizacoes. No caso raro de a projeção geo expirar ou nunca ter sido populada antes de uma categorização manual, o campo pode ser null. Na prática do MVP, toda demanda que chega à D-3 já passou pela D-2 e tem UC resolvida. O nullable é defesa contra cenários de borda.

d3.review_queue com unique em demanda_id. A fila é uma tabela, não um Map. A demanda entra uma única vez; reinserir não duplica. A fila sobrevive a reinicializações e é consultada pelo controller de moderação por ordem de chegada.


A D-3 consome dois tipos de evento e produz um. Os schemas completos (JSON Schema draft-2020-12) estão definidos no Registry (N-0b). Esta seção descreve os contratos do ponto de vista da D-3.

3.1 Evento consumido: demanda.normalizada (gatilho)

Seção intitulada “3.1 Evento consumido: demanda.normalizada (gatilho)”
Propriedade Valor
Tipo demanda.normalizada
Schema version 1.2.0 (o catálogo mantém 1.0.0 e 1.1.0)
Produtor D-1b (Normalização)
Consumidor D-3 (esta colônia)
Descrição Demanda com campos estruturados: título inferido, descrição limpa, coordenadas validadas, score de confiança da normalização.
Papel na D-3 Gatilho de processamento. A chegada deste evento dispara a classificação. O texto da demanda (titulo, descricao_limpa) é a entrada do classificador.

Payload esperado (conforme Registry N-0b):

interface DemandaNormalizadaPayload {
demanda_id: string;
titulo: string;
descricao_limpa: string;
tipo_midia_processada: string;
coordenadas_validadas: {
lat: number;
lng: number;
};
confianca_normalizacao: number; // 0-1
origem_normalizacao: string; // 'automatico' | 'hibrido'
conteudo_suspeito: boolean;
entidades_extraidas?: {
endereco?: string;
cep?: string;
nomeRua?: string; // camelCase no payload real
};
idioma_detectado?: string;
termos_suspeitos?: string[];
categoria_id?: string; // repassado de demanda.recebida v1.1.0
subcategoria_id?: string; // repassado de demanda.recebida v1.1.0
}

A D-3 lê demanda_id, titulo, descricao_limpa e categoria_id. O categoria_id, quando presente e existente na taxonomia, tem precedência sobre o classificador.

3.2 Evento consumido: demanda.georreferenciada (contexto territorial)

Seção intitulada “3.2 Evento consumido: demanda.georreferenciada (contexto territorial)”
Propriedade Valor
Tipo demanda.georreferenciada
Schema version 1.0.0
Produtor D-2 (Georreferenciamento)
Consumidor D-3 (esta colônia)
Descrição Demanda com UC resolvida e cadeia de UCs pai completa.
Papel na D-3 Alimenta d3.projecao_geo. O unidade_civica_id é transportado no evento de saída demanda.categorizada para as colônias a jusante.

Payload esperado (conforme Registry N-0b):

interface DemandaGeorreferenciadaPayload {
demanda_id: string;
unidade_civica_id: string;
nivel_minimo_resolvido: number;
cadeia_ucs: string[];
metodo_resolucao: string;
confianca_geo: string;
coordenadas_lat?: number; // opcionais no schema
coordenadas_lng?: number;
}

A D-3 usa demanda_id, unidade_civica_id e nivel_minimo_resolvido.

Propriedade Valor
Tipo demanda.categorizada
Schema version 1.1.0
Produtor D-3 (esta colônia)
Consumidores D-4 (Priorização e Ranking), D-7 (Transparência), D-11 (Duplicidade, Fase 2), D-12 (Risco, Fase 2)
Descrição Demanda classificada com categoria, nível de precedência, score horizontal, confiança, contexto territorial e sugestões alternativas.

Mudança na versão 1.1.0: Adição dos campos unidade_civica_id, nivel_minimo_resolvido e subcategoria_id. A versão 1.0.0 não os continha. A adição é backward-compatible (campos novos no final do payload). Consumidores antigos que não leem esses campos não quebram. Consumidores novos (D-4, D-7) passam a receber o contexto territorial diretamente no evento de categorização, simplificando o pipeline. O subcategoria_id vem da demanda.normalizada (captura do cidadão) e atravessa a categorização para a D-7 e a D-24 manterem a chave fina do perfil; quando ausente, a subcategoria cai para a categoria.

Payload publicado (v1.1.0):

interface DemandaCategorizadaPayload {
demanda_id: string;
categoria_id: string; // Formato "N.M" (ex: "1.1")
subcategoria_id: string | null; // Da normalização; fallback para a categoria nos consumidores
nivel_precedencia: number; // 1-5
score_horizontal: number; // 0-100
confianca_categorizacao: number; // 0-1
metodo: string; // 'automatico' | 'manual' | 'revisao_pendente'
sugestoes_alternativas: Array<{
categoria_id: string;
score_confianca: number;
}>;
unidade_civica_id: string | null; // UC de menor nível resolvida
nivel_minimo_resolvido: number | null; // 1-7
}

3.4 Ordem de operações no handler (processarDemandaNormalizada)

Seção intitulada “3.4 Ordem de operações no handler (processarDemandaNormalizada)”

O fluxo segue esta ordem exata:

1. Verificar idempotência por event_id
→ consultar d3.categorizacoes WHERE event_id = ?
→ se encontrado e metodo = 'revisao_pendente': re-enfileirar em d3.review_queue
com o texto do evento e as sugestões persistidas
→ se encontrado em outro método: republicar demanda.categorizada com os dados
persistidos
→ avançar o cursor em ambos os casos e retornar
2. Obter contexto territorial
→ consultar d3.projecao_geo WHERE demanda_id = payload.demanda_id
→ se não encontrado (georreferenciada ainda não chegou):
→ log.warn("Contexto geo ausente, diferindo processamento")
→ agendar retry com backoff exponencial (1s, 2s, 4s, 8s, 16s, 32s;
após 6 tentativas, intervalo fixo de 60s)
→ retornar sem processar nem atualizar offset
3. Decidir a categoria de entrada
→ se payload.categoria_id existe e está na taxonomia carregada:
→ registrar como oficial com metodo = 'manual', confianca_categorizacao = 1.0
e sugestoes_alternativas vazio
→ publicar demanda.categorizada
→ avançar o cursor e retornar
→ se payload.categoria_id existe mas não está na taxonomia:
→ log.warn("Categoria do cidadão ausente da taxonomia — seguindo classificador")
→ seguir para o classificador
4. Classificar o texto
→ keywordClassifier.classificar(titulo, descricao_limpa)
→ lista de CategoriaCandidata ranqueada por score
5. Caminho sem candidatas
→ log.warn("Nenhuma categoria encontrada, enfileirando para revisão")
→ inserir d3.categorizacoes com categoria vazia, zeros, metodo = 'revisao_pendente'
→ enfileirar em d3.review_queue
→ avançar o cursor e retornar
6. Selecionar categoria principal e calcular confiança
→ principal = candidatas[0]
→ confianca = confidenceCalculator.calcular(principal.score_confianca, candidatas) (*)
7. Determinar método e destinos
→ confianca >= LIMIAR_AUTOMATICO (0.85):
metodo = 'automatico', publica, não enfileira
→ LIMIAR_MINIMO (0.7) <= confianca < LIMIAR_AUTOMATICO:
metodo = 'automatico', publica e enfileira para revisão por amostragem
→ confianca < LIMIAR_MINIMO:
metodo = 'revisao_pendente', não publica, enfileira
8. Enfileirar (quando aplicável) e persistir em d3.categorizacoes
9. Publicar demanda.categorizada (exceto no caminho de revisão pendente)
10. Atualizar o consumer offset

(*) O classificador pode indicar uma categoria que não existe na taxonomia carregada. Nesse caso, o service loga erro e retorna sem persistir. O caso prático não ocorre enquanto dicionário e tabela estiverem sincronizados.

3.5 Ordem de operações no handler (processarDemandaGeorreferenciada)

Seção intitulada “3.5 Ordem de operações no handler (processarDemandaGeorreferenciada)”
1. Verificar idempotência por event_id
→ consultar d3.projecao_geo WHERE event_id = ?
→ se encontrado: avançar o cursor e retornar
2. Persistir a projeção local
→ upsert em d3.projecao_geo por demanda_id
(update quando o demanda_id já existe, create quando não)
3. Verificar se há demanda normalizada aguardando este contexto
→ consultar a fila de retry em memória pelo demanda_id
→ se houver: remover da fila, classificar com o UC resolvido e avançar o
cursor de demanda.normalizada com a sequência do evento retido
→ se não houver: apenas persistir (a projeção será consultada quando a
normalizada chegar)
4. Avançar o cursor de demanda.georreferenciada
Cenário Comportamento
Evento demanda.normalizada reentregue (replay DLQ) Detectado por event_id em d3.categorizacoes. Se a versão existente é revisao_pendente, a demanda é re-enfileirada; nos demais métodos, o evento é republicado. Cursor avança.
Evento demanda.georreferenciada reentregue Detectado por event_id em d3.projecao_geo. Cursor avança sem ação.
demanda.normalizada chega antes de demanda.georreferenciada Contexto geo ausente na projeção. Retry com backoff exponencial (1s a 32s, 6 tentativas) e retry contínuo em 60s após o esgotamento, sem DLQ. Cursor de demanda.normalizada não avança enquanto o evento está retido.
demanda.normalizada com texto vazio (titulo e descricao_limpa vazios) O classificador devolve lista vazia. Categoria vazia, confiança zero, metodo = 'revisao_pendente'. Demanda enfileirada. Nenhum evento publicado. Cursor avança.
Classificador retorna lista vazia (nenhuma categoria com score > 0) Idem acima: d3.categorizacoes com zeros, fila de revisão e nenhuma publicação.
INSERT falha (violação de unique em demanda_id + versao) Log.error e exceção relançada. O erro é registrado na DLQ e o cursor não avança.
Publish de demanda.categorizada falha Log.error. Registro existe em d3.categorizacoes. Erro relançado para DLQ. Consumer offset NÃO atualizado.
Categoria do cidadão ausente da taxonomia Log.warn e seguimento para o classificador.
Categoria do classificador ausente da taxonomia Log.error e retorno sem persistir. O cursor não avança.

Consumir demanda.normalizada como gatilho e demanda.georreferenciada como contexto. Alternativa rejeitada: consumir apenas demanda.normalizada e obter UC id da projeção pública (D-7). Isso violaria o princípio de isolamento — a D-3 leria dados mantidos por outra colônia. A projeção local d3.projecao_geo mantém a D-3 autônoma. Cada colônia mantém suas próprias dependências de leitura.

Incluir unidade_civica_id no evento de saída. A D-3 não é “dona” do dado geo. A D-2 é. Mas transportar o UC id no evento de saída é pass-through de metadado necessário para as colônias a jusante (D-4, D-7). Sem esse campo, cada colônia downstream precisaria manter sua própria projeção de geo ou depender da D-7. Ambas as alternativas são piores que o pass-through. A referência ao dado original (event_id da D-2 armazenado em d3.projecao_geo) preserva a rastreabilidade.

Categoria escolhida pelo cidadão tem precedência. A captura permite que o cidadão escolha a categoria. A D-1b repassa o categoria_id em demanda.normalizada e a D-3 registra a escolha como oficial, com confiança 1.0 e método manual. O classificador só entra quando não há escolha ou quando a escolha não existe na taxonomia. Isso preserva o trabalho do cidadão e evita que o classificador discorde do autor da demanda.

Persistir antes de publicar. Mesmo princípio de todas as colônias: o estado próprio é a memória do módulo. Se o barramento falhar após o INSERT, a categorização está salva e recuperável. A exceção é o caso de revisao_pendente: a persistência ocorre, mas a publicação é diferida até decisão do moderador.

Classificador por palavras-chave no MVP, não modelo de ML. O classificador baseado em dicionário de termos é determinístico, explicável e não requer infraestrutura de treinamento ou GPU. Para o MVP com 8 categorias, a cobertura é suficiente. O dicionário é versionado no código e carregado na inicialização. A evolução para TF-IDF + similaridade de cosseno e, posteriormente, BERTimbau está documentada na seção 8.3.


A fonte canônica dos dados de categorias, scores horizontais e níveis de precedência é D-3 - Taxonomia.md. Os valores reproduzidos abaixo são um subconjunto ilustrativo — o documento de taxonomia contém a lista completa de 38 categorias, a tabela consolidada de scores e o mapeamento federativo.

O subconjunto MVP extrai 8 categorias da taxonomia completa. O critério de inclusão é duplo: demanda universalmente reconhecível em qualquer bairro urbano e ciclo de resolução conduzível por um conselheiro de bairro sem depender de estrutura institucional específica por país.

Nível 1 — Sobrevivência básica (peso nacional: 100)
1.1 Abastecimento de água — score horizontal: 95
1.2 Esgotamento sanitário — score horizontal: 85
1.3 Energia elétrica — score horizontal: 80
Nível 3 — Infraestrutura e serviços (peso nacional: 60)
3.1 Vias e pavimentação — score horizontal: 85
3.2 Calçadas e acessibilidade — score horizontal: 75
3.3 Coleta de resíduos — score horizontal: 85
3.4 Iluminação pública — score horizontal: 78
3.9 Praças e espaços públicos — score horizontal: 70

O classificador pode atribuir qualquer categoria da taxonomia completa a partir do texto livre do cidadão, mesmo que o front-end do MVP não a exiba como opção direta. As demais 30 categorias estão no dicionário de termos como categorias candidatas, com peso reduzido no MVP.

O arquivo taxonomy/taxonomy-config.ts monta a configuração a partir das categorias vindas do banco:

interface TaxonomiaConfigCategoria {
categoria_id: string;
nome: string;
nivel_precedencia: number;
score_horizontal: number;
}
interface TaxonomiaConfig {
categorias: Map<string, TaxonomiaConfigCategoria>; // categoria_id -> info
dicionario: Map<string, string[]>; // termo normalizado -> [categoria_id, ...]
subcategorias_termos: Map<string, string[]>; // categoria_id -> termos de reforço
}

O dicionário tem cerca de 305 entradas e mapeia termos normalizados (minúsculo, sem acentos; SEM stemming) para uma lista de categoria_id. Exemplos:

"agua" -> ["1.1"]
"esgoto" -> ["1.2"]
"vazamento" -> ["1.1", "1.2"] // termo ambíguo — ambas são candidatas
"buraco" -> ["3.1"]
"asfalto" -> ["3.1"]
"lixo" -> ["3.3"]
"luz" -> ["1.3", "3.4"]
"poste" -> ["1.3", "3.4"]
"praca" -> ["3.9"]
"energia" -> ["1.3"]
"calcada" -> ["3.2"]
"cadeirante" -> ["3.2"]
"esgoto_ceu_aberto" -> ["1.2"] // termo composto (bigrama)

A configuração também inclui subcategorias_termos: mapeamento de categoria para termos de reforço que aumentam o score apenas quando a categoria já foi identificada como provável. Exemplo: se “água” já deu score alto para 1.1, a presença de “vazamento” reforça 1.1 em vez de distribuir entre 1.1 e 1.2. O reforço casa token único ou bigrama e soma 0.5 ao score da categoria, quando o score já é maior que zero.

4.3 KeywordClassifier.classificar() — pseudocódigo

Seção intitulada “4.3 KeywordClassifier.classificar() — pseudocódigo”
função classificar(titulo: string | null | undefined, descricaoLimpa: string):
taxonomia = taxonomiaConfig.obter()
textoCompleto = ((titulo ?? '') + ' ' + descricaoLimpa).slice(0, 5000)
textoNormalizado = normalizarTexto(textoCompleto)
tokens = tokenizar(textoNormalizado).slice(0, 100)
scores = new Map<categoria_id, number>()
// Inicializar todas as categorias com score 0
para cada categoria_id em taxonomia.categorias.keys():
scores.set(categoria_id, 0)
// Passo 1: Matching exato de termos
para cada token em tokens:
categoriasAssociadas = taxonomia.dicionario.get(token)
se existe:
peso = 1.0 / categoriasAssociadas.length
para cada cat em categoriasAssociadas:
scores.set(cat, scores.get(cat) + peso)
// Passo 2: Matching de bigramas (dois tokens consecutivos)
para i de 0 até tokens.length - 2:
bigrama = tokens[i] + '_' + tokens[i+1]
categoriasAssociadas = taxonomia.dicionario.get(bigrama)
se existe:
peso = 2.0 / categoriasAssociadas.length // bigrama tem peso dobrado
para cada cat em categoriasAssociadas:
scores.set(cat, scores.get(cat) + peso)
// Passo 3: Reforço contextual (termos de subcategoria)
para cada token em tokens:
bigramaComEspaco = token + ' ' + proximoToken (quando existe)
para cada [categoria_id, termos] em taxonomia.subcategorias_termos:
termosNormalizados = termos com '_' trocado por ' '
se termosNormalizados inclui token ou bigramaComEspaco:
se scores.get(categoria_id) > 0:
scores.set(categoria_id, scores.get(categoria_id) + 0.5)
// Passo 4: Normalizar scores para 0-1
scoreMaximo = max(...scores.values())
se scoreMaximo > 0:
para cada [cat, score] em scores:
scores.set(cat, score / scoreMaximo)
// Passo 5: Ordenar por score decrescente
resultado = Array.from(scores.entries())
.filter(([_, score]) => score > 0)
.sort((a, b) => b[1] - a[1])
.map(([cat, score]) => ({ categoria_id: cat, score_confianca: score }))
return resultado

O normalizarTexto aplica minúscula, remove acentos (NFD), troca tudo que não é a-z0-9 por espaço e colapsa espaços. A tokenizar mantém tokens com 2 ou mais caracteres.

4.4 ConfidenceCalculator.calcular() — pseudocódigo

Seção intitulada “4.4 ConfidenceCalculator.calcular() — pseudocódigo”
função calcular(scorePrincipal: number, todasCandidatas: CategoriaCandidata[]):
se todasCandidatas.length == 0:
return 0.0
// Fator 1: Score bruto da categoria principal
fatorScore = scorePrincipal
// Fator 2: Distância para a segunda colocada
// Quanto maior a distância, mais confiável é a classificação
se todasCandidatas.length >= 2:
distancia = scorePrincipal - todasCandidatas[1].score_confianca
fatorDistancia = min(distancia * 2.0, 1.0) // distância de 0.5+ = confiança máxima
senão:
fatorDistancia = 1.0 // única categoria, confiança máxima no fator
// Fator 3: Número de candidatas em disputa
// Mais candidatas reduzem a confiança
fatorEvidencia = max(0, 1 - (todasCandidatas.length - 1) * 0.2)
// Confiança final: média ponderada
confianca = (fatorScore * 0.5) + (fatorDistancia * 0.3) + (fatorEvidencia * 0.2)
return arredondar(confianca, 3)

4.5 D3Service.iniciar() — protocolo de inicialização

Seção intitulada “4.5 D3Service.iniciar() — protocolo de inicialização”
função iniciar():
se já iniciado: retornar
// 1. Carregar a taxonomia do banco (primeiro passo, antes do replay)
await taxonomiaConfig.carregar()
// monta o mapa de categorias a partir de core.taxonomia_categorias e o
// dicionário de termos em código; a carga é única por processo
// 2. Seed dos cursores na maior sequência do log (sem sobrescrever)
maiorSequence = await eventBus.obterMaiorSequence()
await offsetRepo.seed(maiorSequence)
// 3. Replay de eventos perdidos por tipo, a partir do cursor persistido;
// falha por evento é logada e o evento permanece pendente
await this.reprocessarEventosPerdidos()
// percorre ['demanda.normalizada', 'demanda.georreferenciada']
// 4. Registrar handlers para eventos futuros
this.registrarConsumidores()
// eventBus.inscrever('demanda.normalizada', 'D-3', ...)
// eventBus.inscrever('demanda.georreferenciada', 'D-3', ...)
// 5. Iniciar o timer da fila de retry (1s de intervalo)
this.iniciarTimerRetry()
logger.log("D-3 Categorização inicializada")

O onModuleDestroy() limpa o timer do retry.

4.6 Fila de retry para eventos com contexto geo pendente

Seção intitulada “4.6 Fila de retry para eventos com contexto geo pendente”

A D-3 mantém uma fila interna em memória (Map<demanda_id, RetryEntry>) para demandas cujo demanda.normalizada chegou antes de demanda.georreferenciada:

RetryEntry:
evento: EventoRecebido
tentativas: number
proximoDisparo: timestamp
backoffMs: number // 1000, 2000, 4000, 8000, 16000, 32000
avisadoEsgotamento: boolean
função agendarRetry(evento):
se retryQueue já tem o demanda_id: retornar
entry = {
evento,
tentativas: 1,
proximoDisparo: now() + 1000,
backoffMs: 1000,
avisadoEsgotamento: false,
}
retryQueue.set(evento.payload.demanda_id, entry)
// Timer interno: a cada 1s, verifica entradas vencidas e tenta de novo
processarRetryQueue():
para cada entrada vencida:
projecao = projecaoGeoRepo.buscarPorDemandaId(demanda_id)
se projecao existe:
remover da fila, classificar com o UC e avançar o cursor de normalizada
continuar
se tentativas >= 6:
se ainda não avisado:
avisadoEsgotamento = true
logger.error("Retry esgotado para demanda sem contexto geo — retry contínuo em backoff máximo")
proximoDisparo = now() + 60000
continuar
tentativas++
backoffMs = min(backoffMs * 2, 60000)
proximoDisparo = now() + backoffMs

O evento retido permanece fora da DLQ. A chegada de demanda.georreferenciada também dispara o processamento da entrada correspondente no handler de geo, sem esperar o timer.

Quando o método é revisao_pendente, a categorização é persistida mas o evento demanda.categorizada NÃO é publicado. A demanda entra em d3.review_queue:

revisao_pendente em d3.review_queue:
id
demanda_id
texto_original: titulo + descricao_limpa
sugestoes: CategoriaCandidata[] (vazio no caminho sem candidatas)
uc_id
enfileirada_em: timestamp

A moderação acontece pelo D3Controller:

GET /admin/d3/review-queue
→ PapelModeradorGuard (JWT com papel moderador ou admin)
→ throttle de 60/min
→ devolve a fila ordenada por enfileirada_em
POST /admin/d3/review-queue
→ PapelModeradorGuard
→ throttle de 60/min
→ D3Service.moderar(demanda_id, categoria_id):
1. consultar d3.review_queue por demanda_id; se ausente → 404
2. validar categoria_id na taxonomia; se ausente → 400
3. buscar a última versão da categorização:
→ sem versão anterior: inserir versão 1 com metodo = 'manual',
confianca 1.0, event_id novo, correlacao_id = demanda_id e
unidade_civica_id vindo da fila
→ com versão anterior: inserir versao + 1 reusando event_id,
correlacao_id, unidade_civica_id, nivel_minimo_resolvido e
sugestoes_alternativas da última versão
4. publicar demanda.categorizada
5. logar par (texto, categoria) para melhoria futura do classificador
6. remover a demanda de d3.review_queue
7. devolver demanda_id, categoria_id, metodo, confianca e versao

O enfileirar é idempotente por demanda_id: uma demanda já na fila não gera linha nova.

Caso Comportamento
Texto da demanda em idioma diferente de português O classificador opera sobre tokens normalizados. Termos em outros idiomas não têm match no dicionário pt-BR. Score zero para todas as categorias. Demanda enfileirada para revisão manual.
Texto muito curto (1-2 palavras) Com uma única candidata, fatorEvidencia = 1.0 e a confiança tende a ser alta. O caso de baixa confiança aparece quando várias candidatas disputam.
Demanda com múltiplas categorias igualmente prováveis (ex: “falta água e esgoto a céu aberto”) Scores divididos entre 1.1 e 1.2. Score principal moderado, distância para a segunda baixa → confiança reduzida. Ambas aparecem em sugestoes_alternativas.
Demanda categorizada manualmente é posteriormente contestada Nova correção gera versao = versao_anterior + 1. A versão anterior é preservada. O evento demanda.categorizada é republicado com a nova versão.
Moderador não revisa demanda em revisao_pendente por tempo prolongado A fila fica em d3.review_queue, exposta em GET /admin/d3/review-queue por ordem de chegada. Alerta de envelhecimento entra na Fase 2.
Classificador alterado (novo deploy com dicionário expandido) Demandas já categorizadas não são reclassificadas automaticamente. Reclassificação em lote é operação manual na Fase 2.
Evento com categoria_id do cidadão fora da taxonomia Log.warn e seguimento para o classificador com o texto do evento.

Classificador por palavras-chave, não TF-IDF ou embeddings. Para o MVP com 8 categorias e volume < 100 demandas/dia, o dicionário de termos é suficiente. A precisão para essas categorias específicas — água, esgoto, energia, buraco, lixo, luz, calçada, praça — é alta. TF-IDF + similaridade de cosseno adiciona complexidade de implementação (pré-processamento de corpus, matriz esparsa) sem ganho proporcional na Fase 1. A evolução para TF-IDF e BERTimbau está na seção 8.3.

Score de confiança composto (score bruto + distância + disputa), não apenas score bruto. Um classificador que retorna “1.1 com score 0.6” com a segunda candidata “1.2 com score 0.58” é fundamentalmente menos confiável do que “1.1 com score 0.6” com a segunda “3.1 com score 0.1”. A distância captura essa ambiguidade. O fator de evidência captura a disputa: quantas candidatas dividem a evidência do texto.

Três faixas de confiança, não binário (automático/manual). A faixa intermediária (0.7-0.85) é o mecanismo de confiança condicional: a categorização avança para não bloquear o pipeline, mas fica sinalizada para revisão por amostragem. Isso evita que o gargalo humano trave o ciclo completo. Na Fase 1, com baixo volume, a amostragem pode ser 100% — toda categorização na faixa intermediária é revisada, mas o pipeline não espera.

Retry contínuo com backoff, sem DLQ para geo pendente. Quando demanda.normalizada chega antes de demanda.georreferenciada, o cenário mais provável é que o evento geo esteja a milissegundos de distância (D-2 processando em paralelo). Enviar para DLQ sobrecarregaria o mecanismo de replay com falso positivo. O retry local cobre o caso comum. Se o contexto demorar mais de 63s, o retry continua em intervalo fixo de 60s com um único log de erro, sem poluir a DLQ.


5. Integração com o Barramento e Outras Colônias

Seção intitulada “5. Integração com o Barramento e Outras Colônias”

A D-3 injeta EventBusService (do módulo @Global() N-0a) para três operações: inscrever() (registrar handlers para dois tipos de evento), publicar() (publicar demanda.categorizada) e replayDeSequence() (replay na inicialização para ambos os tipos).

@Injectable()
export class D3Service {
private readonly logger = new Logger(D3Service.name);
constructor(
private readonly eventBus: EventBusService,
private readonly categorizacaoRepo: CategorizacaoRepository,
private readonly projecaoGeoRepo: ProjecaoGeoRepository,
private readonly offsetRepo: ConsumerOffsetRepository,
private readonly keywordClassifier: KeywordClassifier,
private readonly confidenceCalculator: ConfidenceCalculator,
private readonly reviewQueue: ReviewQueueService,
private readonly taxonomiaConfig: TaxonomiaConfigService,
) {}
}

O logging usa o Logger do NestJS. A D-3 não injeta serviços da N-0c. Nenhuma dependência além do núcleo e das leituras do schema core para a taxonomia.

Cidadão (app)
→ POST /api/demandas (BFF D-1a)
→ demanda.recebida (barramento)
→ [D-1b] → demanda.normalizada
→ [D-2] → demanda.georreferenciada
→ [D-3] armazena projecao_geo (processarDemandaGeorreferenciada)
→ [D-3] classifica e produz → demanda.categorizada (processarDemandaNormalizada)
→ [D-4] → demanda.ranqueada (usa UC id + categoria para posicionar no ranking)
→ [D-7] → timeline + dashboard
→ [D-12] → duplicidade.candidata_detectada
→ [D-13] → demanda.risco_classificado (Fase 2)
private async publicarDemandaCategorizada(
categorizacao: CategorizacaoRegistro,
correlacaoId: string | null,
): Promise<void> {
try {
const novoEventId = uuidv4();
await publicarComRetry(this.eventBus, {
tipo: 'demanda.categorizada',
origem: 'D-3',
versao_schema: '1.1.0',
event_id: novoEventId,
correlacao_id: correlacaoId ?? categorizacao.demanda_id,
payload: {
demanda_id: categorizacao.demanda_id,
categoria_id: categorizacao.categoria_id,
nivel_precedencia: categorizacao.nivel_precedencia,
score_horizontal: categorizacao.score_horizontal,
confianca_categorizacao: categorizacao.confianca_categorizacao,
metodo: categorizacao.metodo,
sugestoes_alternativas: categorizacao.sugestoes_alternativas,
unidade_civica_id: categorizacao.unidade_civica_id,
nivel_minimo_resolvido: categorizacao.nivel_minimo_resolvido,
},
});
} catch (erro: unknown) {
this.logger.error('Falha ao publicar demanda.categorizada', {
demanda_id: categorizacao.demanda_id,
erro: erro instanceof Error ? erro.message : String(erro),
});
throw erro;
}
}

A publicação usa publicarComRetry (4 tentativas, backoff de 500 ms), como o protocolo do AGENTS.md exige: o registro já está persistido e o replay do próximo boot republica a saída pendente pelo caminho de idempotência.

A D-3 não faz chamadas síncronas a outras colônias e não atua como proxy. A única superfície REST é o controller administrativo da fila de revisão, parte do próprio módulo:

Rota Método Proteção
/admin/d3/review-queue GET PapelModeradorGuard (JWT com papel moderador ou admin), 60/min
/admin/d3/review-queue POST PapelModeradorGuard, 60/min

As rotas ficam fora do prefixo api. O papel é concedido no login Google via MODERADORES_CIDADAO_IDS.

A D-3 não consome projeções de leitura de outras colônias. Os dados externos que acessa são:

  • core.event_log via EventBusService.replayDeSequence() — dependência do núcleo, permitida.
  • core.taxonomia_categorias via TaxonomiaRepository — dado de referência do núcleo, populado por migration (exceção controlada do MVP, como em core.uc_polygons).
  • d3.projecao_geo — projeção local, alimentada por demanda.georreferenciada. Mantida pela própria D-3.

A D-3 não aplica rate limiting no consumo de eventos. O volume de eventos é limitado indiretamente pelo rate limiting do BFF (D-1a). O D3Controller aplica throttle de 60/min nas duas rotas de moderação.

Limite Valor Justificativa
Tamanho máximo do texto para classificação 5.000 caracteres Acima disso, truncar. Demandas normais não excedem 500 caracteres.
Tokens máximo extraídos por demanda 100 Otimização: após 100 tokens, a classificação não ganha precisão adicional.
Tamanho do dicionário em memória 305 entradas Termos e bigramas das 8 categorias ativas e das demais categorias candidatas. Menos de 1 MB em memória.
Retry: tentativas com backoff exponencial 6 (1s, 2s, 4s, 8s, 16s, 32s) Após o esgotamento, o retry continua em intervalo fixo de 60s, sem DLQ.
Backoff máximo de retry 60s Ponto de estabilização do retry contínuo.
Sugestões alternativas máximas 5 Apenas as top 5 candidatas são armazenadas em sugestoes_alternativas.
Índice Query atendida
categorizacoes_demanda_id_versao_key (demanda_id, versao, UNIQUE) Numeração de versões da categorização.
categorizacoes_demanda_id_versao_idx (demanda_id, versao DESC) “Última categorização da demanda X”.
categorizacoes_event_id_idx (event_id) Idempotência — toda invocação do handler.
categorizacoes_unidade_civica_id_idx (unidade_civica_id) Dashboard D-7: demandas categorizadas por UC.
categorizacoes_metodo_idx (metodo) Fila de moderação: demandas com revisao_pendente.
categorizacoes_categoria_id_idx (categoria_id) Distribuição de categorias (dashboard, análise).
projecao_geo_unidade_civica_id_idx (unidade_civica_id) Diagnóstico: projeções por UC.
review_queue_demanda_id_key (demanda_id, UNIQUE) Idempotência da fila de revisão.
review_queue_enfileirada_em_idx (enfileirada_em) Listagem da fila por ordem de chegada.
  • buscarPorEventId(): 1 query por evento demanda.normalizada recebido (idempotência).
  • buscarPorDemandaId() da projeção geo: 1 query por evento demanda.normalizada e por verificação do retry.
  • existePorEventId() da projeção geo: 1 query por evento demanda.georreferenciada (idempotência).
  • upsert() da projeção geo: 1 write por evento demanda.georreferenciada.
  • buscarUltimaVersao(): 1 query por correção manual.
  • inserir(): 1 insert por categorização ou correção.
  • obterFila(): 1 query por listagem da fila no endpoint de moderação.

Volume esperado no MVP: < 100 demandas/dia. Tempo médio de processamento: < 5ms (classificação em memória).

  • Taxonomia: carregada de core.taxonomia_categorias no boot e mantida em memória pelo TaxonomiaConfigService (carregar() é idempotente; resetar() existe para testes). O dicionário de termos monta junto. Zero latência de rede no processamento.
  • Projeção geo (d3.projecao_geo): persistida em PostgreSQL. Volume baixo (< 100 linhas/dia no MVP). Cache em memória não justifica a complexidade adicional.
  • Fila de retry: Map em memória. Reiniciar o processo perde a fila, e o replay do boot reprocessa os eventos retidos.
Cenário Demandas/dia Classificações/dia Revisões manuais/dia (est.)
PoC (1 bairro, 10 entusiastas) ~20 ~20 ~4 (20% baixa confiança)
MVP (1 município, centenas de usuários) ~100 ~100 ~20
Fase 2 (regional) ~10.000 ~10.000 ~500 (5% com ML mais preciso)

Teste unitário do D3Service:

beforeEach(async () => {
const taxonomiaConfig = await criarTaxonomiaConfigServiceParaTeste();
const module = await Test.createTestingModule({
providers: [
D3Service,
{ provide: EventBusService, useValue: mockEventBus },
{ provide: CategorizacaoRepository, useValue: mockCategorizacaoRepo },
{ provide: ProjecaoGeoRepository, useValue: mockProjecaoGeoRepo },
{ provide: ConsumerOffsetRepository, useValue: mockOffsetRepo },
{ provide: ReviewQueueService, useValue: mockReviewQueue },
{ provide: TaxonomiaConfigService, useValue: taxonomiaConfig },
KeywordClassifier,
ConfidenceCalculator,
],
}).compile();
service = module.get<D3Service>(D3Service);
mockEventBus.inscrever = jest.fn();
mockEventBus.publicar.mockResolvedValue({ sequence_number: 10n, event_id: 'pub-evt-id' });
mockEventBus.replayDeSequence.mockResolvedValue([]);
mockEventBus.obterMaiorSequence.mockResolvedValue(100);
mockCategorizacaoRepo.buscarPorEventId.mockResolvedValue(null);
mockCategorizacaoRepo.inserir.mockResolvedValue({ /* registro persistido */ });
mockProjecaoGeoRepo.buscarPorDemandaId.mockResolvedValue({
demanda_id: 'dem-1',
unidade_civica_id: 'uc-jardim-flores',
nivel_minimo_resolvido: 2,
event_id: 'geo-evt-1',
criado_em: new Date().toISOString(),
});
mockProjecaoGeoRepo.existePorEventId.mockResolvedValue(false);
mockProjecaoGeoRepo.upsert.mockResolvedValue({ /* projeção persistida */ });
mockOffsetRepo.seed.mockResolvedValue(undefined);
mockOffsetRepo.buscarPorTipoEvento.mockResolvedValue(null);
mockOffsetRepo.upsert.mockResolvedValue(undefined);
});
afterEach(() => {
// limpar o timerRetry criado por iniciar()
});

O helper criarTaxonomiaConfigServiceParaTeste() monta a taxonomia sem banco, a partir das categorias do seed.

Happy path:

# Cenário Verificação
T1 processarDemandaNormalizada() com texto típico, UC resolvida, confiança alta INSERT em d3.categorizacoes com metodo = 'automatico' e confianca >= 0.85. publicar() chamado com demanda.categorizada. Consumer offset atualizado.
T2 processarDemandaGeorreferenciada() popula projeção local upsert() em d3.projecao_geo. Consumer offset de geo atualizado.
T3 iniciar() com taxonomia e cursores Taxonomia carregada. seed() chamado com obterMaiorSequence(). replayDeSequence() por tipo. inscrever() para os dois tipos.
T4 Categoria do cidadão presente e válida INSERT com metodo = 'manual', confianca = 1.0 e sugestoes_alternativas vazio. publicar() chamado.
T5 Confiança na faixa intermediária (0.7-0.85) metodo = 'automatico'. publicar() chamado e enfileirar() também.

Falhas e bordas:

# Cenário Verificação
T6 Evento demanda.normalizada já processado (mesmo event_id) buscarPorEventId() retorna registro. demanda.categorizada republicado.
T7 Versão existente é revisao_pendente (reentrega) A demanda é re-enfileirada e o evento não é republicado. Cursor avança.
T8 demanda.normalizada chega antes de demanda.georreferenciada buscarPorDemandaId() retorna null. Retry agendado com backoff de 1s. Consumer offset NÃO atualizado.
T9 Retry esgotado após 6 tentativas Log.error único e retry contínuo com intervalo de 60s, sem envio à DLQ.
T10 Texto sem match (lista vazia) INSERT com categoria vazia e metodo = 'revisao_pendente'. publicar() NÃO chamado. enfileirar() chamado. Cursor avança.
T11 Confiança abaixo do limiar mínimo (0.7) metodo = 'revisao_pendente'. publicar() NÃO chamado. Demanda na fila. Cursor avança.
T12 Categoria do cidadão fora da taxonomia Log.warn e processamento pelo classificador.
T13 publicar() de demanda.categorizada falha Registro persiste. Erro relançado para DLQ. Consumer offset NÃO atualizado.
T14 Evento demanda.georreferenciada reentregue Detectado por event_id. Cursor avança sem ação.
T15 moderar() sem versão anterior Versão 1 criada com metodo = 'manual', event_id novo e unidade_civica_id da fila.
T16 moderar() com versão anterior Versão incrementada com event_id e correlacao_id reusados. Publicação e remoção da fila.
T17 moderar() para demanda fora da fila NotFoundException.
T18 moderar() com categoria inválida BadRequestException.

Teste de integração:

# Cenário Verificação
T19 Ciclo completo: demanda.normalizada + demanda.georreferenciadademanda.categorizada 1 linha em d3.categorizacoes. 1 evento em core.event_log tipo demanda.categorizada. correlacao_id propagado. unidade_civica_id presente no payload.
T20 Geo chega primeiro, depois normalizada processarDemandaGeorreferenciada() popula projeção. processarDemandaNormalizada() consulta e encontra. Processamento imediato.
T21 Normalizada chega primeiro, geo chega depois Retry agendado. Quando geo chega, o handler dispara o processamento retido. 1 evento demanda.categorizada publicado.
T22 Moderador revisa demanda revisao_pendente Nova versão inserida, metodo = 'manual', demanda.categorizada publicado e fila limpa.
T23 Correção manual gera versão 2, versão 1 preservada SELECT por demanda_id retorna 2 linhas.

O cursor do consumer_offset é semeado no boot com obterMaiorSequence(). A taxonomia é populada pela migration 20260819100000_create_core_taxonomia. O SQL abaixo é ilustrativo de um cenário mínimo.

-- Projeção geo de exemplo
INSERT INTO d3.projecao_geo (demanda_id, unidade_civica_id, nivel_minimo_resolvido, event_id)
VALUES
(
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'uc-jardim-flores',
2,
'b2c3d4e5-f6a7-8901-bcde-f12345678901'
);
-- Categorização de exemplo (automática, alta confiança)
INSERT INTO d3.categorizacoes (
id, demanda_id, categoria_id, nivel_precedencia, score_horizontal,
confianca_categorizacao, metodo, sugestoes_alternativas,
unidade_civica_id, nivel_minimo_resolvido, versao, event_id, correlacao_id
)
VALUES
(
'f6a7b8c9-d0e1-2345-fabc-678901abcdef',
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'1.1',
1,
95,
0.88,
'automatico',
'[{"categoria_id":"1.2","score_confianca":0.15}]',
'uc-jardim-flores',
2,
1,
'c3d4e5f6-a7b8-9012-cdef-123456789012',
'c3d4e5f6-a7b8-9012-cdef-123456789012'
);

Funcionalidade Status
inscrever('demanda.normalizada') com handler idempotente por event_id MVP obrigatório
inscrever('demanda.georreferenciada') com handler de projeção local MVP obrigatório
Categoria escolhida pelo cidadão registrada como oficial MVP obrigatório
Classificador por palavras-chave (dicionário de termos) para 8 categorias MVP MVP obrigatório
Dicionário também cobre as 30 categorias da taxonomia completa como candidatas MVP obrigatório
Cálculo de confiança composto (score + distância + disputa) MVP obrigatório
Três faixas de confiança com thresholds configuráveis MVP obrigatório
Fila de retry local para geo pendente (backoff exponencial e retry contínuo em 60s) MVP obrigatório
Fila de revisão manual em d3.review_queue para revisao_pendente e faixa intermediária MVP obrigatório
Endpoints administrativos de moderação (GET/POST /admin/d3/review-queue) MVP obrigatório
Persistência em d3.categorizacoes com versionamento MVP obrigatório
Publicação de demanda.categorizada com payload v1.1.0 (inclui UC id) MVP obrigatório
Republicação no replay para garantir entrega a D-4 e D-7 MVP obrigatório
Consumer offset para ambos os tipos de evento MVP obrigatório
Propagação de correlacao_id MVP obrigatório
Logs estruturados com demanda_id, event_id, correlacao_id, categoria_id e confianca MVP obrigatório
Simplificação Justificativa Quando remover
Classificador por palavras-chave e regras 8 categorias com vocabulário bem definido. Zero dependência externa. Migrar para TF-IDF + cosine similarity na Fase 2. BERTimbau na Fase 2+.
Taxonomia carregada de tabela no schema core (core.taxonomia_categorias), com dicionário de termos em código (taxonomy-config.ts) As categorias são dados de referência populados por migration e atualizados manualmente via SQL no MVP. Não há sistema de parametrização dinâmica em produção. A D-19 passa a escrever as tabelas e a D-3 recarrega no handler de parâmetros.atualizados na Fase 2.
Projeção geo em tabela local (d3.projecao_geo) Armazenamento próprio para desacoplar do timing de chegada dos eventos. Alternativa seria consumir projeção de leitura da D-7 — violaria isolamento. Quando a D-3 consumir um evento enriquecido único (Fase 2), a projeção local se torna desnecessária.
Fila de retry em memória (Map) Volume baixo no MVP. Reiniciar o processo perde a fila, mas o replay na inicialização reprocessa os eventos normalizados pendentes. Migrar para tabela d3.retry_queue com persistência na Fase 2.
Sem modelo de ML, sem embeddings, sem GPU Fecha o ciclo MVP sem dependência de infraestrutura de ML. TF-IDF dispensa GPU. BERTimbau requer GPU ou inferência em CPU com latência maior.
nivel_precedencia e score_horizontal fixos por categoria_id Derivados deterministicamente da taxonomia estática. Continuam derivados da taxonomia, mas a taxonomia passa a ser versionada e atualizável por evento.
  • Classificador TF-IDF + cosine similarity treinado sobre corpus de demandas rotuladas
  • Fine-tuning de BERTimbau para categorização em português
  • Active learning: demandas com baixa confiança priorizadas na fila de revisão; correções alimentam dataset de treino
  • Consumo do evento parâmetros.atualizados da D-19 para recarregar taxonomia dinamicamente
  • Versão do modelo de classificação registrada em cada categorização (modelo_classificador)
  • Embeddings das demandas para detecção de duplicidade (D-12, Fase 2). No MVP, a D-12 usa heurística determinística sobre texto normalizado, sem embeddings.
  • Métricas Prometheus: d3_demandas_categorizadas_total, d3_confianca_media, d3_taxa_revisao_manual, d3_tempo_classificacao_ms
  • Fila de revisão com notificações push para moderadores e alerta de envelhecimento
  • Retry queue persistida em banco (resiste a reinicializações)

8.4 Verificação de conflitos com outras colônias

Seção intitulada “8.4 Verificação de conflitos com outras colônias”

Conflito potencial: D-3 publica demanda.categorizada com unidade_civica_id — a D-4 usava isso da D-2 diretamente. Isso gera duplicação? Avaliação: não há duplicação, há simplificação. A D-4 antes precisaria consumir tanto demanda.categorizada quanto demanda.georreferenciada e fazer o join por demanda_id. Com o UC id no evento de categorização, a D-4 consome apenas um evento para obter todos os dados necessários ao cálculo do score (categoria + UC). A D-2 continua sendo a fonte da verdade do dado geo. A D-3 apenas o transporta. A rastreabilidade é preservada: o event_id da D-2 está registrado em d3.projecao_geo.

Conflito potencial: D-3 e D-2 processam em paralelo — a D-3 pode publicar demanda.categorizada antes de a D-2 ter persistido sua resolução? Avaliação: sim, é possível, mas inofensivo. A D-3 só publica demanda.categorizada se encontrou o UC id em d3.projecao_geo, o que significa que demanda.georreferenciada já foi publicado pela D-2 e consumido pela D-3. A ordem de persistência da D-2 não afeta a D-3 porque a D-3 consome eventos, não consulta o banco da D-2. O pipeline é eventualmente consistente por design.

Conflito potencial: a interface de moderação da D-3 precisa de autenticação — isso é responsabilidade da D-3? Avaliação: o D3Controller aplica o PapelModeradorGuard, que valida o JWT e o papel moderador/admin concedido no login Google. A identidade vem do mesmo JWT_SECRET usado pelo BFF.

Conflito potencial: o dicionário de termos da D-3 e a taxonomia (D-3 - Taxonomia.md) precisam estar sincronizados. Como garantir? Avaliação: a taxonomia é o documento de referência. As categorias vivem na tabela core.taxonomia_categorias, populada pela migration 20260819100000_create_core_taxonomia a partir da fixture src/shared/taxonomia/taxonomia-seed.json. Os testes do seed verificam contagens, unicidade, faixas e referências; o teste do responsaveis-categorias.ts confirma as 38 categorias; e o teste de consistência taxonomia-dicionário verifica que todo categoria_id do dicionário existe na taxonomia e que toda categoria da taxonomia tem termos correspondentes no dicionário.



Documento de especificação técnica de implementação.