feat(ai): add buildCampaignLinks tool for Sollinha navigation (B162)

Introduce a closed-catalog URL builder that delegates to existing list/detail
href helpers, with role-based guards for leader lockdown and unrestricted-only
assessor routes. Register the tool in the AI chat and teach the system prompt
when to offer markdown links after resolving entity ids/slugs.

Closes #383
This commit is contained in:
Cursor Agent 2026-08-06 00:20:29 +00:00
parent 399707fcc6
commit 5f97b6a21c
No known key found for this signature in database
6 changed files with 1081 additions and 0 deletions

View file

@ -0,0 +1,124 @@
# Impl: B162 — Sollinha: tool de URLs para navegar a vistas de interesse
Status: em execução
Atualizado em: 2026-08-06
Issue: #383
Intenção: docs/plans/sollinha-tool-urls-navegacao.md
Appetite restante: ~0,5–1 dia (herdado)
## Leitura da intenção
- **Outcome:** A Sollinha monta paths `/campanha/…` canônicos (detalhe ou lista filtrada) e os oferece como links markdown clicáveis na resposta, para o staff abrir a vista certa em um clique — sem substituir a navegação do app nem inventar filtros inexistentes.
- **O que NÃO negociar:** catálogo fechado (sem path genérico); reuso dos builders de URL existentes; leader lockdown; ids/slugs já resolvidos (nunca chute); links relativos; sem auto-navegação; sem migration/Consent/collection; não inventar `?advisor=` em dobradinhas.
- **O que reavaliar:** A hipótese de “um arquivo em `tools/` + registro” permanece correta, mas a lógica de montagem deve viver num módulo **puro** (`campaignNavigationUrls.ts`) separado da factory `tool()`, para testes unitários sem mock de Payload e para não inflar o handler da tool.
## Abordagem recomendada
```mermaid
flowchart LR
subgraph model [Sollinha]
P[Pergunta do usuário]
D[Tools de dados]
L[buildCampaignLinks]
end
subgraph pure [campaignNavigationUrls.ts]
C[Catálogo + RBAC]
B[build*Href existentes]
end
P --> D
D -->|ids/slugs| L
L --> C
C --> B
B -->|path + label| P
```
**Opções consideradas:** A) módulo puro + tool fina | B) tool com paths hardcoded inline | C) estender `searchEntities` para devolver todos os hrefs
**Recomendação:** **A** — catálogo tipado + delegação aos `build*Href`/`campaignConceptHref` já canônicos; tool só valida input Zod, chama o módulo com `ctx.user.role`, devolve `{ links: [{ path, label }] }`.
**Rejeitadas:** **B** porque driftaria dos contratos B18+ e duplicaria query params; **C** porque não cobre listas filtradas, mistura busca com navegação e não centraliza RBAC de destino.
### Componentes / mudanças
- **`campaignNavigationUrls.ts`** (`src/utilities/ai/campaignNavigationUrls.ts`): núcleo puro. Tipos discriminados por `destination`, mapa de destinos → função builder, guardas de papel (`isStaffCampaignRole`, `isUnrestrictedCampaignRole`, `canAccessSupporterArea`), retorno `{ ok: { path, label } } | { error, alternatives? }`. Sem Payload, sem I/O.
- **`buildCampaignLinks.ts`** (`src/utilities/ai/tools/buildCampaignLinks.ts`): factory `buildCampaignLinks(ctx)` com Zod `discriminatedUnion('destination', …)` espelhando o catálogo v1; aceita `links: [...]` (batch até ~5) para cenários “ficha + lista filtrada”; `execute` chama o módulo puro.
- **`index.ts`**: registrar `buildCampaignLinks: buildCampaignLinks(ctx)`.
- **`systemPrompt.ts`**: seção “Navegação” — quando usar (híbrido C da intenção: proativo em entidade singular + sempre sob pedido explícito); fluxo `searchEntities`/tool de domínio → `buildCampaignLinks`; exemplos do catálogo E (dobradinhas do assessor → ficha + detalhes, não lista filtrada inventada).
- **Migration:** sem migration
- **Access / Consent:** RBAC na tool via papel + invariante “só ids/slugs fornecidos pelo modelo após tools de dados”; páginas continuam fail-closed no servidor
- **UI:** N/A (markdown GFM já renderiza links no `CampaignAIChat`)
### Catálogo v1 (implementação)
| Grupo | `destination` | Builder / path |
| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- |
| A | `home`, `quadro`, `perfil`, `conceitos`, `giros` | literais + `campaignConceptHref(id)` |
| B detalhe | `municipality`, `leadership`, `dobradinha`, `advisor`, `organization`, `activity`, `demand`, `supporter` | `/campanha/{area}/[slug\|id]` |
| C lista | `municipalityList`, `leadershipList`, `dobradinhaList`, `advisorList`, `organizationList`, `activityList`, `demandList`, `supporterList`, `territoryList` | `build*ListHref` com subset de filtros do catálogo de produto |
| Leader | `leaderContacts` | `LEADER_CONTACTS_HOME` |
Filtros de lista expostos no schema Zod (alto valor operacional, espelhando intenção):
- **municipalityList:** `q`, `slug[]`, `region[]`, `advisor[]`, `coverage`, `priority`, `trend[]`, `class[]`, `level[]`
- **leadershipList:** `q`, `status[]`, `municipality[]`, `organization[]`, `stateDeputy[]`, `access`
- **dobradinhaList:** `q`, `party[]` (sem advisor)
- **advisorList:** `q`, `municipality[]` (gate `unrestricted`)
- **organizationList:** `q`, `kind`
- **activityList:** `tab`, `q`, `kind`, `status`, `municipality`
- **demandList:** `q`, `status`, `kind`, `activity`
- **supporterList:** `q`, `voteIntention`, `source`, `municipality`, `city`
- **territoryList:** `q`, `region[]`, `coverage`, `sort`, `dir`
Destinos **bloqueados para `leader`:** tudo exceto `home`, `perfil`, `leaderContacts`.
Destinos **bloqueados para `advisor`:** `advisor`, `advisorList`, `assessores` paths, `quadro`, `giros`, `conceitos` (staff-only nav).
### Dados → forma
- Forma: links markdown relativos (`[label](/campanha/…)`). O modelo recebe `{ path, label }` e formata na resposta.
- Rejeitadas: URLs absolutas (desnecessárias no mesmo origin); chips/botões “Abrir” (fora do appetite).
## Fases verificáveis
1. **Tracer / schema+server** — `campaignNavigationUrls.ts` com destinos A+B+C mínimos (home, municipality detail, municipalityList com `advisor`/`coverage`/`priority`); `buildCampaignLinks` tool; registro; testes unitários de canonicalização + leader lockdown + recusa de filtro inexistente em dobradinhas.
2. **Cobertura catálogo + prompt** — completar destinos restantes; atualizar `systemPrompt.ts`; testes para cada builder delegado (snapshot de href contra `build*Href` existentes).
3. **Gates** — `pnpm gate:fast`; push via `pnpm push`
## Rabbit holes / Não escopo (engenharia)
- Tool genérica `path: string` — rejeitada na intenção
- Resolver nome→slug dentro da URL tool — usar `searchEntities` / tools de domínio antes
- Cobrir 100% dos query params de sort/página de todas as listas
- Fechar sheet ao clicar link
- Auto `router.push`
- Rotas `…/nova` e município v2
- Alterar `searchEntities` para retornar href em todas as entidades (fora do escopo; pode ser débito barato)
## Riscos e mitigação
| Risco | Mitigação |
| -------------------------------- | -------------------------------------------------------------------------------------------------- |
| Drift entre tool e contratos B18 | Delegar 100% aos `build*Href` / `parse*Params` existentes; testes que comparam output |
| Modelo inventa slug | Schema exige identificadores explícitos; prompt reforça “resolver antes”; tool não faz fuzzy match |
| Leader recebe link staff | Guard centralizado por `destination` + testes por papel |
| Schema Zod enorme | Discriminated union por destino; só params do catálogo v1 |
## Divergência da hipótese de direção (intenção)
- **Módulo puro separado** (`campaignNavigationUrls.ts`) em vez de colocar toda a lógica dentro de `tools/buildCampaignLinks.ts` — melhora testabilidade sem mudar o outcome.
- **Batch `links[]`** na mesma chamada — não estava explícito na intenção, mas suporta o caso “ficha do assessor + N dobradinhas” (catálogo E) sem N invocações.
## Aceite de engenharia
- [ ] Aceite de produto da intenção ainda coberto
- [ ] Invariantes AGENTS/engineering-standards
- [ ] Testes de domínio previstos (unit em `campaignNavigationUrls`; sem int DB — tool não escreve)
## Decisões de engenharia (self-score)
| Decisão | Recomendação | Rejeitadas |
| ------------------ | ---------------------------------------------- | --------------------------------------- |
| Onde vive a lógica | `campaignNavigationUrls.ts` puro + tool fina | Inline na tool; estender searchEntities |
| Formato de path | Relativo `/campanha/…` | Absoluto com host |
| API da tool | `links[]` batch com `destination` discriminado | Uma URL por chamada; path livre |
| RBAC | Guard por destino no módulo puro | Confiar só no fail-closed das páginas |
| Testes | Unit puro + comparação com builders existentes | E2E do chat neste item |
**Self-score decision-quality: 4/5** — decisões caras documentadas; appetite respeitado; rabbit holes nomeados; reusa shells/helpers; outcome intacto.

