mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
feat(C244): faceta pessoa do álbum lê o reconhecimento facial
This commit is contained in:
parent
aaf7d58075
commit
52a00b75e1
12 changed files with 531 additions and 72 deletions
|
|
@ -23,7 +23,7 @@ import {
|
|||
type ArchivePhotoAlbumSearchParams,
|
||||
} from '@/lib/archivePhotoPublicCatalog'
|
||||
import {
|
||||
getApprovedArchivePhotoItems,
|
||||
getApprovedArchivePhotoAlbumItems,
|
||||
hasPublishedArchivePhotos,
|
||||
} from '@/utilities/archivePhotos/archivePhotoReads'
|
||||
import { hasPublishedContentPieces } from '@/utilities/content/contentPieceReads'
|
||||
|
|
@ -101,7 +101,7 @@ export default async function FotosPage({
|
|||
if (album.published === false) notFound()
|
||||
|
||||
const params = parseArchivePhotoAlbumParams(rawSearchParams)
|
||||
const approved = await getApprovedArchivePhotoItems()
|
||||
const approved = await getApprovedArchivePhotoAlbumItems()
|
||||
const removalChannelUrl = isArchivePhotoRemovalChannelUrl(album.removalChannelUrl)
|
||||
? album.removalChannelUrl.trim()
|
||||
: null
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ const FACET_MENU_HEADINGS: Record<ArchivePhotoAlbumFacet, string> = {
|
|||
data: 'Dias com fotos publicadas',
|
||||
municipio: 'Municípios com fotos',
|
||||
atividade: 'Atividades',
|
||||
pessoa: 'Pessoas que aparecem',
|
||||
pessoa: 'Figuras reconhecidas com curadoria',
|
||||
}
|
||||
|
||||
const FIELD_CLASS =
|
||||
|
|
|
|||
|
|
@ -33,40 +33,49 @@ export const ArchivePhotoNoResults = ({
|
|||
activeFilters,
|
||||
}: {
|
||||
activeFilters: readonly ArchivePhotoAlbumActiveFilter[]
|
||||
}) => (
|
||||
<section
|
||||
data-album-no-results
|
||||
className="rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-5 py-10 text-center sm:px-8 sm:py-14"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mx-auto grid size-11 place-items-center rounded-full border border-[#184e92]/20 bg-white text-[#184e92] sm:size-12"
|
||||
}) => {
|
||||
// C244 — the pessoa facet only names curated public figures recognized by the
|
||||
// facial layer: the empty state explains that origin instead of promising
|
||||
// "all photos of a name".
|
||||
const hasPersonFilter = activeFilters.some((filter) => filter.facet === 'pessoa')
|
||||
|
||||
return (
|
||||
<section
|
||||
data-album-no-results
|
||||
className="rounded-2xl border border-[#184e92]/15 bg-[#f9faff] px-5 py-10 text-center sm:px-8 sm:py-14"
|
||||
>
|
||||
<SearchIcon className="size-5 sm:size-6" strokeWidth={2} />
|
||||
</div>
|
||||
<h2 className="mt-4 font-[family-name:var(--font-exo2)] text-xl font-black sm:text-2xl">
|
||||
Nada encontrado com esses filtros
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-(--campaign-muted)">
|
||||
Tente outro termo ou volte a ver todas as fotos aprovadas do álbum.
|
||||
</p>
|
||||
{activeFilters.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap justify-center gap-1.5 text-[11px] text-(--campaign-muted) sm:text-xs">
|
||||
{activeFilters.map((filter) => (
|
||||
<span
|
||||
key={filter.facet}
|
||||
className="rounded-full border border-black/10 bg-white px-2.5 py-1 sm:px-3 sm:py-1.5"
|
||||
>
|
||||
{filter.facet === 'q' ? `Busca: “${filter.value}”` : filter.value}
|
||||
</span>
|
||||
))}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="mx-auto grid size-11 place-items-center rounded-full border border-[#184e92]/20 bg-white text-[#184e92] sm:size-12"
|
||||
>
|
||||
<SearchIcon className="size-5 sm:size-6" strokeWidth={2} />
|
||||
</div>
|
||||
) : null}
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_PATH}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} mt-6 sm:min-w-40`}
|
||||
>
|
||||
Limpar filtros
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
<h2 className="mt-4 font-[family-name:var(--font-exo2)] text-xl font-black sm:text-2xl">
|
||||
Nada encontrado com esses filtros
|
||||
</h2>
|
||||
<p className="mx-auto mt-2 max-w-md text-sm leading-6 text-(--campaign-muted)">
|
||||
{hasPersonFilter
|
||||
? 'O filtro Pessoa pública considera apenas figuras reconhecidas com curadoria no acervo — nunca terceiros. Tente outra pessoa ou volte a ver todas as fotos aprovadas do álbum.'
|
||||
: 'Tente outro termo ou volte a ver todas as fotos aprovadas do álbum.'}
|
||||
</p>
|
||||
{activeFilters.length > 0 ? (
|
||||
<div className="mt-4 flex flex-wrap justify-center gap-1.5 text-[11px] text-(--campaign-muted) sm:text-xs">
|
||||
{activeFilters.map((filter) => (
|
||||
<span
|
||||
key={filter.facet}
|
||||
className="rounded-full border border-black/10 bg-white px-2.5 py-1 sm:px-3 sm:py-1.5"
|
||||
>
|
||||
{filter.facet === 'q' ? `Busca: “${filter.value}”` : filter.value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Link
|
||||
href={ARCHIVE_PHOTO_ALBUM_PATH}
|
||||
className={`${ARCHIVE_PHOTO_PRIMARY_BUTTON} mt-6 sm:min-w-40`}
|
||||
>
|
||||
Limpar filtros
|
||||
</Link>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@
|
|||
* the headings are derived from the items the caller already gated through
|
||||
* `archivePhotoIsPublic`, so a filter that cannot match anything never renders
|
||||
* and the empty states stay honest.
|
||||
*
|
||||
* C244 — `people` is no longer the text `catalog.people` of the ficha: it is
|
||||
* the curated public figures the facial layer recognized in the photo
|
||||
* (`record.figures`, merged by the album read). The URL key `?pessoa=<slug>`
|
||||
* and the facet rendering stay exactly the C233 contract.
|
||||
*/
|
||||
import {
|
||||
ARCHIVE_PHOTO_SCENES,
|
||||
|
|
@ -19,8 +24,7 @@ import {
|
|||
resolveArchivePhotoScene,
|
||||
type ArchivePhotoScene,
|
||||
} from '@/lib/archivePhotoCatalog'
|
||||
import { resolvePublicFigureName } from '@/lib/publicFigureCatalog'
|
||||
import { SLUG_PATTERN, slugify } from '@/lib/slug'
|
||||
import { SLUG_PATTERN } from '@/lib/slug'
|
||||
import { normalizeForSearch } from '@/lib/speechSearch'
|
||||
|
||||
export const ARCHIVE_PHOTO_ALBUM_PATH = '/fotos'
|
||||
|
|
@ -180,18 +184,23 @@ export const archivePhotoShortDateLabel = (value: string | null | undefined): st
|
|||
const archivePhotoDayOf = (value: string | null | undefined): string | null =>
|
||||
dayPartsOf(value)?.join('-') ?? null
|
||||
|
||||
type ArchivePhotoPublicPerson = { slug: string; name: string }
|
||||
/**
|
||||
* C244 — one curated public figure recognized in a photo: the `faceFigure`
|
||||
* slug (the `?pessoa` URL value) and its display name. The album's "Quem
|
||||
* aparece" and the `pessoa` facet come from the facial layer, never from the
|
||||
* text `catalog.people` of the ficha.
|
||||
*/
|
||||
export type ArchivePhotoPublicPerson = { slug: string; name: string }
|
||||
|
||||
/** One appearance: canonical catalog spelling (when it resolves), slug-keyed. */
|
||||
const archivePhotoPublicPeople = (names: readonly string[]): ArchivePhotoPublicPerson[] => {
|
||||
/** Dedupes by slug and drops blank entries; the read already sorts by slug. */
|
||||
const archivePhotoPublicFigures = (
|
||||
figures: readonly ArchivePhotoPublicPerson[],
|
||||
): ArchivePhotoPublicPerson[] => {
|
||||
const people = new Map<string, string>()
|
||||
for (const raw of names) {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) continue
|
||||
const name = resolvePublicFigureName(trimmed) ?? trimmed
|
||||
const slug = slugify(name)
|
||||
if (!slug) continue
|
||||
people.set(slug, name)
|
||||
for (const figure of figures) {
|
||||
const name = figure.name?.trim()
|
||||
if (!figure.slug || !name) continue
|
||||
if (!people.has(figure.slug)) people.set(figure.slug, name)
|
||||
}
|
||||
return [...people.entries()].map(([slug, name]) => ({ slug, name }))
|
||||
}
|
||||
|
|
@ -210,6 +219,12 @@ export type ArchivePhotoPublicSource = {
|
|||
scene?: string | null
|
||||
people?: string[] | null
|
||||
} | null
|
||||
/**
|
||||
* Curated figures recognized in this photo (C244), merged by the album read.
|
||||
* Absent in the plain read: the text `catalog.people` remains ficha content
|
||||
* and never feeds this field.
|
||||
*/
|
||||
figures?: readonly ArchivePhotoPublicPerson[] | null
|
||||
}
|
||||
|
||||
export type ArchivePhotoPublicItem = {
|
||||
|
|
@ -225,7 +240,8 @@ export type ArchivePhotoPublicItem = {
|
|||
municipalitySlug: string | null
|
||||
scene: ArchivePhotoScene | null
|
||||
sceneLabel: string | null
|
||||
people: string[]
|
||||
/** Curated figures recognized by the facial layer (C244), slug-keyed. */
|
||||
people: ArchivePhotoPublicPerson[]
|
||||
peopleLabel: string | null
|
||||
/** "12 set 2026 · Município · Plenária" — the card's metadata line. */
|
||||
metaLabel: string
|
||||
|
|
@ -270,7 +286,7 @@ export const toArchivePhotoPublicItem = (
|
|||
const caption = record.catalog?.caption?.trim() || null
|
||||
const scene = resolveArchivePhotoScene(record.catalog?.scene)
|
||||
const sceneText = scene ? archivePhotoSceneLabel(scene) : null
|
||||
const people = archivePhotoPublicPeople(record.catalog?.people ?? [])
|
||||
const people = archivePhotoPublicFigures(record.figures ?? [])
|
||||
const takenOn = record.takenOn ?? null
|
||||
const shortDateLabel = archivePhotoShortDateLabel(takenOn)
|
||||
const municipalityName = record.municipalityName?.trim() || null
|
||||
|
|
@ -288,7 +304,7 @@ export const toArchivePhotoPublicItem = (
|
|||
municipalitySlug,
|
||||
scene,
|
||||
sceneLabel: sceneText,
|
||||
people: peopleNames,
|
||||
people,
|
||||
peopleLabel: archivePhotoPeopleLabel(peopleNames),
|
||||
metaLabel: [shortDateLabel, municipalityName, sceneText].filter(Boolean).join(' · '),
|
||||
searchText: record.searchText ?? '',
|
||||
|
|
@ -350,7 +366,7 @@ export const archivePhotoAlbumFacets = (
|
|||
municipalities.set(item.municipalitySlug, item.municipalityName)
|
||||
}
|
||||
if (item.scene) scenes.add(item.scene)
|
||||
for (const person of archivePhotoPublicPeople(item.people)) {
|
||||
for (const person of item.people) {
|
||||
people.set(person.slug, person.name)
|
||||
}
|
||||
}
|
||||
|
|
@ -422,10 +438,7 @@ export const filterArchivePhotoAlbumItems = (
|
|||
if (params.data && archivePhotoDayOf(item.takenOn) !== params.data) return false
|
||||
if (params.municipio && item.municipalitySlug !== params.municipio) return false
|
||||
if (params.atividade && item.scene !== params.atividade) return false
|
||||
if (
|
||||
params.pessoa &&
|
||||
!archivePhotoPublicPeople(item.people).some((person) => person.slug === params.pessoa)
|
||||
) {
|
||||
if (params.pessoa && !item.people.some((person) => person.slug === params.pessoa)) {
|
||||
return false
|
||||
}
|
||||
if (!term) return true
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import 'server-only'
|
|||
import {
|
||||
toArchivePhotoPublicItem,
|
||||
type ArchivePhotoPublicItem,
|
||||
type ArchivePhotoPublicPerson,
|
||||
type ArchivePhotoPublicSource,
|
||||
} from '@/lib/archivePhotoPublicCatalog'
|
||||
import { getCollectionListingTag } from '@/utilities/documents'
|
||||
import { loadApprovedPhotoFigureMap } from '@/utilities/faceIndex/faceFigureReads'
|
||||
import configPromise from '@payload-config'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
import { getPayload } from 'payload'
|
||||
|
|
@ -54,11 +56,43 @@ const getCachedApprovedArchivePhotos = () =>
|
|||
tags: [getCollectionListingTag('archivePhoto')],
|
||||
})
|
||||
|
||||
const toApprovedItems = (
|
||||
docs: readonly ArchivePhotoPublicSource[],
|
||||
figuresByPhoto?: ReadonlyMap<number, readonly ArchivePhotoPublicPerson[]>,
|
||||
): ArchivePhotoPublicItem[] =>
|
||||
docs
|
||||
.map((source) => {
|
||||
if (!figuresByPhoto) return toArchivePhotoPublicItem(source)
|
||||
return toArchivePhotoPublicItem({
|
||||
...source,
|
||||
figures: figuresByPhoto.get(source.id) ?? [],
|
||||
})
|
||||
})
|
||||
.filter((item): item is ArchivePhotoPublicItem => item !== null)
|
||||
|
||||
/** Approved photos as serializable public view models, newest first. */
|
||||
export const getApprovedArchivePhotoItems = async (): Promise<ArchivePhotoPublicItem[]> =>
|
||||
(await getCachedApprovedArchivePhotos()()).docs
|
||||
.map((record) => toArchivePhotoPublicItem(record as ArchivePhotoPublicSource))
|
||||
.filter((item): item is ArchivePhotoPublicItem => item !== null)
|
||||
toApprovedItems((await getCachedApprovedArchivePhotos()()).docs)
|
||||
|
||||
/**
|
||||
* C244 — the same approved list, enriched with the curated public figures
|
||||
* recognized in each photo by the facial layer (`loadApprovedPhotoFigureMap`).
|
||||
* Only the album route uses this: the selfie endpoint and the media route stay
|
||||
* on the plain read and never pay for the matching.
|
||||
*/
|
||||
export const getApprovedArchivePhotoAlbumItems = async (): Promise<ArchivePhotoPublicItem[]> => {
|
||||
const [result, photoFigureMatches] = await Promise.all([
|
||||
getCachedApprovedArchivePhotos()(),
|
||||
loadApprovedPhotoFigureMap(),
|
||||
])
|
||||
|
||||
const figuresByPhoto = new Map<number, readonly ArchivePhotoPublicPerson[]>()
|
||||
for (const match of photoFigureMatches) {
|
||||
figuresByPhoto.set(match.photoId, match.figures)
|
||||
}
|
||||
|
||||
return toApprovedItems(result.docs, figuresByPhoto)
|
||||
}
|
||||
|
||||
/**
|
||||
* One approved photo by id (the overlay and the media gate). A draft, a
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import {
|
|||
/** Long edge sent to the engine; the original never leaves the archive. */
|
||||
const IMAGE_MAX_EDGE = 1024
|
||||
|
||||
type ArchivePhotoFaceImage = {
|
||||
export type ArchivePhotoFaceImage = {
|
||||
pixels: Buffer
|
||||
width: number
|
||||
height: number
|
||||
|
|
@ -48,10 +48,10 @@ export type ArchivePhotoFaceAnalyzer = (image: ArchivePhotoFaceImage) => Promise
|
|||
|
||||
/**
|
||||
* Decodes any stored image (EXIF-rotated, alpha flattened, ≤1024px raw RGB) —
|
||||
* the exact input both the batch and the browser compute descriptors over, so
|
||||
* the selfie and the archive enter the same space.
|
||||
* the exact input both the batch, the C244 figure enrollment and the browser
|
||||
* compute descriptors over, so every vector enters the same space.
|
||||
*/
|
||||
const prepareFaceImage = async (filePath: string): Promise<ArchivePhotoFaceImage> => {
|
||||
export const prepareFaceImage = async (filePath: string): Promise<ArchivePhotoFaceImage> => {
|
||||
const prepared = await sharp(filePath)
|
||||
.rotate()
|
||||
.resize({ width: IMAGE_MAX_EDGE, height: IMAGE_MAX_EDGE, fit: 'inside' })
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
readFaceVector,
|
||||
type FaceDescriptorEntry,
|
||||
} from '@/lib/faceSearch'
|
||||
import { revalidateArchivePhotosListing } from '@/utilities/documents'
|
||||
|
||||
/**
|
||||
* C242 — reads of the anonymous face index. The collection is admin-only and
|
||||
|
|
@ -154,5 +155,9 @@ export const deleteFaceDescriptorMatches = async ({
|
|||
overrideAccess: true,
|
||||
})
|
||||
invalidateFaceDescriptorIndex()
|
||||
// C244 — the curated-figure map of the public album is derived from these
|
||||
// rows and cached under the album tag: without this bust the person would
|
||||
// keep being named in the facet after leaving the index.
|
||||
revalidateArchivePhotosListing()
|
||||
return matchedIds.length
|
||||
}
|
||||
|
|
|
|||
95
src/utilities/faceIndex/faceFigureReads.ts
Normal file
95
src/utilities/faceIndex/faceFigureReads.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import 'server-only'
|
||||
|
||||
import { unstable_cache } from 'next/cache'
|
||||
import { getPayload } from 'payload'
|
||||
|
||||
import { FACE_INDEX_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import {
|
||||
findFaceFigureMatches,
|
||||
groupFaceFigureMatchesByPhoto,
|
||||
type FaceFigure,
|
||||
type PhotoFigureMatch,
|
||||
} from '@/lib/faceFigureCatalog'
|
||||
import { getConsentByKey } from '@/utilities/campaignConsent'
|
||||
import { getCollectionListingTag } from '@/utilities/documents'
|
||||
import { loadFaceDescriptorIndex } from '@/utilities/faceIndex/faceDescriptorReads'
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
/**
|
||||
* C244 — the read that turns the curated `faceFigure` catalog plus the C242
|
||||
* anonymous descriptor index into the album's per-photo "who appears" map. The
|
||||
* matching is pure math (`findFaceFigureMatches`) over the already-cached
|
||||
* descriptor index; the result is cached under the album listing tag, so a
|
||||
* figure edit, a photo lifecycle write or a batch run that busts `archivePhotos`
|
||||
* recomputes it once — never per visit.
|
||||
*
|
||||
* The public-notice Consent is checked OUTSIDE the cache and fails closed: with
|
||||
* no `busca-selfie-indice` row the map is empty (nobody is named), and
|
||||
* provisioning the notice takes effect immediately, without a deploy.
|
||||
*/
|
||||
|
||||
const faceFigureSelect = {
|
||||
name: true,
|
||||
slug: true,
|
||||
active: true,
|
||||
references: true,
|
||||
} as const
|
||||
|
||||
const toFaceFigure = (doc: {
|
||||
name: string
|
||||
slug: string
|
||||
active?: boolean | null
|
||||
references?: readonly { model?: string | null; vector?: unknown }[] | null
|
||||
}): FaceFigure => ({
|
||||
slug: doc.slug,
|
||||
name: doc.name,
|
||||
active: doc.active ?? null,
|
||||
references: (doc.references ?? []).map((reference) => ({
|
||||
model: reference.model ?? null,
|
||||
vector: reference.vector,
|
||||
})),
|
||||
})
|
||||
|
||||
const computeApprovedPhotoFigureMatches = async (): Promise<PhotoFigureMatch[]> => {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const figuresResult = await payload.find({
|
||||
collection: 'faceFigure',
|
||||
where: { active: { equals: true } },
|
||||
depth: 0,
|
||||
limit: 0,
|
||||
pagination: false,
|
||||
select: faceFigureSelect,
|
||||
// Intentional bypass: the public album read has no actor; the selector is
|
||||
// the curated catalog itself (admin-only collection).
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
const figures = figuresResult.docs.map(toFaceFigure)
|
||||
// No reference at all: nobody can match, so the descriptor index is not read.
|
||||
if (!figures.some((figure) => (figure.references ?? []).length > 0)) return []
|
||||
|
||||
const descriptors = await loadFaceDescriptorIndex(payload)
|
||||
return groupFaceFigureMatchesByPhoto(findFaceFigureMatches({ figures, descriptors }))
|
||||
}
|
||||
|
||||
const getCachedApprovedPhotoFigureMatches = () =>
|
||||
unstable_cache(computeApprovedPhotoFigureMatches, ['archive-photo-figures'], {
|
||||
tags: [getCollectionListingTag('archivePhoto')],
|
||||
})
|
||||
|
||||
/**
|
||||
* The per-photo curated figures of the approved album, or an empty map when the
|
||||
* public notice Consent is missing or the index cannot be read — the facet and
|
||||
* "Quem aparece" disappear; the album itself never breaks.
|
||||
*/
|
||||
export const loadApprovedPhotoFigureMap = async (): Promise<PhotoFigureMatch[]> => {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const notice = await getConsentByKey(payload, FACE_INDEX_CONSENT_KEY)
|
||||
if (!notice) return []
|
||||
|
||||
try {
|
||||
return await getCachedApprovedPhotoFigureMatches()()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
|
@ -194,7 +194,7 @@ describe('CMS/PII collection access lockdown', () => {
|
|||
expect(authed && 'roles' in authed ? authed.roles : undefined).toEqual(['editor'])
|
||||
})
|
||||
|
||||
it.each(['signature', 'subscription', 'consent'] as const)(
|
||||
it.each(['signature', 'subscription', 'consent', 'faceFigure'] as const)(
|
||||
'denies campaign users and anonymous requests access to %s (PII/legal)',
|
||||
async (collection) => {
|
||||
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
|
||||
|
|
|
|||
284
tests/int/faceFigureReads.int.spec.ts
Normal file
284
tests/int/faceFigureReads.int.spec.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
// @vitest-environment node
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { rm } from 'node:fs/promises'
|
||||
import type { Payload } from 'payload'
|
||||
import { getPayload } from 'payload'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { revalidateTagMock } = vi.hoisted(() => ({ revalidateTagMock: vi.fn() }))
|
||||
|
||||
vi.mock('next/cache', () => ({
|
||||
revalidatePath: () => undefined,
|
||||
revalidateTag: revalidateTagMock,
|
||||
unstable_cache: (fn: unknown) => fn,
|
||||
}))
|
||||
|
||||
import { ARCHIVE_PHOTO_SLUG } from '@/lib/archivePhoto'
|
||||
import { FACE_INDEX_CONSENT_KEY } from '@/lib/campaignConsentKeys'
|
||||
import { FACE_SEARCH_DESCRIPTOR_LENGTH, FACE_SEARCH_MODEL } from '@/lib/faceSearch'
|
||||
import config from '@/payload.config'
|
||||
import { getApprovedArchivePhotoAlbumItems } from '@/utilities/archivePhotos/archivePhotoReads'
|
||||
import {
|
||||
deleteFaceDescriptorMatches,
|
||||
invalidateFaceDescriptorIndex,
|
||||
} from '@/utilities/faceIndex/faceDescriptorReads'
|
||||
import { loadApprovedPhotoFigureMap } from '@/utilities/faceIndex/faceFigureReads'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
import {
|
||||
createFaceTestPhoto,
|
||||
FACE_INDEX_LEASE_KEY,
|
||||
FACE_SPECS_LEASE_KEY,
|
||||
faceTestVector,
|
||||
indexFaceTestPhoto,
|
||||
} from '../helpers/faceIntFixtures'
|
||||
import {
|
||||
acquireTestDatabaseLease,
|
||||
ensureLeasedConsent,
|
||||
PHOTO_ALBUM_LEASE_KEY,
|
||||
withExclusiveTestDatabaseLease,
|
||||
} from '../helpers/testDatabaseLease'
|
||||
|
||||
// C244 — the curated public-figure layer over the real Payload `teqo_test`:
|
||||
// the album read names only figures of the curated catalog recognized in
|
||||
// APPROVED photos, the text `catalog.people` never feeds the facet, and the
|
||||
// Consent notice, unapproval and the opt-out all fail closed.
|
||||
|
||||
let payload: Payload
|
||||
const createdFlickrIds = new Set<string>()
|
||||
const createdFigureIds: number[] = []
|
||||
const tempDirs: string[] = []
|
||||
|
||||
installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
const CHANNEL = 'https://jorge.solla.example.org/remocao'
|
||||
|
||||
const createPhoto = (approved: boolean) =>
|
||||
createFaceTestPhoto({
|
||||
payload,
|
||||
approved,
|
||||
prefix: 'c244',
|
||||
trackedFlickrIds: createdFlickrIds,
|
||||
tempDirs,
|
||||
})
|
||||
|
||||
/**
|
||||
* Direction-unique descriptor: 0.5 at one index, zero elsewhere. Two different
|
||||
* indices sit at ~0.707 euclidean distance (above the 0.45 threshold), so each
|
||||
* test's figure matches only its own photo even though the specs share the
|
||||
* test database.
|
||||
*/
|
||||
let nextVectorIndex = 0
|
||||
const uniqueVectorIndex = (): number => (nextVectorIndex += 1)
|
||||
|
||||
const directionVector = (index: number): number[] => {
|
||||
const vector = faceTestVector(0)
|
||||
vector[index % FACE_SEARCH_DESCRIPTOR_LENGTH] = 0.5
|
||||
return vector
|
||||
}
|
||||
|
||||
const createFigure = async ({
|
||||
index,
|
||||
active = true,
|
||||
model = FACE_SEARCH_MODEL,
|
||||
}: {
|
||||
index: number
|
||||
active?: boolean
|
||||
model?: string
|
||||
}): Promise<{ slug: string; name: string }> => {
|
||||
const suffix = randomUUID().slice(0, 8)
|
||||
const slug = `figura-${suffix}`
|
||||
const name = `Figura ${suffix}`
|
||||
const figure = await payload.create({
|
||||
collection: 'faceFigure',
|
||||
data: {
|
||||
name,
|
||||
slug,
|
||||
active,
|
||||
references: [{ model, vector: directionVector(index), source: 'retrato de teste (C244)' }],
|
||||
},
|
||||
overrideAccess: true,
|
||||
})
|
||||
createdFigureIds.push(figure.id)
|
||||
return { slug, name }
|
||||
}
|
||||
|
||||
const albumItemOf = async (photoId: number) =>
|
||||
(await getApprovedArchivePhotoAlbumItems()).find((item) => item.id === photoId) ?? null
|
||||
|
||||
describe('curated public-figure album read (C244)', () => {
|
||||
let specsLease: Awaited<ReturnType<typeof acquireTestDatabaseLease>>
|
||||
|
||||
beforeAll(async () => {
|
||||
payload = await getPayload({ config: await config })
|
||||
specsLease = await acquireTestDatabaseLease(payload, FACE_SPECS_LEASE_KEY)
|
||||
await withExclusiveTestDatabaseLease(payload, PHOTO_ALBUM_LEASE_KEY, () =>
|
||||
payload.updateGlobal({
|
||||
slug: 'photoAlbum',
|
||||
data: { published: true, removalChannelUrl: CHANNEL, selfieSearchEnabled: false },
|
||||
overrideAccess: true,
|
||||
}),
|
||||
)
|
||||
await ensureLeasedConsent(payload, {
|
||||
consentKey: FACE_INDEX_CONSENT_KEY,
|
||||
leaseKey: FACE_INDEX_LEASE_KEY,
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const flickrId of createdFlickrIds) {
|
||||
await payload.delete({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
where: { flickrId: { equals: flickrId } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
}
|
||||
for (const id of createdFigureIds) {
|
||||
await payload.delete({ collection: 'faceFigure', id, overrideAccess: true })
|
||||
}
|
||||
invalidateFaceDescriptorIndex()
|
||||
await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
await specsLease.release()
|
||||
})
|
||||
|
||||
it('names a curated figure only in the photos where the face was indexed', async () => {
|
||||
const index = uniqueVectorIndex()
|
||||
const figure = await createFigure({ index })
|
||||
const matched = await createPhoto(true)
|
||||
const unmatched = await createPhoto(true)
|
||||
|
||||
await indexFaceTestPhoto({
|
||||
payload,
|
||||
photoId: matched.id,
|
||||
descriptors: [directionVector(index)],
|
||||
})
|
||||
await indexFaceTestPhoto({
|
||||
payload,
|
||||
photoId: unmatched.id,
|
||||
descriptors: [directionVector(uniqueVectorIndex())],
|
||||
})
|
||||
// The text catalog of the matched photo names somebody else: the facet must
|
||||
// ignore it (C244 source change).
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id: matched.id,
|
||||
data: { catalog: { people: ['Pessoa Só no Texto'] } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
invalidateFaceDescriptorIndex()
|
||||
|
||||
const matchedItem = await albumItemOf(matched.id)
|
||||
expect(matchedItem?.people).toEqual([{ slug: figure.slug, name: figure.name }])
|
||||
expect(matchedItem?.peopleLabel).toBe(figure.name)
|
||||
|
||||
const unmatchedItem = await albumItemOf(unmatched.id)
|
||||
expect(unmatchedItem?.people).toEqual([])
|
||||
expect(unmatchedItem?.peopleLabel).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores an inactive figure and a reference of another model', async () => {
|
||||
const inactive = await createFigure({ index: uniqueVectorIndex(), active: false })
|
||||
const foreignModel = await createFigure({
|
||||
index: uniqueVectorIndex(),
|
||||
model: 'outro-modelo@1',
|
||||
})
|
||||
const photo = await createPhoto(true)
|
||||
await indexFaceTestPhoto({
|
||||
payload,
|
||||
photoId: photo.id,
|
||||
descriptors: [directionVector(uniqueVectorIndex())],
|
||||
})
|
||||
invalidateFaceDescriptorIndex()
|
||||
|
||||
const item = await albumItemOf(photo.id)
|
||||
expect(item?.people).toEqual([])
|
||||
expect(item?.people.map((person) => person.slug)).not.toContain(inactive.slug)
|
||||
expect(item?.people.map((person) => person.slug)).not.toContain(foreignModel.slug)
|
||||
})
|
||||
|
||||
it('fails closed with no figure names while the public notice Consent is missing', async () => {
|
||||
const index = uniqueVectorIndex()
|
||||
const figure = await createFigure({ index })
|
||||
const photo = await createPhoto(true)
|
||||
await indexFaceTestPhoto({ payload, photoId: photo.id, descriptors: [directionVector(index)] })
|
||||
invalidateFaceDescriptorIndex()
|
||||
|
||||
expect((await loadApprovedPhotoFigureMap()).length).toBeGreaterThan(0)
|
||||
|
||||
try {
|
||||
await payload.delete({
|
||||
collection: 'consent',
|
||||
where: { key: { equals: FACE_INDEX_CONSENT_KEY } },
|
||||
overrideAccess: true,
|
||||
})
|
||||
|
||||
expect(await loadApprovedPhotoFigureMap()).toEqual([])
|
||||
const item = await albumItemOf(photo.id)
|
||||
expect(item?.people).toEqual([])
|
||||
} finally {
|
||||
await ensureLeasedConsent(payload, {
|
||||
consentKey: FACE_INDEX_CONSENT_KEY,
|
||||
leaseKey: FACE_INDEX_LEASE_KEY,
|
||||
})
|
||||
}
|
||||
|
||||
const restored = await albumItemOf(photo.id)
|
||||
expect(restored?.people).toEqual([{ slug: figure.slug, name: figure.name }])
|
||||
})
|
||||
|
||||
it('drops the figure names when the photo stops being approved', async () => {
|
||||
const index = uniqueVectorIndex()
|
||||
const figure = await createFigure({ index })
|
||||
const photo = await createPhoto(true)
|
||||
await indexFaceTestPhoto({ payload, photoId: photo.id, descriptors: [directionVector(index)] })
|
||||
invalidateFaceDescriptorIndex()
|
||||
|
||||
expect((await albumItemOf(photo.id))?.people).toEqual([
|
||||
{ slug: figure.slug, name: figure.name },
|
||||
])
|
||||
|
||||
await payload.update({
|
||||
collection: ARCHIVE_PHOTO_SLUG,
|
||||
id: photo.id,
|
||||
data: { publicationStatus: 'removed' },
|
||||
overrideAccess: true,
|
||||
})
|
||||
// The descriptor read has a short in-process TTL (C240); a fresh read sees
|
||||
// the purge immediately, exactly like a new request after the cache window.
|
||||
invalidateFaceDescriptorIndex()
|
||||
|
||||
expect(await albumItemOf(photo.id)).toBeNull()
|
||||
expect((await loadApprovedPhotoFigureMap()).some((match) => match.photoId === photo.id)).toBe(
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
it('removes the figure name after the opt-out and busts the album cache', async () => {
|
||||
const index = uniqueVectorIndex()
|
||||
const figure = await createFigure({ index })
|
||||
const photo = await createPhoto(true)
|
||||
const vector = directionVector(index)
|
||||
await indexFaceTestPhoto({ payload, photoId: photo.id, descriptors: [vector] })
|
||||
invalidateFaceDescriptorIndex()
|
||||
|
||||
expect((await albumItemOf(photo.id))?.people).toEqual([
|
||||
{ slug: figure.slug, name: figure.name },
|
||||
])
|
||||
|
||||
revalidateTagMock.mockClear()
|
||||
const deleted = await deleteFaceDescriptorMatches({ payload, vector })
|
||||
|
||||
expect(deleted).toBe(1)
|
||||
expect(revalidateTagMock).toHaveBeenCalledWith('archivePhotos')
|
||||
expect((await loadApprovedPhotoFigureMap()).some((match) => match.photoId === photo.id)).toBe(
|
||||
false,
|
||||
)
|
||||
expect((await albumItemOf(photo.id))?.people).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -41,6 +41,7 @@ const source = (
|
|||
scene: 'plenaria',
|
||||
people: ['Jorge Solla'],
|
||||
},
|
||||
figures: [{ slug: 'jorge-solla', name: 'Jorge Solla' }],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
|
|
@ -143,7 +144,11 @@ describe('toArchivePhotoPublicItem', () => {
|
|||
|
||||
it('builds the card/dialog view: title, meta line and people label', () => {
|
||||
const photo = item(1, {
|
||||
catalog: { caption: null, scene: 'reuniao', people: ['Jorge Solla', 'Rui Costa'] },
|
||||
catalog: { caption: null, scene: 'reuniao', people: [] },
|
||||
figures: [
|
||||
{ slug: 'jorge-solla', name: 'Jorge Solla' },
|
||||
{ slug: 'rui-costa', name: 'Rui Costa' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(photo.title).toBe('Descrição da foto 1')
|
||||
|
|
@ -161,12 +166,21 @@ describe('toArchivePhotoPublicItem', () => {
|
|||
expect(photo.downloadFilename).toBe('jorge-solla-1313-foto-9.jpg')
|
||||
})
|
||||
|
||||
it('canonicalizes a catalog spelling on the people facet and collapses duplicates', () => {
|
||||
const photo = item(1, {
|
||||
catalog: { caption: null, scene: 'plenaria', people: ['rui costa', 'Rui Costa'] },
|
||||
it('derives people only from the curated figures (never the text catalog) and dedupes by slug', () => {
|
||||
const textOnly = item(1, {
|
||||
catalog: { caption: null, scene: 'plenaria', people: ['Rui Costa'] },
|
||||
figures: [],
|
||||
})
|
||||
const duplicated = item(2, {
|
||||
figures: [
|
||||
{ slug: 'rui-costa', name: 'Rui Costa' },
|
||||
{ slug: 'rui-costa', name: 'rui costa' },
|
||||
{ slug: '', name: 'Sem slug' },
|
||||
],
|
||||
})
|
||||
|
||||
expect(photo.people).toEqual(['Rui Costa'])
|
||||
expect(textOnly.people).toEqual([])
|
||||
expect(duplicated.people).toEqual([{ slug: 'rui-costa', name: 'Rui Costa' }])
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -179,6 +193,7 @@ describe('archivePhotoAlbumFacets / filter / heading', () => {
|
|||
municipalitySlug: 'salvador',
|
||||
searchText: 'visita posto salvador rui costa',
|
||||
catalog: { caption: 'Visita', scene: 'visita', people: ['Rui Costa'] },
|
||||
figures: [{ slug: 'rui-costa', name: 'Rui Costa' }],
|
||||
}),
|
||||
item(3, {
|
||||
takenOn: null,
|
||||
|
|
@ -186,6 +201,7 @@ describe('archivePhotoAlbumFacets / filter / heading', () => {
|
|||
municipalitySlug: null,
|
||||
searchText: 'retrato gabinete',
|
||||
catalog: { caption: 'Retrato', scene: 'retrato', people: [] },
|
||||
figures: [],
|
||||
}),
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -98,7 +98,10 @@ describe('toFaceSearchPhotoView', () => {
|
|||
municipalitySlug: 'feira-de-santana',
|
||||
scene: 'plenaria',
|
||||
sceneLabel: 'Plenária',
|
||||
people: ['Jorge Solla', 'Outra Pessoa'],
|
||||
people: [
|
||||
{ slug: 'jorge-solla', name: 'Jorge Solla' },
|
||||
{ slug: 'outra-pessoa', name: 'Outra Pessoa' },
|
||||
],
|
||||
peopleLabel: 'Jorge Solla e Outra Pessoa',
|
||||
metaLabel: '12 set 2026 · Feira de Santana · Plenária',
|
||||
searchText: 'plenaria feira de santana',
|
||||
|
|
|
|||
Loading…
Reference in a new issue