diff --git a/docs/plans/sollinha-prioridades-do-momento-impl.md b/docs/plans/sollinha-prioridades-do-momento-impl.md index 708c911a..3c577215 100644 --- a/docs/plans/sollinha-prioridades-do-momento-impl.md +++ b/docs/plans/sollinha-prioridades-do-momento-impl.md @@ -6,6 +6,19 @@ Issue: #525 Intenção: docs/plans/sollinha-prioridades-do-momento.md Appetite restante: ~1 dia eng (herdado); uma tool nova read-only + módulo puro + testes; sem migration/collection/Consent/UI +## Registro de execução (2026-08-10) + +- **Entrega:** `getMunicipalityPriorities` nova (`src/utilities/ai/tools/getMunicipalityPriorities.ts`) + ranking puro `src/utilities/municipality/municipalityPriorities.ts` + resolver de escopo da B185 extraído (`src/utilities/ai/tools/aiToolScope.ts`, refactor de `getPendingLeaderships` sem mudança de comportamento — 18 testes dela verdes) + registro no `index.ts` + seção no system prompt; 32 unit tests (28 ranking + 10 tool, ajustados no simplify). Mergeado via PR (Closes #525). +- **Divergências aprovadas no gate:** frescor E9 (`max(lastUpdateAt, lastPledgeAt)`) em vez da semântica B185 de só-`municipalityUpdate`; exclusão literal do "boa recente" (vale até para o balde potencial); "última palavra decide"; potencial = top 5 do escopo por `expectedVotes.central` com fallback nos válidos 2022 (artefato), fonte rotulada. +- **Revisão /simplify (3 reviewers paralelos) — fixes aplicados:** + - **Bug real (P1):** comparador da estagnação com dois municípios nunca-sinal produzia `Infinity - Infinity = NaN` → tiebreak (prioridade alta/nome) morto exatamente no topo da lista; comparador explícito + teste. + - **Bomb-relógio (P1):** testes da tool usavam datas absolutas contra o relógio real — iriam apodrecer ~30 dias após 2026-08-08; fixtures agora derivam de `Date.now()` (o módulo puro já pinava `agora`). + - Fronteira da janela alinhada: módulo puro aceita update com exatamente `windowDays` (inclusivo) e a query da tool passou a `greater_than_equal` (antes `greater_than` — assimétrico); testes de fronteira nos dois lados. + - `ultimoSinalAtrasDias` agora reporta a idade da atualização decisiva quando o item veio do balde sinal (antes E9 podia devolver `null` ao lado de "atualização ruim há 2 dias" em linha stale). + - Identificadores do options type em inglês (`windowDays`/`reason`/`sortBy` — regra do repo; as chaves pt da resposta são o contrato B185); `MunicipalityPriorityReason` deixou de ser exportado (knip). + - `agora: new Date(now)` passado da tool (um único relógio); `expectedVotes: { central: true }` (select estreito); `sort: '-createdAt'` removido (o módulo re-deriva o decisivo — trabalho morto); cast redundante removido; `NARROW_SCOPE_HINT` movido para `aiToolScope.ts` (vocabulário de escopo, 2 call sites); `emptyResponse` duplicado unificado em `baseResponse`; docblock órfão da extração removido; alias `ResolvedScope` removido; "estado para o coordenador" no prompt virou "coordenação/candidatura" (candidate é unrestricted); allowlist P3-D estendida com a mesma justificativa da B185. + - Testes novos do simplify: dois nunca-sinal (NaN), exclusão-vence-estagnação, empate de potencial ordena por nome, fronteira exata 30/31 dias. + ## Leitura da intenção - **Outcome:** uma tool nova do Sollinha responde "quais devem ser minhas prioridades neste momento?" com **top N municípios do escopo do usuário**, cada item com **uma linha de evidência** (o fator que o colocou ali), o **critério de ordenação declarado na resposta**, filtro/reordenação por fator, exclusão de município com atualização recente favorável e sem sinais negativos, assessor = portfólio, leader = deny fail-closed, links via `buildCampaignLinks` (precedente B185/B162). @@ -41,7 +54,7 @@ flowchart LR ### Componentes / mudanças -- **`rankMunicipalityPriorities`** (`src/utilities/municipality/municipalityPriorities.ts`, novo): módulo **puro e client-safe** (importa `municipalitySignal.ts`, `municipalityLabels.ts`, `lib/engagementLevel`, `lib/schemas/municipalityUpdate`) com os baldes, a ordenação por gravidade, a exclusão do "favorável recente" e a geração de evidência de uma linha. Entrada: linhas de município (id/nome/slug/regiao/cidade/priority/engagementLevel/expectedVotes.central/lastUpdateAt/politicalTrend.status) + `lastPledgeAt` por id + atualizações da janela + `janelaDias` + filtros; saída: `prioridades[]` ordenadas com `motivo`/`evidencia`/`nivelEngajamento`/`ultimoSinalAtrasDias`/`potencialEstimado`/`fontePotencial`/`trechoAtualizacao`. +- **`rankMunicipalityPriorities`** (`src/utilities/municipality/municipalityPriorities.ts`, novo): módulo **puro e client-safe** (importa `municipalitySignal.ts`, `municipalityLabels.ts`, `lib/engagementLevel`, `lib/schemas/municipalityUpdate`) com os baldes, a ordenação por gravidade, a exclusão do "favorável recente" e a geração de evidência de uma linha. Entrada: linhas de município (id/nome/slug/regiao/cidade/priority/engagementLevel/expectedVotes.central/lastUpdateAt/politicalTrend.status) + `lastPledgeAt` por id + atualizações da janela + `windowDays` + filtros; saída: `MunicipalityPriorityItem[]` ordenadas — `{ id, nome, slug, regiao, cidade, motivo, evidencia, prioridade, nivelEngajamento, ultimoSinalAtrasDias, ultimaAtualizacao, potencialEstimado, fontePotencial }` (o trecho da atualização vai embutido na `evidencia` do balde sinal, não como campo separado). - **`aiToolScope`** (`src/utilities/ai/tools/aiToolScope.ts`, novo): extração do `resolveScope`/`resolveMunicipalities` da B185 (Salvador-primeiro, `resolveMunicipalityName`, território por `normalizeSearchPhrase`, escopo do ator quando omitido) — **refatorar `getPendingLeaderships`** para consumir (mesmo shape, testes verdes). - **`getMunicipalityPriorities`** (`src/utilities/ai/tools/getMunicipalityPriorities.ts`, novo): factory `tool()`; gate staff inline no topo (1 call site, padrão B185: `isStaffCampaignRole` → `{ error: 'Leitura de prioridades de municípios negada.' }` antes de qualquer query); leituras: municípios do escopo (`overrideAccess: false, user: ctx.user`, select estratégico + `lastUpdateAt`), atualizações da janela (`municipalityUpdate` com `municipality in ids` + `createdAt > cutoff`, select `municipality/polarity/urgent/adversarySignal/createdAt/body`, sort `-createdAt`), pledges via `aggregatePledgesByMunicipality` (helper existente — `lastPledgeAt` por id); fallback de potencial via `getMunicipalityFederalBaseline(slug).validVotesByYear['2022']` (artefato commitado, puro). - **`index.ts`** (`src/utilities/ai/tools/index.ts`): registrar `getMunicipalityPriorities: getMunicipalityPriorities(ctx)`. diff --git a/src/utilities/ai/tools/getMunicipalityPriorities.ts b/src/utilities/ai/tools/getMunicipalityPriorities.ts index 7bda5e75..c5db3348 100644 --- a/src/utilities/ai/tools/getMunicipalityPriorities.ts +++ b/src/utilities/ai/tools/getMunicipalityPriorities.ts @@ -5,19 +5,17 @@ import type { AIToolContext } from '@/lib/ai/types' import { getMunicipalityFederalBaseline } from '@/lib/bahiaElectionAggregates' import { isStaffCampaignRole } from '@/lib/campaignRoles' import { DAY_MS } from '@/lib/text' -import { resolveAIToolScope } from '@/utilities/ai/tools/aiToolScope' +import { AI_TOOL_NARROW_SCOPE_HINT, resolveAIToolScope } from '@/utilities/ai/tools/aiToolScope' import { type PoliticalTrendStatus } from '@/utilities/municipality/municipalityLabels' import { PRIORITY_STALE_SIGNAL_DAYS, rankMunicipalityPriorities, - type MunicipalityPriorityReason, type PriorityMunicipalityInput, type PriorityUpdateSignalInput, } from '@/utilities/municipality/municipalityPriorities' import { aggregatePledgesByMunicipality } from '@/utilities/votePledgeData' const DENIED_MESSAGE = 'Leitura de prioridades de municípios negada.' -const NARROW_SCOPE_HINT = 'Estreite o escopo (território, cidade ou município) para ver o restante.' const buildCriterion = (janelaDias: number): string => `Prioridades por gravidade: (1) sinal desfavorável recente (última atualização ruim, urgente ou com alerta de adversário na janela de ${janelaDias} dias); ` + @@ -114,24 +112,23 @@ export const getMunicipalityPriorities = (ctx: AIToolContext) => const scopeIDs = resolved.municipalityIDs // The AI SDK applies the zod defaults before execute in the real route; // the ?? guards keep direct calls (unit tests, future callers) sane. - const janelaDias = days ?? PRIORITY_STALE_SIGNAL_DAYS + const windowDays = days ?? PRIORITY_STALE_SIGNAL_DAYS const safeLimit = limit ?? 10 - const now = Date.now() + const now = new Date() - const emptyResponse = () => ({ + const baseResponse = { escopo: { tipo: resolved.kind, nome: resolved.name }, escopoRestrito: ctx.user.role === 'advisor', - criterio: buildCriterion(janelaDias), - janelaDias, + criterio: buildCriterion(windowDays), + janelaDias: windowDays, motivoAplicado: reason ?? null, - total: 0, - prioridades: [], - truncado: false, - }) + } // Scoped to nothing (advisor asking outside the portfolio): fail closed // before the municipality read — never an existence lie, no wasted trips. - if (scopeIDs !== null && scopeIDs.length === 0) return emptyResponse() + if (scopeIDs !== null && scopeIDs.length === 0) { + return { ...baseResponse, total: 0, prioridades: [], truncado: false } + } const municipalities = await ctx.payload.find({ collection: 'municipality', @@ -147,7 +144,7 @@ export const getMunicipalityPriorities = (ctx: AIToolContext) => region: true, priority: true, engagementLevel: true, - expectedVotes: true, + expectedVotes: { central: true }, lastUpdateAt: true, politicalTrend: { status: true }, }, @@ -167,7 +164,13 @@ export const getMunicipalityPriorities = (ctx: AIToolContext) => and: [ { municipality: { in: ids } }, { - createdAt: { greater_than: new Date(now - janelaDias * DAY_MS).toISOString() }, + // Same boundary as the pure module: an update exactly + // `windowDays` old is still decisive. + createdAt: { + greater_than_equal: new Date( + now.getTime() - windowDays * DAY_MS, + ).toISOString(), + }, }, ], }, @@ -182,7 +185,6 @@ export const getMunicipalityPriorities = (ctx: AIToolContext) => adversarySignal: true, body: true, }, - sort: '-createdAt', overrideAccess: false, user: ctx.user, }), @@ -226,9 +228,10 @@ export const getMunicipalityPriorities = (ctx: AIToolContext) => ) const ranked = rankMunicipalityPriorities(inputs, lastPledgeAtById, signals, { - janelaDias, - motivo: reason as MunicipalityPriorityReason | undefined, - ordenarPor: sortBy, + windowDays, + reason, + sortBy, + agora: now, }) const total = ranked.length @@ -236,15 +239,11 @@ export const getMunicipalityPriorities = (ctx: AIToolContext) => const truncado = total > top.length return { - escopo: { tipo: resolved.kind, nome: resolved.name }, - escopoRestrito: ctx.user.role === 'advisor', - criterio: buildCriterion(janelaDias), - janelaDias, - motivoAplicado: reason ?? null, + ...baseResponse, total, prioridades: top, truncado, - ...(truncado ? { dica: NARROW_SCOPE_HINT } : {}), + ...(truncado ? { dica: AI_TOOL_NARROW_SCOPE_HINT } : {}), } }, }) diff --git a/src/utilities/ai/tools/getPendingLeaderships.ts b/src/utilities/ai/tools/getPendingLeaderships.ts index 078be773..199ff218 100644 --- a/src/utilities/ai/tools/getPendingLeaderships.ts +++ b/src/utilities/ai/tools/getPendingLeaderships.ts @@ -7,8 +7,9 @@ import { isStaffCampaignRole } from '@/lib/campaignRoles' import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship' import { loadAIToolNamesByIds } from '@/utilities/ai/tools/aiToolQueries' import { + AI_TOOL_NARROW_SCOPE_HINT, resolveAIToolScope, - type AIToolScope as ResolvedScope, + type AIToolScope, } from '@/utilities/ai/tools/aiToolScope' import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels' @@ -16,7 +17,6 @@ const DENIED_MESSAGE = 'Leitura de dados de lideranças negada.' const PENDING_CRITERION = 'Status "A abordar" ou "Em disputa"; ou "Engajado" sem compromisso de votos no escopo consultado.' const EMPTY_MUNICIPALITIES_CRITERION = 'Municípios do escopo sem nenhuma liderança vinculada.' -const NARROW_SCOPE_HINT = 'Estreite o escopo (território, cidade ou município) para ver o restante.' const PENDING_SUPPORT_STATUSES = ['a_abordar', 'em_disputa', 'engajado'] as const type PendingLeadershipDoc = { @@ -92,7 +92,7 @@ export const getPendingLeaderships = (ctx: AIToolContext) => const listPendingLeaderships = async ( ctx: AIToolContext, - scope: ResolvedScope, + scope: AIToolScope, semAssessor: boolean, limit: number, ) => { @@ -206,13 +206,13 @@ const listPendingLeaderships = async ( total, liderancas, truncado, - ...(truncado ? { dica: NARROW_SCOPE_HINT } : {}), + ...(truncado ? { dica: AI_TOOL_NARROW_SCOPE_HINT } : {}), } } const listMunicipalitiesWithoutLeadership = async ( ctx: AIToolContext, - scope: ResolvedScope, + scope: AIToolScope, limit: number, ) => { const { payload } = ctx @@ -276,7 +276,7 @@ const listMunicipalitiesWithoutLeadership = async ( cidade: doc.city, })), truncado, - ...(truncado ? { dica: NARROW_SCOPE_HINT } : {}), + ...(truncado ? { dica: AI_TOOL_NARROW_SCOPE_HINT } : {}), } } diff --git a/src/utilities/municipality/municipalityPriorities.ts b/src/utilities/municipality/municipalityPriorities.ts index bbaaefbd..73c9e693 100644 --- a/src/utilities/municipality/municipalityPriorities.ts +++ b/src/utilities/municipality/municipalityPriorities.ts @@ -37,7 +37,7 @@ export const PRIORITY_POTENTIAL_TOP_N = 5 /** Longest update excerpt carried into an evidence line. */ const PRIORITY_UPDATE_EXCERPT_MAX_LENGTH = 200 -export type MunicipalityPriorityReason = 'sinal_desfavoravel' | 'estagnacao' | 'potencial' +type MunicipalityPriorityReason = 'sinal_desfavoravel' | 'estagnacao' | 'potencial' export type PriorityMunicipalityInput = { id: number @@ -81,11 +81,11 @@ export type MunicipalityPriorityItem = { } export type MunicipalityPriorityRankOptions = { - janelaDias: number + windowDays: number /** Narrow the ranking to one reason ("só as sem atualização"). */ - motivo?: MunicipalityPriorityReason + reason?: MunicipalityPriorityReason /** Default gravity (sinal > estagnação > potencial); potential re-sorts all. */ - ordenarPor?: 'gravidade' | 'potencial' + sortBy?: 'gravidade' | 'potencial' /** One clock read for the whole ranking — tests pin `agora`. */ agora?: Date } @@ -140,7 +140,7 @@ const evaluateMunicipality = ( lastPledgeAt: string | null, decisiveUpdate: PriorityUpdateSignalInput | null, now: Date, - janelaDias: number, + windowDays: number, ): EvaluatedMunicipality => { const lastSignalAgeDays = municipalitySignalAgeInDays( resolveMunicipalityLastSignalAt(input.lastUpdateAt, lastPledgeAt), @@ -164,7 +164,7 @@ const evaluateMunicipality = ( ) { bucket = 'sinal_desfavoravel' } - } else if (lastSignalAgeDays === null || lastSignalAgeDays >= janelaDias) { + } else if (lastSignalAgeDays === null || lastSignalAgeDays >= windowDays) { bucket = 'estagnacao' } @@ -230,6 +230,12 @@ const buildEvidence = (evaluated: EvaluatedMunicipality): string => { const priorityRank: Record<'alta' | 'normal', number> = { alta: 0, normal: 1 } +const bucketRank: Record = { + sinal_desfavoravel: 0, + estagnacao: 1, + potencial: 2, +} + const byName = (left: PriorityMunicipalityInput, right: PriorityMunicipalityInput): number => left.name.localeCompare(right.name, 'pt-BR') @@ -240,11 +246,6 @@ const tiebreak = (left: EvaluatedMunicipality, right: EvaluatedMunicipality): nu } const byGravity = (left: EvaluatedMunicipality, right: EvaluatedMunicipality): number => { - const bucketRank: Record = { - sinal_desfavoravel: 0, - estagnacao: 1, - potencial: 2, - } const byBucket = bucketRank[left.bucket as MunicipalityPriorityReason] - bucketRank[right.bucket as MunicipalityPriorityReason] @@ -259,12 +260,13 @@ const byGravity = (left: EvaluatedMunicipality, right: EvaluatedMunicipality): n return tiebreak(left, right) } if (left.bucket === 'estagnacao') { - // Coldest first; never-signaled (+Infinity) outranks every date. - const byAge = - (right.lastSignalAgeDays ?? Number.POSITIVE_INFINITY) - - (left.lastSignalAgeDays ?? Number.POSITIVE_INFINITY) - if (byAge !== 0) return byAge - return tiebreak(left, right) + // Coldest first; never-signaled (+Infinity) outranks every date. The + // explicit equality check keeps two never-signaled rows from comparing + // Infinity - Infinity (NaN), which would silently skip the tiebreak. + const leftAge = left.lastSignalAgeDays ?? Number.POSITIVE_INFINITY + const rightAge = right.lastSignalAgeDays ?? Number.POSITIVE_INFINITY + if (leftAge === rightAge) return tiebreak(left, right) + return rightAge - leftAge } if (left.bucket === 'potencial') { const byPotential = (right.potential ?? 0) - (left.potential ?? 0) @@ -290,7 +292,11 @@ const toItem = (evaluated: EvaluatedMunicipality): MunicipalityPriorityItem => ( evidencia: buildEvidence(evaluated), prioridade: evaluated.input.priority, nivelEngajamento: engagementLevelLabel(evaluated.input.engagementLevel), - ultimoSinalAtrasDias: evaluated.lastSignalAgeDays, + // When a decisive update placed the item, its age IS the item's signal age + // (E9 would hide it behind a null lastUpdateAt on stale rows). + ultimoSinalAtrasDias: evaluated.decisiveUpdate + ? evaluated.decisiveUpdateAgeDays + : evaluated.lastSignalAgeDays, ultimaAtualizacao: evaluated.input.lastUpdateAt, potencialEstimado: evaluated.potential, fontePotencial: evaluated.potentialSource, @@ -309,7 +315,7 @@ export const rankMunicipalityPriorities = ( options: MunicipalityPriorityRankOptions, ): MunicipalityPriorityItem[] => { const now = options.agora ?? new Date() - const cutoff = now.getTime() - options.janelaDias * DAY_MS + const cutoff = now.getTime() - options.windowDays * DAY_MS const updatesByMunicipality = new Map() for (const update of updates) { @@ -330,7 +336,7 @@ export const rankMunicipalityPriorities = ( lastPledgeAtById.get(input.id) ?? null, decisiveByMunicipality.get(input.id) ?? null, now, - options.janelaDias, + options.windowDays, ), ) @@ -351,13 +357,13 @@ export const rankMunicipalityPriorities = ( } } - const sortBy = options.ordenarPor === 'potencial' ? byPotential : byGravity + const sortBy = options.sortBy === 'potencial' ? byPotential : byGravity const ranked = evaluated .filter((entry) => entry.bucket !== 'nada' && entry.bucket !== 'excluido') .sort(sortBy) - if (options.motivo) { - const reason = options.motivo + if (options.reason) { + const reason = options.reason return ranked.filter((entry) => entry.bucket === reason).map(toItem) } diff --git a/tests/unit/municipalityPriorities.unit.spec.ts b/tests/unit/municipalityPriorities.unit.spec.ts index 2037a87c..d5dae51d 100644 --- a/tests/unit/municipalityPriorities.unit.spec.ts +++ b/tests/unit/municipalityPriorities.unit.spec.ts @@ -56,9 +56,9 @@ const rank = ( options.lastPledgeAtById ?? new Map(), options.updates ?? [], { - janelaDias: options.janelaDias ?? 30, - motivo: options.motivo, - ordenarPor: options.ordenarPor, + windowDays: options.janelaDias ?? 30, + reason: options.motivo, + sortBy: options.ordenarPor, agora: NOW, }, ) @@ -407,4 +407,74 @@ describe('rankMunicipalityPriorities options (B186)', () => { const result2 = rank([mun({ id: 2, lastUpdateAt: daysAgo(40) })], { janelaDias: 30 }) expect(result2[0]!.motivo).toBe('estagnacao') }) + + it('fronteira exata da janela: atualização com exatamente 30 dias ainda é decisiva', () => { + const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(30) })], { + updates: [update(1, daysAgo(30))], + }) + + expect(result[0]!.motivo).toBe('sinal_desfavoravel') + + const result2 = rank([mun({ id: 2, lastUpdateAt: daysAgo(31) })], { + updates: [update(2, daysAgo(31))], + }) + expect(result2[0]!.motivo).toBe('estagnacao') + }) + + it('estagnação: sinal com exatamente 30 dias está no limite (>= janela)', () => { + const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(30) })]) + expect(result[0]!.motivo).toBe('estagnacao') + }) +}) + +describe('rankMunicipalityPriorities tiebreaks (B186)', () => { + it('dois municípios nunca-sinal: prioridade alta primeiro, depois nome (Infinity não vira NaN)', () => { + const result = rank([ + mun({ id: 1, name: 'Abreu e Lima', slug: 'a', priority: 'normal' }), + mun({ id: 2, name: 'Belmonte', slug: 'b', priority: 'alta' }), + mun({ id: 3, name: 'Cairu', slug: 'c', priority: 'normal' }), + ]) + + expect(result.map((item) => item.id)).toEqual([2, 1, 3]) + }) + + it('candidatos empatados em potencial ordenam por nome', () => { + const result = rank([ + mun({ + id: 1, + name: 'Zé Ninguém', + slug: 'z', + lastUpdateAt: daysAgo(2), + engagementLevel: 'n0', + expectedVotesCentral: 50000, + }), + mun({ + id: 2, + name: 'Alfa', + slug: 'a', + lastUpdateAt: daysAgo(2), + engagementLevel: 'n0', + expectedVotesCentral: 50000, + }), + mun({ + id: 3, + name: 'Beta', + slug: 'b', + lastUpdateAt: daysAgo(2), + engagementLevel: 'n0', + expectedVotesCentral: 90000, + }), + ]) + + expect(result.map((item) => item.id)).toEqual([3, 2, 1]) + }) + + it('exclusão vence a estagnação: atualização boa recente com pledge antigo fica fora', () => { + const result = rank([mun({ id: 1, lastUpdateAt: daysAgo(2) })], { + lastPledgeAtById: new Map([[1, daysAgo(40)]]), + updates: [update(1, daysAgo(2), { polarity: 'boa' })], + }) + + expect(result).toHaveLength(0) + }) }) diff --git a/tests/unit/prioritiesTool.unit.spec.ts b/tests/unit/prioritiesTool.unit.spec.ts index eeb82091..7d2943a1 100644 --- a/tests/unit/prioritiesTool.unit.spec.ts +++ b/tests/unit/prioritiesTool.unit.spec.ts @@ -9,6 +9,9 @@ import { stub } from '../helpers/stub' const denied = { error: 'Leitura de prioridades de municípios negada.' } +const nowMs = Date.now() +const daysAgo = (days: number): string => new Date(nowMs - days * 86_400_000).toISOString() + type FindMock = ReturnType const findResult = (docs: unknown[]) => ({ docs }) @@ -44,7 +47,7 @@ const municipalityDoc = (overrides: Record) => ({ const updateDoc = (overrides: Record) => ({ municipality: 10, - createdAt: '2026-08-08T12:00:00.000Z', + createdAt: daysAgo(2), polarity: 'ruim', urgent: false, adversarySignal: false, @@ -91,6 +94,9 @@ describe('getMunicipalityPriorities gate (B186)', () => { const result = (await execute(scriptedPayload(find), advisor)({})) as Record expect(result.total).toBe(0) expect(result.escopoRestrito).toBe(true) + const munCall = find.mock.calls[0]![0] as { user: unknown; overrideAccess: boolean } + expect(munCall.overrideAccess).toBe(false) + expect(munCall.user).toBe(advisor) }) }) @@ -104,15 +110,13 @@ describe('getMunicipalityPriorities reads (B186)', () => { id: 11, name: 'Brejões', slug: 'brejoes', - lastUpdateAt: '2026-06-01T12:00:00.000Z', + lastUpdateAt: daysAgo(70), }), ]), ) find.mockResolvedValueOnce(findResult([updateDoc({ municipality: 10 })])) find.mockResolvedValueOnce( - findResult([ - { municipality: 11, declaredVotes: 300, declaredAt: '2026-07-01T12:00:00.000Z' }, - ]), + findResult([{ municipality: 11, declaredVotes: 300, declaredAt: daysAgo(40) }]), ) const result = (await execute(scriptedPayload(find))({})) as { @@ -133,10 +137,9 @@ describe('getMunicipalityPriorities reads (B186)', () => { expect(updateCall.where.and).toHaveLength(2) expect(updateCall.where.and[0]).toEqual({ municipality: { in: [10, 11] } }) expect(updateCall.where.and[1]).toEqual({ - createdAt: { greater_than: expect.any(String) } as unknown, + createdAt: { greater_than_equal: expect.any(String) } as unknown, }) expect(updateCall.overrideAccess).toBe(false) - expect(updateCall.sort).toBe('-createdAt') const pledgeCall = find.mock.calls[2]![0] as { collection: string; where: unknown } expect(pledgeCall.collection).toBe('votePledge') @@ -155,13 +158,14 @@ describe('getMunicipalityPriorities reads (B186)', () => { }) expect(first.evidencia).toContain('prefeito fechou com o adversário') expect(first.ultimaAtualizacao).toBeNull() + expect(first.ultimoSinalAtrasDias).toBe(2) }) it('escopo região resolve antes das leituras e estreita o where', async () => { const find = vi.fn() const doc = municipalityDoc({ id: 10, - lastUpdateAt: '2026-08-08T12:00:00.000Z', + lastUpdateAt: daysAgo(2), engagementLevel: 'n2', }) find.mockResolvedValueOnce(findResult([doc])) @@ -211,8 +215,8 @@ describe('getMunicipalityPriorities reads (B186)', () => { const find = vi.fn() find.mockResolvedValueOnce( findResult([ - municipalityDoc({ id: 1, name: 'A', slug: 'a', lastUpdateAt: '2026-06-01T12:00:00.000Z' }), - municipalityDoc({ id: 2, name: 'B', slug: 'b', lastUpdateAt: '2026-06-02T12:00:00.000Z' }), + municipalityDoc({ id: 1, name: 'A', slug: 'a', lastUpdateAt: daysAgo(70) }), + municipalityDoc({ id: 2, name: 'B', slug: 'b', lastUpdateAt: daysAgo(69) }), ]), ) find.mockResolvedValueOnce(findResult([])) @@ -233,9 +237,7 @@ describe('getMunicipalityPriorities reads (B186)', () => { it('passa motivo, ordenação e janela custom ao ranking', async () => { const find = vi.fn() - find.mockResolvedValueOnce( - findResult([municipalityDoc({ id: 1, lastUpdateAt: '2026-06-01T12:00:00.000Z' })]), - ) + find.mockResolvedValueOnce(findResult([municipalityDoc({ id: 1, lastUpdateAt: daysAgo(70) })])) find.mockResolvedValueOnce(findResult([])) find.mockResolvedValueOnce(findResult([])) @@ -256,8 +258,8 @@ describe('getMunicipalityPriorities reads (B186)', () => { expect(result.criterio).toContain('60 dias') expect(result.prioridades).toHaveLength(1) const updateCall = find.mock.calls[1]![0] as { where: { and: unknown[] } } - const createdAtClause = updateCall.where.and[1] as { createdAt: { greater_than: string } } - const cutoffMs = new Date(createdAtClause.createdAt.greater_than).getTime() + const createdAtClause = updateCall.where.and[1] as { createdAt: { greater_than_equal: string } } + const cutoffMs = new Date(createdAtClause.createdAt.greater_than_equal).getTime() const expectedCutoffMs = Date.now() - 60 * 86_400_000 expect(Math.abs(cutoffMs - expectedCutoffMs)).toBeLessThan(5_000) }) @@ -270,7 +272,7 @@ describe('getMunicipalityPriorities reads (B186)', () => { id: 10, engagementLevel: 'n1', expectedVotes: null, - lastUpdateAt: '2026-08-08T12:00:00.000Z', + lastUpdateAt: daysAgo(2), }), ]), )