fix(C230): fallback da catalogação dá título semântico, não placeholder
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions

Quando a sugestão por IA falhava (timeout de 8s) ou a peça não tinha
texto, o fallback preservava o título provisório `Instagram · <código>`
para sempre. Agora isContentPieceLinkTitle (dono em src/lib) identifica o
placeholder e o fallback gera título determinístico (tipo + cidade +
temas); o caminho sem texto faz o mesmo em vez de deixar o placeholder,
a sugestão da IA que ecoa o placeholder é descartada, e título editado
pela assessoria continua intocado.
This commit is contained in:
Francisco Solla 2026-10-01 01:58:18 -03:00
parent cca5f97482
commit ca843fb4bb
5 changed files with 105 additions and 9 deletions

View file

@ -0,0 +1 @@
**C230-followup — títulos semânticos na Central: fim do placeholder `Instagram · <código>` (2026-10-01):** o fallback determinístico da catalogação preservava o título provisório de link quando a sugestão por IA falhava (timeout de 8s do `deepseek-flash`) ou quando não havia texto (peça-link/carrossel sem transcrição) — a peça ficava eternamente com `Instagram · Dd...`. Correção no dono: `isContentPieceLinkTitle` nasce em `src/lib/contentPiece.ts` ao lado de `contentPieceLinkTitle`; o fallback do `contentPieceCataloging.ts` passa a gerar título semântico determinístico (tipo + cidade + temas, ex. "Vídeo da campanha em Santa Inês sobre Saúde") em vez de preservar o placeholder, o caminho sem texto troca o placeholder pelo mesmo fallback (título significativo da assessoria continua intocado e `source: 'none'` segue valendo), e a sugestão da IA que ecoa o placeholder é descartada. Testes unit novos/atualizados (detector, fallback com contexto, eco da IA, sem-texto). Backfill em produção: 10 peças corrigidas (9 títulos por IA + 1 determinístico sem conteúdo), 0 falhas — a peça 181, que era link-only `indisponivel`, foi reprocessada, baixou a mídia, transcreveu (494 chars) e ganhou o título "Apoio à reeleição de Jorge Solla (PT-BA)"; restam **0 títulos genéricos** no catálogo (281 publicadas) e a Central pública foi revalidada.

View file

