D-6b — Relatoria e Acompanhamento
Parte da D-6 — Conselheiros
Propósito
Seção intitulada “Propósito”Registra cada atualização feita pelo conselheiro sobre a demanda que acompanha. Transforma o trabalho do conselheiro em eventos estruturados e públicos: contatos realizados, protocolos abertos, documentos anexados, entraves encontrados, mudanças de status e prazos registrados.
A primeira atualização de qualquer tipo para um par conselheiro⇄demanda dispara automaticamente conselheiro.demanda_iniciada. Esse é o evento que muda a demanda de atribuido para em_progresso na D-5 e sinaliza para a D-7 que o conselheiro começou a atuar.
A IA sugere como estruturar o texto da atualização (separar fato de interpretação, padronizar linguagem neutra), mas não publica nada autonomamente. O conselheiro sempre revisa e confirma antes da publicação. A sugestão é marcada como automática; o texto final é do conselheiro.
Não avalia o conselheiro, não decide sobre a demanda, não gera a timeline pública. Isso é responsabilidade da D-7 (Transparência). A D-6b apenas garante que cada ação do conselheiro gere um evento rastreável com timestamp, tipo estruturado e referência ao responsável.
Referência
Seção intitulada “Referência”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “D-6b — Relatoria e Acompanhamento”
- Papel do conselheiro e ciclos de atuação: contexto_IA.md, seções 5 (Conselhos de unidade cívica) e 6 (Ciclos de atuação, sorteio e progressão)
- A D-6b é o décimo-segundo elo na ordem de implementação do MVP (Bloco 2, após D-6a).
- Colônia irmã: D-6a - Sorteio e Atribuição.md
- Consumidores dos eventos da D-6b: D-5 - Agenda.md, D-7 - Transparência.md
1. Estrutura do Módulo NestJS
Seção intitulada “1. Estrutura do Módulo NestJS”A D-6b é um módulo NestJS com encapsulamento próprio dentro do monolito modular do MVP. É uma colônia orientada a eventos com três endpoints REST: a sugestão de IA, chamada diretamente pelo app; a leitura do acompanhamento ativo do conselheiro; e a ratificação de conclusão coletiva. Consome eventos do barramento via EventBusService (N-0a), processa internamente e publica eventos de saída. A comunicação de escrita com o front-end (atualizações do conselheiro) é mediada pelo BFF da D-1a, que publica conselheiro.atualização_registrada no barramento.
1.1 Árvore de diretórios
Seção intitulada “1.1 Árvore de diretórios”src/demanda/d-6b-relatoria-acompanhamento/├── d6b.module.ts # Module definition├── d6b.service.ts # Lógica de negócio: handlers de eventos, orquestração da relatoria├── d6b.controller.ts # Endpoints REST: sugestão de IA, acompanhamento, ratificação├── d6b.repository.ts # Acesso a todas as tabelas do schema d6b├── d6b.service.spec.ts # Testes do serviço e dos handlers├── d6b.repository.spec.ts # Testes do repositório├── d6b.controller.spec.ts # Testes dos endpoints├── d6b-schema.spec.ts # Testes de schema e migration das conclusões sociais├── relatoria/│ ├── estruturador-ia.service.ts # Sugestão de estruturação por heurística (fallback)│ ├── estruturador-ia.service.spec.ts│ ├── normalizador-atualizacao.ts # Valida campos estruturados por tipo de atualização│ ├── normalizador-atualizacao.spec.ts│ ├── verificador-prazos.ts # CronJob: verifica prazos próximos e vencidos│ ├── verificador-prazos.spec.ts│ ├── d6b.constants.ts # Config estática: tipos, limiares de prazo, parâmetros da IA│ ├── triggers-demanda-iniciada.ts # Heurística de primeiro contato formal│ └── triggers-demanda-iniciada.spec.ts├── dto/│ ├── sugerir-estruturacao.dto.ts # Request/Response do endpoint de IA│ ├── registrar-atualizacao.dto.ts # Contrato do evento conselheiro.atualização_registrada│ ├── concluir-demanda.dto.ts # Contrato do evento demanda.concluída│ └── acompanhamento-conselheiro.dto.ts # Resposta do GET de acompanhamento└── types.ts # Tipos internos: TipoAtualizacao, StatusAcompanhamento, Payloads1.2 Module definition
Seção intitulada “1.2 Module definition”@Module({ imports: [ScheduleModule.forRoot()], // @nestjs/schedule para CronJob de verificação de prazos controllers: [D6bController], // 3 endpoints: sugerir-estruturacao, acompanhamento, ratificar-conclusao providers: [ D6bService, D6bRepository, EstruturadorIaService, NormalizadorAtualizacao, VerificadorPrazos, TriggersDemandaIniciada, ConselheiroGuard, ], exports: [],})export class D6bModule implements OnModuleInit { constructor(private readonly d6bService: D6bService) {}
async onModuleInit() { await this.d6bService.iniciar(); }}1.3 Pontos de atenção
Seção intitulada “1.3 Pontos de atenção”- O módulo não é
@Global(). A D-6b não é dependência de nenhuma outra colônia. Outras colônias consomem seus eventos (conselheiro.demanda_iniciada,conselheiro.atualização_publicada,conselheiro.prazo_próximo,conselheiro.ciclo_concluído,demanda.concluída), 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 módulo importa
ScheduleModuledo@nestjs/schedulepara o CronJob de verificação de prazos (@CronnoVerificadorPrazos). O cron roda a cada 6 horas e o custo operacional no monolito é desprezível. - O
OnModuleInitdispara o protocolo de inicialização: carrega config estática, replay de eventos perdidos + registro de handlers. - O módulo não registra
ThrottlerModule. As rotas do controller aplicam@Throttlepróprio (10/min na sugestão, 60/min no acompanhamento e 5/min na ratificação); o rate limiting das rotas de escrita do BFF é responsabilidade da D-1a. - O endpoint REST
POST /d6b/sugerir-estruturacaoé uma exceção ao modelo de colônia de eventos. É chamado diretamente pelo front-end do conselheiro durante o registro de atualização, sob oConselheiroGuarde com@Throttlede 10/min. O endpoint é stateless: não persiste, só retorna sugestão. A decisão de publicar é do conselheiro, e a publicação ocorre via evento no barramento, não via REST. As rotas de acompanhamento e ratificação completam o conjunto de leituras do workspace, cada uma restrita ao próprio schemad6b. - Os parâmetros de IA (
iaHabilitada,iaScoreFallback,iaScoreMinimoAuto,IA_TIMEOUT_MS) e de prazo (diasAntecedenciaAlertaPrazo,diasLimiteAcompanhamentoInativo) são carregados derelatoria/d6b.constants.ts, arquivo de configuração estática no MVP.
1.4 Serviço — responsabilidades e contrato
Seção intitulada “1.4 Serviço — responsabilidades e contrato”export class D6bService { iniciar(): Promise<void>; reprocessarEventosPerdidos(): Promise<void>; registrarConsumidores(): void; obterAcompanhamentoConselheiro(conselheiroId: string): Promise<AcompanhamentoConselheiro>;
onConselheiroSorteado(evento: EventoRecebido): Promise<void>; onConselheiroAtualizacaoRegistrada(evento: EventoRecebido): Promise<void>; onConclusaoConfirmada(evento: EventoRecebido): Promise<void>;
ratificarConclusao(demandaId: string, cidadaoId: string): Promise<{ demanda_id: string; status: string }>;
// IA sugerirEstruturacao(textoBruto: string, tipoAtualizacao: string, demandaId: string): Promise<SugestaoIa>;}
// D6bService consome 4 eventos em produção// ('conselheiro.sorteado', 'conselheiro.atualização_registrada', 'conselheiro.atualização_transcrita'// e 'demanda.conclusao_confirmada'), publica 'conselheiro.demanda_iniciada',// 'conselheiro.atualização_publicada' (1.2.0 com suspeição, 1.1.0 na transcrição limpa e 1.0.0 no relato digitado limpo),// 'conselheiro.prazo_próximo', 'conselheiro.ciclo_concluído', 'demanda.concluída' e// 'demanda.resumo_ciclo_atualizado' (1.0.0)O ResumoCicloService compõe o resumo público do ciclo com o template determinístico resumo_v1, a partir das atualizações publicadas e do desfecho. O D6bService o chama depois de cada conselheiro.atualização_publicada e no fecho do ciclo.
A classe é interna ao módulo. Nenhuma outra colônia injeta D6bService. A comunicação com o exterior é via barramento (eventos de saída) e via REST (sugestão de IA, acompanhamento e ratificação, chamados pelo app).
1.5 Controller REST
Seção intitulada “1.5 Controller REST”A D-6b expõe três endpoints REST, fora do prefixo api (exclusão explícita do main.ts): a sugestão de IA, chamada de forma síncrona pelo front-end do conselheiro durante o fluxo de criação de atualização; o acompanhamento ativo do conselheiro (leitura do próprio estado); e a ratificação de conclusão coletiva.
@Controller('d6b')export class D6bController { constructor(private readonly d6bService: D6bService) {}
@Post('sugerir-estruturacao') @Throttle({ default: { limit: 10, ttl: 60000 } }) @UseGuards(ConselheiroGuard) async sugerir(@Body() dto: SugerirEstruturacaoDto): Promise<SugestaoIaResponse> { const sugestao = await this.d6bService.sugerirEstruturacao( dto.textoBruto, dto.tipoAtualizacao, dto.demandaId, ); return { titulo_sugerido: sugestao.titulo_sugerido, texto_sugerido: sugestao.texto_sugerido, campos_estruturados: sugestao.campos_estruturados, score_confianca: sugestao.score_confianca, }; }
@Get('conselheiros/me/acompanhamento') @Throttle({ default: { limit: 60, ttl: 60000 } }) @UseGuards(ConselheiroGuard) async obterAcompanhamento(@Req() request: RequisicaoConselheiro) { return this.d6bService.obterAcompanhamentoConselheiro(request.cidadao_id); }
@Post('demandas/:demanda_id/ratificar-conclusao') @HttpCode(201) @Throttle({ default: { limit: 5, ttl: 60000 } }) @UseGuards(ConselheiroGuard) async ratificarConclusao( @Param('demanda_id', new ParseUUIDPipe({ version: '4' })) demandaId: string, @Req() request: RequisicaoConselheiro, ): Promise<{ demanda_id: string; status: string }> { return this.d6bService.ratificarConclusao(demandaId, request.cidadao_id); }}O endpoint de sugestão exige sessão Google (ConselheiroGuard) como as demais rotas do workspace e responde 401 sem JWT. A operação segue stateless: nada persiste e nada publica. O acompanhamento e a ratificação exigem o mesmo guard; sem o cidadao_id injetado pelo guard, respondem 401. O limite de requisições da sugestão é 10/min.
Acompanhamento: GET /d6b/conselheiros/me/acompanhamento devolve o acompanhamento ativo do conselheiro autenticado, com status, datas, total de atualizações, pendência de ratificação, prazos registrados e as últimas 50 atualizações em ordem decrescente. Cada atualização leva status: publicada, aguardando_transcricao ou falha_transcricao, derivado do marcador transcricao_status do conteúdo estruturado. Sem acompanhamento ativo, o campo acompanhamento é nulo e as listas vêm vazias. A rota lê apenas o schema d6b e responde 401 sem sessão Google e 429 no limite de 60/min.
Ratificação: a ratificação exige JWT com sessão Google (ConselheiroGuard). A rota responde: 201 { demanda_id, status: 'concluida' } no sucesso, 401 sem sessão Google, 404 sem acompanhamento ativo, 409 sem pendência de conclusão, 403 quando o cidadão não é o conselheiro do acompanhamento e 429 no limite de 5/min. O BFF não participa da rota: ela é chamada diretamente pela interface do conselheiro.
O app chama o endpoint de sugestão durante a tela de “Registrar atualização”:
- Conselheiro digita texto livre + seleciona tipo de atualização no front-end.
- Front-end chama
POST /d6b/sugerir-estruturacaodiretamente. - D-6b retorna sugestão com campos estruturados (título sugerido, texto sugerido, campos extraídos).
- Conselheiro vê lado a lado com o texto original, edita se quiser.
- Conselheiro confirma. O front-end envia
POST /api/conselheiros/atualizacoesao BFF, que publicaconselheiro.atualização_registradano barramento com três blocos:texto_bruto,texto_estruturado(versão final do conselheiro) esugestao_ia(o que a sugestão retornou, para auditoria). - D-6b processa o evento de forma assíncrona e publica
conselheiro.atualização_publicada.
1.6 Separação de responsabilidades entre D-6a e D-6b
Seção intitulada “1.6 Separação de responsabilidades entre D-6a e D-6b”| Operação | Responsável |
|---|---|
| Cadastro de conselheiro | BFF D-1a → conselheiro.cadastrado → D-6a |
| Situação do conselheiro (workspace) | D-6a (GET /d6a/conselheiros/me/situacao) |
| Sorteio e atribuição de demanda | D-6a |
| Recusa de atribuição | BFF D-1a → conselheiro.atribuicao_recusada → D-6a |
| Início formal do acompanhamento | D-6b (primeira atualização → conselheiro.demanda_iniciada) |
| Acompanhamento ativo do conselheiro (workspace) | D-6b (GET /d6b/conselheiros/me/acompanhamento) |
| Registro de atualizações | BFF D-1a → conselheiro.atualização_registrada → D-6b |
| Sugestão de IA para estruturação | D-6b (endpoint REST, chamado pelo app) |
| Alerta de prazo próximo | D-6b (CronJob, publica conselheiro.prazo_próximo) |
| Conclusão da demanda | D-6b (publica demanda.concluída + conselheiro.ciclo_concluído) |
| Pendência e ratificação da conclusão coletiva | D-6b (consome demanda.conclusao_confirmada; rota ratificar-conclusao) |
| Liberação do conselheiro | D-6a (consome conselheiro.ciclo_concluído da D-6b) |
| Avaliação do conselheiro | D-17 (Fase 2) |
| Suspensão por múltiplas recusas | D-6a |
2. Banco de Dados — Schema e Entidades
Seção intitulada “2. Banco de Dados — Schema e Entidades”2.1 Schema d6b
Seção intitulada “2.1 Schema d6b”Todas as tabelas da D-6b residem no schema d6b do PostgreSQL. Este schema é de uso exclusivo do módulo D-6b. Nenhuma outra colônia lê ou escreve nestas tabelas.
2.2 Tabela d6b.acompanhamentos
Seção intitulada “2.2 Tabela d6b.acompanhamentos”Registro de cada relação conselheiro⇄demanda sob acompanhamento. Uma linha é criada quando a D-6b recebe conselheiro.sorteado. Uma linha pode ser encerrada por conclusão da demanda, fim de mandato, desistência ou reatribuição (outro conselheiro sorteado para a mesma demanda).
CREATE SCHEMA IF NOT EXISTS d6b;
CREATE TABLE d6b.acompanhamentos ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), demanda_id UUID NOT NULL, conselheiro_id UUID NOT NULL, unidade_civica_id UUID NOT NULL, status VARCHAR(20) NOT NULL DEFAULT 'aguardando_inicio', data_sorteio TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(), data_inicio TIMESTAMPTZ(2), data_conclusao TIMESTAMPTZ(2), motivo_encerramento VARCHAR(30), total_atualizacoes INTEGER NOT NULL DEFAULT 0, texto_resumo_final TEXT, texto_resumo_ciclo TEXT, pendencia_conclusao JSONB, saidas_conclusao JSONB, saidas_conclusao_pendentes BOOLEAN NOT NULL DEFAULT false, event_id_sorteio UUID NOT NULL, event_id_ultima_alteracao UUID NOT NULL, criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(), atualizado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW());
CREATE INDEX acompanhamentos_conselheiro_id_idx ON d6b.acompanhamentos (conselheiro_id);CREATE INDEX acompanhamentos_demanda_id_status_idx ON d6b.acompanhamentos (demanda_id, status);CREATE INDEX acompanhamentos_unidade_civica_id_status_idx ON d6b.acompanhamentos (unidade_civica_id, status);CREATE INDEX acompanhamentos_event_id_sorteio_idx ON d6b.acompanhamentos (event_id_sorteio);O banco não tem CHECKs de status nem de coerência entre status e data_conclusao. Os valores são garantidos em aplicação.
Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
id |
UUID PK | Identificador interno do acompanhamento. Gerado pela D-6b. |
demanda_id |
UUID | FK lógica para o registro de demanda. Sem constraint formal — regra de isolamento. |
conselheiro_id |
UUID | FK lógica para d6a.conselheiros.id ou cidadao_id. |
unidade_civica_id |
UUID | UC da demanda. Extraído do payload de conselheiro.sorteado. |
status |
VARCHAR(20) | Estado atual do acompanhamento. Ver transições em 4.8. |
data_sorteio |
TIMESTAMPTZ(2) | Quando o conselheiro foi sorteado. Extraído de conselheiro.sorteado. |
data_inicio |
TIMESTAMPTZ(2) | Quando o conselheiro fez a primeira ação formal. Preenchido ao publicar conselheiro.demanda_iniciada. |
data_conclusao |
TIMESTAMPTZ(2) | Quando o acompanhamento foi encerrado. Preenchido ao publicar conselheiro.ciclo_concluído. |
motivo_encerramento |
VARCHAR(30) | Razão do encerramento: demanda_concluida, fim_mandato, desistencia, reatribuido. |
total_atualizacoes |
INTEGER | Contador de atualizações registradas. Incrementado no registro de cada atualização, inclusive a pendente de transcrição. A D-7 conta apenas as publicadas. |
texto_resumo_final |
TEXT | Resumo do ciclo ao encerrar. Preenchido com o texto estruturado da atualização de conclusão. |
texto_resumo_ciclo |
TEXT | Resumo público do ciclo, gerado pelo template resumo_v1 a partir das atualizações publicadas e do desfecho. Atualizado a cada publicação e no fecho, com sanitização de PII antes de persistir e publicar. |
pendencia_conclusao |
JSONB | Nulo sem pendência; { conclusao_id, total_conclusoes, data } quando a conclusão coletiva cruzou o limiar e aguarda ratificação. Limpo na ratificação e na conclusão por atualização normal. |
saidas_conclusao |
JSONB | Snapshot das saídas da conclusão (demanda.concluída e conselheiro.ciclo_concluído), com tipo, event_id, correlacao_id e payload. Persistido antes da publicação; a varredura do boot republica o que ficou pendente. |
saidas_conclusao_pendentes |
BOOLEAN | true enquanto as saídas de conclusão persistidas ainda não foram publicadas. A varredura do boot busca os concluídos com a flag ativa e a desliga após a publicação. |
event_id_sorteio |
UUID | event_id do evento conselheiro.sorteado que originou este acompanhamento. Para idempotência. |
event_id_ultima_alteracao |
UUID | event_id do último evento que alterou este registro. Para rastreamento. |
criado_em |
TIMESTAMPTZ(2) | Timestamp de criação do registro. |
atualizado_em |
TIMESTAMPTZ(2) | Última alteração no registro. |
Constraint uq_d6b_acompanhamento_demanda_ativa
Seção intitulada “Constraint uq_d6b_acompanhamento_demanda_ativa”A restrição de acompanhamento ativo é um índice único PARCIAL, criado por migration manual (20260815163000_d6b_unique_acompanhamento_ativo):
CREATE UNIQUE INDEX uq_d6b_acompanhamento_demanda_ativa ON d6b.acompanhamentos (demanda_id) WHERE status IN ('aguardando_inicio', 'em_andamento');Ela garante:
- No máximo um acompanhamento ativo (
aguardando_inicioouem_andamento) por demanda. - Múltiplos registros com status
concluido,desistencia,fim_mandatooureatribuidono histórico. UmUNIQUE (demanda_id, status)bloquearia duas reatribuições.
O Prisma não representa índice único parcial no schema; o índice vive na migration. Quando a D-6b recebe conselheiro.sorteado para uma demanda que já tem acompanhamento ativo com outro conselheiro, o anterior é encerrado como reatribuido antes de criar o novo. O índice garante que dois ativos não coexistem.
2.3 Tabela d6b.atualizacoes
Seção intitulada “2.3 Tabela d6b.atualizacoes”Histórico de todas as atualizações publicadas pelos conselheiros. A atualização com áudio nasce como linha pendente, com texto_estruturado vazio e conteudo_estruturado.transcricao_status = 'aguardando', e é preenchida uma única vez quando a transcrição chega. A partir da publicação, o append-only vale: nenhuma atualização publicada é alterada ou removida. Cada linha publicada corresponde a um evento conselheiro.atualização_publicada.
CREATE TABLE d6b.atualizacoes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), acompanhamento_id UUID NOT NULL, demanda_id UUID NOT NULL, conselheiro_id UUID NOT NULL, tipo VARCHAR(30) NOT NULL, texto_bruto TEXT NOT NULL, texto_estruturado TEXT NOT NULL, sugestao_ia JSONB, conteudo_estruturado JSONB NOT NULL DEFAULT '{}', origem_estruturacao VARCHAR(20) NOT NULL DEFAULT 'conselheiro', versao_formato INTEGER NOT NULL DEFAULT 1, sequencia INTEGER NOT NULL, event_id_publicacao UUID NOT NULL, correlacao_id UUID NOT NULL, criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
CONSTRAINT atualizacoes_event_id_publicacao_key UNIQUE (event_id_publicacao), CONSTRAINT atualizacoes_acompanhamento_id_fkey FOREIGN KEY (acompanhamento_id) REFERENCES d6b.acompanhamentos (id));
CREATE INDEX atualizacoes_acompanhamento_id_idx ON d6b.atualizacoes (acompanhamento_id);CREATE INDEX atualizacoes_demanda_id_idx ON d6b.atualizacoes (demanda_id);CREATE INDEX atualizacoes_conselheiro_id_idx ON d6b.atualizacoes (conselheiro_id);CREATE INDEX atualizacoes_demanda_id_criado_em_idx ON d6b.atualizacoes (demanda_id, criado_em);Sem CHECKs de tipo e de origem_estruturacao no banco. Os valores são validados em aplicação pelo NormalizadorAtualizacao.
Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
id |
UUID PK | Identificador interno da atualização. |
acompanhamento_id |
UUID | FK interna para d6b.acompanhamentos.id. |
demanda_id |
UUID | FK lógica para o registro de demanda. Redundância para queries sem JOIN. |
conselheiro_id |
UUID | FK lógica para o conselheiro. Redundância para queries sem JOIN. |
tipo |
VARCHAR(30) | Tipo estruturado da atualização (ver 2.11). |
texto_bruto |
TEXT | Texto original enviado pelo conselheiro, sem processamento. Preservado para auditoria. |
texto_estruturado |
TEXT | Versão final revisada pelo conselheiro. É o texto publicado. |
sugestao_ia |
JSONB | O que a sugestão retornou antes da revisão do conselheiro. Nulo se origem_estruturacao = 'conselheiro'. Campos: titulo_sugerido, texto_sugerido, campos_estruturados, score_confianca. |
conteudo_estruturado |
JSONB | Campos estruturados específicos do tipo de atualização (ex: protocolo_numero, orgao, data_contato). Schema varia por tipo. No relato por áudio, leva audio_object_key, transcricao_status (aguardando, concluida ou falha), modelo_transcricao e confianca_transcricao. |
origem_estruturacao |
VARCHAR(20) | conselheiro, ia_assistida ou sistema (atualizações geradas pela conclusão coletiva e pela ratificação). |
versao_formato |
INTEGER | Versão do formato de conteudo_estruturado. Incrementado se o schema evoluir. |
sequencia |
INTEGER | Posição da atualização no acompanhamento (1, 2, 3…). É o numero_sequencial publicado no evento. |
event_id_publicacao |
UUID | event_id do evento que originou a atualização. UNIQUE — idempotência. |
correlacao_id |
UUID | correlacao_id do evento de origem. Para trace distribuído. |
criado_em |
TIMESTAMPTZ(2) | Timestamp de criação do registro. |
2.4 Tabela d6b.prazos
Seção intitulada “2.4 Tabela d6b.prazos”Registro de prazos estimados definidos pelos conselheiros. Um acompanhamento pode ter múltiplos prazos ao longo do ciclo. Cada prazo tem uma data de validade e um status de verificação.
CREATE TABLE d6b.prazos ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), acompanhamento_id UUID NOT NULL, demanda_id UUID NOT NULL, data_estimada DATE NOT NULL, descricao TEXT, status VARCHAR(20) NOT NULL DEFAULT 'vigente', alerta_enviado BOOLEAN NOT NULL DEFAULT false, data_alerta TIMESTAMPTZ(2), event_id_atualizacao UUID NOT NULL, criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
CONSTRAINT prazos_event_id_atualizacao_key UNIQUE (event_id_atualizacao), CONSTRAINT prazos_acompanhamento_id_fkey FOREIGN KEY (acompanhamento_id) REFERENCES d6b.acompanhamentos (id));
CREATE INDEX prazos_acompanhamento_id_idx ON d6b.prazos (acompanhamento_id);CREATE INDEX prazos_status_data_estimada_idx ON d6b.prazos (status, data_estimada);Sem CHECK de status no banco.
Colunas — detalhamento
Seção intitulada “Colunas — detalhamento”| Coluna | Tipo | Descrição |
|---|---|---|
id |
UUID PK | Identificador interno do prazo. |
acompanhamento_id |
UUID | FK interna para d6b.acompanhamentos.id. |
demanda_id |
UUID | FK lógica para o registro de demanda. |
data_estimada |
DATE | Data estimada de resolução ou próximo marco. |
descricao |
TEXT | Descrição do que se espera até essa data. |
status |
VARCHAR(20) | vigente (ativo), vencido (passou da data sem resolução), cumprido (resolvido antes/dentro), substituido (substituído por prazo mais recente). |
alerta_enviado |
BOOLEAN | Se o alerta conselheiro.prazo_próximo já foi publicado para este prazo. |
data_alerta |
TIMESTAMPTZ(2) | Quando o alerta foi publicado. |
event_id_atualizacao |
UUID | event_id da atualização que definiu este prazo. UNIQUE — idempotência. |
criado_em |
TIMESTAMPTZ(2) | Timestamp de criação do registro. |
2.5 Tabela d6b.processed_events
Seção intitulada “2.5 Tabela d6b.processed_events”Controle de idempotência. Cada evento processado com sucesso pela D-6b é registrado aqui. Para eventos consumidos que não geram entidade própria com UNIQUE em event_id, esta tabela é a barreira de duplicação.
CREATE TABLE d6b.processed_events ( event_id UUID PRIMARY KEY, event_type VARCHAR(100) NOT NULL, processed_at TIMESTAMPTZ(2) NOT NULL DEFAULT NOW());
CREATE INDEX processed_events_event_type_idx ON d6b.processed_events (event_type);2.6 Tabela d6b.consumer_offset
Seção intitulada “2.6 Tabela d6b.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 d6b.consumer_offset ( tipo_evento VARCHAR(255) PRIMARY KEY, last_sequence BIGINT NOT NULL DEFAULT 0, updated_at TIMESTAMPTZ(2) NOT NULL DEFAULT NOW());No MVP, a tabela tem 4 linhas, uma por tipo consumido: conselheiro.sorteado, conselheiro.atualização_registrada, conselheiro.atualização_transcrita e demanda.conclusao_confirmada.
2.7 Tabela d6b.conclusoes_sociais
Seção intitulada “2.7 Tabela d6b.conclusoes_sociais”Guarda de sorteio tardio. Registra que a demanda concluiu por confirmação coletiva da D-12. Preenchida em dois pontos: quando demanda.conclusao_confirmada chega com ratificação desnecessária e quando o conselheiro ratifica a conclusão. Um conselheiro.sorteado posterior para a mesma demanda não cria acompanhamento.
CREATE TABLE d6b.conclusoes_sociais ( demanda_id UUID PRIMARY KEY, evento_id UUID NOT NULL, criado_em TIMESTAMPTZ(2) NOT NULL DEFAULT NOW(),
CONSTRAINT conclusoes_sociais_evento_id_key UNIQUE (evento_id));2.8 Migrations esperadas
Seção intitulada “2.8 Migrations esperadas”Quatro migrations:
20260813092442_create_d6b_tables— Cria o schemad6be as tabelasacompanhamentos,atualizacoes,prazos,processed_eventseconsumer_offset, com as constraints UNIQUE, as FKs internas e os índices descritos nas seções 2.2 a 2.6.20260815163000_d6b_unique_acompanhamento_ativo— Cria o índice único parcialuq_d6b_acompanhamento_demanda_ativa.20260902193828_d6b_conclusoes_sociais— Cria a tabelad6b.conclusoes_sociaisda guarda de sorteio tardio.20260918170000_d6b_saidas_conclusao— Adicionasaidas_conclusaoesaidas_conclusao_pendentesad6b.acompanhamentospara a varredura de saídas de conclusão.0011_d6b_resumo_ciclo— Adiciona a colunatexto_resumo_cicload6b.acompanhamentos.
O seed dos cursores acontece em runtime no iniciar(): seedOffsets() cria uma linha por tipo consumido com obterMaiorSequence(), sem sobrescrever cursor existente e sem seed zero.
Migrations futuras (Fase 2): adição de coluna avaliacao_conselheiro em acompanhamentos para integração com D-17, índices compostos para queries de dashboard por período.
2.9 Relações internas
Seção intitulada “2.9 Relações internas”O schema d6b tem FKs internas entre suas próprias tabelas:
d6b.atualizacoes.acompanhamento_id → d6b.acompanhamentos.idd6b.prazos.acompanhamento_id → d6b.acompanhamentos.id
Essas FKs são internas ao schema e não violam o princípio de isolamento. As colunas demanda_id e conselheiro_id em acompanhamentos são correlações lógicas, sem FK formal com schemas de outras colônias.
2.10 Decisões de schema
Seção intitulada “2.10 Decisões de schema”Índice único parcial em acompanhamentos para evitar concorrência.
O cenário de dois conselheiro.sorteado para a mesma demanda com conselheiros diferentes é esperado quando há recusa. O índice único parcial garante que o handler feche o acompanhamento anterior (status reatribuido) antes de inserir o novo (status aguardando_inicio). Sem a restrição, dois acompanhamentos ativos coexistiriam, gerando atualizações conflitantes.
Redundância de demanda_id e conselheiro_id em atualizacoes.
Esses campos existem em acompanhamentos e poderiam ser obtidos via JOIN. A redundância permite queries diretas por demanda ou conselheiro sem JOIN, útil para a D-7 (que consumirá a projeção de leitura). O custo de armazenamento é desprezível (dois UUIDs extras por linha).
Tabela prazos separada de atualizacoes.
Um prazo é definido em uma atualização de tipo prazo_registrado, mas tem ciclo de vida próprio: pode ser substituído por prazo mais recente, vencido ou cumprido. A tabela separada permite que o CronJob de verificação faça queries eficientes (WHERE status = 'vigente' AND data_estimada <= ?) sem carregar o conteúdo textual das atualizações.
Índice prazos_status_data_estimada_idx.
A query do CronJob de verificação é “prazos vigentes com alerta_enviado = false cuja data estimada está a menos de N dias”. O índice composto cobre o filtro por status e faixa de data; o filtro por alerta_enviado é resolvido na leitura.
Tabela processed_events com PK em event_id.
Para eventos conselheiro.atualização_registrada, a própria tabela atualizacoes tem UNIQUE em event_id_publicacao. Mas conselheiro.sorteado não gera linha em atualizacoes. A tabela processed_events é a barreira de idempotência genérica para os eventos consumidos.
2.11 Tipos de atualização e seus campos estruturados
Seção intitulada “2.11 Tipos de atualização e seus campos estruturados”Cada tipo de atualização tem um schema de conteudo_estruturado validado pelo NormalizadorAtualizacao. Campos obrigatórios e valores de enum variam por tipo.
| Tipo | Descrição | Campos estruturados (JSONB) |
|---|---|---|
contato_realizado |
Conselheiro fez contato com órgão, empresa ou pessoa relevante. | canal (obrigatório; telefone/email/presencial/oficio), contato_nome, contato_orgao, resultado |
protocolo_aberto |
Número de protocolo formal em órgão público. | protocolo_numero (obrigatório), orgao (obrigatório), data_protocolo, link_acompanhamento |
documento_anexado |
Documento adicionado ao acompanhamento. | tipo_documento (obrigatório), descricao (obrigatório), anexo_id (referência ao evento anexo.processado da D-1c) |
entrave_registrado |
Bloqueio ou dificuldade encontrada. | orgao_envolvido (obrigatório), descricao_entrave (obrigatório), gravidade (baixa/media/alta) |
status_atualizado |
Mudança de status da demanda no mundo real. | novo_status (obrigatório; pendente/em_andamento/aguardando/concluido/bloqueado), fonte_informacao |
prazo_registrado |
Prazo estimado para resolução ou próximo marco. | data_estimada (obrigatório; AAAA-MM-DD, hoje ou futuro), descricao_prazo, fonte_estimativa |
O NormalizadorAtualizacao valida os campos obrigatórios, os enums de canal, gravidade e novo_status, o formato do protocolo_numero e a data de data_estimada (formato e rejeição de data no passado).
Limitação do MVP: o tipo documento_anexado não carrega anexo real. A atualização publica texto descritivo e o campo anexo_id fica reservado para a Fase 2, quando o pipeline de anexo da relatoria existir. O contrato do campo permanece como referência do desenho futuro.
3. Eventos — Contratos Detalhados
Seção intitulada “3. Eventos — Contratos Detalhados”A D-6b consome três tipos de evento como gatilho de processamento e produz cinco. 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-6b.
3.1 Evento consumido: conselheiro.sorteado (gatilho primário)
Seção intitulada “3.1 Evento consumido: conselheiro.sorteado (gatilho primário)”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.sorteado |
| Schema version | 1.0.0 |
| Produtor | D-6a (Sorteio e Atribuição) |
| Consumidores | D-6b (esta colônia), D-5 (Agenda), D-7 (Transparência) e D-12 (Detecção de Duplicidade, confirma o status atribuida da candidata) |
| Descrição | Um conselheiro foi sorteado e atribuído a uma demanda. A D-6b inicia o acompanhamento. |
Payload esperado (campos do catálogo; timestamp é campo adicional):
interface ConselheiroSorteadoPayload { conselheiro_id: string; demanda_id: string; unidade_civica_id: string; metodo_sorteio: string; // 'fisher_yates' seed_publico: string; // HMAC-SHA256 para verificação independente lista_elegiveis_hash?: string; // SHA-256 da lista ordenada de elegíveis posicao_sorteada: number; // Índice 0-based na lista total_elegiveis: number; // Número de elegíveis no momento timestamp: string; // ISO-8601}3.2 Evento consumido: conselheiro.atualização_registrada
Seção intitulada “3.2 Evento consumido: conselheiro.atualização_registrada”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.atualização_registrada |
| Schema version | 1.1.0 (a 1.0.0 permanece aceita) |
| Produtor | BFF da D-1a |
| Consumidor | D-6b (esta colônia) |
| Descrição | Conselheiro revisou, confirmou e publicou uma atualização. O BFF recebe a confirmação do front-end, publica este evento e retorna confirmação ao usuário. A D-6b processa de forma assíncrona. |
Payload esperado (conforme Registry N-0b, v1.1.0):
interface ConselheiroAtualizacaoRegistradaPayload { conselheiro_id: string; demanda_id: string; unidade_civica_id: string; tipo: string; // um dos 6 tipos validados pelo NormalizadorAtualizacao texto_bruto: string; // 0 a 10.000 caracteres; vazio quando há áudio texto_estruturado: string; // 0 a 10.000 caracteres; vazio quando há áudio audio_object_key?: string; // chave do relato em áudio no caminho privado do cidadão conteudo_estruturado?: Record<string, unknown>; origem_estruturacao?: 'conselheiro' | 'ia_assistida'; sugestao_ia?: Record<string, unknown>; // campo adicional publicado pelo BFF timestamp: string; // ISO-8601}O catálogo exige timestamp e limita os textos a 10.000 caracteres. Sem audio_object_key, os textos continuam exigindo pelo menos 1 caractere. sugestao_ia não faz parte do schema do catálogo; o BFF o publica como campo adicional quando o conselheiro usou a sugestão.
Quando o payload traz audio_object_key, a D-6b insere a atualização em estado pendente, com o texto vazio, e aguarda o conselheiro.atualização_transcrita publicado pela D-1b. A transcrição aplica o texto na linha pendente, marca a origem automática e publica a atualização. Sem sucesso na transcrição, a linha fica marcada com falha e nada é publicado. O áudio não é publicado e fica restrito à auditoria.
Fluxo de revisão (o que ocorre antes deste evento ser publicado):
Front-end: conselheiro digita texto livre + seleciona tipo → App: chama POST /d6b/sugerir-estruturacao diretamente → D-6b: retorna sugestão (título, texto sugerido, campos) → Front-end: exibe texto original + sugestão lado a lado → conselheiro edita se necessário → conselheiro clica "Publicar" → BFF D-1a: publica conselheiro.atualização_registrada no barramento → BFF: retorna confirmação síncrona ao front-end → D-6b: processa assíncrona → publica conselheiro.atualização_publicada3.2.1 Evento consumido: conselheiro.atualização_transcrita
Seção intitulada “3.2.1 Evento consumido: conselheiro.atualização_transcrita”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.atualização_transcrita |
| Schema version | 1.0.0 |
| Produtor | D-1b (Normalização) |
| Consumidor | D-6b (esta colônia) |
| Descrição | Resultado da transcrição em segundo plano do relato em áudio do conselheiro. O atualizacao_event_id é o event_id do conselheiro.atualização_registrada correspondente e casa com a linha pendente por d6b.atualizacoes.event_id_publicacao. |
Payload consumido (conforme Registry N-0b, v1.0.0):
interface ConselheiroAtualizacaoTranscritaPayload { atualizacao_event_id: string; // event_id do atualização_registrada conselheiro_id: string; demanda_id: string; unidade_civica_id: string; sucesso: boolean; texto_transcrito: string; // vazio quando sucesso = false confianca: number; motivo_falha?: string; // removido na redação timestamp: string;}Comportamento:
- Com sucesso, a D-6b aplica
texto_transcritoemtexto_estruturado, gravaconteudo_estruturado.transcricao_status = 'concluida',modelo_transcricaoeconfianca_transcricao, e publicaconselheiro.atualização_publicadacomorigem_texto,modelo_transcricaoeconfianca_transcricao. A versão é a 1.2.0 quando a denylist encontra termo no texto transcrito e a 1.1.0 no texto limpo. O workspace usa a marcação gravada no conteúdo estruturado para o selo de texto automático com a confiança. - Sem sucesso, a linha fica com
transcricao_status = 'falha'e nada é publicado. O painel orienta o conselheiro a registrar o relato por texto. - A publicação usa os dados da linha e não depende de o acompanhamento estar ativo.
- Evento sem linha correspondente é descartado com log e o cursor avança. A idempotência é por
event_idemd6b.processed_events, e a linha só é preenchida enquantotranscricao_status = 'aguardando'. - O
prazo_registradocom áudio já grava o prazo no registro da atualização; a transcrição não altera prazos. - A primeira atualização com áudio continua publicando
conselheiro.demanda_iniciadano registro, antes da transcrição.
3.3 Evento produzido: conselheiro.demanda_iniciada
Seção intitulada “3.3 Evento produzido: conselheiro.demanda_iniciada”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.demanda_iniciada |
| Schema version | 1.0.0 |
| Produtor | D-6b (esta colônia) |
| Consumidores | D-5 (Agenda — transição atribuido → em_progresso), D-7 (Transparência) |
| Descrição | Conselheiro fez o primeiro contato formal. Publicado automaticamente na primeira atualização registrada para o par conselheiro⇄demanda. |
Payload publicado (conforme Registry N-0b, v1.0.0):
interface ConselheiroDemandaIniciadaPayload { demanda_id: string; conselheiro_id: string; unidade_civica_id: string; tipo_primeira_acao: string; // Tipo da atualização que disparou o início timestamp_inicio: string; // ISO-8601}Momento da publicação: dentro do handler onConselheiroAtualizacaoRegistrada, após persistir a atualização e atualizar o acompanhamento de aguardando_inicio para em_andamento. As escritas e a publicação não usam transação única: a atualização é persistida primeiro, o acompanhamento é atualizado em seguida e o evento é publicado depois.
3.4 Evento produzido: conselheiro.atualização_publicada
Seção intitulada “3.4 Evento produzido: conselheiro.atualização_publicada”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.atualização_publicada |
| Schema version | 1.2.0 (a 1.1.0 e a 1.0.0 permanecem no catálogo) |
| Produtor | D-6b (esta colônia) |
| Consumidores | D-7 (Transparência — timeline da demanda), D-24 (Memória de Caminhos — campos estruturados do caminho) e D-1d (moderação do relato) |
| Descrição | Atualização do conselheiro processada, validada e publicada. Contém o texto final e os campos estruturados. A atualização transcrita leva a marcação automática. A 1.2.0 marca o texto retido pela denylist. O campo opcional caminho leva a cópia sanitizada dos campos do caminho usada pela D-24. |
Payload publicado (conforme Registry N-0b, v1.2.0):
interface ConselheiroAtualizacaoPublicadaPayload { atualizacao_id: string; // ID interno da atualização (UUID) demanda_id: string; conselheiro_id: string; unidade_civica_id: string; tipo: string; // Tipo de atualização texto_estruturado: string; // Texto final (não o texto_bruto, que fica no estado próprio) descricao_sanitizada: string; // Descrição curta derivada do tipo e dos campos, com PII sanitizada conteudo_estruturado: Record<string, unknown>; // Campos estruturados específicos caminho?: CaminhoAtualizacaoPublicada; // Cópia sanitizada dos campos do caminho para a D-24 origem_estruturacao: string; // 'conselheiro' | 'ia_assistida' origem_texto?: string; // 'automatico' quando o texto veio de transcrição modelo_transcricao?: string; // Modelo que produziu a transcrição confianca_transcricao?: number; // Confiança da transcrição numero_sequencial: number; // Sequência da atualização no acompanhamento (1, 2, 3...) timestamp: string; // ISO-8601 conteudo_suspeito?: boolean; // true quando a denylist encontra termo no texto termos_suspeitos?: string[]; // Termos que dispararam a sinalização}descricao_sanitizada é obrigatória no catálogo (até 2.000 caracteres). A D-6b a gera com gerarDescricaoAtualizacaoPublicada a partir do tipo e do conteúdo estruturado, com o texto truncado, e a passa por sanitizarTexto antes de publicar. O caminho é gerado por gerarCaminhoSanitizado, também com sanitizarTexto e truncamento por campo, e é preservado pela redação do log para o rebuild da D-24. O evento é o que a D-7 usa para a timeline pública, e a atualização de conclusão não gera conselheiro.atualização_publicada: a conclusão aparece na timeline por demanda.concluída.
Denylist de texto na publicação. Antes de publicar, a D-6b verifica o texto_estruturado com verificarDenylist do helper compartilhado src/shared/moderacao/denylist-texto.ts, o mesmo da D-1b. A lista vem de MODERACAO_DENYLIST_TEXTO e fica restrita ao secret de ambiente. A escolha da versão segue o resultado:
| Condição | Versão publicada |
|---|---|
| Denylist encontra termo | 1.2.0, com conteudo_suspeito=true e termos_suspeitos |
| Texto limpo vindo de transcrição | 1.1.0, com a marcação automática |
| Texto limpo digitado | 1.0.0 |
A checagem cobre o relato digitado e o transcrito, porque a publicação é o ponto único dos dois caminhos. O relato suspeito nasce interno na D-7 e entra na fila da D-1d como relato; o relato limpo segue público sem passar pela fila. Na redação do log, conteudo_suspeito é preservado e termos_suspeitos é descartado, como em demanda.normalizada.
3.5 Evento produzido: conselheiro.prazo_próximo
Seção intitulada “3.5 Evento produzido: conselheiro.prazo_próximo”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.prazo_próximo |
| Schema version | 1.0.0 |
| Produtor | D-6b (esta colônia — CronJob) |
| Consumidores | Sistema de notificação (Fase 2), D-7 (Transparência) |
| Descrição | Um prazo registrado está próximo do vencimento. Publicado N dias antes da data estimada. |
Payload publicado:
interface ConselheiroPrazoProximoPayload { demanda_id: string; conselheiro_id: string; unidade_civica_id: string; prazo_id: string; // ID do prazo em d6b.prazos data_estimada: string; // Data limite (ISO-8601 date) dias_restantes: number; descricao_prazo: string; timestamp: string; // ISO-8601}Frequência de verificação: a cada 6 horas. Antecedência padrão: 3 dias. Se um prazo vigente tem data estimada dentro do limite de N dias e ainda não recebeu alerta (alerta_enviado = false), o evento é publicado e alerta_enviado é marcado como true.
3.6 Evento produzido: demanda.concluída
Seção intitulada “3.6 Evento produzido: demanda.concluída”| Propriedade | Valor |
|---|---|
| Tipo | demanda.concluída |
| Schema version | 1.0.0 |
| Produtor | D-6b (esta colônia) |
| Consumidores | D-5 (Agenda, transição para concluido), D-7 (Transparência) e D-12 (Detecção de Duplicidade, índice da candidata) |
| Descrição | A demanda foi concluída. Publicado quando o conselheiro marca a demanda como resolvida. |
Payload publicado:
interface DemandaConcluidaPayload { demanda_id: string; unidade_civica_id: string; data_conclusao: string; // ISO-8601 conselheiro_id: string; categoria_id?: string; // campo adicional, presente quando o conteúdo traz categoria_id dias_ate_conclusao: number; // Dias entre sorteio e conclusão total_atualizacoes: number; // Total de atualizações no ciclo, incluindo a de conclusão resumo_final: string; // Texto estruturado da atualização de conclusão timestamp: string; // ISO-8601}Publicado antes de conselheiro.ciclo_concluído. A distinção é semântica: demanda.concluída informa que a demanda foi resolvida; conselheiro.ciclo_concluído informa que o conselheiro está liberado. O estado do acompanhamento é persistido primeiro; os dois eventos são publicados em seguida, nessa ordem, sem transação única.
3.7 Evento produzido: conselheiro.ciclo_concluído
Seção intitulada “3.7 Evento produzido: conselheiro.ciclo_concluído”| Propriedade | Valor |
|---|---|
| Tipo | conselheiro.ciclo_concluído |
| Schema version | 1.0.0 |
| Produtor | D-6b (esta colônia) |
| Consumidores | D-6a (Sorteio — libera conselheiro), D-7 (Transparência), D-16 (Controle de Mandato, Fase 2) |
| Descrição | Ciclo de acompanhamento do conselheiro foi encerrado. |
Payload publicado (conforme Registry N-0b, v1.0.0):
interface ConselheiroCicloConcluidoPayload { conselheiro_id: string; demanda_id: string; unidade_civica_id: string; motivo_encerramento: string; // 'demanda_concluida' | 'fim_mandato' | 'desistencia' total_atualizacoes: number; data_inicio: string; // ISO-8601 data_fim: string; // ISO-8601 dias_duracao: number; // Dias entre início e fim resumo_final?: string; timestamp: string; // ISO-8601}3.7.1 Evento produzido: demanda.resumo_ciclo_atualizado
Seção intitulada “3.7.1 Evento produzido: demanda.resumo_ciclo_atualizado”| Propriedade | Valor |
|---|---|
| Tipo | demanda.resumo_ciclo_atualizado |
| Schema version | 1.0.0 |
| Produtor | D-6b (esta colônia) |
| Consumidores | D-7 (Transparência) |
| Descrição | Resumo público do ciclo de acompanhamento, publicado a cada atualização publicada e no fecho. |
Payload publicado (conforme Registry N-0b, v1.0.0):
interface DemandaResumoCicloAtualizadoPayload { demanda_id: string; resumo: string; // template resumo_v1, teto de 6000 caracteres total_atualizacoes: number; data_inicio: string | null; // ISO-8601; nulo com o ciclo sem primeira ação formal data_fim: string | null; // ISO-8601; nulo com o ciclo em andamento versao_metodo: string; // 'resumo_v1' gerado_em: string; // ISO-8601 fontes: Array<{ atualizacao_id: string }>;}O texto é sanitizado antes de persistir e publicar. O event_id é determinístico por demanda e total de atualizações, o que evita republicação idêntica. O fecho entra no snapshot saidas_conclusao e a varredura do boot republica a saída quando a publicação falha. A D-6b não consome caminho.atualizado no MVP: o dossiê do caminho chega ao conselheiro pela projeção da D-7.
3.8 Ordem de operações no handler onConselheiroSorteado
Seção intitulada “3.8 Ordem de operações no handler onConselheiroSorteado”1. Verificar idempotência → repo.buscarProcessedEvent(event_id) → se processado: log, avançar o cursor e retornar
2. Validar payload mínimo → conselheiro_id, demanda_id e unidade_civica_id obrigatórios → se ausente: log.error, avançar o cursor e retornar
3. Guarda de sorteio tardio → repo.buscarConclusaoSocial(demanda_id) → se a demanda concluiu socialmente: log.warn, registrar processed_event, avançar o cursor e retornar
4. Verificar acompanhamento ativo da demanda → ativo = repo.buscarAcompanhamentoAtivoPorDemanda(demanda_id) → se existe com o mesmo conselheiro: log.warn, avançar o cursor e retornar → se existe com outro conselheiro: → repo.encerrarAcompanhamento(ativo.id, { status: 'reatribuido', motivo_encerramento: 'reatribuido', data_conclusao: agora, event_id }) → repo.inserirProcessedEvent(ativo.event_id_sorteio, 'conselheiro.sorteado') → log com o acompanhamento anterior e o novo conselheiro
5. Criar o acompanhamento → repo.inserirAcompanhamento com status 'aguardando_inicio' e data_sorteio do timestamp do payload (ou do momento, se ausente) → repo.inserirProcessedEvent(event_id, 'conselheiro.sorteado')
6. Avançar o cursor de 'conselheiro.sorteado'As escritas não usam transação. O registro do processed_event propaga erro de banco: o handler lança, o EventBus registra na DLQ e o cursor não avança. A violação de unicidade do próprio event_id é o único caso já coberto pela guarda de idempotência.
3.9 Ordem de operações no handler onConselheiroAtualizacaoRegistrada
Seção intitulada “3.9 Ordem de operações no handler onConselheiroAtualizacaoRegistrada”1. Verificar idempotência → repo.buscarAtualizacaoPorEventIdPublicacao(event_id) → se encontrada: log, avançar o cursor e retornar
2. Validar payload mínimo → conselheiro_id, demanda_id, unidade_civica_id, tipo, texto_bruto e texto_estruturado obrigatórios → se ausente: log.error, avançar o cursor e retornar → tipo precisa estar em TIPOS_ATUALIZACAO_VALIDOS → origem_estruturacao aceita 'conselheiro' ou 'ia_assistida' (default 'conselheiro') → origem 'ia_assistida' exige sugestao_ia → normalizador.validar(tipo, conteudo_estruturado ?? {}) precisa retornar válido
3. Buscar o acompanhamento ativo → repo.buscarAcompanhamentoAtivoPorConselheiroEDemanda(conselheiro_id, demanda_id) → se não encontrado: log.warn, avançar o cursor e retornar
4. Entrar no lock por acompanhamento (fila de Promises por acompanhamento.id) → reler o acompanhamento ativo dentro do lock para pegar o total_atualizacoes vigente → se o acompanhamento encerrou nesse intervalo: log.warn, avançar o cursor e retornar
5. Determinar a primeira atualização e a nova sequência → primeiraAtualizacao = triggers.ehPrimeiraAtualizacao(acompanhamento.status) → novoTotal = acompanhamento.total_atualizacoes + 1
6. Persistir a atualização → repo.inserirAtualizacao com sequencia = novoTotal, event_id_publicacao = event_id, correlacao_id = correlacao_id do evento ?? event_id
7. Atualizar o acompanhamento → repo.registrarAtualizacaoNoAcompanhamento: total_atualizacoes = novoTotal, status 'em_andamento' e data_inicio na primeira atualização, event_id
8. Se tipo = 'prazo_registrado' → repo.marcarPrazosVigentesSubstituidos(acompanhamento.id) → repo.inserirPrazo com data_estimada do conteúdo estruturado
9. Se tipo = 'status_atualizado' e novo_status = 'concluido' → se primeira atualização: publicar conselheiro.demanda_iniciada → concluirDemanda (seção 3.10) → avançar o cursor e retornar. A atualização de conclusão não gera conselheiro.atualização_publicada; a conclusão aparece por demanda.concluída.
10. Se o payload traz audio_object_key → a linha foi persistida com texto_estruturado vazio e conteudo_estruturado.transcricao_status = 'aguardando' → se primeira atualização: publicar conselheiro.demanda_iniciada → não publicar conselheiro.atualização_publicada → avançar o cursor e retornar. A publicação fica para a transcrição (3.9.1)
11. Publicar eventos → se primeira atualização: conselheiro.demanda_iniciada → conselheiro.atualização_publicada com a versão da denylist de texto: 1.2.0 com suspeição e 1.0.0 no texto digitado limpo
12. Avançar o cursor de 'conselheiro.atualização_registrada'As escritas e as publicações não usam transação. O lock em memória por acompanhamento serializa as atualizações concorrentes e a ratificação; vale para instância única do monolito no MVP, e o lock distribuído fica para a Fase 2.
3.9.1 Ordem de operações no handler onAtualizacaoTranscrita
Seção intitulada “3.9.1 Ordem de operações no handler onAtualizacaoTranscrita”1. Verificar idempotência por event_id em d6b.processed_events → se já processado: avançar o cursor e retornar
2. Validar o payload mínimo → atualizacao_event_id e sucesso obrigatórios → se ausente: log.error, avançar o cursor e retornar
3. Buscar a atualização por event_id_publicacao = atualizacao_event_id → se não encontrada: log.warn, registrar processed_event, avançar o cursor e retornar
4. Buscar o acompanhamento por id (não exige status ativo) → se não encontrado: log.error e avançar o cursor
5. Entrar no lock por acompanhamento e reler a linha → se a linha sumiu ou o status não é 'aguardando_transcricao': log.warn, registrar processed_event, avançar o cursor e retornar
6. Sem sucesso na transcrição → repo.atualizarTranscricaoPorEventId com transcricao_status = 'falha' → registrar processed_event, avançar o cursor e retornar sem publicar
7. Com sucesso → repo.atualizarTranscricaoPorEventId com texto e transcricao_status = 'concluida' → publicar conselheiro.atualização_publicada com origem_texto 'automatico', modelo_transcricao e confianca_transcricao (publicarComRetry). A versão é a 1.2.0 quando a denylist encontra termo no texto e a 1.1.0 no texto limpo → registrar processed_event e avançar o cursorA deduplicação da saída é da própria linha: a publicação usa os dados persistidos e o event_id_publicacao, e a D-7 recebe o evento uma única vez por linha.
3.10 Método concluirDemanda(acompanhamento, atualizacao, novoTotal, eventId, correlacaoId)
Seção intitulada “3.10 Método concluirDemanda(acompanhamento, atualizacao, novoTotal, eventId, correlacaoId)”Chamado quando o conselheiro registra uma atualização de tipo status_atualizado com novo_status = 'concluido'. Marca a demanda como concluída e encerra o ciclo do conselheiro publicando dois eventos distintos.
função concluirDemanda(acompanhamento, atualizacao, novoTotal, eventId, correlacaoId):
dataConclusao = agora diasAteConclusao = ceil( (dataConclusao.getTime() - acompanhamento.data_sorteio.getTime()) / (1000 * 60 * 60 * 24) ) dataInicio = acompanhamento.data_inicio ?? dataConclusao.toISOString()
// 1. Encerrar o acompanhamento → repo.concluirAcompanhamento(acompanhamento.id, { data_conclusao: dataConclusao, texto_resumo_final: atualizacao.texto_estruturado, event_id: eventId }) // grava status 'concluido', motivo_encerramento 'demanda_concluida' // e limpa pendencia_conclusao → repo.inserirProcessedEvent(eventId, 'conclusao_demanda')
// 2. Persistir as saídas de conclusão como pendentes → repo.registrarSaidasConclusaoPendentes(acompanhamento.id, [ { tipo: 'demanda.concluída', event_id: uuid, correlacao_id: correlacaoId, payload: { demanda_id: acompanhamento.demanda_id, unidade_civica_id: acompanhamento.unidade_civica_id, data_conclusao: dataConclusao.toISOString(), conselheiro_id: acompanhamento.conselheiro_id, categoria_id: atualizacao.conteudo_estruturado.categoria_id (quando presente), dias_ate_conclusao: diasAteConclusao, total_atualizacoes: novoTotal, resumo_final: atualizacao.texto_estruturado, timestamp: dataConclusao.toISOString() } }, { tipo: 'conselheiro.ciclo_concluído', event_id: uuid, correlacao_id: correlacaoId, payload: { conselheiro_id: acompanhamento.conselheiro_id, demanda_id: acompanhamento.demanda_id, unidade_civica_id: acompanhamento.unidade_civica_id, motivo_encerramento: 'demanda_concluida', total_atualizacoes: novoTotal, data_inicio: dataInicio, data_fim: dataConclusao.toISOString(), dias_duracao: dias entre data_inicio e data_fim, resumo_final: atualizacao.texto_estruturado, timestamp: dataConclusao.toISOString() } }, ]) // saidas_conclusao guarda o snapshot; saidas_conclusao_pendentes = true
// 3. Publicar demanda.concluída com retry inline (publicarComRetry) // 4. Publicar conselheiro.ciclo_concluído com retry inline (publicarComRetry)
// 5. Marcar as saídas como publicadas → repo.marcarSaidasConclusaoPublicadas(acompanhamento.id)Sem transação: o acompanhamento é persistido primeiro, as saídas entram no snapshot e só então os dois eventos são publicados, nessa ordem. Cada evento usa o event_id persistido no snapshot; a idempotência do barramento absorve uma republicação. Se uma das publicações falhar definitivamente, o erro é relançado, o cursor não avança e a varredura republicarSaidasConclusaoPendentes do boot seguinte republica as duas saídas a partir do snapshot. O caminho de conclusão vale para a atualização status_atualizado com novo_status = 'concluido' e para a ratificação da conclusão coletiva.
3.11 Encerramento administrativo (Fase 2)
Seção intitulada “3.11 Encerramento administrativo (Fase 2)”Os encerramentos que não passam por atualização do conselheiro (fim_mandato e desistencia) não estão implementados no MVP. O desenho previsto:
- o status e o
motivo_encerramentodo acompanhamento recebem o motivo; data_conclusaoé preenchida;- publica-se
conselheiro.ciclo_concluídocom o motivo,data_inicio(nula se o acompanhamento nunca iniciou),data_fim,dias_duracaoeresumo_final; demanda.concluídanão é publicado. A demanda permanece no backlog emem_progressona D-5 e pode ser reatribuída a outro conselheiro quando a D-6a processarconselheiro.ciclo_concluído.
Os motivos serão acionados por eventos da D-17 (Controle de Mandato) ou por ação do conselheiro no front-end.
3.12 Ordem de operações no CronJob VerificadorPrazos
Seção intitulada “3.12 Ordem de operações no CronJob VerificadorPrazos”@Cron('0 */6 * * *') // A cada 6 horasfunção verificarPrazosProximos():
config = carregarD6bConstants() dataLimite = agora + config.diasAntecedenciaAlertaPrazo dias // fim do dia alertasEnviados = 0
prazosProximos = repo.buscarPrazosVigentesAte(dataLimite) // status 'vigente', alerta_enviado = false, data_estimada <= dataLimite
para cada prazo em prazosProximos: acompanhamento = repo.buscarAcompanhamentoPorId(prazo.acompanhamento_id) se acompanhamento == null: continuar
se acompanhamento.status NOT IN ('aguardando_inicio', 'em_andamento'): repo.marcarPrazoVencido(prazo.id) continuar
publicarPrazoProximo(prazo, acompanhamento) repo.marcarPrazoAlertaEnviado(prazo.id) alertasEnviados += 1
prazosVencidos = repo.buscarPrazosVencidos(inicio do dia) para cada prazo em prazosVencidos: repo.marcarPrazoVencido(prazo.id)
log("Verificação de prazos concluída", { alertas_enviados: alertasEnviados, prazos_vencidos: prazosVencidos.length, })publicarPrazoProximo publica conselheiro.prazo_próximo com versao_schema 1.0.0, correlacao_id da demanda, data_estimada no formato AAAA-MM-DD e dias_restantes calculado em dias UTC entre hoje e a data. descricao_prazo vira string vazia quando o prazo não tem descrição. A falha de publicação relança o erro: o prazo não é marcado e o próximo ciclo tenta de novo.
3.13 Tratamento de erro e idempotência
Seção intitulada “3.13 Tratamento de erro e idempotência”| Cenário | Comportamento |
|---|---|
Evento conselheiro.sorteado reentregue (replay/DLQ) |
Detectado por event_id em d6b.processed_events. Log, cursor avançado. |
Evento conselheiro.atualização_registrada reentregue |
Detectado por event_id em d6b.atualizacoes.event_id_publicacao. Log, cursor avançado. |
Evento demanda.conclusao_confirmada reentregue |
Detectado por event_id em d6b.processed_events. Log, cursor avançado. |
| Payload incompleto em qualquer handler | Log.error. O cursor avança em todos os handlers. |
conselheiro.atualização_registrada para acompanhamento inexistente ou já encerrado |
Log.warn, cursor avançado. Ocorre quando o BFF publica após o ciclo ter sido encerrado. |
conselheiro.sorteado para demanda com acompanhamento ativo de outro conselheiro |
Fecha o anterior como reatribuido, registra o event_id_sorteio anterior em processed_events e cria o novo acompanhamento. Comportamento esperado em recusa. |
conselheiro.sorteado para demanda com acompanhamento do mesmo conselheiro |
Log.warn, cursor avançado, sem duplicação. |
conselheiro.sorteado tardio para demanda concluída socialmente |
d6b.conclusoes_sociais bloqueia a abertura. Registra processed_event, cursor avançado. |
INSERT em d6b.atualizacoes falha (unique event_id_publicacao) |
Indica caminho duplicado. O erro propaga para a DLQ e o cursor não avança. |
Publish de conselheiro.demanda_iniciada falha após as tentativas do publicarComRetry |
Log.error. Estado persistido (atualização criada, acompanhamento em em_andamento). Erro relançado para a DLQ. Cursor não avança. |
Publish de conselheiro.atualização_publicada falha após as tentativas do publicarComRetry |
Log.error. Estado persistido. Erro relançado para a DLQ. Cursor não avança. |
Publish de demanda.concluída falha após persistir a conclusão |
Log.error. Acompanhamento já está concluido, a atualização e o snapshot saidas_conclusao persistidos. Erro relançado para a DLQ. Cursor não avança. A varredura republicarSaidasConclusaoPendentes do boot seguinte republica as duas saídas pelo snapshot. |
Publish de conselheiro.ciclo_concluído falha após publicar demanda.concluída |
Log.error. demanda.concluída já foi publicado; o snapshot ainda está pendente. Erro relançado para a DLQ. Cursor não avança. A varredura do boot republica as duas saídas; a idempotência do barramento absorve a que já entrou. |
Falha de inserirProcessedEvent ou upsertOffset |
Erro propagado pelo repositório (sem catch silencioso). O handler lança, o EventBus registra na DLQ e o cursor não avança. O replay do boot retenta. |
| CronJob de prazos falha (exceção não tratada) | O @Cron captura e loga. O próximo ciclo (6 horas depois) tenta novamente. Nenhum estado é corrompido. |
| Publicação do CronJob falha para um prazo | O erro relança e interrompe o ciclo. O prazo não é marcado como alertado e entra no próximo ciclo. |
Duas execuções concorrentes de onConselheiroAtualizacaoRegistrada para o mesmo acompanhamento |
O lock em memória por acompanhamento.id serializa as execuções; cada uma relê o total_atualizacoes vigente dentro do lock e grava sequencia distinta. A unicidade de event_id_publicacao continua barrando o mesmo evento. Lock distribuído fica para a Fase 2. |
3.14 Decisões de design com justificativa
Seção intitulada “3.14 Decisões de design com justificativa”Publicação de dois eventos na conclusão: demanda.concluída antes de conselheiro.ciclo_concluído.
A D-5 consome demanda.concluída para atualizar o backlog. A D-6a consome conselheiro.ciclo_concluído para liberar o conselheiro. São responsabilidades disjuntas. Publicar eventos separados em vez de um evento unificado permite que cada consumidor reaja apenas ao que lhe interessa. A ordem importa: se conselheiro.ciclo_concluído for publicado primeiro, a D-6a libera o conselheiro e pode sorteá-lo para uma nova demanda antes que a D-5 atualize o backlog, gerando uma janela de inconsistência. Publicar demanda.concluída primeiro garante que o backlog esteja atualizado quando o conselheiro for liberado.
Endpoints REST na D-6b para sugestão de IA, não para publicação de atualização. A alternativa seria a D-6b expor um endpoint para o conselheiro publicar atualizações diretamente, sem passar pelo BFF. Isso violaria o princípio de que o BFF é o ponto de entrada HTTP do sistema. O endpoint de sugestão de IA é uma exceção justificada: é uma operação de leitura (não altera estado) que precisa de resposta síncrona para o fluxo de UI, pois o conselheiro está esperando a sugestão para revisar. A publicação em si (operação de escrita) segue o fluxo padrão: BFF → barramento → D-6b.
Primeira atualização dispara conselheiro.demanda_iniciada automaticamente.
A alternativa seria exigir que o conselheiro clicasse em “Iniciar demanda” antes de poder registrar atualizações. Isso adiciona um passo extra na UI e pode gerar esquecimento: o conselheiro sorteado que esquece de clicar “Iniciar” fica em aguardando_inicio indefinidamente. A heurística automática elimina esse risco e registra no payload qual foi a primeira ação concreta (tipo_primeira_acao), preservando a rastreabilidade.
Fechamento de acompanhamento anterior na reatribuição.
Quando a D-6a re-sorteia após recusa, publica um novo conselheiro.sorteado para a mesma demanda. A D-6b recebe e, em vez de criar um segundo acompanhamento ativo, fecha o anterior como reatribuido. A alternativa seria consumir conselheiro.atribuicao_recusada, mas isso adicionaria mais um tipo de evento consumido. A abordagem escolhida mantém a interface de eventos mais enxuta e resolve o problema no próprio handler de conselheiro.sorteado, que é o ponto natural de início de acompanhamento.
3.15 Evento consumido: demanda.conclusao_confirmada
Seção intitulada “3.15 Evento consumido: demanda.conclusao_confirmada”A conclusão coletiva da D-12 chega à relatoria com replay e cursor próprios (consumer_offset, tipo demanda.conclusao_confirmada). O handler onConclusaoConfirmada é idempotente por event_id.
1. Verificar idempotência por event_id em d6b.processed_events → se processado: avançar o cursor, retornar
2. Validar payload mínimo → demanda_id, conclusao_id, data_conclusao, total_conclusoes e ratificacao_necessaria obrigatórios → se ausente: log.error, avançar o cursor, retornar
3. Se ratificacao_necessaria = false: → registrar a conclusão social em d6b.conclusoes_sociais (guarda de sorteio tardio)
4. Buscar o acompanhamento ativo da demanda → se não existe: log, registrar processed_event, avançar o cursor, retornar
5. Entrar no lock por acompanhamento e reler o ativo → o relê pega o total_atualizacoes vigente; se encerrou nesse intervalo: log, registrar processed_event, avançar o cursor, retornar
6. Gravar a pendência no acompanhamento → registrarPendenciaConclusao com { conclusao_id, total_conclusoes, data } → novoTotal = total_atualizacoes + 1 → inserirAtualizacao de sistema no histórico: tipo 'status_atualizado', conteudo_estruturado { novo_status: 'conclusao_confirmada', total_conclusoes, data_conclusao }, origem_estruturacao 'sistema', sequencia = novoTotal → registrarAtualizacaoNoAcompanhamento com total_atualizacoes = novoTotal → registrar processed_event, avançar o cursorAcompanhamento já encerrado (concluído, desistência, fim de mandato, reatribuído) não é encontrado pela busca de ativo: o evento avança o cursor sem gerar pendência. A atualização de sistema entra no histórico com a sequência seguinte e incrementa total_atualizacoes do acompanhamento, mantendo o contador coerente com a maior sequencia gravada.
3.16 Rota de ratificação: POST /d6b/demandas/:demanda_id/ratificar-conclusao
Seção intitulada “3.16 Rota de ratificação: POST /d6b/demandas/:demanda_id/ratificar-conclusao”1. Validar sessão Google (ConselheiroGuard) → sem JWT válido com auth_provider google: 401
2. Buscar acompanhamento ativo da demanda → sem acompanhamento ativo: 404
3. Entrar no lock por acompanhamento e reler o ativo → sem acompanhamento ativo: 404
4. Validar pendência → pendencia_conclusao null: 409 "Demanda sem pendência de conclusão"
5. Validar conselheiro → cidadao_id do token ≠ conselheiro_id do acompanhamento: 403
6. Registrar a atualização de sistema (tipo 'status_atualizado', conteudo_estruturado { novo_status: 'concluido' }, origem_estruturacao 'sistema', sequencia = total_atualizacoes + 1) → atualizar o acompanhamento com a nova contagem e, se for a primeira atualização, status 'em_andamento' e data_inicio
7. Chamar concluirDemanda (mesmo fluxo da conclusão por atualização normal) → publica demanda.concluída e conselheiro.ciclo_concluído com motivo_encerramento 'demanda_concluida' → limpa a pendência (pendencia_conclusao = null)
8. Registrar a conclusão social em d6b.conclusoes_sociais
9. Retornar { demanda_id, status: 'concluida' } (201)A ratificação reusa o fluxo existente: não cria motivo de encerramento novo. A conclusão por atualização normal do conselheiro (novo_status concluido) também limpa a pendência, para o caso de o conselheiro concluir pelo caminho tradicional antes de ratificar.
3.17 Guarda de sorteio tardio
Seção intitulada “3.17 Guarda de sorteio tardio”conselheiro.sorteado que chega depois da conclusão social não cria acompanhamento. O handler de sorteio consulta d6b.conclusoes_sociais: demanda registrada ali já encerrou por confirmação coletiva, então o evento registra processed_event e avança o cursor sem abrir acompanhamento. A tabela d6b.conclusoes_sociais guarda demanda_id, evento_id e timestamp, e é preenchida em dois pontos: quando demanda.conclusao_confirmada chega com ratificação desnecessária e na ratificação. A monotonicidade é preservada: a demanda concluída socialmente não regride a atribuida por sorteio tardio.
4. Lógica de Negócio — Algoritmos e Fluxos
Seção intitulada “4. Lógica de Negócio — Algoritmos e Fluxos”4.1 EstruturadorIaService.sugerir(textoBruto, tipoAtualizacao, demandaId) — pseudocódigo
Seção intitulada “4.1 EstruturadorIaService.sugerir(textoBruto, tipoAtualizacao, demandaId) — pseudocódigo”função sugerir(textoBruto, tipoAtualizacao, demandaId) -> SugestaoIa:
// 1. Validar o tipo se tipoAtualizacao não está em TIPOS_ATUALIZACAO_VALIDOS: lançar BadRequestException("Tipo de atualização inválido...")
// 2. Caminho da IA se carregarD6bConstants().iaHabilitada: construirPrompt(textoBruto, tipoAtualizacao, demandaId) retornar sugerirFallback(textoBruto, tipoAtualizacao)
// 3. Caminho padrão do MVP retornar sugerirFallback(textoBruto, tipoAtualizacao)
função sugerirFallback(textoBruto, tipoAtualizacao) -> SugestaoIa: retornar { titulo_sugerido: extrairTitulo(textoBruto), texto_sugerido: textoBruto.trim(), campos_estruturados: extrairCamposPorRegex(textoBruto, tipoAtualizacao), score_confianca: carregarD6bConstants().iaScoreFallback, // 0,5 }A chamada ao modelo de linguagem não existe no MVP: mesmo com iaHabilitada = true, o serviço monta o prompt e devolve o fallback. extrairTitulo usa a primeira frase, truncada em 100 caracteres com reticências. extrairCamposPorRegex extrai protocolo_numero em protocolo_aberto, data_estimada (dd/mm/aaaa convertida para ISO) em prazo_registrado e novo_status em status_atualizado.
A sugestão com score 0,5 sinaliza ao front-end que a sugestão é heurística e o conselheiro vê a indicação de revisão. A funcionalidade não é bloqueante: o conselheiro pode editar ou ignorar a sugestão e publicar com origem_estruturacao = 'conselheiro'.
4.2 NormalizadorAtualizacao.validar(tipo, conteudoEstruturado) — pseudocódigo
Seção intitulada “4.2 NormalizadorAtualizacao.validar(tipo, conteudoEstruturado) — pseudocódigo”função validar(tipo: string, conteudo: Record<string, unknown>) -> ResultadoValidacao:
erros = []
se tipo não está em TIPOS_ATUALIZACAO_VALIDOS: retornar { valido: false, erros: [{ campo: 'tipo', mensagem: 'Tipo de atualização inválido.' }] }
para cada campo em CAMPOS_OBRIGATORIOS_POR_TIPO[tipo]: se conteudo[campo] é undefined, null ou string vazia: erros.push({ campo, mensagem: 'Campo obrigatório.' })
para cada par (campo, valores) de VALORES_PERMITIDOS: se conteudo[campo] existe e não está em valores: erros.push({ campo, mensagem: `Valor inválido. Esperado um dos: ${valores.join(', ')}.` })
se tipo == 'protocolo_aberto' e conteudo.protocolo_numero preenchido: se !validarProtocoloNumero(conteudo.protocolo_numero): erros.push({ campo: 'protocolo_numero', mensagem: 'Formato de protocolo inválido.' })
se tipo == 'prazo_registrado' e conteudo.data_estimada definida: resultado = validarDataEstimada(conteudo.data_estimada) se resultado == 'formato_invalido': erros.push({ campo: 'data_estimada', mensagem: 'Data inválida.' }) senão se resultado == 'no_passado': erros.push({ campo: 'data_estimada', mensagem: 'Data estimada no passado.' })
retornar { valido: erros.length == 0, erros }CAMPOS_OBRIGATORIOS_POR_TIPO cobre canal (contato), protocolo_numero e orgao (protocolo), tipo_documento e descricao (documento), orgao_envolvido e descricao_entrave (entrave), novo_status (status) e data_estimada (prazo). VALORES_PERMITIDOS cobre os enums de canal (telefone, email, presencial, oficio), gravidade (baixa, media, alta) e novo_status (pendente, em_andamento, aguardando, concluido, bloqueado). O protocolo aceita letras, números, ponto, barra, espaço e hífen, com pelo menos 4 caracteres. A data estimada exige o formato AAAA-MM-DD, aceita o dia de hoje e rejeita datas passadas.
4.3 D6bService.iniciar() — protocolo de inicialização
Seção intitulada “4.3 D6bService.iniciar() — protocolo de inicialização”função iniciar(): se iniciado: retornar iniciado = true
// 1. Carregar configuração estática carregarD6bConstants()
// 2. Semear os cursores do banco próprio maiorSequence = eventBus.obterMaiorSequence() repo.seedOffsets([ 'conselheiro.sorteado', 'conselheiro.atualização_registrada', 'demanda.conclusao_confirmada', ], maiorSequence)
// 3. Replay de eventos perdidos, tipo a tipo reprocessarEventosPerdidos()
// 4. Registrar consumidores para eventos futuros registrarConsumidores()
// 5. Acompanhamentos aguardando_inicio antigos (possível conselheiro inativo) try: verificarAcompanhamentosInativos() catch: log.error
// 6. Saídas de conclusão pendentes (publicação falhou depois da persistência) try: republicarSaidasConclusaoPendentes() catch: log.error
log("D-6b Relatoria e Acompanhamento inicializada")reprocessarEventosPerdidos lê buscarOffsets() e, para cada um dos três tipos, chama replayDeSequence(cursor, [tipo]) e despacha pelo handler correspondente, logando falhas sem interromper os demais tipos. registrarConsumidores inscreve os três tipos via inscrever. A flag iniciado torna a chamada idempotente. O seed usa o maior sequence_number do barramento no boot e não sobrescreve linhas existentes.
republicarSaidasConclusaoPendentes busca os acompanhamentos concluido com saidas_conclusao_pendentes = true (limite de LIMITE_VARREDURA_SAIDAS_CONCLUSAO, 100 por boot), republica cada evento do snapshot saidas_conclusao com publicarComRetry e desliga a flag. A idempotência do barramento absorve os eventos já publicados; falha em um acompanhamento é logada e mantém a flag para o próximo boot.
4.4 verificarAcompanhamentosInativos() — inicialização
Seção intitulada “4.4 verificarAcompanhamentosInativos() — inicialização”função verificarAcompanhamentosInativos():
config = carregarD6bConstants() dataCorte = agora - config.diasLimiteAcompanhamentoInativo dias // 7
inativos = repo.buscarAcompanhamentosInativosDesde(dataCorte) // status 'aguardando_inicio' com criado_em anterior ao corte
para cada acompanhamento em inativos: log.warn("Acompanhamento sem início após 7 dias do sorteio", { acompanhamento_id: acompanhamento.id, demanda_id: acompanhamento.demanda_id, conselheiro_id: acompanhamento.conselheiro_id, data_sorteio: acompanhamento.data_sorteio, }) // MVP: apenas log. Fase 2: timeout automático → conselheiro.atribuicao_recusada4.5 Lógica de IA — construção do prompt
Seção intitulada “4.5 Lógica de IA — construção do prompt”função construirPrompt(textoBruto: string, tipo: string, demandaId: string) -> string:
basePrompt = "Você é um assistente de relatoria cívica. " basePrompt += "Seu papel é estruturar informações mantendo linguagem neutra " basePrompt += "e separando fatos de interpretações. Não invente dados que " basePrompt += "não estão no texto original.\n\n"
retornar basePrompt + "Demanda de referência: " + demandaId + ".\n" + "Tipo de atualização: " + tipo + ".\n\n" + "Texto original: " + textoBrutoExiste um prompt único, com a referência da demanda e o tipo. O prompt só é montado quando iaHabilitada = true e não é enviado a nenhum modelo no MVP; a sugestão devolvida é sempre o fallback heurístico da seção 4.1.
4.6 Constantes de IA e prazos
Seção intitulada “4.6 Constantes de IA e prazos”Definidos em relatoria/d6b.constants.ts:
export const TIPOS_ATUALIZACAO_VALIDOS: TipoAtualizacao[] = [...]; // os 6 tiposexport const DIAS_ANTECEDENCIA_ALERTA_PRAZO = 3;export const DIAS_LIMITE_ACOMPANHAMENTO_INATIVO = 7;export const IA_HABILITADA = false;export const IA_SCORE_FALLBACK = 0.5;export const IA_SCORE_MINIMO_AUTO = 0.7;export const IA_TIMEOUT_MS = 5000;export const CRON_VERIFICACAO_PRAZOS = '0 */6 * * *';export const VERSAO_METODO_RESUMO = 'resumo_v1';export const VERSAO_SCHEMA_RESUMO_CICLO = '1.0.0';export const TAMANHO_MAXIMO_RESUMO_CICLO = 6000;export const TAMANHO_MAXIMO_TRECHO_RESUMO_CICLO = 400;carregarD6bConstants() devolve diasAntecedenciaAlertaPrazo, diasLimiteAcompanhamentoInativo, iaHabilitada, iaScoreFallback e iaScoreMinimoAuto. No MVP iaHabilitada = false e a sugestão é sempre o fallback heurístico com score fixo 0,5. IA_SCORE_MINIMO_AUTO e IA_TIMEOUT_MS existem como declaração para a Fase 2, quando o modelo de linguagem for ligado; não são lidos por nenhum fluxo atual.
O ResumoCicloService usa VERSAO_METODO_RESUMO e VERSAO_SCHEMA_RESUMO_CICLO na publicação, TAMANHO_MAXIMO_RESUMO_CICLO como teto do texto e TAMANHO_MAXIMO_TRECHO_RESUMO_CICLO como teto de cada trecho. O resumo não prescreve e é marcado como automático.
4.7 Handlers de endpoint REST
Seção intitulada “4.7 Handlers de endpoint REST”O endpoint POST /d6b/sugerir-estruturacao é stateless. A cada chamada:
1. Recebe { textoBruto, tipoAtualizacao, demandaId }2. Valida o tipo (400 se inválido)3. Se iaHabilitada = true: monta o prompt → retorna o fallback com score 0,5 Senão: retorna o fallback com score 0,54. Retorna SugestaoIaResponse ao app5. NÃO persiste nada. NÃO publica eventos.4.8 Máquina de estados do acompanhamento
Seção intitulada “4.8 Máquina de estados do acompanhamento”Transições válidas de status em d6b.acompanhamentos:
┌─────────────────────┐ │ aguardando_inicio │ ← conselheiro.sorteado (D-6a) └──────────┬──────────┘ │ primeira atualização ┌──────────▼──────────┐ │ em_andamento │ └──────────┬──────────┘ │ ┌────────────────────┼────────────────────┐ │ status_atualizado │ fim_mandato │ desistencia │ + novo_status = │ (D-17, │ (Fase 2) │ 'concluido' │ Fase 2) │ ┌────▼────────┐ ┌──────▼──────┐ ┌───────▼──────┐ │ concluido │ │ fim_mandato │ │ desistencia │ └─────────────┘ └─────────────┘ └──────────────┘
┌─────────────┐ │ reatribuido │ ← novo conselheiro.sorteado para mesma demanda └─────────────┘ (vindo de aguardando_inicio ou em_andamento)
Transições proibidas: - concluido → qualquer outro (demanda encerrada não reabre) - fim_mandato → qualquer outro - desistencia → qualquer outro - reatribuido → qualquer outro - em_andamento → aguardando_inicio (não há "desiniciar" demanda)Pendência de ratificação. A pendência não altera o status do acompanhamento. demanda.conclusao_confirmada com acompanhamento ativo grava pendencia_conclusao e mantém o status corrente (aguardando_inicio ou em_andamento). A transição para concluido ocorre apenas na ratificação ou na conclusão por atualização normal, pelos dois caminhos descritos na seção “Rota de ratificação”.
4.9 Casos de borda adicionais
Seção intitulada “4.9 Casos de borda adicionais”| Caso | Comportamento |
|---|---|
| Conselheiro sorteado, mas nunca registra atualização | Acompanhamento fica aguardando_inicio. verificarAcompanhamentosInativos() loga após 7 dias. Sem ação automática no MVP. |
Conselheiro registra atualização de tipo status_atualizado com novo_status = 'concluido' mas a demanda não está resolvida |
Erro humano. A D-6b não valida a veracidade da conclusão. O sistema confia na declaração do conselheiro. Mecanismo de correção: contestação pela comunidade (Fase 2) ou reabertura via nova demanda. |
| Mesmo conselheiro sorteado duas vezes para demandas diferentes | Dois acompanhamentos independentes em em_andamento. A D-6b não impõe o limite de 1 conselheiro = 1 demanda ativa; confia no controle da D-6a. |
Atualização com origem_estruturacao = 'ia_assistida' e sugestao_ia ausente |
Payload inválido. O handler exige a presença de sugestao_ia quando a origem é ia_assistida; log.error, cursor avançado. |
CronJob de prazos encontra acompanhamento encerrado com prazo ainda vigente |
Verifica acompanhamento.status. Se encerrado, marca prazo como vencido. |
Dois conselheiro.sorteado para demandas diferentes do mesmo conselheiro em curto intervalo |
Dois acompanhamentos criados. Ambos aguardando_inicio. Ambos podem ser iniciados. Cenário improvável porque a D-6a publica conselheiro.sorteado sequencialmente para a mesma UC. |
| Conselheiro publica 50 atualizações em 1 hora (burst) | Permitido. total_atualizacoes é incrementado. O rate limiting das atualizações é do BFF (5/min); as rotas próprias da D-6b têm throttle de 10/min na sugestão, 60/min no acompanhamento e 5/min na ratificação. Se necessário, a D-7 pode expor métrica de frequência de atualizações. |
| Duas atualizações concorrentes do mesmo acompanhamento | O lock em memória por acompanhamento serializa as execuções e relê o total gravado; cada atualização recebe uma sequencia distinta. |
| Publicação de conclusão falha definitivamente | O snapshot saidas_conclusao permanece pendente e a varredura do boot seguinte republica demanda.concluída e conselheiro.ciclo_concluído. |
| Conclusão coletiva chega com acompanhamento ativo | Pendência gravada no acompanhamento; status inalterado até a ratificação. |
| Conclusão coletiva chega sem acompanhamento ativo | Evento processado e cursor avançado, sem pendência. A demanda conclui pela D-5/D-7. |
| Ratificação sem pendência | 409 “Demanda sem pendência de conclusão”. |
| Ratificação por cidadão diferente do conselheiro | 403 “Apenas o conselheiro do acompanhamento pode ratificar”. |
| Ratificação de acompanhamento já encerrado | 404 “Acompanhamento ativo não encontrado para a demanda”. |
| Sorteio tardio de demanda concluída socialmente | Não cria acompanhamento: d6b.conclusoes_sociais bloqueia a abertura. |
| Conclusão social com ratificação desnecessária | Registrada em d6b.conclusoes_sociais para a guarda de sorteio tardio. |
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 e publicação de eventos via EventBusService
Seção intitulada “5.1 Consumo e publicação de eventos via EventBusService”A D-6b injeta EventBusService (do módulo @Global() N-0a) para as operações de barramento: inscrever() (registrar consumidores), publicar() (publicar eventos de saída), replayDeSequence() (replay na inicialização) e obterMaiorSequence() (seed dos cursores).
@Injectable()export class D6bService { private readonly logger = new Logger(D6bService.name);
constructor( private readonly eventBus: EventBusService, private readonly repo: D6bRepository, private readonly estruturadorIa: EstruturadorIaService, private readonly normalizador: NormalizadorAtualizacao, private readonly triggers: TriggersDemandaIniciada, ) {}}O repositório único concentra o acesso a todas as tabelas do schema d6b. O Logger do NestJS é instanciado no próprio serviço. O VerificadorPrazos é um provider separado, com o próprio logger, que injeta o repositório e o barramento. Nenhuma dependência além do núcleo.
5.2 Fluxo de eventos — cadeia completa com D-6b
Seção intitulada “5.2 Fluxo de eventos — cadeia completa com D-6b”Cidadão (app) → ... pipeline completo ... → D-6a → conselheiro.sorteado ─────────────┐ │ │ ▼ │ [D-6b] — esta colônia │ ├── consome conselheiro.sorteado ◄───────┘ │ └─> cria acompanhamento │ ├── endpoint REST: POST /d6b/sugerir-estruturacao │ └─> chamado pelo app durante fluxo de atualização │ └─> retorna sugestão de IA (stateless) │ ├── endpoint REST: GET /d6b/conselheiros/me/acompanhamento │ └─> leitura do próprio acompanhamento (workspace do conselheiro) │ ├── endpoint REST: POST /d6b/demandas/:demanda_id/ratificar-conclusao │ └─> ratificação da conclusão coletiva (ConselheiroGuard) │ ├── consome conselheiro.atualização_registrada (BFF D-1a) │ └─> processa → publica: │ ├── conselheiro.demanda_iniciada ──> [D-5] [D-7] │ ├── conselheiro.atualização_publicada ──> [D-7] │ ├── demanda.concluída ──> [D-5] [D-7] │ └── conselheiro.ciclo_concluído ──> [D-6a] [D-7] │ ├── consome demanda.conclusao_confirmada (D-12) │ └─> grava pendência de ratificação ou a guarda de sorteio tardio │ └── CronJob (@every 6h) └─> verifica prazos → publica conselheiro.prazo_próximo ──> [Notif.] [D-7]
Recusa (não consumido diretamente): BFF → conselheiro.atribuicao_recusada → [D-6a] → D-6a publica novo conselheiro.sorteado → D-6b fecha acompanhamento anterior (reatribuido) e cria novo5.3 Publicação de eventos — exemplos
Seção intitulada “5.3 Publicação de eventos — exemplos”private async publicarConselheiroDemandaIniciada( acompanhamento: AcompanhamentoRegistro, tipoPrimeiraAcao: string, correlacaoId: string,): Promise<void> { try { await publicarComRetry(this.eventBus, { tipo: 'conselheiro.demanda_iniciada', origem: 'D-6b', versao_schema: '1.0.0', event_id: uuidv4(), correlacao_id: correlacaoId, payload: { demanda_id: acompanhamento.demanda_id, conselheiro_id: acompanhamento.conselheiro_id, unidade_civica_id: acompanhamento.unidade_civica_id, tipo_primeira_acao: tipoPrimeiraAcao, timestamp_inicio: new Date().toISOString(), }, }); } catch (erro) { this.logger.error('Falha ao publicar conselheiro.demanda_iniciada', { demanda_id: acompanhamento.demanda_id, erro: erro instanceof Error ? erro.message : String(erro), }); throw erro; }}As cinco publicações usam publicarComRetry (4 tentativas, backoff 500/1000/2000 ms): conselheiro.demanda_iniciada, conselheiro.atualização_publicada, conselheiro.prazo_próximo, demanda.concluída e conselheiro.ciclo_concluído. A falha definitiva é logada e o erro relançado. As duas de conclusão usam os event_id persistidos no snapshot saidas_conclusao, o que permite a republicação pela varredura do boot sem duplicar no barramento.
5.4 Chamadas síncronas via BFF
Seção intitulada “5.4 Chamadas síncronas via BFF”A D-6b expõe três endpoints REST, todos fora do prefixo api e sob o ConselheiroGuard. A sugestão de estruturação (POST /d6b/sugerir-estruturacao, 10/min) é chamada diretamente pelo front-end do conselheiro durante o registro de atualização, sem intermediação do BFF: a operação é stateless e o app a chama com o JWT da sessão. O acompanhamento (GET /d6b/conselheiros/me/acompanhamento, 60/min) e a ratificação (POST /d6b/demandas/:demanda_id/ratificar-conclusao, 5/min) também são chamados diretamente pelo app. Esses são os únicos pontos de entrada síncronos da colônia.
A D-6b não faz chamadas síncronas a outras colônias. Não atua como proxy.
5.5 Dependências de projeções de leitura
Seção intitulada “5.5 Dependências de projeções de leitura”A D-6b não consome projeções de leitura de outras colônias. Os dados externos que acessa são:
core.event_logviaEventBusService.replayDeSequence()eobterMaiorSequence(). Dependência do núcleo, permitida.- Arquivo de configuração estática
relatoria/d6b.constants.tscom os parâmetros de IA, prazos e limites.
5.6 Independência de Redis
Seção intitulada “5.6 Independência de Redis”A D-6b não utiliza Redis. O volume de dados é pequeno (< 100 acompanhamentos ativos, < 500 atualizações/dia no MVP) e o PostgreSQL com índices adequados é suficiente. O CronJob de prazos roda em memória no próprio processo NestJS (@Cron), sem necessidade de fila distribuída.
5.7 Dependência circular D-6a ↔ D-6b
Seção intitulada “5.7 Dependência circular D-6a ↔ D-6b”O fluxo entre as duas colônias forma um ciclo. A D-6b não se comunica diretamente com a D-6a: consome conselheiro.sorteado e produz conselheiro.ciclo_concluído. A D-6a faz o inverso. Ambas consomem eventos de forma assíncrona. É um ciclo de eventos, não de chamadas síncronas, padrão comum em arquiteturas orientadas a eventos.
6. Performance e Limites
Seção intitulada “6. Performance e Limites”6.1 Rate limiting
Seção intitulada “6.1 Rate limiting”O volume de atualizações é limitado indiretamente pelo rate limiting do BFF (D-1a), que aplica 5/min nas rotas de escrita do conselheiro. As três rotas da própria D-6b aplicam @Throttle no controller: 10/min em POST /d6b/sugerir-estruturacao, 60/min no GET /d6b/conselheiros/me/acompanhamento e 5/min no POST /d6b/demandas/:demanda_id/ratificar-conclusao. O endpoint de sugestão é o mais pesado e por isso tem limite próprio de 10/min.
6.2 Cotas e limites de tamanho
Seção intitulada “6.2 Cotas e limites de tamanho”| Limite | Valor | Justificativa |
|---|---|---|
Tamanho máximo de texto_bruto por atualização |
10.000 caracteres | Suficiente para relatos detalhados. Validado no BFF antes de publicar. |
Tamanho máximo de texto_estruturado por atualização |
10.000 caracteres | Mesmo limite do texto bruto. |
Tamanho máximo de conteudo_estruturado (JSONB) |
5 KB | Campos curtos e tipados. |
Tamanho máximo de sugestao_ia (JSONB) |
10 KB | Inclui texto sugerido + campos. |
| Número máximo de atualizações por acompanhamento | 500 | Média esperada: 5-20. Teto alto para evitar que ciclo excepcionalmente complexo seja bloqueado. |
| Dias de antecedência para alerta de prazo | 3 | Padrão. Configurável em d6b.constants.ts. |
| Frequência do CronJob de prazos | 6 horas | Balance entre responsividade e carga. |
| Timeout da chamada de IA | 5 segundos | Declarado em IA_TIMEOUT_MS para a Fase 2; não há chamada de modelo no MVP. |
| Acompanhamentos ativos simultâneos por conselheiro | 1 (via D-6a) | Garantido pela D-6a: 1 conselheiro = 1 demanda ativa. A D-6b não impõe o limite, mas confia no controle da D-6a. |
| Acompanhamentos ativos simultâneos por demanda | 1 | Garantido pelo índice único parcial uq_d6b_acompanhamento_demanda_ativa em d6b.acompanhamentos. |
| Acompanhamentos com saída de conclusão republicados por boot | 100 | Teto da varredura republicarSaidasConclusaoPendentes (LIMITE_VARREDURA_SAIDAS_CONCLUSAO). |
6.3 Índices e padrões de query
Seção intitulada “6.3 Índices e padrões de query”| Índice | Query atendida |
|---|---|
acompanhamentos_pkey (id) |
Acesso por ID em updates de status. |
uq_d6b_acompanhamento_demanda_ativa (UNIQUE parcial) |
Garantia de no máximo um acompanhamento ativo por demanda. Usado no handler de conselheiro.sorteado. |
acompanhamentos_conselheiro_id_idx |
“Quais demandas este conselheiro está acompanhando?”. |
acompanhamentos_demanda_id_status_idx |
“Acompanhamento ativo da demanda X?”. |
acompanhamentos_unidade_civica_id_status_idx |
Dashboard D-7: “acompanhamentos ativos na UC X”. |
acompanhamentos_event_id_sorteio_idx |
Idempotência de conselheiro.sorteado. |
atualizacoes_pkey (id) |
Acesso por ID. |
atualizacoes_event_id_publicacao_key (UNIQUE) |
Idempotência de conselheiro.atualização_registrada. |
atualizacoes_acompanhamento_id_idx |
“Todas as atualizações deste acompanhamento”. Timeline da D-7. |
atualizacoes_demanda_id_idx |
“Todas as atualizações desta demanda”. Timeline da D-7. |
atualizacoes_conselheiro_id_idx |
“Todas as atualizações deste conselheiro”. Visão de conselheiro da D-7. |
atualizacoes_demanda_id_criado_em_idx (composto) |
Consultas por demanda ordenadas por data. |
prazos_pkey (id) |
Acesso por ID. |
prazos_event_id_atualizacao_key (UNIQUE) |
Idempotência de prazo registrado. |
prazos_acompanhamento_id_idx |
“Todos os prazos deste acompanhamento”. |
prazos_status_data_estimada_idx (composto) |
CronJob: “prazos vigentes próximos do vencimento”. Query principal do VerificadorPrazos. |
processed_events_pkey (event_id) |
Idempotência genérica. |
processed_events_event_type_idx |
Depuração: “eventos processados por tipo”. |
conclusoes_sociais_pkey (demanda_id) |
Guarda de sorteio tardio por demanda. |
conclusoes_sociais_evento_id_key (UNIQUE) |
Idempotência da conclusão social registrada. |
6.4 Padrões de query esperados
Seção intitulada “6.4 Padrões de query esperados”buscarAcompanhamentoAtivoPorDemanda(demanda_id): 1 query porconselheiro.sorteado,conselheiro.atualização_registrada,demanda.conclusao_confirmadae pela ratificação. Usa índice composto(demanda_id, status).buscarAcompanhamentoAtivoPorConselheiro(conselheiro_id): 1 query na leitura do workspace. Usa índice emconselheiro_id.buscarAtualizacoesPorAcompanhamento(acompanhamento_id): 1 query na leitura do workspace, com as últimas 50 porcriado_emesequenciadecrescentes. Usa índice emacompanhamento_id.buscarPrazosVigentesAte(data_limite): 1 query a cada 6 horas (CronJob), comstatus = 'vigente'ealerta_enviado = false. Usa índice composto(status, data_estimada).buscarPrazosVencidos(hoje): 1 query a cada 6 horas (CronJob), comstatus = 'vigente'edata_estimadaanterior a hoje. Usa o mesmo índice.buscarSaidasConclusaoPendentes(limite): 1 query no boot, comstatus = 'concluido'esaidas_conclusao_pendentes = true.inserirAtualizacao: 1 write por atualização recebida.
Volume esperado no MVP: < 20 conselheiro.sorteado/dia, < 50 conselheiro.atualização_registrada/dia, < 3 conclusões/dia. Tempo médio de processamento: < 10ms para os handlers e para a sugestão (heurística local).
6.5 Estratégia de cache
Seção intitulada “6.5 Estratégia de cache”A D-6b não implementa cache. As consultas públicas são feitas pela D-7 (Transparência) em seu próprio banco de leitura. A leitura direta da colônia é apenas a do próprio conselheiro, no endpoint de acompanhamento.
A sugestão de IA pode se beneficiar de cache se o mesmo texto for submetido múltiplas vezes (conselheiro editando e reenviando), mas o volume não justifica a complexidade no MVP.
6.6 Projeção de volume
Seção intitulada “6.6 Projeção de volume”| Cenário | Acompanhamentos ativos | Atualizações/dia | Conclusões/dia | Alertas de prazo/dia |
|---|---|---|---|---|
| PoC (1 bairro) | 3-5 | 5-10 | 1 | 0-1 |
| MVP (1 município) | 20-50 | 30-80 | 3-5 | 1-5 |
| Fase 2 (regional) | 200-500 | 500-2000 | 50-100 | 20-100 |
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 D6bService (d6b.service.spec.ts):
beforeEach(async () => { resetarD6bConstants();
mockEventBus = { inscrever: jest.fn(), publicar: jest.fn().mockResolvedValue({ sequence_number: 20n, event_id: 'published-id' }), replayDeSequence: jest.fn().mockResolvedValue([]), obterMaiorSequence: jest.fn().mockResolvedValue(100), };
mockRepo = { seedOffsets: jest.fn().mockResolvedValue(undefined), upsertOffset: jest.fn().mockResolvedValue(undefined), buscarOffsets: jest.fn().mockResolvedValue([]), buscarProcessedEvent: jest.fn().mockResolvedValue(false), inserirProcessedEvent: jest.fn().mockResolvedValue(undefined), buscarAcompanhamentoAtivoPorDemanda: jest.fn().mockResolvedValue(null), buscarAcompanhamentoAtivoPorConselheiro: jest.fn().mockResolvedValue(null), buscarAcompanhamentoAtivoPorConselheiroEDemanda: jest.fn().mockResolvedValue(null), buscarPrazosPorAcompanhamento: jest.fn().mockResolvedValue([]), buscarAtualizacoesPorAcompanhamento: jest.fn().mockResolvedValue([]), buscarAcompanhamentoPorId: jest.fn().mockResolvedValue(null), buscarAcompanhamentosInativosDesde: jest.fn().mockResolvedValue([]), inserirAcompanhamento: jest .fn() .mockResolvedValue(criarAcompanhamento({ id: 'a1', status: 'aguardando_inicio' })), encerrarAcompanhamento: jest.fn().mockResolvedValue(undefined), registrarAtualizacaoNoAcompanhamento: jest.fn().mockResolvedValue(undefined), concluirAcompanhamento: jest.fn().mockResolvedValue(undefined), registrarPendenciaConclusao: jest.fn().mockResolvedValue(undefined), buscarConclusaoSocial: jest.fn().mockResolvedValue(false), registrarConclusaoSocial: jest.fn().mockResolvedValue(undefined), buscarAtualizacaoPorEventIdPublicacao: jest.fn().mockResolvedValue(null), inserirAtualizacao: jest.fn().mockImplementation((dados) => Promise.resolve({ id: 'u1', tipo: dados.tipo, texto_estruturado: dados.texto_estruturado, conteudo_estruturado: dados.conteudo_estruturado, origem_estruturacao: dados.origem_estruturacao, }), ), marcarPrazosVigentesSubstituidos: jest.fn().mockResolvedValue(undefined), inserirPrazo: jest.fn().mockResolvedValue(undefined), registrarSaidasConclusaoPendentes: jest.fn().mockResolvedValue(undefined), marcarSaidasConclusaoPublicadas: jest.fn().mockResolvedValue(undefined), buscarSaidasConclusaoPendentes: jest.fn().mockResolvedValue([]), };
mockEstruturadorIa = { sugerir: jest.fn().mockResolvedValue({ titulo_sugerido: 'Título', texto_sugerido: 'Texto sugerido', campos_estruturados: {}, score_confianca: 0.5, }), };
const module = await Test.createTestingModule({ providers: [ D6bService, { provide: EventBusService, useValue: mockEventBus }, { provide: D6bRepository, useValue: mockRepo }, { provide: EstruturadorIaService, useValue: mockEstruturadorIa }, NormalizadorAtualizacao, TriggersDemandaIniciada, ], }).compile();
service = module.get<D6bService>(D6bService);});As demais suítes cobrem o repositório (d6b.repository.spec.ts), os endpoints (d6b.controller.spec.ts), o VerificadorPrazos (relatoria/verificador-prazos.spec.ts), o normalizador, o estruturador e o schema com a migration das conclusões sociais (d6b-schema.spec.ts).
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 | onConselheiroSorteado() com payload completo |
Acompanhamento criado com status aguardando_inicio. processed_events registrado. |
| T2 | onConselheiroAtualizacaoRegistrada() — primeira atualização de um acompanhamento |
Atualização persistida. Acompanhamento status → em_andamento. data_inicio preenchida. conselheiro.demanda_iniciada publicado. conselheiro.atualização_publicada publicado. |
| T3 | onConselheiroAtualizacaoRegistrada() — segunda atualização (não é a primeira) |
Atualização persistida. Acompanhamento mantém em_andamento. Apenas conselheiro.atualização_publicada publicado (sem conselheiro.demanda_iniciada). |
| T4 | onConselheiroAtualizacaoRegistrada() — tipo status_atualizado com novo_status = 'concluido' |
Atualização persistida. demanda.concluída publicado. conselheiro.ciclo_concluído publicado com motivo_encerramento = 'demanda_concluida'. Acompanhamento status → concluido. |
| T5 | onConselheiroAtualizacaoRegistrada() — tipo prazo_registrado |
Atualização persistida. Prazo registrado em d6b.prazos com status vigente. Prazo anterior (se existir) marcado substituido. |
| T6 | sugerirEstruturacao() com texto e tipo válidos |
Retorna sugestão com titulo_sugerido, texto_sugerido, campos_estruturados, score_confianca. NÃO persiste. NÃO publica eventos. |
| T7 | CronJob verificarPrazosProximos() com prazo a 2 dias do vencimento |
conselheiro.prazo_próximo publicado. alerta_enviado = true. |
| T8 | Conclusão de demanda: demanda.concluída publicado antes de conselheiro.ciclo_concluído |
Ordem verificada no mock: publicar chamado primeiro para demanda.concluída, depois para conselheiro.ciclo_concluído. |
Falhas e bordas:
| # | Cenário | Verificação |
|---|---|---|
| T9 | onConselheiroSorteado() para demanda já com acompanhamento ativo de outro conselheiro |
Acompanhamento anterior status → reatribuido. Novo acompanhamento criado com status aguardando_inicio. |
| T10 | onConselheiroSorteado() duplicado (mesmo event_id) |
Detectado em processed_events. Log, cursor avançado. |
| T11 | onConselheiroAtualizacaoRegistrada() duplicado (mesmo event_id) |
Detectado em atualizacoes.event_id_publicacao. Log, cursor avançado. |
| T12 | onConselheiroAtualizacaoRegistrada() para acompanhamento inexistente ou já encerrado |
Log.warn, cursor avançado. |
| T13 | Payload sem demanda_id ou conselheiro_id |
Log.error, cursor avançado. |
| T14 | Payload com tipo inválido |
Log.error, cursor avançado. |
| T15 | Payload prazo_registrado sem data_estimada |
Log.error, cursor avançado. |
| T16 | Payload protocolo_aberto sem protocolo_numero |
Log.error, cursor avançado. |
| T17 | Payload com origem_estruturacao = 'ia_assistida' sem sugestao_ia |
Log.error, cursor avançado. |
| T18 | sugerirEstruturacao() com tipo inválido |
BadRequestException (400). |
| T19 | sugerirEstruturacao() com IA desabilitada |
Retorna fallback com score_confianca = 0.5. |
| T20 | publicar('conselheiro.demanda_iniciada') falha após o publicarComRetry |
Log.error. Estado persistido. Erro relançado para a DLQ. Cursor não avança. |
| T21 | publicar('conselheiro.atualização_publicada') falha após o publicarComRetry |
Log.error. Estado persistido. Erro relançado para a DLQ. Cursor não avança. |
| T22 | CronJob falha com exceção | @Cron captura. Log.error. Próximo ciclo tenta novamente. |
| T23 | Prazo com alerta_enviado = true não gera novo alerta |
A consulta do CronJob filtra alerta_enviado = false. |
| T24 | demanda.conclusao_confirmada sem acompanhamento ativo |
Registra a conclusão social quando a ratificação é desnecessária, grava processed_event, cursor avançado, sem pendência. |
| T25 | demanda.conclusao_confirmada reentregue |
Detectado em processed_events. Cursor avançado. |
| T26 | conselheiro.sorteado tardio para demanda concluída socialmente |
Não cria acompanhamento: a guarda em d6b.conclusoes_sociais bloqueia. |
| T27 | ratificarConclusao() sem pendência, sem acompanhamento ou com cidadão diferente |
409, 404 e 403, respectivamente. |
| T28 | ratificarConclusao() com pendência |
Atualização de sistema registrada, demanda.concluída e conselheiro.ciclo_concluído publicados, pendência limpa, conclusão social registrada, retorno 201. |
| T34 | demanda.conclusao_confirmada com acompanhamento ativo |
A atualização de sistema usa sequencia = total_atualizacoes + 1 e incrementa total_atualizacoes do acompanhamento. |
| T35 | Duas execuções concorrentes para o mesmo acompanhamento | O lock serializa; cada atualização recebe sequencia distinta (1 e 2). |
| T36 | Conclusão persiste as saídas antes de publicar | registrarSaidasConclusaoPendentes é chamado antes do publish; marcarSaidasConclusaoPublicadas fecha o ciclo. |
| T37 | Boot com saída de conclusão pendente | republicarSaidasConclusaoPendentes relê o snapshot, republica os dois eventos e desliga a flag. |
Teste de integração:
| # | Cenário | Verificação |
|---|---|---|
| T29 | Ciclo completo: D-6a → D-6b (sorteio → 3 atualizações → conclusão) | 1 acompanhamento criado. 3 atualizações persistidas. conselheiro.demanda_iniciada (1x). conselheiro.atualização_publicada (2x, a conclusão não publica). demanda.concluída + conselheiro.ciclo_concluído (1x cada). |
| T30 | Recusa → re-sorteio → novo conselheiro → atualização → conclusão | Conselheiro 1: acompanhamento reatribuido. Conselheiro 2: acompanhamento concluido. Atualizações do conselheiro 1 são ignoradas se chegarem após a reatribuição. |
| T31 | 3 prazos registrados, 1 vence amanhã, 2 vencem na semana que vem | CronJob: 1 alerta publicado. alerta_enviado marcado. 2 prazos sem alerta. Vencidos verificados no próximo ciclo. |
| T32 | Sugestão de IA → conselheiro edita → publica com ia_assistida |
sugestao_ia preservada no evento. texto_estruturado é a versão editada. texto_bruto é o original. |
| T33 | Replay após reinício: 5 eventos, 3 já processados | 3 ignorados por idempotência. 2 novos processados. Acompanhamentos e atualizações sem duplicação. |
7.3 Dados de seed para desenvolvimento local
Seção intitulada “7.3 Dados de seed para desenvolvimento local”Os cursores não entram por migration nem por seed SQL. seedOffsets() os cria no boot com obterMaiorSequence(), sem sobrescrever linhas existentes.
O seed de desenvolvimento (prisma/seed-dev.ts, npm run seed:dev) cria a UC de bairro de teste, cinco cidadãos e dois conselheiros em d6a.conselheiros. Não há seed de d6b.acompanhamentos, d6b.atualizacoes nem d6b.prazos: os acompanhamentos nascem do fluxo de eventos, do conselheiro.sorteado ao conselheiro.atualização_registrada.
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('conselheiro.sorteado') com handler idempotente |
MVP obrigatório |
inscrever('conselheiro.atualização_registrada') com handler idempotente |
MVP obrigatório |
inscrever('demanda.conclusao_confirmada') com pendência de ratificação e guarda de sorteio tardio |
MVP obrigatório |
Endpoint REST POST /d6b/sugerir-estruturacao com ConselheiroGuard, throttle 10/min e fallback heurístico |
MVP obrigatório |
Endpoint REST GET /d6b/conselheiros/me/acompanhamento com ConselheiroGuard e throttle 60/min |
MVP obrigatório |
Endpoint REST POST /d6b/demandas/:demanda_id/ratificar-conclusao com ConselheiroGuard e throttle 5/min |
MVP obrigatório |
Criação de acompanhamento ao receber conselheiro.sorteado |
MVP obrigatório |
Fechamento de acompanhamento anterior no re-sorteio (reatribuido) |
MVP obrigatório |
Heurística de primeira atualização → conselheiro.demanda_iniciada |
MVP obrigatório |
| Normalização e validação de campos estruturados por tipo | MVP obrigatório |
Registro de todos os 6 tipos de atualização com texto_bruto preservado |
MVP obrigatório |
Preservação de sugestao_ia para auditoria |
MVP obrigatório |
Publicação de conselheiro.demanda_iniciada |
MVP obrigatório |
Publicação de conselheiro.atualização_publicada com descricao_sanitizada |
MVP obrigatório |
| Denylist de texto na publicação, com a 1.2.0 e a marca de suspeição | MVP obrigatório |
Publicação de demanda.concluída + conselheiro.ciclo_concluído na conclusão |
MVP obrigatório |
Snapshot saidas_conclusao com republicação pendente no boot |
MVP obrigatório |
| Serialização em memória das atualizações por acompanhamento | MVP obrigatório |
CronJob de verificação de prazos (@Cron) com publicação de conselheiro.prazo_próximo |
MVP obrigatório |
| Consumer offsets para os 3 tipos de evento | MVP obrigatório |
processed_events para idempotência genérica |
MVP obrigatório |
Propagação de correlacao_id |
MVP obrigatório |
Logs estruturados com demanda_id, conselheiro_id, acompanhamento_id e event_id |
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 |
|---|---|---|
IA opera em modo fallback (iaHabilitada = false) |
Evita dependência de API externa para o MVP. A sugestão funciona com heurísticas regex. Mesmo com a flag ligada, o serviço ainda devolve o fallback e apenas monta o prompt. | Ligar a chamada ao modelo de linguagem e usar IA_SCORE_MINIMO_AUTO e IA_TIMEOUT_MS (Fase 2). |
Conclusão de demanda via status_atualizado com novo_status = 'concluido', sem formulário dedicado |
No MVP, a conclusão é um caso especial do tipo status_atualizado (por atualização do conselheiro ou pela ratificação da conclusão coletiva). |
Criar evento dedicado demanda.conclusão_registrada com formulário de conclusão (resumo, evidências, avaliação pós-execução) na Fase 2. |
Atualização do tipo documento_anexado carrega texto descritivo, sem anexo real |
O pipeline de anexo da relatoria não existe no MVP. O campo anexo_id fica reservado. |
Integrar a D-1c ao registro de atualização quando o pipeline de anexo da relatoria existir (Fase 2). |
Encerramento administrativo (fim_mandato, desistencia) não implementado |
Front-end de conselheiro no MVP cobre cadastro, atribuição, acompanhamento e atualizações. Os motivos dependem da D-17. | Criar os fluxos de desistência e de encerramento de mandato (Fase 2, integrado com D-17). |
| Sem notificação push ao conselheiro sobre prazo próximo | O sistema publica conselheiro.prazo_próximo no barramento e na timeline. O conselheiro descobre consultando a interface. |
Adicionar colônia de notificação que consome conselheiro.prazo_próximo e envia push/email (Fase 2). |
| Sem validação de que o conselheiro está ativo na D-6a ao processar atualização | A D-6b confia que o BFF publicou conselheiro.atualização_registrada apenas para conselheiros com atribuição ativa. Se houver inconsistência, o handler rejeita por falta de acompanhamento ativo. |
Consumir evento da D-6a ou consultar projeção para validação cruzada (Fase 2). |
| Sem distinção entre “demanda concluída” e “demanda arquivada” | A conclusão é binária no MVP. Não há workflow de “concluída aguardando validação” ou “concluída e arquivada após avaliação”. | Adicionar estados intermediários e avaliação pós-execução (Fase 2, integrado com a D-21 de Monitoramento Executivo). |
| Lock em memória nas atualizações do mesmo acompanhamento, sem lock distribuído | O mutex por acompanhamento.id serializa as execuções na instância única do monolito. A unicidade de event_id_publicacao segue como barreira do mesmo evento. |
Adicionar lock distribuído quando o monolito for particionado (Fase 2). |
Configuração de IA estática em d6b.constants.ts, sem hot-swap |
A troca exige redeploy no MVP. | Adicionar colônia de configuração dinâmica ou consumir parâmetros.atualizados para os parâmetros de IA (Fase 2). |
8.3 O que vai para a Fase 2
Seção intitulada “8.3 O que vai para a Fase 2”- IA completa com modelo de linguagem (BERTimbau ou similar) para sugestão de estruturação com alta confiança
- Formulário dedicado de conclusão de demanda (
demanda.conclusão_registrada) com resumo, evidências e avaliação - Encerramento de ciclo por
fim_mandatointegrado com D-17 (Controle de Mandato) - Encerramento de ciclo por
desistenciavia front-end do conselheiro - Lock distribuído para as atualizações concorrentes quando o monolito for particionado
- Notificação push/email para
conselheiro.prazo_próximo - Validação cruzada de status do conselheiro com a D-6a
- Avaliação pós-execução da demanda (2 meses, 6 meses, 1 ano, 5 anos, conforme os ciclos de avaliação)
- Integração com D-21 (Monitoramento Executivo) para rastreamento de desempenho do executor
- Métricas Prometheus:
d6b_acompanhamentos_ativos,d6b_atualizacoes_por_tipo,d6b_tempo_medio_conclusao,d6b_taxa_conclusao,d6b_alertas_prazo - Tabela
d6b.configcom histórico de versões de parâmetros de IA e prazos - Hot-swap de modelo de IA sem redeploy
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: a D-6b introduz conselheiro.atualização_registrada e conselheiro.atualização_publicada no catálogo.
Avaliação: os dois tipos estão registrados no Registry (N-0b). O Registry é versionado, e adicionar versões não quebra compatibilidade: conselheiro.atualização_publicada evoluiu para 1.1.0 e 1.2.0 como adições opcionais. Nenhuma outra colônia consome conselheiro.atualização_registrada; conselheiro.atualização_publicada é consumido pela D-7 (Transparência), pela D-24 (Memória de Caminhos) e pela D-1d (moderação do relato).
Conflito potencial: a D-6b publica demanda.concluída, que a D-5 consome.
Avaliação: o Registry (N-0b) define demanda.concluída como produzido pela D-6b, e a D-5 especifica o consumo deste evento (ver D-5 - Agenda.md, seção “Evento consumido: demanda.concluída”). A ficha da D-6b no Apêndice B precisa listar este evento; a conferência é da fase do Apêndice B.
Conflito potencial: o payload de conselheiro.atualização_publicada inclui numero_sequencial. O que acontece se duas atualizações forem processadas em ordem diferente da publicação?
Avaliação: o numero_sequencial é derivado de acompanhamento.total_atualizacoes no momento do processamento. O lock em memória por acompanhamento serializa as execuções e cada uma relê o contador dentro do lock, então duas atualizações do mesmo acompanhamento gravam sequências distintas na ordem de processamento. O lock distribuído para o cenário de múltiplas instâncias está na lista da Fase 2.
Conflito potencial: a D-6b e a D-5 mantêm status diferentes para a mesma demanda (em_andamento vs. em_progresso). Isso não gera inconsistência?
Avaliação: são dimensões diferentes. A D-6b controla o status do acompanhamento (relação conselheiro⇄demanda). A D-5 controla o status da demanda no backlog. Os estados são correlacionados mas independentes: aguardando_inicio (D-6b) ≈ atribuido (D-5), em_andamento (D-6b) ≈ em_progresso (D-5). A divergência só ocorre se um evento for perdido, e nesse caso o mecanismo de replay resolve.
Conflito potencial: a ordem de deploy. Se D-6b subir antes de D-6a publicar conselheiro.sorteado?
Avaliação: a D-6b opera em modo degradado. Sem eventos, não há acompanhamentos. Os handlers ficam registrados e processarão eventos assim que a D-6a começar a publicar. O iniciar() faz replay a partir do último cursor conhecido. Ordem natural do Bloco 2 (D-5 → D-6a → D-6b → D-7) garante que as colônias a montante já estão publicando quando a D-6b sobe em produção.
Referências
Seção intitulada “Referências”- Ficha técnica da colônia: Apêndice B - Colônias.md, seção “D-6b — Relatoria e Acompanhamento”
- Papel do conselheiro e ciclos de atuação: contexto_IA.md, seções 5 (Conselhos de unidade cívica) e 6 (Ciclos de atuação, sorteio e progressão)
- Uso de IA: contexto_IA.md, seção 12 (Uso de IA)
- 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-6a - Sorteio e Atribuição.md, Apêndice B - Colônias.md, seção “D-12 — Detecção de Duplicidade”
- Colônias a jusante: D-5 - Agenda.md, D-6a - Sorteio e Atribuição.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.