refactor(B189): simplify — nomes alinhados à B186, loader de nomes compartilhado, guard de escopo vazio

- aiToolScope.ts: AIToolScope/AIToolScopeKind/AIToolScopeMunicipality
  (mesmos nomes da extração da B186 em voo — conflito de merge vira trivial)
- loadNamesByIds da B185 e loadAdvisorNames da B189 fundidos em
  aiToolQueries.ts (loadAIToolNamesByIds) — don't twin
- guard fail-closed de escopo vazio (assessor fora do portfólio) + teste
- sortStale sem ?? 0 (partição garante ISO) — type honesty
- prompt: exemplo de Salvador com limiar estrito; index ordenado
This commit is contained in:
Francisco Solla 2026-08-10 08:10:55 -03:00
parent 6027aac941
commit 2b3c48e063
7 changed files with 128 additions and 104 deletions

View file

@ -49,7 +49,7 @@ export const AI_SYSTEM_PROMPT = `Você é o Sollinha, assistente virtual da camp
- Use a ferramenta "getMunicipalitiesWithoutUpdate" para "quais municípios estão sem atualização há mais de X dias", "quais municípios nunca foram atualizados" ou perguntas de cobertura do acompanhamento ("o que está ficando para trás no registro").
- A resposta da ferramenta traz o limiar e o critério — declare-os sempre na sua resposta (ex.: "critério: municípios sem atualização de acompanhamento há mais de 30 dias; nunca atualizados contam como estagnação máxima"). Se o usuário pedir outro limiar na pergunta ("há mais de 15 dias?"), passe o valor em "days".
- A lista vem ordenada: municípios nunca atualizados primeiro (rotule-os como "nunca atualizado"), depois do mais antigo ao mais recente. Quando a lista for longa, apresente a contagem total e os mais antigos, e sugira estreitar o escopo para ver o restante.
- Quando o escopo incluir Salvador, resuma a capital no início da resposta (ex.: "Salvador: 7 das 19 zonas sem atualização 30+ dias") usando a contagem e o campo cidade; detalhe zona a zona quando o usuário pedir.
- Quando o escopo incluir Salvador, resuma a capital no início da resposta (ex.: "Salvador: 7 das 19 zonas sem atualização há mais de 30 dias") usando a contagem e o campo cidade; detalhe zona a zona quando o usuário pedir.
- Quando a resposta trouxer "escopoRestrito: true", deixe claro que os resultados estão limitados aos municípios do portfólio do usuário.
- Se o usuário pedir "agrupa por assessor", agrupe a lista pelos nomes de assessores presentes nos itens.
- Ofereça links de navegação (buildCampaignLinks) para os municípios citados (por slug).

View file

@ -0,0 +1,27 @@
import type { AIToolContext } from '@/lib/ai/types'
/**
* Shared name lookups of the campaign intelligence tools (family B185/B189):
* names are resolved in dedicated queries with the actor's own access — never
* `depth` populating `campaignUser`/`contact`.
*/
export const loadAIToolNamesByIds = async (
ctx: AIToolContext,
collection: 'contact' | 'campaignUser',
ids: number[],
): Promise<Map<number, string>> => {
const names = new Map<number, string>()
if (ids.length === 0) return names
const result = await ctx.payload.find({
collection,
where: { id: { in: ids } },
depth: 0,
limit: 0,
pagination: false,
select: { name: true },
overrideAccess: false,
user: ctx.user,
})
for (const doc of result.docs) names.set(doc.id, doc.name)
return names
}

View file

@ -1,3 +1,12 @@
/**
* Shared AI-tool scope resolution for the campaign-management tool family
* (B185 `getPendingLeaderships`, B186 `getMunicipalityPriorities`, B189
* `getMunicipalitiesWithoutUpdate`, …): a user-spoken scope ("Vale do
* Jiquiriça", "Salvador", "Feira de Santana") is resolved into its
* municipality set through the actor's own access. Extracted so the subtle
* behavior (Salvador-primeiro, aliases canônicos, tolerância a acento) has
* one owner instead of a copy per tool.
*/
import type { Where } from 'payload'
import type { AIToolContext } from '@/lib/ai/types'
@ -6,7 +15,9 @@ import { resolveMunicipalityName } from '@/lib/municipalityNameAliases'
import { salvadorCity } from '@/lib/salvadorCity'
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
type ScopeMunicipality = {
type AIToolScopeKind = 'regiao' | 'cidade' | 'municipio' | 'todos'
type AIToolScopeMunicipality = {
id: number
name: string
slug: string
@ -14,55 +25,12 @@ type ScopeMunicipality = {
region: string | null
}
export type AIToolResolvedScope = {
kind: 'regiao' | 'cidade' | 'municipio' | 'todos'
export type AIToolScope = {
kind: AIToolScopeKind
name: string | null
/** `null` = the actor's whole scope (access control narrows the queries). */
municipalityIDs: number[] | null
municipalities: ScopeMunicipality[]
}
/**
* Shared scope resolution of the campaign intelligence tools (family B185/B189;
* B186 will be the third consumer): a territory of identity, the virtual city
* Salvador (19 ZE together) or a municipality. Extracted from B185 so the
* family never twins the "Salvador first, then municipality, then territory"
* lookup order.
*/
export const resolveAIToolScope = async (
ctx: AIToolContext,
scope: string | undefined,
): Promise<AIToolResolvedScope | { error: string }> => {
if (!scope) {
return { kind: 'todos', name: null, municipalityIDs: null, municipalities: [] }
}
// Salvador first: the virtual city is a first-class scope (19 ZE juntas) and
// the catalog has no row literally named "Salvador" — the municipality query
// would be a guaranteed-miss round trip.
if (normalizeSearchPhrase(scope) === normalizeSearchPhrase(salvadorCity.city)) {
const resolved = await resolveScopeMunicipalities(ctx, { city: { equals: salvadorCity.city } })
return { kind: 'cidade', name: salvadorCity.name, ...resolved }
}
const canonicalMunicipality = resolveMunicipalityName(scope)
if (canonicalMunicipality) {
const resolved = await resolveScopeMunicipalities(ctx, {
name: { equals: canonicalMunicipality },
})
return { kind: 'municipio', name: canonicalMunicipality, ...resolved }
}
const territory = bahiaIdentityTerritories.find(
(name) => normalizeSearchPhrase(name) === normalizeSearchPhrase(scope),
)
if (territory) {
const resolved = await resolveScopeMunicipalities(ctx, { region: { equals: territory } })
return { kind: 'regiao', name: territory, ...resolved }
}
return {
error: `Escopo não reconhecido: "${scope}". Use um município, "Salvador" ou um território de identidade da Bahia.`,
}
municipalities: AIToolScopeMunicipality[]
}
/**
@ -71,10 +39,45 @@ export const resolveAIToolScope = async (
* set — never an existence lie). The generic error stays reserved for names
* that match nothing at all.
*/
const resolveScopeMunicipalities = async (
export const resolveAIToolScope = async (
ctx: AIToolContext,
scope: string | undefined,
): Promise<AIToolScope | { error: string }> => {
if (!scope) {
return { kind: 'todos', name: null, municipalityIDs: null, municipalities: [] }
}
// Salvador first: the virtual city is a first-class scope (19 ZE juntas) and
// the catalog has no row literally named "Salvador" — the municipality query
// would be a guaranteed-miss round trip.
if (normalizeSearchPhrase(scope) === normalizeSearchPhrase(salvadorCity.city)) {
const resolved = await resolveMunicipalities(ctx, { city: { equals: salvadorCity.city } })
return { kind: 'cidade', name: salvadorCity.name, ...resolved }
}
const canonicalMunicipality = resolveMunicipalityName(scope)
if (canonicalMunicipality) {
const resolved = await resolveMunicipalities(ctx, { name: { equals: canonicalMunicipality } })
return { kind: 'municipio', name: canonicalMunicipality, ...resolved }
}
const territory = bahiaIdentityTerritories.find(
(name) => normalizeSearchPhrase(name) === normalizeSearchPhrase(scope),
)
if (territory) {
const resolved = await resolveMunicipalities(ctx, { region: { equals: territory } })
return { kind: 'regiao', name: territory, ...resolved }
}
return {
error: `Escopo não reconhecido: "${scope}". Use um município, "Salvador" ou um território de identidade da Bahia.`,
}
}
const resolveMunicipalities = async (
ctx: AIToolContext,
where: Where,
): Promise<{ municipalityIDs: number[]; municipalities: ScopeMunicipality[] }> => {
): Promise<{ municipalityIDs: number[]; municipalities: AIToolScopeMunicipality[] }> => {
const result = await ctx.payload.find({
collection: 'municipality',
where,

View file

@ -5,7 +5,8 @@ import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { resolveAIToolScope, type AIToolResolvedScope } from '@/utilities/ai/tools/aiToolScope'
import { loadAIToolNamesByIds } from '@/utilities/ai/tools/aiToolQueries'
import { resolveAIToolScope, type AIToolScope } from '@/utilities/ai/tools/aiToolScope'
import { municipalitySignalAgeInDays } from '@/utilities/municipality/municipalitySignal'
const DENIED_MESSAGE = 'Leitura de municípios negada.'
@ -79,6 +80,20 @@ export const getMunicipalitiesWithoutUpdate = (ctx: AIToolContext) =>
const resolved = await resolveAIToolScope(ctx, scope)
if ('error' in resolved) return resolved
// An advisor asking for a territory outside the portfolio gets the empty
// scoped set — never an existence lie, and no wasted municipality read.
if (resolved.municipalityIDs !== null && resolved.municipalityIDs.length === 0) {
return {
escopo: { tipo: resolved.kind, nome: resolved.name },
escopoRestrito: ctx.user.role === 'advisor',
limiarDias: days,
criterio: buildCriterion(days),
total: 0,
nuncaAtualizados: 0,
municipios: [],
}
}
const docs = await loadScopeMunicipalities(ctx, resolved)
const items = docs
.map(buildCoverageItem)
@ -90,7 +105,7 @@ export const getMunicipalitiesWithoutUpdate = (ctx: AIToolContext) =>
const advisorIDs = uniqueRelationshipIds(
municipios.flatMap((item) => item.assessores.map((a) => a.id)),
)
const advisorNames = await loadAdvisorNames(ctx, advisorIDs)
const advisorNames = await loadAIToolNamesByIds(ctx, 'campaignUser', advisorIDs)
for (const item of municipios) {
item.assessores = item.assessores.map((advisor) => ({
id: advisor.id,
@ -102,7 +117,7 @@ export const getMunicipalitiesWithoutUpdate = (ctx: AIToolContext) =>
escopo: { tipo: resolved.kind, nome: resolved.name },
escopoRestrito: ctx.user.role === 'advisor',
limiarDias: days,
criterio: `Municípios sem atualização de acompanhamento há mais de ${days} dias (última atualização registrada); nunca atualizados contam como estagnação máxima.`,
criterio: buildCriterion(days),
total: municipios.length,
nuncaAtualizados: neverUpdated.length,
municipios,
@ -110,9 +125,12 @@ export const getMunicipalitiesWithoutUpdate = (ctx: AIToolContext) =>
},
})
const buildCriterion = (days: number): string =>
`Municípios sem atualização de acompanhamento há mais de ${days} dias (última atualização registrada); nunca atualizados contam como estagnação máxima.`
const loadScopeMunicipalities = async (
ctx: AIToolContext,
scope: AIToolResolvedScope,
scope: AIToolScope,
): Promise<ScopedMunicipality[]> => {
const where: Where = scope.municipalityIDs ? { id: { in: scope.municipalityIDs } } : {}
const result = await ctx.payload.find({
@ -179,28 +197,10 @@ const sortNeverUpdated = (items: CoverageItem[]): CoverageItem[] => [...items].s
const sortStale = (items: CoverageItem[]): CoverageItem[] =>
[...items].sort((left, right) => {
const leftAt = new Date(left.ultimaAtualizacao ?? 0).getTime()
const rightAt = new Date(right.ultimaAtualizacao ?? 0).getTime()
// The stale partition never contains never-updated items, so the ISO
// timestamp is always present here.
const leftAt = new Date(left.ultimaAtualizacao!).getTime()
const rightAt = new Date(right.ultimaAtualizacao!).getTime()
if (leftAt !== rightAt) return leftAt - rightAt
return byName(left, right)
})
const loadAdvisorNames = async (
ctx: AIToolContext,
ids: number[],
): Promise<Map<number, string>> => {
const names = new Map<number, string>()
if (ids.length === 0) return names
const result = await ctx.payload.find({
collection: 'campaignUser',
where: { id: { in: ids } },
depth: 0,
limit: 0,
pagination: false,
select: { name: true },
overrideAccess: false,
user: ctx.user,
})
for (const doc of result.docs) names.set(doc.id, doc.name)
return names
}

View file

@ -5,7 +5,11 @@ import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { relationshipId, uniqueRelationshipIds } from '@/lib/relationship'
import { resolveAIToolScope, type AIToolResolvedScope } from '@/utilities/ai/tools/aiToolScope'
import { loadAIToolNamesByIds } from '@/utilities/ai/tools/aiToolQueries'
import {
resolveAIToolScope,
type AIToolScope as ResolvedScope,
} from '@/utilities/ai/tools/aiToolScope'
import { supportStatusLabels } from '@/utilities/leadership/leadershipLabels'
const DENIED_MESSAGE = 'Leitura de dados de lideranças negada.'
@ -15,8 +19,6 @@ const EMPTY_MUNICIPALITIES_CRITERION = 'Municípios do escopo sem nenhuma lidera
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 ResolvedScope = AIToolResolvedScope
type PendingLeadershipDoc = {
id: number
contact: number | string | null
@ -162,9 +164,9 @@ const listPendingLeaderships = async (
const advisorIDs = uniqueRelationshipIds(top.flatMap((doc) => doc.advisors ?? []))
const [contactNames, municipalityNames, advisorNames] = await Promise.all([
loadNamesByIds(ctx, 'contact', contactIDs),
loadAIToolNamesByIds(ctx, 'contact', contactIDs),
loadMunicipalityNamesByIds(ctx, municipalityIDs),
loadNamesByIds(ctx, 'campaignUser', advisorIDs),
loadAIToolNamesByIds(ctx, 'campaignUser', advisorIDs),
])
const liderancas = top.map((doc) => {
@ -278,27 +280,6 @@ const listMunicipalitiesWithoutLeadership = async (
}
}
const loadNamesByIds = async (
ctx: AIToolContext,
collection: 'contact' | 'campaignUser',
ids: number[],
): Promise<Map<number, string>> => {
const names = new Map<number, string>()
if (ids.length === 0) return names
const result = await ctx.payload.find({
collection,
where: { id: { in: ids } },
depth: 0,
limit: 0,
pagination: false,
select: { name: true },
overrideAccess: false,
user: ctx.user,
})
for (const doc of result.docs) names.set(doc.id, doc.name)
return names
}
const loadMunicipalityNamesByIds = async (
ctx: AIToolContext,
ids: number[],

View file

@ -28,10 +28,10 @@ export const buildAITools = (ctx: AIToolContext) => ({
getTopDeputies: getTopDeputies(ctx),
getLeadingMunicipalities: getLeadingMunicipalities(ctx),
getDobradinhas: getDobradinhas(ctx),
getMunicipalitiesWithoutUpdate: getMunicipalitiesWithoutUpdate(ctx),
getMunicipalityOverview: getMunicipalityOverview(ctx),
getLeaderships: getLeaderships(ctx),
getPendingLeaderships: getPendingLeaderships(ctx),
getMunicipalitiesWithoutUpdate: getMunicipalitiesWithoutUpdate(ctx),
getOrganizations: getOrganizations(ctx),
getPartnershipCoverage: getPartnershipCoverage(ctx),
searchEntities: searchEntities(ctx),

View file

@ -212,6 +212,19 @@ describe('getMunicipalitiesWithoutUpdate scope resolution (B189)', () => {
expect(find).not.toHaveBeenCalled()
})
it('fails closed with the empty scoped set for an advisor asking outside the portfolio (no municipality read)', async () => {
const find = vi.fn().mockResolvedValue(findResult([]))
const result = await run(find, { scope: 'Vale do Jiquiriça' }, advisor)
expect(result.total).toBe(0)
expect(result.nuncaAtualizados).toBe(0)
expect(result.municipios).toEqual([])
expect(result.escopoRestrito).toBe(true)
expect(find).toHaveBeenCalledTimes(1)
expect(find).toHaveBeenCalledWith(
expect.objectContaining({ where: { region: { equals: 'Vale do Jiquiriçá' } } }),
)
})
it('filters the scope query by municipality ids when a scope resolved them', async () => {
const find = vi.fn()
find.mockResolvedValueOnce(findResult([municipalityDoc({ id: 1 }), municipalityDoc({ id: 2 })]))