mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
Modo "Por tema" no acervo de falas: expansão determinística do tema via DeepSeek (generateObject, timeout, fallback null) alimenta o motor lexical existente (searchText/keywords OR), mantendo facetas AND, paginação por fala, o OR de cortes do C174 e o access control intacto. Resultado de tema mostra selo e "Por que apareceu" com trecho real (frase contínua), sem score; degradação honesta para termo exato com aviso e vazio honesto com saídas. - URL: mode=tema (default omitido; canonicaliza sem q), seletor otimista - ai/expandSpeechSearchTheme + lib/speechThemeTerms (sanitiza saída do modelo) - view model com themeMatchTerm + espelho speechMatchesSearchTerm - UI do card de tema (selos, proveniência, ações) e aviso degradado - sem migration/Consent/exposição pública; gate de leitura inalterado - testes unit/int/e2e; design hi-fi estendido e certificado pelo designer
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
/**
|
|
* Speech acervo view models (C154). Pure: the loader hands raw Payload rows
|
|
* and the view model decides labels, the matching excerpt and the links the
|
|
* list/detail render.
|
|
*/
|
|
import { CAMPAIGN_COMMUNICATION_ACERVO } from '@/lib/campaignPaths'
|
|
import { formatSpeechClock, formatSpeechDate, formatSpeechSpan } from '@/lib/speechClock'
|
|
import type { SpeechCutSummaryViewModel } from '@/lib/speechCut'
|
|
import type { SpeechExcerptSegment } from '@/lib/speechExcerpt'
|
|
import type { SpeechScope, SpeechTopic } from '@/lib/speechFacets'
|
|
import {
|
|
buildHighlightedExcerpt,
|
|
matchesSearchTerms,
|
|
speechSearchTerms,
|
|
splitHighlightedParts,
|
|
type SpeechHighlightedExcerpt,
|
|
type SpeechHighlightPart,
|
|
} from '@/lib/speechHighlight'
|
|
import { speechPosterHref, speechPosterTarget } from '@/lib/speechPoster'
|
|
import {
|
|
normalizeForSearch,
|
|
speechMatchesSearchQuery,
|
|
speechMatchesSearchTerm,
|
|
} from '@/lib/speechSearch'
|
|
import {
|
|
correctedExcerptOffsetSeconds,
|
|
excerptOffsetSeconds,
|
|
measuredVideoLagSeconds,
|
|
parseYoutubeVideoId,
|
|
sessionLagSeconds,
|
|
speechCoverUrl,
|
|
speechVodCoordinates,
|
|
} from '@/lib/speechVod'
|
|
import type { Municipality } from '@/payload-types'
|
|
import { speechScopeLabels, speechTopicLabels } from '@/utilities/speech/speechListUrl'
|
|
|
|
/** ASR segment shape, owned by the C158 excerpt builder (`lib/speechExcerpt`). */
|
|
export type SpeechSegmentRecord = SpeechExcerptSegment
|
|
|
|
export type SpeechListRecord = {
|
|
id: number
|
|
speechAt: string
|
|
type?: string | null
|
|
durationSeconds?: number | null
|
|
summary?: string | null
|
|
officialTranscript?: string | null
|
|
keywords?: string[] | null
|
|
topics?: SpeechTopic[] | null
|
|
scopes?: SpeechScope[] | null
|
|
mentionedMunicipalities?: (number | Municipality)[] | null
|
|
presidingOfficer?: string | null
|
|
officialTextUrl?: string | null
|
|
youtubeUrl?: string | null
|
|
/** C182 — the Câmara coordinates that make the frame resolvable. */
|
|
eventId?: number | null
|
|
audioId?: number | null
|
|
excerptTMs?: number | null
|
|
/** C174 — normalized search text; lets the row tell whether it matched `q`. */
|
|
searchText?: string | null
|
|
}
|
|
|
|
type SpeechListMatchKind = 'segment' | 'keyword' | 'theme' | 'fallback'
|
|
|
|
export type SpeechListItemViewModel = {
|
|
id: number
|
|
speechAtLabel: string
|
|
type: string | null
|
|
durationLabel: string | null
|
|
presidingOfficer: string | null
|
|
excerpt: SpeechHighlightedExcerpt
|
|
matchKind: SpeechListMatchKind
|
|
topics: { value: SpeechTopic; label: string }[]
|
|
scopes: { value: SpeechScope; label: string }[]
|
|
keywords: string[]
|
|
municipalities: { id: number; name: string }[]
|
|
/**
|
|
* C182 — the frame of the middle of the speech when the Câmara can re-resolve
|
|
* the excerpt, else the YouTube cover of the session (C175); null when
|
|
* neither exists. The list never sees the raw VOD URLs.
|
|
*/
|
|
thumbnailUrl: string | null
|
|
watchHref: string
|
|
/**
|
|
* C177 — the official source (Diário) link; null when the speech has no
|
|
* official text. Never falls back to `youtubeUrl`: a source button that
|
|
* opened a video without the point is the duplicate atalho this item killed.
|
|
*/
|
|
officialTextUrl: string | null
|
|
/** C174 — the cuts already made from this speech, newest first. */
|
|
cuts: SpeechCutSummaryViewModel[]
|
|
/**
|
|
* C174 — false when the speech surfaced only because one of its cuts matched
|
|
* the term; the card then swaps the excerpt for the honest origin note.
|
|
*/
|
|
matchedTextSearch: boolean
|
|
/**
|
|
* C192 — the expanded theme term that matched the speech text/keywords, or
|
|
* null when the search was literal (or the expansion did not surface this
|
|
* row). The card uses it to label the result and to explain why it appeared.
|
|
*/
|
|
themeMatchTerm: string | null
|
|
}
|
|
|
|
export type SpeechDetailSegmentViewModel = {
|
|
startSeconds: number
|
|
endSeconds: number
|
|
startLabel: string
|
|
parts: SpeechHighlightPart[]
|
|
}
|
|
|
|
export type SpeechDetailViewModel = {
|
|
id: number
|
|
speechAtLabel: string
|
|
/** Day-only label (`dd/mm/aaaa`) for the share message. */
|
|
speechDateLabel: string
|
|
type: string | null
|
|
phase: string | null
|
|
durationLabel: string | null
|
|
/** Raw duration for the C166 excerpt picker; null when the row has none. */
|
|
durationSeconds: number | null
|
|
presidingOfficer: string | null
|
|
summary: string | null
|
|
officialTranscript: string | null
|
|
officialTextUrl: string | null
|
|
keywords: string[]
|
|
topics: { value: SpeechTopic; label: string }[]
|
|
scopes: { value: SpeechScope; label: string }[]
|
|
municipalities: { id: number; name: string }[]
|
|
segments: SpeechDetailSegmentViewModel[]
|
|
/** YouTube default source (C162): id when the session link parses, else null. */
|
|
youtubeVideoId: string | null
|
|
/**
|
|
* Session offset in seconds minus the video's own start delay (C172), null
|
|
* when the excerpt has no known session offset.
|
|
*/
|
|
youtubeOffsetSeconds: number | null
|
|
/** Stored VOD + excerpt coordinates — the Câmara may be asked on click. */
|
|
vodResolvable: boolean
|
|
}
|
|
|
|
/**
|
|
* `speechAt` is the Câmara wall-clock string ("2026-08-11T18:48", no timezone);
|
|
* slicing it keeps the local reading and avoids a `Date` shifting it.
|
|
*/
|
|
export const formatSpeechAt = (speechAt: string): string => {
|
|
const match = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/.exec(speechAt)
|
|
if (!match) return speechAt
|
|
const [, year, month, day, hour, minute] = match
|
|
return `${day}/${month}/${year} · ${hour}:${minute}`
|
|
}
|
|
|
|
const formatSpeechDuration = (seconds: number | null | undefined): string | null =>
|
|
seconds === null || seconds === undefined || !Number.isFinite(seconds) || seconds <= 0
|
|
? null
|
|
: formatSpeechSpan(seconds)
|
|
|
|
type MunicipalityMentions = {
|
|
mentionedMunicipalities?: (number | Municipality)[] | null
|
|
}
|
|
|
|
/** Cited municipality ids from a speech row (depth 0 keeps them as numbers). */
|
|
export const municipalityIdsOfSpeech = (speech: MunicipalityMentions): number[] =>
|
|
(speech.mentionedMunicipalities ?? []).flatMap((value) =>
|
|
typeof value === 'number' ? [value] : [value.id],
|
|
)
|
|
|
|
const municipalityViewModels = (
|
|
speech: SpeechListRecord,
|
|
labels: ReadonlyMap<number, string>,
|
|
): { id: number; name: string }[] =>
|
|
(speech.mentionedMunicipalities ?? []).flatMap((value) => {
|
|
if (typeof value !== 'number') return [{ id: value.id, name: value.name }]
|
|
const name = labels.get(value)
|
|
return name ? [{ id: value, name }] : []
|
|
})
|
|
|
|
const topicViewModels = (speech: SpeechListRecord) =>
|
|
(speech.topics ?? []).map((value) => ({ value, label: speechTopicLabels[value] }))
|
|
|
|
const scopeViewModels = (speech: SpeechListRecord) =>
|
|
(speech.scopes ?? []).map((value) => ({ value, label: speechScopeLabels[value] }))
|
|
|
|
const pickMatchingSegment = (
|
|
segments: readonly SpeechSegmentRecord[],
|
|
query: string | undefined,
|
|
): SpeechSegmentRecord | undefined => {
|
|
const q = query?.trim()
|
|
if (!q || segments.length === 0) return undefined
|
|
const allTerms = segments.find((segment) => matchesSearchTerms(segment.text, q))
|
|
if (allTerms) return allTerms
|
|
const terms = speechSearchTerms(q)
|
|
return segments.find((segment) => {
|
|
const normalized = normalizeForSearch(segment.text)
|
|
return terms.some((term) => normalized.includes(term))
|
|
})
|
|
}
|
|
|
|
/**
|
|
* C192 — the first expanded theme term that actually surfaced this speech. The
|
|
* gate is the `speechMatchesSearchTerm` mirror (the same predicate the `where`
|
|
* runs), so the card never claims a theme the search did not use. The evidence
|
|
* is the passage that carries the term, or the official keyword when that is
|
|
* what matched. Returns undefined when no theme term applies to this row.
|
|
*/
|
|
const pickThemeMatch = (
|
|
speech: SpeechListRecord,
|
|
segments: readonly SpeechSegmentRecord[],
|
|
themeTerms: readonly string[],
|
|
): { term: string; segment?: SpeechSegmentRecord; keyword?: string } | undefined => {
|
|
for (const term of themeTerms) {
|
|
const trimmed = term.trim()
|
|
const normalized = normalizeForSearch(trimmed)
|
|
if (!normalized || !speechMatchesSearchTerm(speech, trimmed)) continue
|
|
|
|
if (normalizeForSearch(speech.searchText ?? '').includes(normalized)) {
|
|
const segment =
|
|
segments.find((item) => normalizeForSearch(item.text).includes(normalized)) ??
|
|
segments.find((item) => {
|
|
const text = normalizeForSearch(item.text)
|
|
return speechSearchTerms(trimmed).some((word) => text.includes(word))
|
|
}) ??
|
|
segments[0]
|
|
return { term: trimmed, segment }
|
|
}
|
|
|
|
const lowered = trimmed.toLowerCase()
|
|
const keyword = (speech.keywords ?? []).find((item) => item.toLowerCase().includes(lowered))
|
|
if (keyword) return { term: trimmed, keyword }
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
export const buildWatchHref = (
|
|
speechId: number,
|
|
segment: SpeechSegmentRecord | undefined,
|
|
query: string | undefined,
|
|
): string => {
|
|
const params = new URLSearchParams()
|
|
// ASR timestamps are fractional seconds; the URL carries whole seconds.
|
|
if (segment) params.set('t', String(Math.max(0, Math.floor(segment.startSeconds))))
|
|
if (query) params.set('q', query)
|
|
const queryString = params.toString()
|
|
return `${CAMPAIGN_COMMUNICATION_ACERVO}/${speechId}${queryString ? `?${queryString}` : ''}`
|
|
}
|
|
|
|
/** Seek offset from the detail URL (`?t=`), non-negative seconds or null. */
|
|
export const parseSpeechSeekSeconds = (raw: string | undefined): number | null => {
|
|
if (raw === undefined) return null
|
|
const seconds = Number(raw)
|
|
return Number.isFinite(seconds) && seconds >= 0 ? seconds : null
|
|
}
|
|
|
|
type SpeechCuts = readonly SpeechCutSummaryViewModel[]
|
|
|
|
const thumbnailUrlOf = (speech: SpeechListRecord): string | null =>
|
|
speechPosterTarget(speech) ? speechPosterHref(speech.id) : speechCoverUrl(speech.youtubeUrl)
|
|
|
|
export const toSpeechListItemViewModel = ({
|
|
speech,
|
|
segments,
|
|
query,
|
|
municipalityLabels,
|
|
cuts = [],
|
|
themeTerms = [],
|
|
}: {
|
|
speech: SpeechListRecord
|
|
segments: readonly SpeechSegmentRecord[]
|
|
query?: string
|
|
municipalityLabels: ReadonlyMap<number, string>
|
|
/** C174 — the cuts of this speech, nested in the result card. */
|
|
cuts?: SpeechCuts
|
|
/** C192 — expanded theme terms; empty in the literal search. */
|
|
themeTerms?: readonly string[]
|
|
}): SpeechListItemViewModel => {
|
|
const matchedSegment = pickMatchingSegment(segments, query)
|
|
const q = query?.trim()
|
|
const normalizedQuery = q ? normalizeForSearch(q) : ''
|
|
const keywordMatch =
|
|
!matchedSegment &&
|
|
Boolean(normalizedQuery) &&
|
|
(speech.keywords ?? []).some((keyword) => normalizeForSearch(keyword).includes(normalizedQuery))
|
|
|
|
// C192 — the theme evidence wins the excerpt so the card's "Por que apareceu"
|
|
// block shows the passage that actually matched, even when the speech also
|
|
// contains the literal query.
|
|
const themeMatch = pickThemeMatch(speech, segments, themeTerms)
|
|
|
|
const excerptSource =
|
|
themeMatch?.segment?.text ??
|
|
themeMatch?.keyword ??
|
|
matchedSegment?.text ??
|
|
segments[0]?.text ??
|
|
speech.summary ??
|
|
speech.officialTranscript ??
|
|
''
|
|
|
|
// C192 — the theme passage highlights the whole phrase as one band (the
|
|
// per-term split would drop the "à" and break it into pieces).
|
|
const excerpt = themeMatch
|
|
? buildHighlightedExcerpt(excerptSource, themeMatch.term, { phrase: true })
|
|
: buildHighlightedExcerpt(excerptSource, q ?? '')
|
|
|
|
return {
|
|
id: speech.id,
|
|
speechAtLabel: formatSpeechAt(speech.speechAt),
|
|
type: speech.type ?? null,
|
|
durationLabel: formatSpeechDuration(speech.durationSeconds),
|
|
presidingOfficer: speech.presidingOfficer ?? null,
|
|
excerpt,
|
|
matchKind: matchedSegment
|
|
? 'segment'
|
|
: keywordMatch
|
|
? 'keyword'
|
|
: themeMatch
|
|
? 'theme'
|
|
: 'fallback',
|
|
topics: topicViewModels(speech),
|
|
scopes: scopeViewModels(speech),
|
|
keywords: speech.keywords ?? [],
|
|
municipalities: municipalityViewModels(speech, municipalityLabels),
|
|
thumbnailUrl: thumbnailUrlOf(speech),
|
|
watchHref: buildWatchHref(speech.id, matchedSegment, q),
|
|
officialTextUrl: speech.officialTextUrl ?? null,
|
|
cuts: [...cuts],
|
|
matchedTextSearch: speechMatchesSearchQuery(speech, query),
|
|
themeMatchTerm: themeMatch?.term ?? null,
|
|
}
|
|
}
|
|
|
|
export type SpeechDetailRecord = SpeechListRecord & {
|
|
phase?: string | null
|
|
eventStartAt?: string | null
|
|
}
|
|
|
|
export const toSpeechDetailViewModel = ({
|
|
speech,
|
|
segments,
|
|
query,
|
|
municipalityLabels,
|
|
youtubeVideoStartAt = null,
|
|
}: {
|
|
speech: SpeechDetailRecord
|
|
segments: readonly SpeechSegmentRecord[]
|
|
query?: string
|
|
municipalityLabels: ReadonlyMap<number, string>
|
|
/** Broadcast start (ISO) of the session video — the C172 anchor, when known. */
|
|
youtubeVideoStartAt?: string | null
|
|
}): SpeechDetailViewModel => {
|
|
// The stored VOD link is a cache and never a URL handed to the client; it
|
|
// only signals that this record had a generated excerpt to re-resolve.
|
|
const vodResolvable = speechVodCoordinates(speech) !== null
|
|
const youtubeVideoId = parseYoutubeVideoId(speech.youtubeUrl)
|
|
// C172 — measured evidence for this recording wins over the API anchor.
|
|
const lagSeconds =
|
|
measuredVideoLagSeconds(youtubeVideoId) ??
|
|
sessionLagSeconds(youtubeVideoStartAt, speech.eventStartAt)
|
|
|
|
return {
|
|
id: speech.id,
|
|
speechAtLabel: formatSpeechAt(speech.speechAt),
|
|
speechDateLabel: formatSpeechDate(speech.speechAt),
|
|
type: speech.type ?? null,
|
|
phase: speech.phase ?? null,
|
|
durationLabel: formatSpeechDuration(speech.durationSeconds),
|
|
durationSeconds: speech.durationSeconds ?? null,
|
|
presidingOfficer: speech.presidingOfficer ?? null,
|
|
summary: speech.summary ?? null,
|
|
officialTranscript: speech.officialTranscript ?? null,
|
|
officialTextUrl: speech.officialTextUrl ?? null,
|
|
keywords: speech.keywords ?? [],
|
|
topics: topicViewModels(speech),
|
|
scopes: scopeViewModels(speech),
|
|
municipalities: municipalityViewModels(speech, municipalityLabels),
|
|
segments: segments.map((segment) => ({
|
|
startSeconds: segment.startSeconds,
|
|
endSeconds: segment.endSeconds,
|
|
startLabel: formatSpeechClock(segment.startSeconds),
|
|
parts: splitHighlightedParts(segment.text, query ?? ''),
|
|
})),
|
|
youtubeVideoId,
|
|
youtubeOffsetSeconds: correctedExcerptOffsetSeconds(
|
|
excerptOffsetSeconds(speech.excerptTMs, speech.eventStartAt),
|
|
lagSeconds,
|
|
),
|
|
vodResolvable,
|
|
}
|
|
}
|