From 2b3c48e063562a7f083fb9a141124bab8b13558e Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Mon, 10 Aug 2026 08:10:55 -0300 Subject: [PATCH] =?UTF-8?q?refactor(B189):=20simplify=20=E2=80=94=20nomes?= =?UTF-8?q?=20alinhados=20=C3=A0=20B186,=20loader=20de=20nomes=20compartil?= =?UTF-8?q?hado,=20guard=20de=20escopo=20vazio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/utilities/ai/systemPrompt.ts | 2 +- src/utilities/ai/tools/aiToolQueries.ts | 27 +++++ src/utilities/ai/tools/aiToolScope.ts | 103 +++++++++--------- .../tools/getMunicipalitiesWithoutUpdate.ts | 52 ++++----- .../ai/tools/getPendingLeaderships.ts | 33 ++---- src/utilities/ai/tools/index.ts | 2 +- ...nicipalitiesWithoutUpdateTool.unit.spec.ts | 13 +++ 7 files changed, 128 insertions(+), 104 deletions(-) create mode 100644 src/utilities/ai/tools/aiToolQueries.ts diff --git a/src/utilities/ai/systemPrompt.ts b/src/utilities/ai/systemPrompt.ts index c8e77dce..101e5449 100644 --- a/src/utilities/ai/systemPrompt.ts +++ b/src/utilities/ai/systemPrompt.ts @@ -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). diff --git a/src/utilities/ai/tools/aiToolQueries.ts b/src/utilities/ai/tools/aiToolQueries.ts new file mode 100644 index 00000000..c8983042 --- /dev/null +++ b/src/utilities/ai/tools/aiToolQueries.ts @@ -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> => { + const names = new Map() + 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 +} diff --git a/src/utilities/ai/tools/aiToolScope.ts b/src/utilities/ai/tools/aiToolScope.ts index 291236ce..843fb3b6 100644 --- a/src/utilities/ai/tools/aiToolScope.ts +++ b/src/utilities/ai/tools/aiToolScope.ts @@ -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 => { - 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 => { + 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, diff --git a/src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts b/src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts index 61e37f38..0127b599 100644 --- a/src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts +++ b/src/utilities/ai/tools/getMunicipalitiesWithoutUpdate.ts @@ -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 => { 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> => { - const names = new Map() - 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 -} diff --git a/src/utilities/ai/tools/getPendingLeaderships.ts b/src/utilities/ai/tools/getPendingLeaderships.ts index d22ef1a4..078be773 100644 --- a/src/utilities/ai/tools/getPendingLeaderships.ts +++ b/src/utilities/ai/tools/getPendingLeaderships.ts @@ -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> => { - const names = new Map() - 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[], diff --git a/src/utilities/ai/tools/index.ts b/src/utilities/ai/tools/index.ts index c38674c9..f5832f09 100644 --- a/src/utilities/ai/tools/index.ts +++ b/src/utilities/ai/tools/index.ts @@ -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), diff --git a/tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts b/tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts index 5f386e71..2d28d983 100644 --- a/tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts +++ b/tests/unit/municipalitiesWithoutUpdateTool.unit.spec.ts @@ -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 })]))