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:
parent
6027aac941
commit
2b3c48e063
7 changed files with 128 additions and 104 deletions
|
|
@ -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).
|
||||
|
|
|
|||
27
src/utilities/ai/tools/aiToolQueries.ts
Normal file
27
src/utilities/ai/tools/aiToolQueries.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 })]))
|
||||
|
|
|
|||
Loading…
Reference in a new issue