@ -476,6 +476,14 @@ export const contentPieceOriginFromLink = (
export const contentPieceLinkTitle = (link: ContentPieceLink): string =>
link.origin === 'instagram' ? `Instagram · ${link.shortcode}` : `YouTube · ${link.videoId}`
/**
* Whether a title is still the generated link placeholder (`contentPieceLinkTitle`).
* The cataloguing refuses to preserve it: a placeholder is never a title, so the
* deterministic fallback (or the assessoria) must replace it.
*/
export const isContentPieceLinkTitle = (title: string | null | undefined): boolean =>
/^(Instagram|YouTube) · \S+$/.test(title?.trim() ?? '')
/**
* C230 — every canonical spelling of one post's `sourceUrl`. The Instagram
* kind in the path (`p`, `reel`, `tv`) is presentation: the same post can be

View file

@ -11,6 +11,7 @@ import {
CONTENT_PIECE_TITLE_MAX_LENGTH,
contentPieceTopicLabel,
contentPieceTypeLabels,
isContentPieceLinkTitle,
type ContentPieceType,
} from '@/lib/contentPiece'
import { institutionCatalog, institutionSpellings } from '@/lib/institutionCatalog'
@ -89,17 +90,25 @@ export const buildContentPieceFallbackMetadata = ({
cityLabel: string | null
topics: readonly SpeechTopic[]
}): { title: string; description: string; source: 'fallback' } => {
const typeLabel = contentPieceTypeLabels[type].toLowerCase()
const typeLabel = contentPieceTypeLabels[type]
const topicLabels = topics.slice(0, 3).map(contentPieceTopicLabel)
const parts = [
`${contentPieceTypeLabels[type]} da campanha de Jorge Solla 1313`,
const context = [
cityLabel ? `em ${cityLabel}` : null,
topicLabels.length > 0 ? `sobre ${topicLabels.join(', ')}` : null,
].filter((part): part is string => part !== null)
// The provisional link placeholder (`Instagram · <shortcode>`) is never a
// title: when the AI suggestion is unavailable the deterministic semantic
// title (type + city + topics) replaces it instead of being preserved.
const preservedTitle = isContentPieceLinkTitle(currentTitle) ? '' : currentTitle
return {
title: clip(currentTitle, CONTENT_PIECE_TITLE_MAX_LENGTH) || `Peça de ${typeLabel}`,
description: clip(`${parts.join(' ')}.`, CONTENT_PIECE_DESCRIPTION_MAX_LENGTH),
title:
clip(preservedTitle, CONTENT_PIECE_TITLE_MAX_LENGTH) ||
clip([`${typeLabel} da campanha`, ...context].join(' '), CONTENT_PIECE_TITLE_MAX_LENGTH),
description: clip(
`${[`${typeLabel} da campanha de Jorge Solla 1313`, ...context].join(' ')}.`,
CONTENT_PIECE_DESCRIPTION_MAX_LENGTH,
),
source: 'fallback',
}
}
@ -142,7 +151,8 @@ const suggestContentPieceMetadata: ContentPieceSuggester = async ({
const title = clip(object.title, CONTENT_PIECE_TITLE_MAX_LENGTH)
const description = clip(object.description, CONTENT_PIECE_DESCRIPTION_MAX_LENGTH)
if (!title || !description) return fallback
// A model echoing the provisional placeholder is not a suggestion.
if (!title || !description || isContentPieceLinkTitle(title)) return fallback
return { title, description, source: 'ai' }
} catch {
return fallback
@ -187,6 +197,8 @@ export const resolveContentPieceInstitution = (transcript: string): string | und
* classifier + the suggestion, and returns only what it is confident about.
* An empty transcript (a photo, a card, a silent video) skips every external
* call and returns `source: 'none'` — the type/title the upload derived stay.
* The one exception is a provisional link placeholder: with no title to keep,
* the deterministic semantic fallback replaces it (no external call).
*/
export const catalogContentPiece = async ({
payload,
@ -204,7 +216,18 @@ export const catalogContentPiece = async ({
suggest?: ContentPieceSuggester
}): Promise<ContentPieceCatalogResult> => {
const text = transcript?.trim() ?? ''
if (!text) return { source: 'none' }
if (!text) {
if (isContentPieceLinkTitle(title)) {
const fallback = buildContentPieceFallbackMetadata({
type,
currentTitle: title,
cityLabel: null,
topics: [],
})
return { title: fallback.title, description: fallback.description, source: 'fallback' }
}
return { source: 'none' }
}
let topics: SpeechTopic[] = []
try {

View file

@ -18,6 +18,7 @@ import {
contentPieceTypeFromMime,
isContentPieceCuratedField,
isContentPieceLinkFailureReason,
isContentPieceLinkTitle,
isContentPieceOrigin,
isContentPieceProcessingStatus,
isContentPieceStatus,
@ -235,9 +236,20 @@ describe('content piece links', () => {
it('gives a link a provisional title', () => {
const instagram = parseContentPieceLink('https://www.instagram.com/reel/ABC/')!
expect(contentPieceLinkTitle(instagram)).toBe('Instagram · ABC')
const youtube = parseContentPieceLink('https://youtu.be/VID')!
const youtube = parseContentPieceLink('https://youtu.be/VID/')!
expect(contentPieceLinkTitle(youtube)).toBe('YouTube · VID')
})
it('recognizes the generated placeholder and nothing else', () => {
expect(isContentPieceLinkTitle('Instagram · ABC')).toBe(true)
expect(isContentPieceLinkTitle('YouTube · VID')).toBe(true)
expect(isContentPieceLinkTitle(' Instagram · ABC ')).toBe(true)
expect(isContentPieceLinkTitle('Instagram · ')).toBe(false)
expect(isContentPieceLinkTitle('Instagram para gestores')).toBe(false)
expect(isContentPieceLinkTitle('Mensagem do grupo')).toBe(false)
expect(isContentPieceLinkTitle('')).toBe(false)
expect(isContentPieceLinkTitle(null)).toBe(false)
})
})
describe('C230 — the identity and the import candidate of a profile post', () => {

View file

@ -109,7 +109,7 @@ describe('buildContentPieceFallbackMetadata', () => {
cityLabel: null,
topics: [],
})
expect(fallback.title).toBe('Peça de vídeo')
expect(fallback.title).toBe('Vídeo da campanha')
expect(fallback.description).toContain('Vídeo da campanha de Jorge Solla 1313')
expect(fallback.source).toBe('fallback')
})
@ -126,6 +126,19 @@ describe('buildContentPieceFallbackMetadata', () => {
'Áudio da campanha de Jorge Solla 1313 em Feira de Santana sobre Saúde, Economia e Trabalho.',
)
})
it('replaces the provisional link placeholder with the semantic title', () => {
const fallback = buildContentPieceFallbackMetadata({
type: 'video',
currentTitle: 'Instagram · Dd6mTjAqzc9',
cityLabel: 'Santa Inês',
topics: ['saude'],
})
expect(fallback.title).toBe('Vídeo da campanha em Santa Inês sobre Saúde')
expect(fallback.description).toBe(
'Vídeo da campanha de Jorge Solla 1313 em Santa Inês sobre Saúde.',
)
})
})
describe('catalogContentPiece', () => {
@ -146,6 +159,27 @@ describe('catalogContentPiece', () => {
expect(suggest).not.toHaveBeenCalled()
})
it('still replaces a link placeholder when there is no text', async () => {
const classify = classifierOf([])
const suggest = suggesterOf('t', 'd')
const result = await catalogContentPiece({
payload: fakePayload(),
type: 'video',
title: 'Instagram · DdsAC82NXsC',
transcript: null,
classify,
suggest,
})
expect(result).toEqual({
title: 'Vídeo da campanha',
description: 'Vídeo da campanha de Jorge Solla 1313.',
source: 'fallback',
})
expect(classify).not.toHaveBeenCalled()
expect(suggest).not.toHaveBeenCalled()
})
it('fills city, topics, institution and the suggestion when confident', async () => {
const payload = fakePayload({
municipalitySlug: 'feira-de-santana',
@ -208,6 +242,24 @@ describe('catalogContentPiece', () => {
expect(result.topics).toBeUndefined()
})
it('falls back when the model echoes the provisional placeholder', async () => {
generateObjectMock.mockReset()
generateObjectMock.mockResolvedValue({
object: { title: 'Instagram · ABC', description: 'Descrição qualquer.' },
})
const result = await catalogContentPiece({
payload: fakePayload(),
type: 'video',
title: 'Instagram · ABC',
transcript: 'Uma fala sobre saúde em Salvador.',
classify: classifierOf([]),
})
expect(result.source).toBe('fallback')
expect(result.title).toBe('Vídeo da campanha')
})
it('desliga o thinking e mantém teto de saída acima do orçamento de raciocínio', async () => {
generateObjectMock.mockReset()
generateObjectMock.mockResolvedValue({