mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
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
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:
parent
cca5f97482
commit
ca843fb4bb
5 changed files with 105 additions and 9 deletions
1
docs/changelog/2026-10-01-c230-titulos-semanticos.md
Normal file
1
docs/changelog/2026-10-01-c230-titulos-semanticos.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue