mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-14 08:53:11 +00:00
style(B186): simplify fixes — NaN tiebreak, janela simétrica, testes sem bomb-relógio
- byGravity estagnação: Infinity - Infinity (NaN) matava o tiebreak para dois
municípios nunca-sinal (topo da lista) — comparação explícita + teste
- fronteira da janela: módulo puro inclusivo (>= cutoff) e query da tool em
greater_than_equal (antes greater_than, assimétrico) + testes 30/31 dias
- ultimoSinalAtrasDias reporta a idade da atualização decisiva no balde sinal
(E9 podia devolver null ao lado de evidência 'há 2 dias' em linha stale)
- fixtures da tool com datas relativas a Date.now() (absolutas apodreceriam
em ~30 dias); assert de user: ctx.user no caminho do assessor
- options type em inglês (windowDays/reason/sortBy); MunicipalityPriorityReason
não exportado (knip); agora passado da tool (um relógio)
- expectedVotes select estreito { central: true }; sort '-createdAt' removido
(módulo re-deriva o decisivo); NARROW_SCOPE_HINT compartilhado em aiToolScope
- baseResponse unifica o shape (short-circuit + retorno); docblock órfão da
extração removido; prompt: coordenação/candidatura
- impl plan: registro de execução + shape entregue
This commit is contained in:
parent
11886a653d
commit
b354ad55d6
6 changed files with 164 additions and 74 deletions
|
|
@ -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)`.
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<MunicipalityPriorityReason, number> = {
|
||||
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<MunicipalityPriorityReason, number> = {
|
||||
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<number, PriorityUpdateSignalInput[]>()
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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<typeof vi.fn>
|
||||
|
||||
const findResult = (docs: unknown[]) => ({ docs })
|
||||
|
|
@ -44,7 +47,7 @@ const municipalityDoc = (overrides: Record<string, unknown>) => ({
|
|||
|
||||
const updateDoc = (overrides: Record<string, unknown>) => ({
|
||||
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<string, unknown>
|
||||
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),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in a new issue