feat(C244): collection curada faceFigure, migração e matcher puro

This commit is contained in:
Francisco Solla 2026-10-02 09:05:00 -03:00
parent 9a049bbbb4
commit aaf7d58075
8 changed files with 21441 additions and 1 deletions

View file

@ -0,0 +1,157 @@
import { FACE_SEARCH_MODEL } from '@/lib/faceSearch'
import { slugify } from '@/lib/slug'
import { payloadAdminOnly } from '@/utilities/campaignAccess'
import { revalidateArchivePhotosListing } from '@/utilities/documents'
import type {
CollectionAfterChangeHook,
CollectionAfterDeleteHook,
CollectionConfig,
} from 'payload'
/**
* C244 — the curated catalog of public figures the album's `pessoa` facet may
* name. Each row is a person of the mandate's curated catalog (the figure) and
* its reference descriptors (1–8 official/archive portraits, enrolled by
* `pnpm faces:enroll-figure`), compared against the anonymous index of C242 at
* read time. This is biometric identification of NAMED public figures only,
* under the legal sign-off registered in the runbook; a face outside this
* catalog stays anonymous in the index and is never named.
*
* The collection is admin-only and auditable: curation can deactivate a figure
* (it leaves the facet) or remove a reference, and both revalidate the public
* album. The descriptors never leave the server (`admin.hidden` on the vector).
*/
const revalidateAlbum: CollectionAfterChangeHook = ({ doc }) => {
revalidateArchivePhotosListing()
return doc
}
const revalidateAlbumAfterDelete: CollectionAfterDeleteHook = ({ doc }) => {
revalidateArchivePhotosListing()
return doc
}
export const FaceFigure: CollectionConfig = {
slug: 'faceFigure',
labels: {
singular: 'Figura pública',
plural: 'Figuras públicas',
},
admin: {
group: 'Comunicação',
useAsTitle: 'name',
description:
'Catálogo curado de figuras públicas do filtro "Pessoa pública" do álbum (C244). Só entram nomes com aval jurídico registrado; a referência biométrica é curada e auditável, e nunca inclui terceiros. Descritores são gravados pela CLI faces:enroll-figure.',
},
access: {
create: payloadAdminOnly,
read: payloadAdminOnly,
update: payloadAdminOnly,
delete: payloadAdminOnly,
},
hooks: {
afterChange: [revalidateAlbum],
afterDelete: [revalidateAlbumAfterDelete],
},
fields: [
{
name: 'name',
type: 'text',
label: 'Nome público',
required: true,
unique: true,
admin: {
description: 'Nome exibido na faceta (ex.: "Lula", "Jerônimo").',
},
},
{
name: 'slug',
type: 'text',
label: 'Slug',
required: true,
unique: true,
index: true,
admin: {
description:
'Valor de ?pessoa=<slug> no álbum; gerado do nome quando vazio. Mudar o slug quebra a URL pública do filtro.',
},
hooks: {
beforeValidate: [
({ value, siblingData }) => {
if (value) return slugify(value as string)
if (siblingData?.name) return slugify(siblingData.name as string)
return value
},
],
},
},
{
name: 'fullName',
type: 'text',
label: 'Nome completo',
admin: {
description: 'Proveniência da curadoria; não aparece no site.',
},
},
{
name: 'active',
type: 'checkbox',
label: 'Ativa',
defaultValue: true,
index: true,
admin: {
description: 'Desmarcar tira a figura do filtro imediatamente.',
},
},
{
name: 'references',
type: 'array',
label: 'Descritores de referência',
labels: {
singular: 'Descritor de referência',
plural: 'Descritores de referência',
},
maxRows: 3,
admin: {
description:
'Retratos oficiais/arquivo, 1–3 por figura, gravados pela CLI faces:enroll-figure. Remover uma linha corrige a curadoria; adicionar só pela CLI (o vetor não é editável à mão).',
},
fields: [
{
name: 'vector',
type: 'json',
label: 'Descriptor',
admin: {
hidden: true,
},
},
{
name: 'model',
type: 'text',
label: 'Modelo',
admin: {
readOnly: true,
description: `Modelo do descriptor (atual: ${FACE_SEARCH_MODEL}).`,
},
},
{
name: 'source',
type: 'text',
label: 'Origem',
required: true,
admin: {
description: 'Retrato/arquivo de origem da referência (proveniência da curadoria).',
},
},
{
name: 'addedAt',
type: 'date',
label: 'Adicionado em',
admin: {
readOnly: true,
},
},
],
},
],
}

View file

@ -0,0 +1,115 @@
/**
* C244 — pure contract of the curated public-figure layer over the anonymous
* face index of C242. A `faceFigure` row carries the curated reference
* descriptors of a named public figure; this module compares those references
* against the indexed descriptors of the approved archive and answers WHICH
* photos show each figure — never a distance, a score or a percentage, not even
* internally exposed (the caller only receives photo ids and identity).
*
* Only an explicitly active figure with at least one reference of the current
* model participates; a malformed or foreign-model reference is ignored
* (fail-closed). No I/O and no `server-only`: the cached read and the unit
* tests share this module.
*/
import {
FACE_SEARCH_MODEL,
findFaceDescriptorPhotoIds,
readFaceVector,
type FaceDescriptorEntry,
} from '@/lib/faceSearch'
/** One curated reference portrait of a figure (vector + the model that made it). */
type FaceFigureReference = {
model?: string | null
vector?: unknown
}
/** The slice of a `faceFigure` row this contract needs. */
export type FaceFigure = {
slug: string
name: string
active?: boolean | null
references?: readonly FaceFigureReference[] | null
}
/** The public identity of a matched figure; no numeric field by shape. */
type FaceFigurePerson = {
slug: string
name: string
}
/** One figure with the distinct photo ids it was recognized in, in index order. */
export type FaceFigureMatch = FaceFigurePerson & {
photoIds: number[]
}
/** One photo with the figures recognized in it (the album's "Quem aparece"). */
export type PhotoFigureMatch = {
photoId: number
figures: FaceFigurePerson[]
}
/**
* Every active figure with at least one photo under the shared C242 threshold;
* a figure only participates when `active === true` (an absent flag never
* names anyone) and only current-model references count. Photos are deduped per
* figure (a group photo with several faces of the same person is one match).
*/
export const findFaceFigureMatches = ({
figures,
descriptors,
}: {
figures: readonly FaceFigure[]
descriptors: readonly FaceDescriptorEntry[]
}): FaceFigureMatch[] => {
const matches: FaceFigureMatch[] = []
for (const figure of [...figures].sort((a, b) => a.slug.localeCompare(b.slug))) {
if (figure.active !== true) continue
const photoIds = new Set<number>()
for (const reference of figure.references ?? []) {
if (reference.model !== FACE_SEARCH_MODEL) continue
const vector = readFaceVector(reference.vector)
if (!vector) continue
for (const photoId of findFaceDescriptorPhotoIds(vector, descriptors)) {
photoIds.add(photoId)
}
}
if (photoIds.size > 0) {
matches.push({ slug: figure.slug, name: figure.name, photoIds: [...photoIds] })
}
}
return matches
}
/**
* Pivots the per-figure matches into the per-photo map the album read merges
* into each item: photos in id order, figures in slug order. Pure and
* serializable (`unstable_cache` stores its result).
*/
export const groupFaceFigureMatchesByPhoto = (
matches: readonly FaceFigureMatch[],
): PhotoFigureMatch[] => {
const byPhoto = new Map<number, Map<string, FaceFigurePerson>>()
for (const match of matches) {
for (const photoId of match.photoIds) {
let figures = byPhoto.get(photoId)
if (!figures) {
figures = new Map()
byPhoto.set(photoId, figures)
}
if (!figures.has(match.slug)) figures.set(match.slug, { slug: match.slug, name: match.name })
}
}
return [...byPhoto.entries()]
.sort((a, b) => a[0] - b[0])
.map(([photoId, figures]) => ({
photoId,
figures: [...figures.values()].sort((a, b) => a.slug.localeCompare(b.slug)),
}))
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,47 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
CREATE TABLE "face_figure_references" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"vector" jsonb,
"model" varchar,
"source" varchar NOT NULL,
"added_at" timestamp(3) with time zone
);
CREATE TABLE "face_figure" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar NOT NULL,
"slug" varchar NOT NULL,
"full_name" varchar,
"active" boolean DEFAULT true,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload_locked_documents_rels" ADD COLUMN "face_figure_id" integer;
ALTER TABLE "face_figure_references" ADD CONSTRAINT "face_figure_references_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "public"."face_figure"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "face_figure_references_order_idx" ON "face_figure_references" USING btree ("_order");
CREATE INDEX "face_figure_references_parent_id_idx" ON "face_figure_references" USING btree ("_parent_id");
CREATE UNIQUE INDEX "face_figure_name_idx" ON "face_figure" USING btree ("name");
CREATE UNIQUE INDEX "face_figure_slug_idx" ON "face_figure" USING btree ("slug");
CREATE INDEX "face_figure_active_idx" ON "face_figure" USING btree ("active");
CREATE INDEX "face_figure_updated_at_idx" ON "face_figure" USING btree ("updated_at");
CREATE INDEX "face_figure_created_at_idx" ON "face_figure" USING btree ("created_at");
ALTER TABLE "payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_face_figure_fk" FOREIGN KEY ("face_figure_id") REFERENCES "public"."face_figure"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "payload_locked_documents_rels_face_figure_id_idx" ON "payload_locked_documents_rels" USING btree ("face_figure_id");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "face_figure_references" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "face_figure" DISABLE ROW LEVEL SECURITY;
ALTER TABLE "payload_locked_documents_rels" DROP CONSTRAINT "payload_locked_documents_rels_face_figure_fk";
DROP INDEX "payload_locked_documents_rels_face_figure_id_idx";
ALTER TABLE "payload_locked_documents_rels" DROP COLUMN "face_figure_id";
DROP TABLE "face_figure_references" CASCADE;
DROP TABLE "face_figure" CASCADE;`)
}

View file

@ -92,6 +92,7 @@ import * as migration_20260926_132106_add_archive_photo_catalog from './20260926
import * as migration_20260926_163248_add_archive_photo_publication from './20260926_163248_add_archive_photo_publication';
import * as migration_20260927_193954_add_face_subject from './20260927_193954_add_face_subject';
import * as migration_20261001_040904_add_archive_photo_face from './20261001_040904_add_archive_photo_face';
import * as migration_20261001_235632_add_face_figure from './20261001_235632_add_face_figure';
export const migrations = [
{
@ -562,6 +563,11 @@ export const migrations = [
{
up: migration_20261001_040904_add_archive_photo_face.up,
down: migration_20261001_040904_add_archive_photo_face.down,
name: '20261001_040904_add_archive_photo_face'
name: '20261001_040904_add_archive_photo_face',
},
{
up: migration_20261001_235632_add_face_figure.up,
down: migration_20261001_235632_add_face_figure.down,
name: '20261001_235632_add_face_figure'
},
];

View file

@ -97,6 +97,7 @@ export interface Config {
contentMedia: ContentMedia;
archivePhoto: ArchivePhoto;
archivePhotoFace: ArchivePhotoFace;
faceFigure: FaceFigure;
contentEvent: ContentEvent;
recording: Recording;
recordingMedia: RecordingMedia;
@ -155,6 +156,7 @@ export interface Config {
contentMedia: ContentMediaSelect<false> | ContentMediaSelect<true>;
archivePhoto: ArchivePhotoSelect<false> | ArchivePhotoSelect<true>;
archivePhotoFace: ArchivePhotoFaceSelect<false> | ArchivePhotoFaceSelect<true>;
faceFigure: FaceFigureSelect<false> | FaceFigureSelect<true>;
contentEvent: ContentEventSelect<false> | ContentEventSelect<true>;
recording: RecordingSelect<false> | RecordingSelect<true>;
recordingMedia: RecordingMediaSelect<false> | RecordingMediaSelect<true>;
@ -1613,6 +1615,59 @@ export interface ArchivePhotoFace {
updatedAt: string;
createdAt: string;
}
/**
* Catálogo curado de figuras públicas do filtro "Pessoa pública" do álbum (C244). Só entram nomes com aval jurídico registrado; a referência biométrica é curada e auditável, e nunca inclui terceiros. Descritores são gravados pela CLI faces:enroll-figure.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "faceFigure".
*/
export interface FaceFigure {
id: number;
/**
* Nome exibido na faceta (ex.: "Lula", "Jerônimo").
*/
name: string;
/**
* Valor de ?pessoa=<slug> no álbum; gerado do nome quando vazio. Mudar o slug quebra a URL pública do filtro.
*/
slug: string;
/**
* Proveniência da curadoria; não aparece no site.
*/
fullName?: string | null;
/**
* Desmarcar tira a figura do filtro imediatamente.
*/
active?: boolean | null;
/**
* Retratos oficiais/arquivo, 1–3 por figura, gravados pela CLI faces:enroll-figure. Remover uma linha corrige a curadoria; adicionar só pela CLI (o vetor não é editável à mão).
*/
references?:
| {
vector?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
/**
* Modelo do descriptor (atual: face-api@1.7.15/faceRecognitionNet).
*/
model?: string | null;
/**
* Retrato/arquivo de origem da referência (proveniência da curadoria).
*/
source: string;
addedAt?: string | null;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
}
/**
* Eventos anônimos de circulação (abertura, download, compartilhamento).
*
@ -2451,6 +2506,10 @@ export interface PayloadLockedDocument {
relationTo: 'archivePhotoFace';
value: number | ArchivePhotoFace;
} | null)
| ({
relationTo: 'faceFigure';
value: number | FaceFigure;
} | null)
| ({
relationTo: 'contentEvent';
value: number | ContentEvent;
@ -3273,6 +3332,27 @@ export interface ArchivePhotoFaceSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "faceFigure_select".
*/
export interface FaceFigureSelect<T extends boolean = true> {
name?: T;
slug?: T;
fullName?: T;
active?: T;
references?:
| T
| {
vector?: T;
model?: T;
source?: T;
addedAt?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "contentEvent_select".
@ -4164,6 +4244,7 @@ export interface TaskCreateCollectionExport {
| 'contentMedia'
| 'archivePhoto'
| 'archivePhotoFace'
| 'faceFigure'
| 'contentEvent'
| 'recording'
| 'recordingMedia'

View file

@ -27,6 +27,7 @@ import { ContentPiece } from './collections/ContentPiece'
import { ElectionCandidate } from './collections/ElectionCandidate'
import { ElectionCandidateVote } from './collections/ElectionCandidateVote'
import { ElectionTally } from './collections/ElectionTally'
import { FaceFigure } from './collections/FaceFigure'
import { GoogleCalendarSync } from './collections/GoogleCalendarSync'
import { InternetSpeechMedia } from './collections/InternetSpeechMedia'
import { Jingle } from './collections/Jingle'
@ -141,6 +142,7 @@ export default buildConfig({
ContentMedia,
ArchivePhoto,
ArchivePhotoFace,
FaceFigure,
ContentEvent,
Recording,
RecordingMedia,

View file

@ -0,0 +1,139 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest'
import {
findFaceFigureMatches,
groupFaceFigureMatchesByPhoto,
type FaceFigure,
} from '@/lib/faceFigureCatalog'
import {
FACE_SEARCH_DESCRIPTOR_LENGTH,
FACE_SEARCH_MAX_DISTANCE,
FACE_SEARCH_MODEL,
type FaceDescriptorEntry,
} from '@/lib/faceSearch'
// C244 — the pure curated-figure matcher: only active figures with a
// current-model reference, only photos under the shared threshold, no score in
// the answer, deterministic grouping for the album read.
const zeroVector = (): number[] => Array.from({ length: FACE_SEARCH_DESCRIPTOR_LENGTH }, () => 0)
/** Constant vector whose euclidean distance to the zero vector is `distance`. */
const vectorAtDistance = (distance: number): number[] => {
const component = distance / Math.sqrt(FACE_SEARCH_DESCRIPTOR_LENGTH)
return Array.from({ length: FACE_SEARCH_DESCRIPTOR_LENGTH }, () => component)
}
const reference = (distance: number, model: string = FACE_SEARCH_MODEL) => ({
model,
vector: vectorAtDistance(distance),
})
const figure = (overrides: Partial<FaceFigure> = {}): FaceFigure => ({
slug: 'lula',
name: 'Lula',
active: true,
references: [reference(0)],
...overrides,
})
const descriptors: FaceDescriptorEntry[] = [
{ photoId: 10, vector: zeroVector() },
{ photoId: 20, vector: zeroVector() },
]
describe('findFaceFigureMatches', () => {
it('matches the distinct photos of an active figure and keeps the index order', () => {
expect(findFaceFigureMatches({ figures: [figure()], descriptors })).toEqual([
{ slug: 'lula', name: 'Lula', photoIds: [10, 20] },
])
})
it('never matches under the threshold distance (strictly below)', () => {
const below = findFaceFigureMatches({
figures: [figure({ references: [reference(FACE_SEARCH_MAX_DISTANCE - 0.01)] })],
descriptors,
})
const at = findFaceFigureMatches({
figures: [figure({ references: [reference(FACE_SEARCH_MAX_DISTANCE)] })],
descriptors,
})
expect(below).toHaveLength(1)
expect(at).toEqual([])
})
it('ignores a figure that is not explicitly active', () => {
expect(findFaceFigureMatches({ figures: [figure({ active: false })], descriptors })).toEqual([])
expect(findFaceFigureMatches({ figures: [figure({ active: null })], descriptors })).toEqual([])
expect(
findFaceFigureMatches({ figures: [figure({ active: undefined })], descriptors }),
).toEqual([])
})
it('ignores foreign-model references and malformed vectors', () => {
const foreignModel = figure({ references: [reference(0, 'outro-modelo')] })
const malformed = figure({ references: [{ model: FACE_SEARCH_MODEL, vector: [1, 2, 3] }] })
const poisoned = figure({
references: [{ model: FACE_SEARCH_MODEL, vector: zeroVector().fill(Number.NaN) }],
})
expect(findFaceFigureMatches({ figures: [foreignModel], descriptors })).toEqual([])
expect(findFaceFigureMatches({ figures: [malformed], descriptors })).toEqual([])
expect(findFaceFigureMatches({ figures: [poisoned], descriptors })).toEqual([])
})
it('dedupes the same photo across faces and references and sorts by slug', () => {
const matches = findFaceFigureMatches({
figures: [
figure({ slug: 'wagner', name: 'Wagner' }),
figure({
slug: 'lula',
name: 'Lula',
references: [reference(0), reference(0.05)],
}),
],
descriptors: [
{ photoId: 10, vector: zeroVector() },
{ photoId: 10, vector: zeroVector() },
],
})
expect(matches.map((match) => match.slug)).toEqual(['lula', 'wagner'])
expect(matches[0]!.photoIds).toEqual([10])
})
it('carries only slug, name and photoIds — no distance or score by shape', () => {
const [match] = findFaceFigureMatches({ figures: [figure()], descriptors })
expect(Object.keys(match!).sort()).toEqual(['name', 'photoIds', 'slug'])
})
})
describe('groupFaceFigureMatchesByPhoto', () => {
it('pivots into photos ordered by id and figures ordered by slug', () => {
const grouped = groupFaceFigureMatchesByPhoto([
{ slug: 'wagner', name: 'Wagner', photoIds: [20] },
{ slug: 'lula', name: 'Lula', photoIds: [20, 10] },
])
expect(grouped).toEqual([
{ photoId: 10, figures: [{ slug: 'lula', name: 'Lula' }] },
{
photoId: 20,
figures: [
{ slug: 'lula', name: 'Lula' },
{ slug: 'wagner', name: 'Wagner' },
],
},
])
})
it('keeps only the public identity of each figure', () => {
const [photoMatch] = groupFaceFigureMatchesByPhoto([
{ slug: 'lula', name: 'Lula', photoIds: [10] },
])
expect(Object.keys(photoMatch!.figures[0]!).sort()).toEqual(['name', 'slug'])
})
})