mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
A catalogação do C232 nunca rodou em produção: catalog.* zerado nas 6.492 fotos publicadas e o engine de visão fora do ar. O pnpm archive:catalog ganha --metadata-only: deriva município (gazetteer) e temas do texto já ingerido, sem engine e sem mídia, grava catalog.metadataCheckedAt (migration aditiva) e não marca catalogedAt — a fila da IA segue aberta. Nunca escreve catalog.people (C244 assume a faceta por facial). A passada de IA passa a carimbar os dois markers; a fila de metadados exclui linha já catalogada pela IA (não rebaixa o source); o --verify relata as duas camadas com exit pela selecionada; o --apply de metadados dispensa S3_* e engine. Runbook §C245: metadados → revalidate archivePhotos → IA. Closes #1417
662 lines
23 KiB
TypeScript
662 lines
23 KiB
TypeScript
/**
|
|
* C232 — pure contract of the AI pre-cataloguing of the photo archive: the
|
|
* closed scene vocabulary, the curated-field vocabulary (the same mechanism
|
|
* C230 persists on a content piece), the source states of a ficha, the vision
|
|
* output parser and the deterministic merge that decides what gets written.
|
|
* No I/O and no `server-only`: the collection hooks, the Payload pipeline, the
|
|
* CLI and the tests share this module.
|
|
*
|
|
* The rules encoded here are product rules: the model never names people (only
|
|
* the curated `publicFigureCatalog` matched against TEXT does, and the
|
|
* assessoria confirms on the ficha), the scene/themes are closed vocabularies
|
|
* (unknown tokens are dropped, never invented) and a failed call is not a
|
|
* state — it stays out of the row so the next run retries it.
|
|
*/
|
|
import { z } from 'zod'
|
|
|
|
import { publicFigureCatalog } from '@/lib/publicFigureCatalog'
|
|
import { SPEECH_TOPICS, resolveSpeechTopic, type SpeechTopic } from '@/lib/speechFacets'
|
|
import { escapeRegExp, normalizeForSearch } from '@/lib/speechSearch'
|
|
|
|
/** Closed vocabulary of atividade/cena (the facet C233 consumes). */
|
|
export const ARCHIVE_PHOTO_SCENES = [
|
|
{ value: 'plenaria', label: 'Plenária' },
|
|
{ value: 'audiencia', label: 'Audiência' },
|
|
{ value: 'reuniao', label: 'Reunião' },
|
|
{ value: 'evento', label: 'Evento' },
|
|
{ value: 'mobilizacao', label: 'Mobilização' },
|
|
{ value: 'visita', label: 'Visita' },
|
|
{ value: 'entrevista', label: 'Entrevista' },
|
|
{ value: 'discurso', label: 'Discurso' },
|
|
{ value: 'retrato', label: 'Retrato' },
|
|
{ value: 'gabinete', label: 'Gabinete' },
|
|
{ value: 'outro', label: 'Outro' },
|
|
] as const
|
|
|
|
export type ArchivePhotoScene = (typeof ARCHIVE_PHOTO_SCENES)[number]['value']
|
|
|
|
/**
|
|
* Resolves a scene token (value or pt-BR label, accent/case insensitive) to its
|
|
* taxonomy entry. Fail-closed: unknown tokens return null.
|
|
*/
|
|
export const resolveArchivePhotoScene = (token: unknown): ArchivePhotoScene | null => {
|
|
if (typeof token !== 'string') return null
|
|
const key = normalizeForSearch(token).replace(/-/g, ' ')
|
|
if (!key) return null
|
|
const match = ARCHIVE_PHOTO_SCENES.find(
|
|
(scene) =>
|
|
normalizeForSearch(scene.value).replace(/-/g, ' ') === key ||
|
|
normalizeForSearch(scene.label) === key,
|
|
)
|
|
return match?.value ?? null
|
|
}
|
|
|
|
/** The pt-BR label of a scene value (the facet's own label once it has one). */
|
|
export const archivePhotoSceneLabel = (value: ArchivePhotoScene): string =>
|
|
ARCHIVE_PHOTO_SCENES.find((scene) => scene.value === value)?.label ?? value
|
|
|
|
/**
|
|
* The fields a human edit freezes: the cataloguing never writes a field listed
|
|
* in `curatedFields` (C230's mechanism, one literal per persisted field name).
|
|
*/
|
|
export const ARCHIVE_PHOTO_CURATED_FIELDS = [
|
|
'alt',
|
|
'caption',
|
|
'description',
|
|
'scene',
|
|
'visibleText',
|
|
'hasPeople',
|
|
'themes',
|
|
'people',
|
|
'municipality',
|
|
] as const
|
|
|
|
export type ArchivePhotoCuratedField = (typeof ARCHIVE_PHOTO_CURATED_FIELDS)[number]
|
|
|
|
/** pt-BR names of the curated fields for the admin's read-only chip list. */
|
|
export const archivePhotoCuratedFieldLabels: Record<ArchivePhotoCuratedField, string> = {
|
|
alt: 'Texto alternativo',
|
|
caption: 'Legenda',
|
|
description: 'Descrição',
|
|
scene: 'Atividade/cena',
|
|
visibleText: 'Texto visível',
|
|
hasPeople: 'Pessoas na cena',
|
|
themes: 'Temas',
|
|
people: 'Pessoas públicas',
|
|
municipality: 'Município',
|
|
}
|
|
|
|
/** Outcome of one photo: the model contributed, only metadata did, or nothing. */
|
|
export const ARCHIVE_PHOTO_CATALOG_SOURCES = ['ai', 'metadata', 'none'] as const
|
|
|
|
export type ArchivePhotoCatalogSource = (typeof ARCHIVE_PHOTO_CATALOG_SOURCES)[number]
|
|
|
|
export const archivePhotoCatalogSourceLabels: Record<ArchivePhotoCatalogSource, string> = {
|
|
ai: 'IA',
|
|
metadata: 'Metadados',
|
|
none: 'Nada a propor',
|
|
}
|
|
|
|
/**
|
|
* C233 — the publication gate between the C232 pre-catalogue and the public
|
|
* album. `draft` is the honest initial state (nothing is public by default),
|
|
* `approved` is what the assessoria deliberately sent to the album and
|
|
* `removed` is the sticky takedown state: a removal request served and
|
|
* recorded. The cataloguing pipeline never writes this field, and only a human
|
|
* edit leaves `removed` — fail-closed right to erasure.
|
|
*/
|
|
export const ARCHIVE_PHOTO_PUBLICATION_STATUSES = ['draft', 'approved', 'removed'] as const
|
|
|
|
export type ArchivePhotoPublicationStatus = (typeof ARCHIVE_PHOTO_PUBLICATION_STATUSES)[number]
|
|
|
|
export const isArchivePhotoPublicationStatus = (
|
|
value: unknown,
|
|
): value is ArchivePhotoPublicationStatus =>
|
|
ARCHIVE_PHOTO_PUBLICATION_STATUSES.includes(value as ArchivePhotoPublicationStatus)
|
|
|
|
export const archivePhotoPublicationStatusLabels: Record<ArchivePhotoPublicationStatus, string> = {
|
|
draft: 'Rascunho',
|
|
approved: 'Aprovada',
|
|
removed: 'Removida',
|
|
}
|
|
|
|
/**
|
|
* The predicate every public read filters through (mirror of
|
|
* `contentPieceIsPublic`): only an approved photo is public, and a missing or
|
|
* unknown status fails closed.
|
|
*/
|
|
export const archivePhotoIsPublic = (photo: { publicationStatus?: unknown }): boolean =>
|
|
photo.publicationStatus === 'approved'
|
|
|
|
/**
|
|
* C233 — the removal channel the album advertises ("é você nesta foto? peça a
|
|
* remoção"): an absolute http(s) URL or a `mailto:`. Presence of a valid
|
|
* channel is a precondition for a photo to be public — the address itself is
|
|
* product data configured in the admin, never invented in code.
|
|
*/
|
|
export const isArchivePhotoRemovalChannelUrl = (value: unknown): value is string => {
|
|
if (typeof value !== 'string') return false
|
|
const trimmed = value.trim()
|
|
if (!trimmed) return false
|
|
try {
|
|
const { protocol } = new URL(trimmed)
|
|
return protocol === 'https:' || protocol === 'http:' || protocol === 'mailto:'
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
export const ARCHIVE_PHOTO_CAPTION_MAX_LENGTH = 200
|
|
export const ARCHIVE_PHOTO_DESCRIPTION_MAX_LENGTH = 600
|
|
export const ARCHIVE_PHOTO_VISIBLE_TEXT_MAX_LENGTH = 1000
|
|
const ARCHIVE_PHOTO_ALT_MAX_LENGTH = 300
|
|
const ARCHIVE_PHOTO_PEOPLE_MAX = 8
|
|
|
|
/** What one vision call proposes; every field can be empty (nothing to propose). */
|
|
export type ArchivePhotoSuggestion = {
|
|
caption: string | null
|
|
description: string | null
|
|
scene: ArchivePhotoScene | null
|
|
visibleText: string | null
|
|
hasPeople: boolean | null
|
|
themes: SpeechTopic[]
|
|
}
|
|
|
|
const clip = (value: string | null, maximum: number): string | null => {
|
|
if (value === null) return null
|
|
const text = value.trim().slice(0, maximum).trim()
|
|
return text === '' ? null : text
|
|
}
|
|
|
|
/**
|
|
* Resolves the model's theme tokens against the existing taxonomy (value or
|
|
* pt-BR label); unknown tokens are dropped and duplicates collapse. Never
|
|
* invents a slug outside `SPEECH_TOPICS`.
|
|
*/
|
|
export const archivePhotoThemesFrom = (values: readonly unknown[]): SpeechTopic[] => {
|
|
const themes: SpeechTopic[] = []
|
|
for (const value of values) {
|
|
const entry = typeof value === 'string' ? resolveSpeechTopic(value) : null
|
|
if (entry && !themes.includes(entry.value)) themes.push(entry.value)
|
|
}
|
|
return themes
|
|
}
|
|
|
|
/**
|
|
* Names of the curated catalog mentioned in the TEXT (title, album, caption,
|
|
* visible text…). This is a reading of what is written — never of a face — and
|
|
* an unresolved or ambiguous value never becomes a name. Whole-word,
|
|
* accent/case insensitive, same normalization family as the catalog resolver.
|
|
*/
|
|
export const matchPublicFigureMentions = (text: string): string[] => {
|
|
const haystack = normalizeForSearch(text)
|
|
if (!haystack) return []
|
|
|
|
const mentioned: string[] = []
|
|
for (const entry of publicFigureCatalog) {
|
|
const needle = normalizeForSearch(entry.name)
|
|
if (!needle) continue
|
|
const pattern = new RegExp(`(^|[^a-z0-9])${escapeRegExp(needle)}([^a-z0-9]|$)`)
|
|
if (!pattern.test(haystack)) continue
|
|
if (!mentioned.includes(entry.name) && mentioned.length < ARCHIVE_PHOTO_PEOPLE_MAX) {
|
|
mentioned.push(entry.name)
|
|
}
|
|
}
|
|
return mentioned
|
|
}
|
|
|
|
const archiveVisionField = {
|
|
text: z.string().nullish().catch(null),
|
|
boolean: z.boolean().nullish().catch(null),
|
|
}
|
|
|
|
const archiveVisionOutputSchema = z
|
|
.object({
|
|
caption: archiveVisionField.text,
|
|
description: archiveVisionField.text,
|
|
scene: archiveVisionField.text,
|
|
visibleText: archiveVisionField.text,
|
|
hasPeople: archiveVisionField.boolean,
|
|
themes: z.array(z.unknown()).nullish().catch(null),
|
|
})
|
|
.passthrough()
|
|
|
|
const parseJsonObject = (raw: string): unknown => {
|
|
const text = raw
|
|
.trim()
|
|
.replace(/^```(?:json)?/i, '')
|
|
.replace(/```$/, '')
|
|
.trim()
|
|
try {
|
|
return JSON.parse(text)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fail-closed parser of one vision answer: a non-object (or unparsable) answer
|
|
* is null — the caller reports a named failure and retries next run. Inside a
|
|
* valid object each field degrades alone: an invalid scene or theme is
|
|
* dropped, never guessed.
|
|
*/
|
|
export const parseArchiveVisionOutput = (raw: unknown): ArchivePhotoSuggestion | null => {
|
|
const parsed = typeof raw === 'string' ? parseJsonObject(raw) : raw
|
|
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
const result = archiveVisionOutputSchema.safeParse(parsed)
|
|
if (!result.success) return null
|
|
|
|
const data = result.data
|
|
return {
|
|
caption: clip(data.caption ?? null, ARCHIVE_PHOTO_CAPTION_MAX_LENGTH),
|
|
description: clip(data.description ?? null, ARCHIVE_PHOTO_DESCRIPTION_MAX_LENGTH),
|
|
scene: resolveArchivePhotoScene(data.scene),
|
|
visibleText: clip(data.visibleText ?? null, ARCHIVE_PHOTO_VISIBLE_TEXT_MAX_LENGTH),
|
|
hasPeople: data.hasPeople ?? null,
|
|
themes: archivePhotoThemesFrom(data.themes ?? []),
|
|
}
|
|
}
|
|
|
|
type ArchivePhotoSearchInput = {
|
|
alt?: string | null
|
|
title?: string | null
|
|
description?: string | null
|
|
catalogDescription?: string | null
|
|
caption?: string | null
|
|
scene?: ArchivePhotoScene | null
|
|
visibleText?: string | null
|
|
themes?: readonly SpeechTopic[] | null
|
|
people?: readonly string[] | null
|
|
municipalityName?: string | null
|
|
albumTitles?: readonly string[] | null
|
|
tagNames?: readonly string[] | null
|
|
}
|
|
|
|
/**
|
|
* The normalized haystack the admin list search matches (the same shape C199/
|
|
* C230 persist): what a person would type to find the photo — names, places,
|
|
* themes, what is written in it.
|
|
*/
|
|
export const archivePhotoSearchText = (input: ArchivePhotoSearchInput): string =>
|
|
normalizeForSearch(
|
|
[
|
|
input.alt ?? '',
|
|
input.title ?? '',
|
|
input.description ?? '',
|
|
input.catalogDescription ?? '',
|
|
input.caption ?? '',
|
|
input.scene ? archivePhotoSceneLabel(input.scene) : '',
|
|
input.visibleText ?? '',
|
|
...(input.themes ?? []).map((theme) => resolveSpeechTopic(theme)?.label ?? theme),
|
|
...(input.people ?? []),
|
|
input.municipalityName ?? '',
|
|
...(input.albumTitles ?? []),
|
|
...(input.tagNames ?? []),
|
|
]
|
|
.map((value) => value.trim())
|
|
.filter(Boolean)
|
|
.join(' '),
|
|
)
|
|
|
|
/**
|
|
* The date facet derived from the Flickr wall clock (`YYYY-MM-DD HH:MM:SS`):
|
|
* fixed at noon UTC so the admin never shows the previous day. Invalid input
|
|
* (or an impossible date) is null — never a guessed day.
|
|
*/
|
|
export const archivePhotoTakenOn = (takenAt: unknown): string | null => {
|
|
if (typeof takenAt !== 'string') return null
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})(?:[ T]|$)/.exec(takenAt.trim())
|
|
if (!match) return null
|
|
const [, year, month, day] = match
|
|
const utc = Date.UTC(Number(year), Number(month) - 1, Number(day), 12)
|
|
const date = new Date(utc)
|
|
if (
|
|
date.getUTCFullYear() !== Number(year) ||
|
|
date.getUTCMonth() !== Number(month) - 1 ||
|
|
date.getUTCDate() !== Number(day)
|
|
) {
|
|
return null
|
|
}
|
|
return date.toISOString()
|
|
}
|
|
|
|
const comparableCuratedValue = (field: ArchivePhotoCuratedField, value: unknown): unknown => {
|
|
if (field === 'municipality') {
|
|
if (typeof value === 'number') return value
|
|
if (value !== null && typeof value === 'object' && 'id' in (value as object)) {
|
|
return (value as { id: unknown }).id
|
|
}
|
|
return null
|
|
}
|
|
if (field === 'themes' || field === 'people') {
|
|
return Array.isArray(value)
|
|
? value.filter((entry): entry is string => typeof entry === 'string' && entry.trim() !== '')
|
|
: []
|
|
}
|
|
if (field === 'hasPeople') {
|
|
return typeof value === 'boolean' ? value : null
|
|
}
|
|
return typeof value === 'string' ? value : null
|
|
}
|
|
|
|
/**
|
|
* The admin edit path marks which fields the assessoria touched: an incoming
|
|
* value that differs from the stored one (arrays normalized, clearing counts)
|
|
* enters `curatedFields`. Only runs for a request with a user — the system
|
|
* writes (ingest, cataloguing) never mark.
|
|
*/
|
|
export const changedArchivePhotoCuratedFields = ({
|
|
data,
|
|
originalDoc,
|
|
}: {
|
|
data: Record<string, unknown> | null | undefined
|
|
originalDoc: Record<string, unknown> | null | undefined
|
|
}): ArchivePhotoCuratedField[] => {
|
|
if (!data) return []
|
|
const catalog = data.catalog
|
|
const incomingCatalog =
|
|
catalog !== null && typeof catalog === 'object' ? (catalog as Record<string, unknown>) : null
|
|
const originalCatalog = originalDoc?.catalog
|
|
const storedCatalog =
|
|
originalCatalog !== null && typeof originalCatalog === 'object'
|
|
? (originalCatalog as Record<string, unknown>)
|
|
: null
|
|
|
|
const changed: ArchivePhotoCuratedField[] = []
|
|
for (const field of ARCHIVE_PHOTO_CURATED_FIELDS) {
|
|
const incoming = field === 'alt' ? data.alt : incomingCatalog?.[field]
|
|
if (incoming === undefined) continue
|
|
const stored = field === 'alt' ? originalDoc?.alt : storedCatalog?.[field]
|
|
const before = comparableCuratedValue(field, stored)
|
|
const after = comparableCuratedValue(field, incoming)
|
|
if (JSON.stringify(before) !== JSON.stringify(after)) changed.push(field)
|
|
}
|
|
return changed
|
|
}
|
|
|
|
/**
|
|
* C245 — the shared base text of the ficha: what the deterministic rules read
|
|
* (gazetteer municipality, gazetteer themes, curated-catalog name mentions).
|
|
* The AI layer appends its own caption/visibleText; the metadata layer passes
|
|
* nothing — both derive from the SAME join, so the two layers cannot drift.
|
|
*/
|
|
export const archivePhotoCatalogText = (
|
|
input: {
|
|
alt?: string | null
|
|
title?: string | null
|
|
description?: string | null
|
|
albumTitles?: readonly (string | null | undefined)[] | null
|
|
tagNames?: readonly (string | null | undefined)[] | null
|
|
},
|
|
extras: readonly (string | null | undefined)[] = [],
|
|
): string =>
|
|
[
|
|
input.alt,
|
|
input.title,
|
|
input.description,
|
|
...(input.albumTitles ?? []),
|
|
...(input.tagNames ?? []),
|
|
...extras,
|
|
]
|
|
.filter((value): value is string => typeof value === 'string' && value.trim() !== '')
|
|
.join(' ')
|
|
|
|
type ArchivePhotoCatalogCurrent = {
|
|
caption?: string | null
|
|
description?: string | null
|
|
scene?: ArchivePhotoScene | null
|
|
visibleText?: string | null
|
|
hasPeople?: boolean | null
|
|
themes?: SpeechTopic[] | null
|
|
people?: string[] | null
|
|
municipality?: number | null
|
|
}
|
|
|
|
/**
|
|
* The curated-wins merge both layers share: `writes` says whether a proposal
|
|
* is non-empty and unfrozen; `pick` keeps the curated value verbatim and
|
|
* normalizes the proposal otherwise (blank strings/empty arrays are "nothing
|
|
* to propose", never a written value). `undefined` never reaches the DB —
|
|
* Payload leaves the stored value untouched — so omitted keys are the "do not
|
|
* touch" signal.
|
|
*/
|
|
const createArchivePhotoCatalogMerge = ({
|
|
curatedFields,
|
|
currentCatalog,
|
|
}: {
|
|
curatedFields: readonly ArchivePhotoCuratedField[]
|
|
currentCatalog?: ArchivePhotoCatalogCurrent | null
|
|
}) => {
|
|
const curated = new Set<ArchivePhotoCuratedField>(curatedFields)
|
|
const current: ArchivePhotoCatalogCurrent = currentCatalog ?? {}
|
|
|
|
const writes = (field: ArchivePhotoCuratedField, value: unknown): boolean =>
|
|
!curated.has(field) &&
|
|
value !== null &&
|
|
value !== undefined &&
|
|
!(typeof value === 'string' && value.trim() === '') &&
|
|
!(Array.isArray(value) && value.length === 0)
|
|
|
|
const pick = <Key extends keyof ArchivePhotoCatalogCurrent>(
|
|
field: Key,
|
|
proposed: NonNullable<ArchivePhotoCatalogCurrent[Key]> | null | undefined,
|
|
): NonNullable<ArchivePhotoCatalogCurrent[Key]> | undefined => {
|
|
if (curated.has(field)) {
|
|
const kept = current[field]
|
|
return kept === null || kept === undefined
|
|
? undefined
|
|
: (kept as NonNullable<ArchivePhotoCatalogCurrent[Key]>)
|
|
}
|
|
if (proposed === null || proposed === undefined) return undefined
|
|
if (Array.isArray(proposed)) {
|
|
return proposed.length === 0
|
|
? undefined
|
|
: (proposed as NonNullable<ArchivePhotoCatalogCurrent[Key]>)
|
|
}
|
|
if (typeof proposed === 'string') {
|
|
const text = proposed.trim()
|
|
return text === '' ? undefined : (text as NonNullable<ArchivePhotoCatalogCurrent[Key]>)
|
|
}
|
|
return proposed as NonNullable<ArchivePhotoCatalogCurrent[Key]>
|
|
}
|
|
|
|
return { writes, pick, curated }
|
|
}
|
|
|
|
export type ArchivePhotoMetadataCatalogWrite = {
|
|
data: {
|
|
catalog: {
|
|
municipality?: number
|
|
themes?: SpeechTopic[]
|
|
source: Exclude<ArchivePhotoCatalogSource, 'ai'>
|
|
metadataCheckedAt: string
|
|
}
|
|
}
|
|
source: Exclude<ArchivePhotoCatalogSource, 'ai'>
|
|
}
|
|
|
|
/**
|
|
* C245 — the deterministic metadata-only write: municipality (gazetteer) and
|
|
* themes (gazetteer) derived from the text already ingested. People are NEVER
|
|
* proposed here (C244 owns the facial facet) and the AI layer is not marked as
|
|
* done — `catalogedAt` is untouched, so the photo stays in the AI queue;
|
|
* `metadataCheckedAt` is this layer's own idempotency key.
|
|
*/
|
|
export const buildArchivePhotoMetadataCatalogWrite = ({
|
|
municipalityId,
|
|
gazetteerThemes,
|
|
curatedFields,
|
|
currentCatalog,
|
|
metadataCheckedAt,
|
|
}: {
|
|
municipalityId: number | null
|
|
gazetteerThemes: readonly SpeechTopic[]
|
|
curatedFields: readonly ArchivePhotoCuratedField[]
|
|
currentCatalog?: ArchivePhotoCatalogCurrent | null
|
|
metadataCheckedAt: string
|
|
}): ArchivePhotoMetadataCatalogWrite => {
|
|
const { writes, pick } = createArchivePhotoCatalogMerge({ curatedFields, currentCatalog })
|
|
const themes = [...new Set<SpeechTopic>(gazetteerThemes)]
|
|
const written = {
|
|
municipality: pick('municipality', municipalityId),
|
|
themes: pick('themes', themes),
|
|
}
|
|
const source: Exclude<ArchivePhotoCatalogSource, 'ai'> =
|
|
writes('municipality', municipalityId) || writes('themes', themes) ? 'metadata' : 'none'
|
|
|
|
return {
|
|
data: {
|
|
catalog: {
|
|
...written,
|
|
source,
|
|
metadataCheckedAt,
|
|
},
|
|
},
|
|
source,
|
|
}
|
|
}
|
|
|
|
type ArchivePhotoCatalogValues = {
|
|
caption?: string
|
|
description?: string
|
|
scene?: ArchivePhotoScene
|
|
visibleText?: string
|
|
hasPeople?: boolean
|
|
themes?: SpeechTopic[]
|
|
people?: string[]
|
|
municipality?: number
|
|
source: ArchivePhotoCatalogSource
|
|
catalogedAt: string
|
|
metadataCheckedAt: string
|
|
}
|
|
|
|
export type ArchivePhotoCatalogWrite = {
|
|
data: {
|
|
alt?: string
|
|
catalog: ArchivePhotoCatalogValues
|
|
}
|
|
source: ArchivePhotoCatalogSource
|
|
}
|
|
|
|
/**
|
|
* The deterministic merge of one photo: proposed values (model + text
|
|
* metadata) fill only fields the assessoria never curated, and the source
|
|
* states what actually landed — `ai` when a model field was written,
|
|
* `metadata` when only the text-derived ones were, `none` when there was
|
|
* nothing to propose. `catalogedAt` is set on every outcome, so "nada a
|
|
* propor" is a final honest state instead of a silent retry loop; the same
|
|
* pass derives the metadata layer, so `metadataCheckedAt` is stamped here too.
|
|
*/
|
|
export const buildArchivePhotoCatalogWrite = ({
|
|
suggestion,
|
|
municipalityId,
|
|
mentionedPeople,
|
|
gazetteerThemes,
|
|
curatedFields,
|
|
currentCatalog,
|
|
currentAlt,
|
|
catalogedAt,
|
|
metadataCheckedAt,
|
|
}: {
|
|
suggestion: ArchivePhotoSuggestion | null
|
|
municipalityId: number | null
|
|
mentionedPeople: readonly string[]
|
|
gazetteerThemes: readonly SpeechTopic[]
|
|
curatedFields: readonly ArchivePhotoCuratedField[]
|
|
currentCatalog?: ArchivePhotoCatalogCurrent | null
|
|
currentAlt?: string | null
|
|
catalogedAt: string
|
|
metadataCheckedAt: string
|
|
}): ArchivePhotoCatalogWrite => {
|
|
const { writes, pick, curated } = createArchivePhotoCatalogMerge({
|
|
curatedFields,
|
|
currentCatalog,
|
|
})
|
|
|
|
const aiThemes = suggestion?.themes ?? []
|
|
const themes = [...new Set<SpeechTopic>([...aiThemes, ...gazetteerThemes])]
|
|
const people = [...new Set(mentionedPeople)]
|
|
|
|
const caption = suggestion?.caption ?? null
|
|
const written = {
|
|
caption: pick('caption', caption),
|
|
description: pick('description', suggestion?.description ?? null),
|
|
scene: pick('scene', suggestion?.scene ?? null),
|
|
visibleText: pick('visibleText', suggestion?.visibleText ?? null),
|
|
hasPeople: pick('hasPeople', suggestion?.hasPeople ?? null),
|
|
themes: pick('themes', themes.length > 0 ? themes : null),
|
|
people: pick('people', people.length > 0 ? people : null),
|
|
municipality: pick('municipality', municipalityId),
|
|
}
|
|
|
|
const modelWrote =
|
|
writes('caption', caption) ||
|
|
writes('description', suggestion?.description) ||
|
|
writes('scene', suggestion?.scene) ||
|
|
writes('visibleText', suggestion?.visibleText) ||
|
|
writes('hasPeople', suggestion?.hasPeople) ||
|
|
writes('themes', aiThemes)
|
|
const metadataWrote =
|
|
!modelWrote &&
|
|
(writes('municipality', municipalityId) ||
|
|
writes('people', people) ||
|
|
writes('themes', gazetteerThemes))
|
|
const source: ArchivePhotoCatalogSource = modelWrote ? 'ai' : metadataWrote ? 'metadata' : 'none'
|
|
|
|
// The AI refines the alt only from its OWN caption (never from human text):
|
|
// a curated caption leaves the alt exactly where it was.
|
|
const alt = curated.has('alt')
|
|
? (currentAlt ?? undefined)
|
|
: curated.has('caption')
|
|
? undefined
|
|
: (clip(caption, ARCHIVE_PHOTO_ALT_MAX_LENGTH) ?? undefined)
|
|
|
|
return {
|
|
data: {
|
|
...(alt !== undefined ? { alt } : {}),
|
|
catalog: {
|
|
...written,
|
|
source,
|
|
catalogedAt,
|
|
metadataCheckedAt,
|
|
},
|
|
},
|
|
source,
|
|
}
|
|
}
|
|
|
|
const ARCHIVE_VISION_SCENE_LINES = ARCHIVE_PHOTO_SCENES.filter((scene) => scene.value !== 'outro')
|
|
.map((scene) => `${scene.value}=${scene.label}`)
|
|
.join('; ')
|
|
|
|
/** The theme vocabulary is offered from its owner — no copied literal to drift. */
|
|
const ARCHIVE_VISION_THEME_LINES = SPEECH_TOPICS.map(
|
|
({ value, label }) => `${value}=${label}`,
|
|
).join('; ')
|
|
|
|
export const ARCHIVE_VISION_SYSTEM_PROMPT =
|
|
'Você pré-cataloga fotos do acervo do deputado federal Jorge Solla (PT-BA) para uma base interna da campanha. ' +
|
|
'Descreva apenas o que está visível na imagem: nunca invente lugar, data, evento, número ou fala. ' +
|
|
'NUNCA identifique pessoas por rosto nem escreva nomes de pessoas (nem do deputado). ' +
|
|
'Não escreva nomes de cidades: o local é resolvido por outra via. ' +
|
|
'Responda SOMENTE com um objeto JSON válido, sem markdown e sem comentários.'
|
|
|
|
/**
|
|
* The user prompt of one photo: the closed vocabularies the answer must use.
|
|
* The model is free to answer null/empty for anything it cannot see — the
|
|
* parser drops whatever leaves the vocabulary.
|
|
*/
|
|
export const buildArchiveVisionPrompt = (): string =>
|
|
[
|
|
'Analise a foto e devolva um JSON com exatamente estas chaves:',
|
|
'{"caption": "legenda curta em português (máx. 200 caracteres)",',
|
|
' "description": "uma ou duas frases factuais em português (máx. 600 caracteres)",',
|
|
' "scene": "um dos valores de cena ou null",',
|
|
' "visibleText": "texto legível de faixas, placas e banners ou null",',
|
|
' "hasPeople": true, false ou null,',
|
|
' "themes": ["zero ou mais valores de tema"]}',
|
|
'',
|
|
`Cenas: ${ARCHIVE_VISION_SCENE_LINES}; outro=Outro (use outro só se nenhuma se aplicar).`,
|
|
`Temas: ${ARCHIVE_VISION_THEME_LINES}.`,
|
|
'Use null ou lista vazia quando não houver certeza; é melhor não propor do que inventar.',
|
|
].join('\n')
|