mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Merge pull request #387 from fsolla/cursor/sollinha-url-tool-498e
feat(ai): Sollinha buildCampaignLinks tool for canonical /campanha navigation (B162)
This commit is contained in:
commit
cfceffc4bd
6 changed files with 1081 additions and 0 deletions
124
docs/plans/sollinha-tool-urls-navegacao-impl.md
Normal file
124
docs/plans/sollinha-tool-urls-navegacao-impl.md
Normal 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.
|
||||
594
src/utilities/ai/campaignNavigationUrls.ts
Normal file
594
src/utilities/ai/campaignNavigationUrls.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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%).
|
||||
|
||||
|
|
|
|||
228
src/utilities/ai/tools/buildCampaignLinks.ts
Normal file
228
src/utilities/ai/tools/buildCampaignLinks.ts
Normal 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).',
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
124
tests/unit/campaignNavigationUrls.unit.spec.ts
Normal file
124
tests/unit/campaignNavigationUrls.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue