teqo-backup/src/utilities/speech/speechViewModels.ts

317 lines
11 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 { SpeechCutViewModel } 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 { normalizeForSearch } from '@/lib/speechSearch'
import {
correctedExcerptOffsetSeconds,
excerptOffsetSeconds,
measuredVideoLagSeconds,
parseYoutubeVideoId,
sessionLagSeconds,
speechVodCoordinates,
youtubeThumbnailUrl,
} 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
/** C174 — normalized search text; lets the row tell whether it matched `q`. */
searchText?: string | null
}
type SpeechListMatchKind = 'segment' | 'keyword' | '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 }[]
/** YouTube cover of the session link (C175); null when the speech has none. */
thumbnailUrl: string | null
watchHref: string
sourceUrl: string | null
/** C174 — the cuts already made from this speech, newest first. */
cuts: SpeechCutViewModel[]
/**
* 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
}
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
youtubeUrl: 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))
})
}
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 SpeechCutViewModel[]
/**
* C174 — does the speech itself match `q`? Mirrors the textual `where` branch
* (`speechListFilters.buildSpeechTextBranches`): normalized `searchText` LIKE
* and raw keyword contains. Keep the two spellings in step.
*/
const speechMatchesText = (speech: SpeechListRecord, query: string | undefined): boolean => {
const trimmed = query?.trim()
if (!trimmed) return true
if (normalizeForSearch(speech.searchText ?? '').includes(normalizeForSearch(trimmed))) return true
return (speech.keywords ?? []).some((keyword) => keyword.includes(trimmed))
}
export const toSpeechListItemViewModel = ({
speech,
segments,
query,
municipalityLabels,
cuts = [],
}: {
speech: SpeechListRecord
segments: readonly SpeechSegmentRecord[]
query?: string
municipalityLabels: ReadonlyMap<number, string>
/** C174 — the cuts of this speech, nested in the result card. */
cuts?: SpeechCuts
}): 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))
const excerptSource =
matchedSegment?.text ?? segments[0]?.text ?? speech.summary ?? speech.officialTranscript ?? ''
return {
id: speech.id,
speechAtLabel: formatSpeechAt(speech.speechAt),
type: speech.type ?? null,
durationLabel: formatSpeechDuration(speech.durationSeconds),
presidingOfficer: speech.presidingOfficer ?? null,
excerpt: buildHighlightedExcerpt(excerptSource, q ?? ''),
matchKind: matchedSegment ? 'segment' : keywordMatch ? 'keyword' : 'fallback',
topics: topicViewModels(speech),
scopes: scopeViewModels(speech),
keywords: speech.keywords ?? [],
municipalities: municipalityViewModels(speech, municipalityLabels),
thumbnailUrl: youtubeThumbnailUrl(parseYoutubeVideoId(speech.youtubeUrl)),
watchHref: buildWatchHref(speech.id, matchedSegment, q),
sourceUrl: speech.officialTextUrl ?? speech.youtubeUrl ?? null,
cuts: [...cuts],
matchedTextSearch: speechMatchesText(speech, query),
}
}
export type SpeechDetailRecord = SpeechListRecord & {
phase?: string | null
vodPlaybackUrl?: string | null
vodDownloadUrl?: string | null
eventId?: number | null
audioId?: number | null
excerptTMs?: number | 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,
youtubeUrl: speech.youtubeUrl ?? 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,
}
}