From ca843fb4bbbe3bc5a2df90a4804bac62a6a26678 Mon Sep 17 00:00:00 2001 From: Francisco Solla Date: Thu, 1 Oct 2026 01:58:18 -0300 Subject: [PATCH] =?UTF-8?q?fix(C230):=20fallback=20da=20cataloga=C3=A7?= =?UTF-8?q?=C3=A3o=20d=C3=A1=20t=C3=ADtulo=20sem=C3=A2ntico,=20n=C3=A3o=20?= =?UTF-8?q?placeholder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 · ` 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. --- .../2026-10-01-c230-titulos-semanticos.md | 1 + src/lib/contentPiece.ts | 8 +++ .../content/contentPieceCataloging.ts | 37 ++++++++++--- tests/unit/contentPiece.unit.spec.ts | 14 ++++- .../unit/contentPieceCataloging.unit.spec.ts | 54 ++++++++++++++++++- 5 files changed, 105 insertions(+), 9 deletions(-) create mode 100644 docs/changelog/2026-10-01-c230-titulos-semanticos.md diff --git a/docs/changelog/2026-10-01-c230-titulos-semanticos.md b/docs/changelog/2026-10-01-c230-titulos-semanticos.md new file mode 100644 index 00000000..2f509fb4 --- /dev/null +++ b/docs/changelog/2026-10-01-c230-titulos-semanticos.md @@ -0,0 +1 @@ +**C230-followup — títulos semânticos na Central: fim do placeholder `Instagram · ` (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. diff --git a/src/lib/contentPiece.ts b/src/lib/contentPiece.ts index 73a9d265..68c9b180 100644 --- a/src/lib/contentPiece.ts +++ b/src/lib/contentPiece.ts @@ -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 diff --git a/src/utilities/content/contentPieceCataloging.ts b/src/utilities/content/contentPieceCataloging.ts index cff718a8..f9b79004 100644 --- a/src/utilities/content/contentPieceCataloging.ts +++ b/src/utilities/content/contentPieceCataloging.ts @@ -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 · `) 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 => { 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 { diff --git a/tests/unit/contentPiece.unit.spec.ts b/tests/unit/contentPiece.unit.spec.ts index 598a375f..31599daf 100644 --- a/tests/unit/contentPiece.unit.spec.ts +++ b/tests/unit/contentPiece.unit.spec.ts @@ -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', () => { diff --git a/tests/unit/contentPieceCataloging.unit.spec.ts b/tests/unit/contentPieceCataloging.unit.spec.ts index 77a2bc8f..6122da04 100644 --- a/tests/unit/contentPieceCataloging.unit.spec.ts +++ b/tests/unit/contentPieceCataloging.unit.spec.ts @@ -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({