D-3 — Categorização
Parte do Ciclo de Demandas — Fase 1
Propósito
Seção intitulada “Propósito”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.
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”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.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”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.tsOs 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.
1.2 Module definition
Seção intitulada “1.2 Module definition”@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(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- 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
EventBusModuleexplicitamente.EventBusModuleé@Global(), e oEventBusServiceé 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 dopublicar(). - O
OnModuleInitdispara 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. OD3Controlleraplica throttle de 60/min por rota, além doPapelModeradorGuard. - A taxonomia é carregada de
core.taxonomia_categoriasnoiniciar(), peloTaxonomiaConfigService; o dicionário de termos permanece em código, emtaxonomy/taxonomy-config.ts. As tabelascore.taxonomia_*são populadas pela migration20260819100000_create_core_taxonomia, gerada a partir da fixturesrc/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 deparâmetros.atualizados.
1.4 Serviço — responsabilidades e contrato
Seção intitulada “1.4 Serviço — responsabilidades e contrato”// 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.
1.5 Colônia pura de eventos
Seção intitulada “1.5 Colônia pura de eventos”A D-3 não tem BFF acoplado. É uma colônia de processamento puro: escuta, classifica, publica. O input é sempre via barramento:
demanda.normalizadada D-1b — gatilho de processamentodemanda.georreferenciadada D-2 — forneceunidade_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.georreferenciadachegar primeiro: a projeção é populada. Quandodemanda.normalizadachegar, o UC id está disponível. Processamento imediato. - Se
demanda.normalizadachegar 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 emd3.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.
2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema d3
Seção intitulada “2.1 Schema d3”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.
2.2 Tabela d3.categorizacoes
Seção intitulada “2.2 Tabela d3.categorizacoes”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'.
Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| 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. |
2.3 Tabela d3.projecao_geo
Seção intitulada “2.3 Tabela d3.projecao_geo”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);2.4 Tabela d3.consumer_offset
Seção intitulada “2.4 Tabela d3.consumer_offset”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'.
2.5 Tabela d3.review_queue
Seção intitulada “2.5 Tabela d3.review_queue”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.
2.6 Migrations
Seção intitulada “2.6 Migrations”Migrations reais do schema d3 e da taxonomia:
20260809200029_d3_categorizacao— Cria o schemad3, as tabelasd3.categorizacoes,d3.projecao_geoed3.consumer_offset.20260814142948_add_d3_review_queue— Criad3.review_queuecom unique emdemanda_id.20260819100000_create_core_taxonomia— Criacore.taxonomia_areas,core.taxonomia_categoriasecore.taxonomia_subcategorias, com FKs, CHECKs de faixa e o seed inicial (17 áreas, 38 categorias e 47 subcategorias), gerado a partir desrc/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.
2.7 Relações internas
Seção intitulada “2.7 Relações internas”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.
2.8 Decisões de schema
Seção intitulada “2.8 Decisões de schema”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.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”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.
3.3 Evento produzido: demanda.categorizada
Seção intitulada “3.3 Evento produzido: demanda.categorizada”| 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.georreferenciada3.6 Tratamento de erro e idempotência
Seção intitulada “3.6 Tratamento de erro e idempotência”| 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. |
3.7 Decisões de design com justificativa
Seção intitulada “3.7 Decisões de design com justificativa”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.
4. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”4.1 Taxonomia estática do MVP — 8 categorias
Seção intitulada “4.1 Taxonomia estática do MVP — 8 categorias”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: 70O 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.
4.2 Dicionário de termos — estrutura
Seção intitulada “4.2 Dicionário de termos — estrutura”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 resultadoO 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 novoprocessarRetryQueue(): 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() + backoffMsO 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.
4.7 Fluxo de revisão manual
Seção intitulada “4.7 Fluxo de revisão manual”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: timestampA 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 versaoO enfileirar é idempotente por demanda_id: uma demanda já na fila não gera linha nova.
4.8 Casos de borda
Seção intitulada “4.8 Casos de borda”| 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. |
4.9 Decisões de design com justificativa
Seção intitulada “4.9 Decisões de design com justificativa”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”5.1 Consumo de eventos via EventBusService
Seção intitulada “5.1 Consumo de eventos via EventBusService”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.
5.2 Fluxo de eventos — cadeia completa
Seção intitulada “5.2 Fluxo de eventos — cadeia completa”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)5.3 Publicação de demanda.categorizada
Seção intitulada “5.3 Publicação de demanda.categorizada”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.
5.4 Chamadas síncronas via BFF
Seção intitulada “5.4 Chamadas síncronas via BFF”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.
5.5 Dependências de projeções de leitura
Seção intitulada “5.5 Dependências de projeções de leitura”A D-3 não consome projeções de leitura de outras colônias. Os dados externos que acessa são:
core.event_logviaEventBusService.replayDeSequence()— dependência do núcleo, permitida.core.taxonomia_categoriasviaTaxonomiaRepository— dado de referência do núcleo, populado por migration (exceção controlada do MVP, como emcore.uc_polygons).d3.projecao_geo— projeção local, alimentada pordemanda.georreferenciada. Mantida pela própria D-3.
6. Performance e Limites
Seção intitulada “6. Performance e Limites”6.1 Rate limiting
Seção intitulada “6.1 Rate limiting”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.
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| 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. |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”| Í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. |
6.4 Padrões de query esperados
Seção intitulada “6.4 Padrões de query esperados”buscarPorEventId(): 1 query por eventodemanda.normalizadarecebido (idempotência).buscarPorDemandaId()da projeção geo: 1 query por eventodemanda.normalizadae por verificação do retry.existePorEventId()da projeção geo: 1 query por eventodemanda.georreferenciada(idempotência).upsert()da projeção geo: 1 write por eventodemanda.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).
6.5 Estratégia de cache
Seção intitulada “6.5 Estratégia de cache”- Taxonomia: carregada de
core.taxonomia_categoriasno boot e mantida em memória peloTaxonomiaConfigService(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:
Mapem memória. Reiniciar o processo perde a fila, e o replay do boot reprocessa os eventos retidos.
6.6 Projeção de volume
Seção intitulada “6.6 Projeção de volume”| 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) |
7. Testabilidade
Seção intitulada “7. Testabilidade”7.1 Como testar o módulo isolado
Seção intitulada “7.1 Como testar o módulo isolado”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.
7.2 Cenários de teste críticos
Seção intitulada “7.2 Cenários de teste críticos”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.georreferenciada → demanda.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. |
7.3 Dados de seed para desenvolvimento local
Seção intitulada “7.3 Dados de seed para desenvolvimento local”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 exemploINSERT 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' );8. Alinhamento com o MVP
Seção intitulada “8. Alinhamento com o MVP”8.1 O que é MVP obrigatório
Seção intitulada “8.1 O que é MVP obrigatório”| 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 |
8.2 Simplificações válidas no MVP
Seção intitulada “8.2 Simplificações válidas no MVP”| 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. |
8.3 O que vai para a Fase 2
Seção intitulada “8.3 O que vai para a Fase 2”- 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.atualizadosda 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.
Referências
Seção intitulada “Referências”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “D-3 — Categorização”
- Taxonomia completa: D-3 - Taxonomia.md
- Schemas de eventos: N-0b - Registry.md
- Barramento de eventos: N-0a - Event Bus.md
- Observabilidade: N-0c - Observabilidade.md
- Colônias a montante: D-1b - Normalização.md, D-2 - Georreferenciamento.md
- Colônias a jusante: D-4 - Priorização e Ranking.md, D-7 - Transparência.md
- Stack de referência e arquitetura do MVP: Apêndice B - Colônias.md, seção “Arquitetura do MVP — Monolito Modular”
- Mapa de dependências de eventos: Apêndice B - Colônias.md, seção “Mapa de Dependências de Eventos entre Colônias”
- Princípios do Formigueiro: Apêndice B - Colônias.md, seção “Princípios herdados do Formigueiro”
Documento de especificação técnica de implementação.