View file

@ -0,0 +1,594 @@
import type { BahiaIdentityTerritory } from '@/lib/bahiaTerritories'
import {
CAMPAIGN_CONCEPTS_PATH,
campaignConceptHref,
campaignIntelligenceConcepts,
type CampaignConceptId,
} from '@/lib/campaignIntelligenceConcepts'
import { CAMPAIGN_HOME, CAMPAIGN_PROFILE_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import { ACTIVITY_TOUR_COMPOSER_PATH } from '@/lib/campaignQuickActionPaths'
import type { CampaignRole } from '@/lib/campaignRoles'
import { isStaffCampaignRole, isUnrestrictedCampaignRole } from '@/lib/campaignRoles'
import { isMunicipalitySlug } from '@/lib/municipalityCatalog'
import { activityKinds, activityStatuses, type ActivityKind } from '@/lib/schemas/activity'
import {
campaignDemandKinds,
campaignDemandStatuses,
type CampaignDemandKind,
type CampaignDemandStatus,
} from '@/lib/schemas/campaignDemand'
import { leadershipSupportStatuses, type SupportStatus } from '@/lib/schemas/leadership'
import { organizationKinds, type OrganizationKind } from '@/lib/schemas/organization'
import {
isSupporterSource,
isSupporterVoteIntention,
resolveBahiaMunicipality,
type SupporterSource,
type SupporterVoteIntention,
} from '@/lib/schemas/supporter'
import type { CampaignUser } from '@/payload-types'
import { activityTabs, buildActivityListHref, type ActivityTab } from '@/utilities/activityUi'
import { advisorListHrefForPage } from '@/utilities/advisor/advisorListUrl'
import { buildDemandListHref } from '@/utilities/demand/demandListUrl'
import { buildLeadershipListHref } from '@/utilities/leadership/leadershipListUrl'
import {
politicalTrendLabels,
territorialClassLabels,
type PoliticalTrendStatus,
} from '@/utilities/municipality/municipalityLabels'
import {
buildMunicipalityListHref,
municipalityListLevelFilterValues,
type MunicipalityListLevelFilterValue,
} from '@/utilities/municipality/municipalityListUrl'
import type { MunicipalityTerritorialClass } from '@/utilities/municipality/municipalityTerritorialClass'
import { buildOrganizationListHref } from '@/utilities/organization/organizationListUrl'
import { buildStateDeputyListHref } from '@/utilities/stateDeputyListUrl'
import { buildSupporterListHref, canAccessSupporterArea } from '@/utilities/supporter/supporterUi'
import type {
TerritoryCoverage,
TerritoryListSortKey,
} from '@/utilities/territory/territoryListUrl'
import { buildTerritoryListHref } from '@/utilities/territory/territoryListUrl'
const CAMPAIGN_STAFF_QUADRO_PATH = '/campanha/quadro' as const
const campaignConceptIdSet = new Set<string>(
campaignIntelligenceConcepts.map((concept) => concept.id),
)
const politicalTrendSet = new Set<string>(Object.keys(politicalTrendLabels))
const territorialClassSet = new Set<string>(Object.keys(territorialClassLabels))
const municipalityLevelFilterSet = new Set<string>(municipalityListLevelFilterValues)
const STAFF_DESTINATIONS = new Set<CampaignNavigationDestination>([
'quadro',
'conceitos',
'giros',
'municipality',
'leadership',
'dobradinha',
'organization',
'activity',
'demand',
'supporter',
'municipalityList',
'leadershipList',
'dobradinhaList',
'organizationList',
'activityList',
'demandList',
'supporterList',
'territoryList',
])
const UNRESTRICTED_DESTINATIONS = new Set<CampaignNavigationDestination>(['advisor', 'advisorList'])
type CampaignNavigationDestination =
| 'home'
| 'quadro'
| 'perfil'
| 'conceitos'
| 'giros'
| 'leaderContacts'
| 'municipality'
| 'leadership'
| 'dobradinha'
| 'advisor'
| 'organization'
| 'activity'
| 'demand'
| 'supporter'
| 'municipalityList'
| 'leadershipList'
| 'dobradinhaList'
| 'advisorList'
| 'organizationList'
| 'activityList'
| 'demandList'
| 'supporterList'
| 'territoryList'
type BaseLinkFields = { label?: string }
export type CampaignNavigationLinkRequest =
| (BaseLinkFields & { destination: 'home' })
| (BaseLinkFields & { destination: 'quadro' })
| (BaseLinkFields & { destination: 'perfil' })
| (BaseLinkFields & { destination: 'giros' })
| (BaseLinkFields & { destination: 'leaderContacts' })
| (BaseLinkFields & { destination: 'conceitos'; conceptId?: CampaignConceptId })
| (BaseLinkFields & { destination: 'municipality'; slug: string })
| (BaseLinkFields & { destination: 'leadership'; id: number })
| (BaseLinkFields & { destination: 'dobradinha'; slug: string })
| (BaseLinkFields & { destination: 'advisor'; id: number })
| (BaseLinkFields & { destination: 'organization'; slug: string })
| (BaseLinkFields & { destination: 'activity'; slug: string })
| (BaseLinkFields & { destination: 'demand'; slug: string })
| (BaseLinkFields & { destination: 'supporter'; id: number })
| (BaseLinkFields & {
destination: 'municipalityList'
q?: string
slugs?: string[]
regions?: BahiaIdentityTerritory[]
advisors?: number[]
coverage?: 'com_assessor' | 'sem_assessor'
priority?: 'alta'
trends?: PoliticalTrendStatus[]
classes?: MunicipalityTerritorialClass[]
levels?: MunicipalityListLevelFilterValue[]
})
| (BaseLinkFields & {
destination: 'leadershipList'
q?: string
statuses?: SupportStatus[]
municipalities?: number[]
organizations?: number[]
stateDeputies?: number[]
access?: 'com' | 'sem'
})
| (BaseLinkFields & {
destination: 'dobradinhaList'
q?: string
parties?: string[]
})
| (BaseLinkFields & {
destination: 'advisorList'
q?: string
municipalities?: number[]
})
| (BaseLinkFields & {
destination: 'organizationList'
q?: string
kind?: OrganizationKind
})
| (BaseLinkFields & {
destination: 'activityList'
tab?: ActivityTab
q?: string
kind?: ActivityKind
status?: (typeof activityStatuses)[number]
municipality?: number
})
| (BaseLinkFields & {
destination: 'demandList'
q?: string
status?: CampaignDemandStatus
kind?: CampaignDemandKind
activityId?: number
})
| (BaseLinkFields & {
destination: 'supporterList'
q?: string
voteIntention?: SupporterVoteIntention
source?: SupporterSource
city?: string
municipality?: number
})
| (BaseLinkFields & {
destination: 'territoryList'
q?: string
regions?: BahiaIdentityTerritory[]
coverage?: TerritoryCoverage
sort?: TerritoryListSortKey
dir?: 'asc' | 'desc'
})
type CampaignNavigationLinkSuccess = { path: string; label: string }
type CampaignNavigationLinkFailure = {
error: string
alternatives?: string[]
}
type CampaignNavigationLinkOutcome =
| ({ ok: true } & CampaignNavigationLinkSuccess)
| ({ ok: false } & CampaignNavigationLinkFailure)
type BuildCampaignNavigationLinksResult = {
links: CampaignNavigationLinkSuccess[]
errors?: Array<{ index: number } & CampaignNavigationLinkFailure>
}
const defaultLabel = (fallback: string, label?: string): string => label?.trim() || fallback
const positiveInt = (value: number, _field: string): number | null => {
if (!Number.isSafeInteger(value) || value <= 0) return null
return value
}
const detailSlug = (value: string, _field: string): string | null => {
const trimmed = value.trim()
if (!trimmed || trimmed.includes('/')) return null
if (trimmed === 'nova' || trimmed === 'giros') return null
return trimmed
}
const assertDestinationAccess = (
role: CampaignRole,
destination: CampaignNavigationDestination,
): CampaignNavigationLinkFailure | null => {
if (role === 'leader') {
if (destination === 'home' || destination === 'perfil' || destination === 'leaderContacts') {
return null
}
return {
error:
'Este destino não está disponível para liderança. Use Início, Meus contatos ou Perfil.',
alternatives: [CAMPAIGN_HOME, LEADER_CONTACTS_HOME, CAMPAIGN_PROFILE_HOME],
}
}
if (UNRESTRICTED_DESTINATIONS.has(destination) && !isUnrestrictedCampaignRole(role)) {
return {
error: 'A área de assessores é restrita a coordenador e candidato.',
}
}
if (STAFF_DESTINATIONS.has(destination) && !isStaffCampaignRole(role)) {
return { error: 'Este destino exige perfil de equipe da campanha.' }
}
if (
(destination === 'supporter' || destination === 'supporterList') &&
!canAccessSupporterArea(role)
) {
return { error: 'A área de apoiadores não está disponível para este perfil.' }
}
return null
}
const buildPathForRequest = (
request: CampaignNavigationLinkRequest,
): CampaignNavigationLinkOutcome => {
switch (request.destination) {
case 'home':
return { ok: true, path: CAMPAIGN_HOME, label: defaultLabel('Início', request.label) }
case 'quadro':
return {
ok: true,
path: CAMPAIGN_STAFF_QUADRO_PATH,
label: defaultLabel('Quadro', request.label),
}
case 'perfil':
return { ok: true, path: CAMPAIGN_PROFILE_HOME, label: defaultLabel('Perfil', request.label) }
case 'leaderContacts':
return {
ok: true,
path: LEADER_CONTACTS_HOME,
label: defaultLabel('Meus contatos', request.label),
}
case 'giros':
return {
ok: true,
path: ACTIVITY_TOUR_COMPOSER_PATH,
label: defaultLabel('Compositor de giro', request.label),
}
case 'conceitos': {
const path =
request.conceptId && campaignConceptIdSet.has(request.conceptId)
? campaignConceptHref(request.conceptId)
: CAMPAIGN_CONCEPTS_PATH
const label =
request.label?.trim() ||
(request.conceptId ? `Conceito: ${request.conceptId}` : 'Conceitos')
return { ok: true, path, label }
}
case 'municipality': {
if (!isMunicipalitySlug(request.slug)) {
return {
ok: false,
error: `Slug de município inválido: "${request.slug}". Resolva o município com searchEntities antes de montar o link.`,
}
}
return {
ok: true,
path: `/campanha/municipios/${request.slug}`,
label: defaultLabel(`Município`, request.label),
}
}
case 'leadership': {
const id = positiveInt(request.id, 'id')
if (id === null) {
return { ok: false, error: 'ID de liderança inválido.' }
}
return {
ok: true,
path: `/campanha/liderancas/${id}`,
label: defaultLabel('Liderança', request.label),
}
}
case 'dobradinha': {
const slug = detailSlug(request.slug, 'slug')
if (!slug) {
return { ok: false, error: 'Slug de dobradinha inválido.' }
}
return {
ok: true,
path: `/campanha/dobradinhas/${slug}`,
label: defaultLabel('Dobradinha', request.label),
}
}
case 'advisor': {
const id = positiveInt(request.id, 'id')
if (id === null) {
return { ok: false, error: 'ID de assessor inválido.' }
}
return {
ok: true,
path: `/campanha/assessores/${id}`,
label: defaultLabel('Assessor', request.label),
}
}
case 'organization': {
const slug = detailSlug(request.slug, 'slug')
if (!slug) {
return { ok: false, error: 'Slug de organização inválido.' }
}
return {
ok: true,
path: `/campanha/organizacoes/${slug}`,
label: defaultLabel('Organização', request.label),
}
}
case 'activity': {
const slug = detailSlug(request.slug, 'slug')
if (!slug) {
return { ok: false, error: 'Slug de atividade inválido.' }
}
return {
ok: true,
path: `/campanha/atividades/${slug}`,
label: defaultLabel('Atividade', request.label),
}
}
case 'demand': {
const slug = detailSlug(request.slug, 'slug')
if (!slug) {
return { ok: false, error: 'Slug de demanda inválido.' }
}
return {
ok: true,
path: `/campanha/demandas/${slug}`,
label: defaultLabel('Demanda', request.label),
}
}
case 'supporter': {
const id = positiveInt(request.id, 'id')
if (id === null) {
return { ok: false, error: 'ID de apoiador inválido.' }
}
return {
ok: true,
path: `/campanha/apoiadores/${id}`,
label: defaultLabel('Apoiador', request.label),
}
}
case 'municipalityList': {
const slugs = request.slugs?.filter(isMunicipalitySlug)
const advisors = request.advisors?.filter((id) => positiveInt(id, 'advisor') !== null)
const trends = request.trends?.filter((t): t is PoliticalTrendStatus =>
politicalTrendSet.has(t),
)
const classes = request.classes?.filter((c): c is MunicipalityTerritorialClass =>
territorialClassSet.has(c),
)
const levels = request.levels?.filter((level): level is MunicipalityListLevelFilterValue =>
municipalityLevelFilterSet.has(level),
)
const path = buildMunicipalityListHref(
{
page: 1,
...(request.q?.trim() ? { q: request.q.trim() } : {}),
...(slugs?.length ? { slugs } : {}),
...(request.regions?.length ? { regions: request.regions } : {}),
...(advisors?.length ? { advisors } : {}),
...(request.coverage ? { coverage: request.coverage } : {}),
...(request.priority ? { priority: request.priority } : {}),
...(trends?.length ? { trends } : {}),
...(classes?.length ? { classes } : {}),
...(levels?.length ? { levels } : {}),
},
1,
)
return { ok: true, path, label: defaultLabel('Municípios', request.label) }
}
case 'leadershipList': {
const statuses = request.statuses?.filter((s): s is SupportStatus =>
leadershipSupportStatuses.includes(s),
)
const municipalities = request.municipalities?.filter(
(id) => positiveInt(id, 'municipality') !== null,
)
const organizations = request.organizations?.filter(
(id) => positiveInt(id, 'organization') !== null,
)
const stateDeputies = request.stateDeputies?.filter(
(id) => positiveInt(id, 'stateDeputy') !== null,
)
const path = buildLeadershipListHref(
{
page: 1,
...(request.q?.trim() ? { q: request.q.trim() } : {}),
...(statuses?.length ? { statuses } : {}),
...(municipalities?.length ? { municipalities } : {}),
...(organizations?.length ? { organizations } : {}),
...(stateDeputies?.length ? { stateDeputies } : {}),
...(request.access ? { access: request.access } : {}),
},
1,
)
return { ok: true, path, label: defaultLabel('Lideranças', request.label) }
}
case 'dobradinhaList': {
const path = buildStateDeputyListHref(
{
page: 1,
...(request.q?.trim() ? { q: request.q.trim() } : {}),
...(request.parties?.length ? { parties: request.parties } : {}),
},
1,
)
return { ok: true, path, label: defaultLabel('Dobradinhas', request.label) }
}
case 'advisorList': {
const municipalities = request.municipalities?.filter(
(id) => positiveInt(id, 'municipality') !== null,
)
const path = advisorListHrefForPage(
{
page: 1,
...(request.q?.trim() ? { q: request.q.trim() } : {}),
...(municipalities?.length ? { municipalities } : {}),
},
1,
)
return { ok: true, path, label: defaultLabel('Assessores', request.label) }
}
case 'organizationList': {
const kind =
request.kind && organizationKinds.includes(request.kind) ? request.kind : undefined
const path = buildOrganizationListHref(
{
page: 1,
...(request.q?.trim() ? { q: request.q.trim() } : {}),
...(kind ? { kind } : {}),
},
1,
)
return { ok: true, path, label: defaultLabel('Organizações', request.label) }
}
case 'activityList': {
const tab = request.tab && activityTabs.includes(request.tab) ? request.tab : undefined
const kind = request.kind && activityKinds.includes(request.kind) ? request.kind : undefined
const status =
request.status && activityStatuses.includes(request.status) ? request.status : undefined
const municipality =
request.municipality !== undefined
? positiveInt(request.municipality, 'municipality')
: undefined
const path = buildActivityListHref(
{
page: 1,
tab: tab ?? 'proximos',
...(request.q?.trim() ? { q: request.q.trim() } : {}),
...(kind ? { kind } : {}),
...(status ? { status } : {}),
...(municipality ? { municipality } : {}),
},
1,
)
return { ok: true, path, label: defaultLabel('Atividades', request.label) }
}
case 'demandList': {
const status =
request.status && campaignDemandStatuses.includes(request.status)
? request.status
: undefined
const kind =
request.kind && campaignDemandKinds.includes(request.kind) ? request.kind : undefined
const activityId =
request.activityId !== undefined ? positiveInt(request.activityId, 'activity') : undefined
const path = buildDemandListHref(
{
page: 1,
...(request.q?.trim() ? { q: request.q.trim() } : {}),
...(status ? { status } : {}),
...(kind ? { kind } : {}),
...(activityId ? { activityId } : {}),
},
1,
)
return { ok: true, path, label: defaultLabel('Demandas', request.label) }
}
case 'supporterList': {
const voteIntention =
request.voteIntention && isSupporterVoteIntention(request.voteIntention)
? request.voteIntention
: undefined
const source =
request.source && isSupporterSource(request.source) ? request.source : undefined
const city = request.city ? (resolveBahiaMunicipality(request.city) ?? undefined) : undefined
const municipality =
request.municipality !== undefined
? positiveInt(request.municipality, 'municipality')
: undefined
const path = buildSupporterListHref(
{
page: 1,
...(request.q?.trim() ? { q: request.q.trim() } : {}),
...(voteIntention ? { voteIntention } : {}),
...(source ? { source } : {}),
...(city ? { city } : {}),
...(municipality ? { municipality } : {}),
},
1,
)
return { ok: true, path, label: defaultLabel('Apoiadores', request.label) }
}
case 'territoryList': {
const path = buildTerritoryListHref({
...(request.q?.trim() ? { q: request.q.trim() } : {}),
...(request.regions?.length ? { regions: request.regions } : {}),
...(request.coverage ? { coverage: request.coverage } : {}),
...(request.sort ? { sort: request.sort } : {}),
...(request.dir ? { dir: request.dir } : {}),
})
return { ok: true, path, label: defaultLabel('Territórios', request.label) }
}
default: {
const _exhaustive: never = request
return _exhaustive
}
}
}
export const buildCampaignNavigationLink = (
role: CampaignUser['role'],
request: CampaignNavigationLinkRequest,
): CampaignNavigationLinkOutcome => {
const accessError = assertDestinationAccess(role, request.destination)
if (accessError) return { ok: false, ...accessError }
return buildPathForRequest(request)
}
export const buildCampaignNavigationLinks = (
role: CampaignUser['role'],
requests: CampaignNavigationLinkRequest[],
): BuildCampaignNavigationLinksResult => {
const links: CampaignNavigationLinkSuccess[] = []
const errors: Array<{ index: number } & CampaignNavigationLinkFailure> = []
requests.forEach((request, index) => {
const outcome = buildCampaignNavigationLink(role, request)
if (outcome.ok) {
links.push({ path: outcome.path, label: outcome.label })
return
}
errors.push({ index, error: outcome.error, alternatives: outcome.alternatives })
})
return errors.length ? { links, errors } : { links }
}

View file

@ -17,6 +17,15 @@ export const AI_SYSTEM_PROMPT = `Você é a Sollinha, assistente virtual da camp
- Se o usuário perguntar algo que você não tem ferramenta para responder, diga educadamente que ainda não tem acesso a essa informação.
- Para cálculos matemáticos (porcentagens, somas, taxas de crescimento), SEMPRE use a ferramenta "calculate". Não faça contas de cabeça.
- Se uma pergunta for ambígua (ex: "me fala sobre Salvador"), use a ferramenta "searchEntities" primeiro para descobrir o que o usuário quer.
## Navegação no app
- Use a ferramenta "buildCampaignLinks" para montar links clicáveis que levam o usuário à tela certa em /campanha.
- Ofereça links quando: (1) o usuário pedir explicitamente para abrir/ir/mandar o link; (2) você responder sobre uma entidade singular concreta (município, liderança, dobradinha, etc.) — inclua um link "ver no app" quando ajudar.
- SEMPRE resolva ids/slugs com searchEntities ou outras ferramentas de dados ANTES de chamar buildCampaignLinks. Nunca invente slug ou id.
- Formate os links em markdown relativo: [rótulo](/campanha/…). O chat abre na mesma aba — não tente navegar automaticamente.
- Para "dobradinhas do assessor X": NÃO existe filtro por assessor na lista de dobradinhas. Ofereça a ficha do assessor e/ou links de detalhe de cada dobradinha (via getDobradinhas), nunca invente ?advisor= em /campanha/dobradinhas.
- Para "municípios do assessor X": prefira municipalityList com filtro advisor + opcionalmente ficha do assessor.
- Liderança (role leader) só recebe links para Início, Meus contatos e Perfil — não ofereça municípios, dobradinhas ou outras áreas staff.
- Apresente números grandes com separador de milhar (ex: 1.234.567).
- Percentuais sempre com 1 casa decimal (ex: 24,7%).

View file

@ -0,0 +1,228 @@
import { tool } from 'ai'
import { z } from 'zod'
import type { AIToolContext } from '@/lib/ai/types'
import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
import {
campaignIntelligenceConcepts,
type CampaignConceptId,
} from '@/lib/campaignIntelligenceConcepts'
import { activityKinds, activityStatuses } from '@/lib/schemas/activity'
import { campaignDemandKinds, campaignDemandStatuses } from '@/lib/schemas/campaignDemand'
import { leadershipSupportStatuses } from '@/lib/schemas/leadership'
import { organizationKinds } from '@/lib/schemas/organization'
import { TERRITORIAL_CLASSES } from '@/lib/territorialClassAnchors'
import { activityTabs } from '@/utilities/activityUi'
import type { CampaignNavigationLinkRequest } from '@/utilities/ai/campaignNavigationUrls'
import { buildCampaignNavigationLinks } from '@/utilities/ai/campaignNavigationUrls'
import { politicalTrendLabels } from '@/utilities/municipality/municipalityLabels'
import { municipalityListLevelFilterValues } from '@/utilities/municipality/municipalityListUrl'
const labelSchema = z
.string()
.optional()
.describe('Rótulo humano para o link na resposta (ex.: "Ilhéus", "Municípios do assessor João").')
const territoryRegionSchema = z.enum(
bahiaIdentityTerritories as [
(typeof bahiaIdentityTerritories)[number],
...(typeof bahiaIdentityTerritories)[number][],
],
)
const politicalTrendKeys = Object.keys(politicalTrendLabels) as [
keyof typeof politicalTrendLabels,
...(keyof typeof politicalTrendLabels)[],
]
const conceptIdSchema = z.enum(
campaignIntelligenceConcepts.map((concept) => concept.id) as [
CampaignConceptId,
...CampaignConceptId[],
],
)
const supporterVoteIntentions = ['certo', 'tende_a_certo', 'indeciso', 'outro'] as const
const supporterSources = ['import_csv', 'manual', 'lideranca', 'convite', 'evento'] as const
const territorySortKeys = [
'region',
'municipalities',
'votes2022',
'pct',
'validVotes2022',
'estimate2026',
'coverage',
'cobertura',
'captura',
'classe',
] as const
const linkRequestSchema = z.discriminatedUnion('destination', [
z.object({ destination: z.literal('home'), label: labelSchema }),
z.object({ destination: z.literal('quadro'), label: labelSchema }),
z.object({ destination: z.literal('perfil'), label: labelSchema }),
z.object({ destination: z.literal('giros'), label: labelSchema }),
z.object({ destination: z.literal('leaderContacts'), label: labelSchema }),
z.object({
destination: z.literal('conceitos'),
conceptId: conceptIdSchema.optional(),
label: labelSchema,
}),
z.object({
destination: z.literal('municipality'),
slug: z
.string()
.describe('Slug canônico do município (resolvido via searchEntities ou tools de dados).'),
label: labelSchema,
}),
z.object({
destination: z.literal('leadership'),
id: z.number().int().positive(),
label: labelSchema,
}),
z.object({
destination: z.literal('dobradinha'),
slug: z.string(),
label: labelSchema,
}),
z.object({
destination: z.literal('advisor'),
id: z.number().int().positive(),
label: labelSchema,
}),
z.object({
destination: z.literal('organization'),
slug: z.string(),
label: labelSchema,
}),
z.object({
destination: z.literal('activity'),
slug: z.string(),
label: labelSchema,
}),
z.object({
destination: z.literal('demand'),
slug: z.string(),
label: labelSchema,
}),
z.object({
destination: z.literal('supporter'),
id: z.number().int().positive(),
label: labelSchema,
}),
z.object({
destination: z.literal('municipalityList'),
q: z.string().optional(),
slugs: z.array(z.string()).optional(),
regions: z.array(territoryRegionSchema).optional(),
advisors: z.array(z.number().int().positive()).optional(),
coverage: z.enum(['com_assessor', 'sem_assessor']).optional(),
priority: z.literal('alta').optional(),
trends: z.array(z.enum(politicalTrendKeys)).optional(),
classes: z.array(z.enum(TERRITORIAL_CLASSES)).optional(),
levels: z.array(z.enum(municipalityListLevelFilterValues as [string, ...string[]])).optional(),
label: labelSchema,
}),
z.object({
destination: z.literal('leadershipList'),
q: z.string().optional(),
statuses: z.array(z.enum(leadershipSupportStatuses)).optional(),
municipalities: z.array(z.number().int().positive()).optional(),
organizations: z.array(z.number().int().positive()).optional(),
stateDeputies: z.array(z.number().int().positive()).optional(),
access: z.enum(['com', 'sem']).optional(),
label: labelSchema,
}),
z.object({
destination: z.literal('dobradinhaList'),
q: z.string().optional(),
parties: z.array(z.string()).optional(),
label: labelSchema,
}),
z.object({
destination: z.literal('advisorList'),
q: z.string().optional(),
municipalities: z.array(z.number().int().positive()).optional(),
label: labelSchema,
}),
z.object({
destination: z.literal('organizationList'),
q: z.string().optional(),
kind: z.enum(organizationKinds).optional(),
label: labelSchema,
}),
z.object({
destination: z.literal('activityList'),
tab: z.enum(activityTabs).optional(),
q: z.string().optional(),
kind: z.enum(activityKinds).optional(),
status: z.enum(activityStatuses).optional(),
municipality: z.number().int().positive().optional(),
label: labelSchema,
}),
z.object({
destination: z.literal('demandList'),
q: z.string().optional(),
status: z.enum(campaignDemandStatuses).optional(),
kind: z.enum(campaignDemandKinds).optional(),
activityId: z.number().int().positive().optional(),
label: labelSchema,
}),
z.object({
destination: z.literal('supporterList'),
q: z.string().optional(),
voteIntention: z.enum(supporterVoteIntentions).optional(),
source: z.enum(supporterSources).optional(),
city: z.string().optional(),
municipality: z.number().int().positive().optional(),
label: labelSchema,
}),
z.object({
destination: z.literal('territoryList'),
q: z.string().optional(),
regions: z.array(territoryRegionSchema).optional(),
coverage: z.enum(['com_assessor', 'sem_assessor']).optional(),
sort: z.enum(territorySortKeys).optional(),
dir: z.enum(['asc', 'desc']).optional(),
label: labelSchema,
}),
])
export const buildCampaignLinks = (ctx: AIToolContext) =>
tool({
description:
'Build canonical /campanha navigation links for the staff to open in the app. ' +
'Use AFTER resolving entity ids/slugs with searchEntities or domain tools — never guess slugs. ' +
'Offer links when the user asks to open/go to a view, or proactively when answering about a single entity. ' +
'For "dobradinhas do assessor X" there is NO advisor filter on the dobradinhas list — use advisor detail plus individual dobradinha detail links instead.',
inputSchema: z.object({
links: z
.array(linkRequestSchema)
.min(1)
.max(5)
.describe(
'One or more navigation targets to build (batch for e.g. assessor + dobradinhas).',
),
}),
execute: async ({ links }) => {
const result = buildCampaignNavigationLinks(
ctx.user.role,
links as CampaignNavigationLinkRequest[],
)
if (result.errors?.length) {
return {
links: result.links,
errors: result.errors,
hint: 'Corrija os parâmetros inválidos ou use searchEntities para resolver ids/slugs antes de tentar de novo.',
}
}
return {
links: result.links,
formatting:
'Inclua cada link na resposta em markdown relativo: [rótulo](path). Ex.: [Ilhéus](/campanha/municipios/ilheus).',
}
},
})

View file

@ -7,6 +7,7 @@
import type { AIToolContext } from '@/lib/ai/types'
import { buildCampaignLinks } from '@/utilities/ai/tools/buildCampaignLinks'
import { calculate } from '@/utilities/ai/tools/calculate'
import { getDobradinhas } from '@/utilities/ai/tools/getDobradinhas'
import { getLeaderships } from '@/utilities/ai/tools/getLeaderships'
@ -18,6 +19,7 @@ import { searchEntities } from '@/utilities/ai/tools/searchEntities'
export const buildAITools = (ctx: AIToolContext) => ({
calculate,
buildCampaignLinks: buildCampaignLinks(ctx),
getMunicipalityVotes: getMunicipalityVotes(ctx),
getTopDeputies: getTopDeputies(ctx),
getDobradinhas: getDobradinhas(ctx),

View file

@ -0,0 +1,124 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import { campaignConceptHref } from '@/lib/campaignIntelligenceConcepts'
import { CAMPAIGN_HOME, CAMPAIGN_PROFILE_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import type { CampaignUser } from '@/payload-types'
import {
buildCampaignNavigationLink,
buildCampaignNavigationLinks,
} from '@/utilities/ai/campaignNavigationUrls'
import { buildMunicipalityListHref } from '@/utilities/municipality/municipalityListUrl'
const coordinator = { role: 'coordinator' } as CampaignUser
const advisor = { role: 'advisor' } as CampaignUser
const leader = { role: 'leader' } as CampaignUser
describe('campaignNavigationUrls', () => {
it('builds home and detail paths for staff', () => {
const home = buildCampaignNavigationLink(coordinator.role, { destination: 'home' })
expect(home).toEqual({ ok: true, path: CAMPAIGN_HOME, label: 'Início' })
const municipality = buildCampaignNavigationLink(coordinator.role, {
destination: 'municipality',
slug: 'ilheus',
label: 'Ilhéus',
})
expect(municipality).toEqual({
ok: true,
path: '/campanha/municipios/ilheus',
label: 'Ilhéus',
})
})
it('rejects invalid municipality slugs', () => {
const outcome = buildCampaignNavigationLink(coordinator.role, {
destination: 'municipality',
slug: 'slug-inventado',
})
expect(outcome.ok).toBe(false)
if (!outcome.ok) {
expect(outcome.error).toMatch(/Slug de município inválido/)
}
})
it('delegates municipality list filters to buildMunicipalityListHref', () => {
const outcome = buildCampaignNavigationLink(coordinator.role, {
destination: 'municipalityList',
coverage: 'sem_assessor',
priority: 'alta',
advisors: [42],
})
expect(outcome.ok).toBe(true)
if (outcome.ok) {
expect(outcome.path).toBe(
buildMunicipalityListHref(
{ page: 1, coverage: 'sem_assessor', priority: 'alta', advisors: [42] },
1,
),
)
}
})
it('blocks staff destinations for leaders', () => {
const outcome = buildCampaignNavigationLink(leader.role, {
destination: 'municipality',
slug: 'ilheus',
})
expect(outcome.ok).toBe(false)
if (!outcome.ok) {
expect(outcome.alternatives).toEqual([
CAMPAIGN_HOME,
LEADER_CONTACTS_HOME,
CAMPAIGN_PROFILE_HOME,
])
}
})
it('allows leader-safe destinations', () => {
const contacts = buildCampaignNavigationLink(leader.role, { destination: 'leaderContacts' })
expect(contacts).toEqual({
ok: true,
path: LEADER_CONTACTS_HOME,
label: 'Meus contatos',
})
const profile = buildCampaignNavigationLink(leader.role, { destination: 'perfil' })
expect(profile).toEqual({ ok: true, path: CAMPAIGN_PROFILE_HOME, label: 'Perfil' })
})
it('blocks assessor area for advisors', () => {
const outcome = buildCampaignNavigationLink(advisor.role, {
destination: 'advisor',
id: 1,
})
expect(outcome.ok).toBe(false)
if (!outcome.ok) {
expect(outcome.error).toMatch(/assessores/)
}
})
it('builds concept glossary links with anchors', () => {
const outcome = buildCampaignNavigationLink(coordinator.role, {
destination: 'conceitos',
conceptId: 'captura',
label: 'Captura',
})
expect(outcome).toEqual({
ok: true,
path: campaignConceptHref('captura'),
label: 'Captura',
})
})
it('returns partial success in batch mode', () => {
const result = buildCampaignNavigationLinks(coordinator.role, [
{ destination: 'home', label: 'Início' },
{ destination: 'municipality', slug: 'slug-invalido' },
])
expect(result.links).toEqual([{ path: CAMPAIGN_HOME, label: 'Início' }])
expect(result.errors).toHaveLength(1)
expect(result.errors?.[0]?.index).toBe(1)
})
})