diff --git a/docs/plans/sollinha-tool-urls-navegacao-impl.md b/docs/plans/sollinha-tool-urls-navegacao-impl.md new file mode 100644 index 00000000..3253653d --- /dev/null +++ b/docs/plans/sollinha-tool-urls-navegacao-impl.md @@ -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. diff --git a/src/utilities/ai/campaignNavigationUrls.ts b/src/utilities/ai/campaignNavigationUrls.ts new file mode 100644 index 00000000..0d1d7185 --- /dev/null +++ b/src/utilities/ai/campaignNavigationUrls.ts @@ -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( + campaignIntelligenceConcepts.map((concept) => concept.id), +) + +const politicalTrendSet = new Set(Object.keys(politicalTrendLabels)) +const territorialClassSet = new Set(Object.keys(territorialClassLabels)) +const municipalityLevelFilterSet = new Set(municipalityListLevelFilterValues) + +const STAFF_DESTINATIONS = new Set([ + 'quadro', + 'conceitos', + 'giros', + 'municipality', + 'leadership', + 'dobradinha', + 'organization', + 'activity', + 'demand', + 'supporter', + 'municipalityList', + 'leadershipList', + 'dobradinhaList', + 'organizationList', + 'activityList', + 'demandList', + 'supporterList', + 'territoryList', +]) + +const UNRESTRICTED_DESTINATIONS = new Set(['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 } +} diff --git a/src/utilities/ai/systemPrompt.ts b/src/utilities/ai/systemPrompt.ts index 474c3af3..5a58c252 100644 --- a/src/utilities/ai/systemPrompt.ts +++ b/src/utilities/ai/systemPrompt.ts @@ -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%). diff --git a/src/utilities/ai/tools/buildCampaignLinks.ts b/src/utilities/ai/tools/buildCampaignLinks.ts new file mode 100644 index 00000000..d40d768b --- /dev/null +++ b/src/utilities/ai/tools/buildCampaignLinks.ts @@ -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).', + } + }, + }) diff --git a/src/utilities/ai/tools/index.ts b/src/utilities/ai/tools/index.ts index 5a4434c2..35688a46 100644 --- a/src/utilities/ai/tools/index.ts +++ b/src/utilities/ai/tools/index.ts @@ -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), diff --git a/tests/unit/campaignNavigationUrls.unit.spec.ts b/tests/unit/campaignNavigationUrls.unit.spec.ts new file mode 100644 index 00000000..504876d8 --- /dev/null +++ b/tests/unit/campaignNavigationUrls.unit.spec.ts @@ -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) + }) +})